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.
- Текстовые кнопки в модалках не сжимают текст:
- минимальный горизонтальный отступ от текста до края кнопки: `10px`
- для CTA предпочтительно использовать общий padding `1.25rem` или больше
- текст не должен визуально прилипать к радиусу кнопки
- Primary button:
- фон: акцентный или `active_card_rgb`
- текст: определяется автоматически по контрасту заливки
@ -80,6 +84,24 @@
- единая вертикальная высота для одного класса контролов
- 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 и верхние панели
- Элементы верхней панели центрируются по одной горизонтальной оси.
- Активный layout/tool mode выделяется кругом акцентного цвета, не квадратной плашкой.
@ -220,6 +242,7 @@
## Drag and drop
- Drag overlay использует акцентный контур.
- Во внутреннем kanban после успешного ручного переноса карточка остается в активной заливке `active_card_rgb` до выбора другой карточки/следующего переноса; сам drag-жест не открывает detail pane.
- Delete dropzone:
- без красного технического свечения и без red-tinted text/fill
- текст локализован

View File

@ -25,7 +25,8 @@ x-aws-s3-env: &aws-s3-env
x-proxy-env: &proxy-env
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_ACME_CA: ${CERT_ACME_CA}
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_BUCKET_NAME=uploads
FILE_SIZE_LIMIT=5242880
PROXY_BODY_SIZE_LIMIT=1073741824
POSTHOG_API_KEY=
POSTHOG_HOST=
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
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_ACME_CA: ${CERT_ACME_CA}
CERT_ACME_DNS: ${CERT_ACME_DNS}
@ -47,9 +48,9 @@ x-live-env: &live-env
LIVE_SERVER_SECRET_KEY: ${LIVE_SERVER_SECRET_KEY:-2FiJk1U2aiVPEQtzLehYGlTSnTnrs7LW}
x-app-env: &app-env
WEB_URL: ${WEB_URL:-http://localhost}
WEB_URL: ${WEB_URL:-http://localhost:8090}
DEBUG: ${DEBUG:-0}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:8090}
GUNICORN_WORKERS: 1
POSTHOG_API_KEY: ${POSTHOG_API_KEY:-}
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_BUCKET_NAME=uploads
FILE_SIZE_LIMIT=5242880
PROXY_BODY_SIZE_LIMIT=1073741824
POSTHOG_API_KEY=
POSTHOG_HOST=
INSTANCE_CHANGELOG_URL=

View File

@ -31,6 +31,7 @@ from plane.utils.content_validator import (
validate_html_content,
validate_binary_data,
)
from plane.utils.date_utils import set_default_issue_start_date
from .base import BaseSerializer
from .cycle import CycleLiteSerializer, CycleSerializer
@ -73,6 +74,9 @@ class IssueSerializer(BaseSerializer):
exclude = ["description_json", "description_stripped"]
def validate(self, data):
if self.instance is None:
data = set_default_issue_start_date(data)
if (
data.get("start_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,
)
from plane.utils.exception_logger import log_exception
from plane.utils.upload_limits import resolve_workspace_upload_size_limit
class UserAssetEndpoint(BaseAPIView):
@ -512,9 +513,6 @@ class GenericAssetEndpoint(BaseAPIView):
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
if not type or type not in settings.ATTACHMENT_MIME_TYPES:
return Response(
@ -525,6 +523,9 @@ class GenericAssetEndpoint(BaseAPIView):
# Get the workspace
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 = 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 plane.utils.host import base_host
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.app.permissions import ROLE
from plane.utils.openapi import (
@ -1874,8 +1876,6 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView):
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:
return Response(
{"error": "Invalid file type.", "status": False},
@ -1885,6 +1885,8 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView):
# Get the workspace
workspace = Workspace.objects.get(slug=slug)
size_limit = resolve_workspace_upload_size_limit(workspace, size)
# asset key
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
@ -2100,13 +2102,8 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
storage = S3Storage(request=request)
presigned_url = storage.generate_presigned_url(
object_name=asset.asset.name,
disposition="attachment",
filename=asset.attributes.get("name"),
)
return HttpResponseRedirect(presigned_url)
disposition = "inline" if request.GET.get("preview") == "true" else "attachment"
return get_attachment_preview_response(request, asset, disposition=disposition)
@issue_attachment_docs(
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)
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
if not issue_attachment.is_uploaded:

View File

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

View File

@ -192,6 +192,7 @@ class DynamicBaseSerializer(BaseSerializer):
issue_attachments = FileAsset.objects.filter(
issue_id=issue_id,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
is_uploaded=True,
)
# Serialize issue_attachments and add them to the response
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_binary_data,
)
from plane.utils.date_utils import set_default_issue_start_date
class IssueFlatSerializer(BaseSerializer):
@ -124,6 +125,9 @@ class IssueCreateSerializer(BaseSerializer):
allow_triage = self.context.get("allow_triage_state", False)
state_manager = State.triage_objects if allow_triage else State.objects
if self.instance is None:
attrs = set_default_issue_start_date(attrs)
if (
attrs.get("start_date", None) is not None
and attrs.get("target_date", None) is not None
@ -799,6 +803,8 @@ class IssueSerializer(DynamicBaseSerializer):
"link_count",
"is_draft",
"archived_at",
"external_source",
"external_id",
]
read_only_fields = fields
@ -852,6 +858,8 @@ class IssueListDetailSerializer(serializers.Serializer):
"updated_by": instance.updated_by_id,
"is_draft": instance.is_draft,
"archived_at": instance.archived_at,
"external_source": instance.external_source,
"external_id": instance.external_id,
"source_project_name": getattr(instance, "source_project_name", None),
# Computed fields
"cycle_id": instance.cycle_id,
@ -933,6 +941,7 @@ class IssueDetailSerializer(IssueSerializer):
class Meta(IssueSerializer.Meta):
fields = IssueSerializer.Meta.fields + [
"description_html",
"detail_layout",
"is_subscribed",
"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
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:
model = Workspace
fields = "__all__"

View File

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

View File

@ -44,6 +44,11 @@ urlpatterns = [
UserEndpoint.as_view({"patch": "update_email"}),
name="user-email-update",
),
path(
"users/me/email/direct/",
UserEndpoint.as_view({"patch": "update_email_without_verification"}),
name="user-email-direct-update",
),
# Profile
path("users/me/profile/", ProfileEndpoint.as_view(), name="accounts"),
# 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,
)
from .voice_tasker import (
VoiceTaskCommitEndpoint,
VoiceTaskParseEndpoint,
VoiceTaskPreflightEndpoint,
WorkspaceAISettingsEndpoint,
WorkspaceAISettingsTestConnectionEndpoint,
)
from .error_404 import custom_404_view
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.bgtasks.storage_metadata_task import get_asset_object_metadata
from plane.throttles.asset import AssetRateThrottle
from plane.utils.upload_limits import resolve_workspace_upload_size_limit
class UserAssetsV2Endpoint(BaseAPIView):
@ -342,12 +343,12 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
# Get the size limit
size_limit = min(settings.FILE_SIZE_LIMIT, size)
# Get the workspace
workspace = Workspace.objects.get(slug=slug)
# Get the size limit
size_limit = resolve_workspace_upload_size_limit(workspace, size)
# asset key
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
@ -541,12 +542,12 @@ class ProjectAssetEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
# Get the size limit
size_limit = min(settings.FILE_SIZE_LIMIT, size)
# Get the workspace
workspace = Workspace.objects.get(slug=slug)
# Get the size limit
size_limit = resolve_workspace_upload_size_limit(workspace, size)
# asset key
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"

View File

@ -10,7 +10,6 @@ import uuid
from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder
from django.conf import settings
from django.http import HttpResponseRedirect
# Third Party imports
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.bgtasks.storage_metadata_task import get_asset_object_metadata
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):
@ -86,7 +87,13 @@ class IssueAttachmentEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
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)
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}"
# 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
asset = FileAsset.objects.create(
@ -179,13 +186,8 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
storage = S3Storage(request=request)
presigned_url = storage.generate_presigned_url(
object_name=asset.asset.name,
disposition="attachment",
filename=asset.attributes.get("name"),
)
return HttpResponseRedirect(presigned_url)
disposition = "inline" if request.GET.get("preview") == "true" else "attachment"
return get_attachment_preview_response(request, asset, disposition=disposition)
# Get all the attachments
issue_attachments = FileAsset.objects.filter(
@ -203,6 +205,11 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
def patch(self, request, slug, project_id, issue_id, pk):
issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
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
if not issue_attachment.is_uploaded:

View File

@ -3,6 +3,7 @@
# See the LICENSE file for details.
# Python imports
import os
import uuid
import json
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.bgtasks.user_email_update_task import send_email_update_magic_code, send_email_update_confirmation
from plane.authentication.rate_limit import EmailVerificationThrottle
from plane.license.utils.instance_value import get_configuration_value
logger = logging.getLogger("plane")
@ -133,6 +135,10 @@ class UserEndpoint(BaseViewSet):
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):
"""
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
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):
# Check all workspace user is active
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"]
]
sort_order_counter = 1
for preference in keys:
for sort_order_counter, preference in enumerate(keys, start=1):
if preference not in get_preference.values_list("key", flat=True):
create_preference_keys.append(preference)
@ -55,7 +53,6 @@ class WorkspaceHomePreferenceViewSet(BaseAPIView):
batch_size=10,
ignore_conflicts=True,
)
sort_order_counter += 1
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.db.models import (
CycleIssue,
FileAsset,
IntakeIssue,
Issue,
IssueActivity,
FileAsset,
IssueLink,
IssueSubscriber,
Project,
@ -108,6 +109,18 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
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(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.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 .view import IssueView
from .webhook import Webhook, WebhookLog
from .voice_tasker import VoiceTaskSession, WorkspaceAICredential, WorkspaceAISettings
from .workspace import (
Workspace,
WorkspaceBaseModel,

View File

@ -134,6 +134,7 @@ class Issue(ProjectBaseModel):
name = models.CharField(max_length=255, verbose_name="Issue Name")
description_json = models.JSONField(blank=True, default=dict)
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_binary = models.BinaryField(null=True)
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)
timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES)
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):
"""Return name of the Workspace"""
@ -378,6 +380,7 @@ class WorkspaceHomePreference(BaseModel):
QUICK_LINKS = "quick_links", "Quick Links"
RECENTS = "recents", "Recents"
MY_STICKIES = "my_stickies", "My Stickies"
PROJECT_LATEST_ISSUES = "project_latest_issues", "Project Latest Issues"
NEW_AT_PLANE = "new_at_plane", "New at Plane"
QUICK_TUTORIAL = "quick_tutorial", "Quick Tutorial"

View File

@ -36,6 +36,7 @@ from plane.utils.content_validator import (
validate_html_content,
validate_binary_data,
)
from plane.utils.date_utils import set_default_issue_start_date
class IssueStateFlatSerializer(BaseSerializer):
@ -277,6 +278,9 @@ class IssueCreateSerializer(BaseSerializer):
return data
def validate(self, data):
if self.instance is None:
data = set_default_issue_start_date(data)
if (
data.get("start_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)
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(
slug: str,
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) {
request_body {
max_size {$FILE_SIZE_LIMIT}
max_size {$PROXY_BODY_SIZE_LIMIT:1073741824}
}
handle /spaces/* {
@ -43,4 +43,4 @@
{$SITE_ADDRESS} {
import plane_proxy
}
}

View File

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

View File

@ -23,7 +23,7 @@ import { useTranslation } from "@plane/i18n";
import { InboxIcon, PlusIcon, ProjectIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
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 { useCommandPalette } from "@/hooks/store/use-command-palette";
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),
[pathname, workspacePreferences, workspaceSlug]
);
const workspaceSlugValue = workspaceSlug?.toString();
const isWorkspaceHome = pathname === `/${workspaceSlugValue}` || pathname === `/${workspaceSlugValue}/`;
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="flex min-w-0 items-center gap-3">
<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 { GlobalModals } from "@/plane-web/components/common/modal/global";
import { WorkspaceAuthWrapper } from "@/layouts/auth-layout/workspace-wrapper";
import { WorkspaceSettingsModal } from "@/components/workspace/settings/workspace-settings-modal";
import type { Route } from "./+types/layout";
export default function WorkspaceLayout(props: Route.ComponentProps) {
@ -19,8 +20,9 @@ export default function WorkspaceLayout(props: Route.ComponentProps) {
<AuthenticationWrapper>
<WorkspaceAuthWrapper>
<AppRailVisibilityProvider>
<WorkspaceContentWrapper>
<WorkspaceContentWrapper workspaceSlug={workspaceSlug}>
<GlobalModals workspaceSlug={workspaceSlug} />
<WorkspaceSettingsModal />
<Outlet />
</WorkspaceContentWrapper>
</AppRailVisibilityProvider>

View File

@ -289,6 +289,10 @@ export const coreRoutes: RouteConfigEntry[] = [
":workspaceSlug/settings/webhooks/:webhookId",
"./(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;
selectedProjectAnalytics?: TProjectAnalyticsCount;
recents?: TActivityEntityData[];
workspaceName?: string;
};
export function HomePageHeader(props: HomePageHeaderProps) {
const { currentUser, selectedProject, selectedProjectAnalytics, recents } = props;
const { currentUser, selectedProject, selectedProjectAnalytics, recents, workspaceName } = props;
const { currentLocale } = useTranslation();
const { currentTime } = useCurrentTime();
@ -48,6 +49,7 @@ export function HomePageHeader(props: HomePageHeaderProps) {
{ label: "Открытые задачи", value: openIssues.toString(), caption: "в работе" },
{ label: "Касания 7 дней", value: recentTouchpoints.toString(), caption: "recent" },
];
const workspaceDisplayName = workspaceName?.trim() || "Workspace";
return (
<section className="nodedc-home-hero">
@ -57,26 +59,19 @@ export function HomePageHeader(props: HomePageHeaderProps) {
</div>
<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="min-w-0">
<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="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"}
</div>
<div className="mt-1 truncate text-12 font-medium text-black/[0.54]">
{selectedProject?.description || selectedProject?.identifier || "Координационный обзор"}
</div>
{selectedProject?.identifier && (
<div className="mt-1 truncate text-12 font-medium text-black/[0.54]">
{selectedProject.identifier}
</div>
)}
</div>
</div>
</div>
@ -86,7 +81,7 @@ export function HomePageHeader(props: HomePageHeaderProps) {
<div key={metric.label} className="min-w-0">
<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-2 h-2 overflow-hidden rounded-full bg-black/[0.18]">
<div className="nodedc-home-market-progress">
<div
className="h-full rounded-full bg-black"
style={{
@ -102,6 +97,11 @@ export function HomePageHeader(props: HomePageHeaderProps) {
))}
</div>
</div>
<div className="nodedc-home-hero-title-cell">
<div className="nodedc-home-hero-title-label">Workspace</div>
<h1>{workspaceDisplayName}</h1>
</div>
</div>
</section>
);

View File

@ -10,14 +10,9 @@ import { CalendarDays } from "lucide-react";
import { observer } from "mobx-react";
import { EUserPermissions } from "@plane/constants";
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 type {
IState,
TExternalContourBoardDirection,
TExternalContourRequest,
TIssue,
} from "@plane/types";
import type { IState, TExternalContourBoardDirection, TExternalContourRequest, TIssue } from "@plane/types";
import { Avatar } from "@plane/ui";
import { cn, renderFormattedDate, renderFormattedPayloadDate } from "@plane/utils";
import { DateDropdown } from "@/components/dropdowns/date";
@ -56,7 +51,7 @@ const buildSourceStateMap = (
state.id,
{
id: state.id,
color: state.color,
color: getStateGroupColor(state.group, state.color),
default: false,
description: "",
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;
if (!stateGroup) return fallbackStatus;
return stateGroup === "completed" || stateGroup === "cancelled" ? "closed" : "open";
@ -83,23 +81,20 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
const { getUserDetails, workspace } = useMember();
const { getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
const { getStateById, getProjectStateIds } = useProjectState();
const {
fetchBoard,
upsertBoardItems,
} = useProjectExternalContoursBoard();
const {
fetchTargetOptions,
getTargetOptionsByProjectId,
updateRequest,
updateRequestIssue,
} = useProjectExternalContours();
const { fetchBoard, upsertBoardItems } = useProjectExternalContoursBoard();
const { fetchTargetOptions, getTargetOptionsByProjectId, updateRequest, updateRequestIssue } =
useProjectExternalContours();
const [isUpdating, setIsUpdating] = useState(false);
const [isSourceOptionsLoading, setIsSourceOptionsLoading] = useState(false);
const issue = request.issue;
const selectedInboxIssueId = searchParams.get("inboxIssueId");
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 counterpartContourName =
direction === "outgoing"
@ -110,8 +105,12 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
? getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, targetProjectId)
: undefined;
const canEditTargetIssue =
direction === "incoming" && !!targetProjectId && projectRole !== undefined && projectRole !== EUserPermissions.GUEST;
const canEditSourceRequest = direction === "outgoing" && !!request.capabilities?.can_edit_request && !!targetProjectId;
direction === "incoming" &&
!!targetProjectId &&
projectRole !== undefined &&
projectRole !== EUserPermissions.GUEST;
const canEditSourceRequest =
direction === "outgoing" && !!request.capabilities?.can_edit_request && !!targetProjectId;
const canEditCard = canEditTargetIssue || canEditSourceRequest;
const requestLink = `/${workspaceSlug}/projects/${projectId}/external-contours?inboxIssueId=${request.id}`;
const targetOptions = getTargetOptionsByProjectId(targetProjectId);
@ -124,12 +123,11 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
const projectStateIds = issue.project_id ? getProjectStateIds(issue.project_id) : [];
const foregroundClasses = isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white";
const subtleTextClasses = isActive ? "text-[#2F4721]" : "text-[#B3B3B8]";
const pillBackgroundClasses =
isActive
? "bg-black/10 text-[rgb(var(--nodedc-on-card-active-rgb))]"
: "bg-[rgb(var(--nodedc-card-passive-rgb))] text-white";
const pillBackgroundClasses = isActive
? "bg-black/10 text-[rgb(var(--nodedc-on-card-active-rgb))]"
: "bg-[rgb(var(--nodedc-card-passive-rgb))] text-white";
const iconBubbleClasses = isActive ? "bg-black text-[rgb(var(--nodedc-card-active-rgb))]" : "bg-[#111214] text-white";
const 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");
if (!issue) return null;
@ -221,6 +219,7 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
<div className="group/kanban-block relative mb-2">
<div
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"
role="button"
tabIndex={0}
@ -314,13 +313,13 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
</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")}
</div>
</div>
<div className="flex flex-1 items-center justify-center px-5 py-4 text-center">
<div className="line-clamp-4 max-w-full text-lg font-semibold leading-6">{issue.name}</div>
<div className="text-lg line-clamp-4 max-w-full leading-6 font-semibold">{issue.name}</div>
</div>
<div className="flex items-center justify-between gap-3" onClick={stopCardPropagation}>
@ -333,7 +332,7 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
disabled={!canEditCard || isUpdating}
buttonVariant="transparent-without-text"
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" />
</div>
}
@ -351,7 +350,7 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
}}
buttonVariant="transparent-without-text"
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" />
</div>
}

View File

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

View File

@ -59,6 +59,7 @@ export const ExternalContoursListItem = observer(function ExternalContoursListIt
>
<div
data-active={isActive}
data-priority={issue.priority ?? "none"}
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",
{ "ring-0": isActive }

View File

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

View File

@ -8,16 +8,30 @@
import { MAX_FILE_SIZE } from "@plane/constants";
// hooks
import { useInstance } from "@/hooks/store/use-instance";
import { useWorkspace } from "@/hooks/store/use-workspace";
type TReturnProps = {
maxFileSize: number;
fileSizeLimitEnabled: boolean;
};
export const useFileSize = (): TReturnProps => {
// store hooks
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 {
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="nodedc-external-panel flex items-center justify-between gap-3 px-5 py-4 md:px-6">
<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>
</div>
</div>

View File

@ -65,18 +65,18 @@ export function DataTable<TData, TValue>({ columns, data, searchPlaceholder, act
});
return (
<div className="space-y-4">
<div className="flex w-full items-center justify-between">
<div className="nodedc-analytics-table space-y-4">
<div className="nodedc-analytics-table-toolbar flex w-full items-center justify-between">
<div className="relative flex max-w-[300px] items-center gap-4">
{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}
</div>
)}
{!isSearchOpen && (
<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={() => {
setIsSearchOpen(true);
inputRef.current?.focus();
@ -87,7 +87,7 @@ export function DataTable<TData, TValue>({ columns, data, searchPlaceholder, act
)}
<div
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,
}
@ -97,7 +97,7 @@ export function DataTable<TData, TValue>({ columns, data, searchPlaceholder, act
<input
ref={inputRef}
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}
onChange={(e) => {
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>}
</div>
<div className="rounded-md">
<div className="nodedc-analytics-table-surface rounded-md">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (

View File

@ -39,6 +39,7 @@ export function InsightTable<T extends Exclude<TAnalyticsTabsBase, "overview">>(
actions={(table: Table<AnalyticsTableDataMap[T]>) => (
<Button
variant="secondary"
className="nodedc-analytics-export-button"
prependIcon={<Download className="h-3.5 w-3.5" />}
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={[
{
key: "count",
name: "Count",
name: "Количество",
fill: "var(--text-color-accent-primary)",
stroke: "var(--text-color-accent-primary)",
fillOpacity: 0.6,

View File

@ -16,6 +16,7 @@ import type { IAnalyticsParams } from "@plane/types";
import { ChartYAxisMetric } from "@plane/types";
import { cn } from "@plane/utils";
// plane web components
import { getAnalyticsXAxisLabel, getAnalyticsYAxisLabel } from "../labels";
import { SelectXAxis } from "./select-x-axis";
import { SelectYAxis } from "./select-y-axis";
@ -31,17 +32,33 @@ type Props = {
export const AnalyticsSelectParams = observer(function AnalyticsSelectParams(props: Props) {
const { control, params, classNames, isEpic } = props;
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]
);
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]
);
const yAxisOptions = useMemo(
() =>
ANALYTICS_Y_AXIS_VALUES.map((option) => ({
...option,
label: getAnalyticsYAxisLabel(option.value, option.label),
})),
[]
);
return (
<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
name="y_axis"
control={control}
@ -51,7 +68,7 @@ export const AnalyticsSelectParams = observer(function AnalyticsSelectParams(pro
onChange={(val: ChartYAxisMetric | null) => {
onChange(val);
}}
options={ANALYTICS_Y_AXIS_VALUES}
options={yAxisOptions}
hiddenOptions={[
ChartYAxisMetric.ESTIMATE_POINT_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">
<CalendarLayoutIcon className="h-3 w-3" />
<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>
</div>
}
@ -93,12 +110,12 @@ export const AnalyticsSelectParams = observer(function AnalyticsSelectParams(pro
<div className="flex items-center gap-2">
<SlidersHorizontal className="h-3 w-3" />
<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>
</div>
}
options={groupByOptions}
placeholder="Group By"
placeholder="Группировка"
allowNoValue
/>
)}

View File

@ -14,6 +14,7 @@ import { useTranslation } from "@plane/i18n";
// types
import { SelectionDropdown } from "@/components/common/selection-dropdown";
import type { TDropdownProps } from "@/components/dropdowns/types";
import { getAnalyticsDurationLabel } from "../labels";
type Props = TDropdownProps & {
value: string | null;
@ -27,12 +28,12 @@ type Props = TDropdownProps & {
tabIndex?: number;
};
function DurationDropdown({ placeholder = "Duration", onChange, value }: Props) {
function DurationDropdown({ placeholder = "Период", onChange, value }: Props) {
useTranslation();
const options = ANALYTICS_DURATION_FILTER_OPTIONS.map((option) => ({
key: option.value,
title: option.name,
title: getAnalyticsDurationLabel(option.value, option.name),
isChecked: value === option.value,
onClick: () => onChange(option.value),
}));
@ -42,10 +43,16 @@ function DurationDropdown({ placeholder = "Duration", onChange, value }: Props)
menuButton={
<div className="flex items-center gap-2 p-1">
<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>
}
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" />
{value && value.length > 3
? `3+ projects`
? `3+ проекта`
: value && value.length > 0
? projectIds
?.filter((p) => value.includes(p))
.map((p) => getProjectById(p)?.name)
.join(", ")
: "All projects"}
: "Все проекты"}
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</div>
)}

View File

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

View File

@ -45,10 +45,12 @@ export const SelectYAxis = observer(function SelectYAxis({ value, onChange, hidd
return (
<SelectionDropdown
menuButtonWrapperClassName="nodedc-analytics-select-trigger"
dropdownClassName="nodedc-analytics-dropdown"
menuButton={
<div className="flex items-center gap-2">
<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>
}
options={options

View File

@ -22,6 +22,29 @@ import InsightCard from "./insight-card";
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 = (
analyticsType: TAnalyticsTabsBase,
item: IInsightField,
@ -61,6 +84,13 @@ const TotalInsights = observer(function TotalInsights({
const workspaceSlug = params.workspaceSlug.toString();
const { t } = useTranslation();
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(
`total-insights-${analyticsType}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isEpic}`,
() =>
@ -77,18 +107,38 @@ const TotalInsights = observer(function TotalInsights({
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 (
<div
className={cn(
"grid grid-cols-1 gap-8 sm:grid-cols-2 md:gap-10",
!peekView
? ANALYTICS_INSIGHTS_FIELDS[analyticsType]?.length % 5 === 0
? "gap-10 lg:grid-cols-5"
: "gap-8 lg:grid-cols-4"
: "grid-cols-2"
!peekView ? (insightFields.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
key={`${analyticsType}-${item.key}`}
isLoading={isLoading}

View File

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

View File

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

View File

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

View File

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

View File

@ -125,7 +125,7 @@ const WorkItemsInsightTable = observer(function WorkItemsInsightTable() {
)}
</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>
),

View File

@ -7,6 +7,7 @@
import { observer } from "mobx-react";
import { Combobox } from "@headlessui/react";
// hooks
import { getStateGroupColor } from "@plane/propel/icons";
import type { ISearchIssueResponse } from "@plane/types";
// plane web hooks
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) {
const { issue, canDeleteIssueIds } = props;
const color = issue.state__color;
const color = getStateGroupColor(issue.state__group, issue.state__color);
return (
<Combobox.Option

View File

@ -22,7 +22,7 @@ import { useUser } from "@/hooks/store/user";
import { AuthService } from "@/services/auth.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 TUniqueCodeValuesForm = { email: string; code: string };
@ -33,7 +33,7 @@ const defaultValues: TUniqueCodeValuesForm = { email: "", code: "" };
const authService = new AuthService();
export const ChangeEmailModal = observer(function ChangeEmailModal(props: Props) {
const { isOpen, onClose } = props;
const { isOpen, onClose, isSMTPConfigured = true } = props;
// states
const [currentStep, setCurrentStep] = useState<TModalStep>("EMAIL");
// store hooks
@ -107,6 +107,20 @@ export const ChangeEmailModal = observer(function ChangeEmailModal(props: Props)
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
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}>
<div className="space-y-0 px-4 py-4">
<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>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 px-4" noValidate>
<div className="flex flex-col gap-1">

View File

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

View File

@ -81,7 +81,7 @@ export const BlockRow = observer(function BlockRow(props: Props) {
return (
<div
className="relative w-max min-w-full"
className="nodedc-project-gantt-row relative w-max min-w-full"
onMouseEnter={() => updateActiveBlockId(blockId)}
onMouseLeave={() => updateActiveBlockId(null)}
style={{
@ -89,19 +89,18 @@ export const BlockRow = observer(function BlockRow(props: Props) {
}}
>
<div
className={cn("relative h-full bg-layer-transparent hover:bg-layer-transparent-hover", {
"rounded-l-sm border border-r-0 border-accent-strong": getIsIssuePeeked(block.data.id),
"bg-layer-transparent-hover": isBlockHoveredOn,
"bg-accent-primary/5 hover:bg-accent-primary/10": isBlockSelected,
"bg-accent-primary/10": isBlockSelected && isBlockHoveredOn,
"border border-r-0 border-strong-1": isBlockFocused,
className={cn("nodedc-project-gantt-row-bg relative h-full", {
"nodedc-project-gantt-row-peeked": getIsIssuePeeked(block.data.id),
"nodedc-project-gantt-row-hovered": isBlockHoveredOn,
"nodedc-project-gantt-row-selected": isBlockSelected,
"nodedc-project-gantt-row-focused": isBlockFocused,
})}
>
{isBlockVisibleOnChart
? isHidden && (
<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={{
left: `${SIDEBAR_WIDTH + 4}px`,
}}

View File

@ -83,7 +83,7 @@ export const GanttChartBlock = observer(function GanttChartBlock(props: Props) {
horizontalOffset={100}
verticalOffset={200}
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}
forceRender={isCurrentDependencyDragging}
>

View File

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

View File

@ -177,7 +177,7 @@ export const GanttChartMainContent = observer(function GanttChartMainContent(pro
// DO NOT REMOVE THE ID
id="gantt-container"
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,
}
@ -199,11 +199,11 @@ export const GanttChartMainContent = observer(function GanttChartMainContent(pro
showAllBlocks={showAllBlocks}
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 />
{currentViewData && (
<div
className="relative h-full"
className="nodedc-project-gantt-layer relative h-full"
style={{
width: `${itemsContainerWidth}px`,
transform: `translateY(${HEADER_HEIGHT}px)`,

View File

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

View File

@ -30,12 +30,12 @@ export const MonthChartView = observer(function MonthChartView(_props: any) {
const marginLeftDays = getNumberOfDaysBetweenTwoDates(monthsStartDate, weeksStartDate);
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 && (
<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 */}
<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={{
height: `${HEADER_HEIGHT}px`,
}}
@ -45,18 +45,22 @@ export const MonthChartView = observer(function MonthChartView(_props: any) {
{months?.map((monthBlock) => (
<div
key={`month-${monthBlock?.month}-${monthBlock?.year}`}
className="flex outline-[0.5px] outline-subtle-1"
className="flex"
style={{ width: `${monthBlock.days * currentViewData?.data.dayWidth}px` }}
>
<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={{
left: `${SIDEBAR_WIDTH}px`,
}}
>
{monthBlock?.title}
{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
</span>
)}
@ -70,9 +74,9 @@ export const MonthChartView = observer(function MonthChartView(_props: any) {
<div
key={`sub-title-${weekBlock.startDate.toString()}-${weekBlock.endDate.toString()}`}
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` }}
@ -80,7 +84,8 @@ export const MonthChartView = observer(function MonthChartView(_props: any) {
<div className="space-x-1 text-11 font-medium text-placeholder">
<span
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()}
@ -96,8 +101,8 @@ export const MonthChartView = observer(function MonthChartView(_props: any) {
{weeks?.map((weekBlock) => (
<div
key={`column-${weekBlock.startDate.toString()}-${weekBlock.endDate.toString()}`}
className={cn("h-full overflow-hidden outline-[0.25px] outline-subtle", {
"bg-accent-primary/20": weekBlock.today,
className={cn("nodedc-project-gantt-column h-full overflow-hidden", {
"nodedc-project-gantt-column-today": weekBlock.today,
})}
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);
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 &&
quarterBlocks?.map((quarterBlock, rootIndex) => (
<div
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 */}
<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={{
height: `${HEADER_HEIGHT}px`,
}}
@ -38,19 +38,23 @@ export const QuarterChartView = observer(function QuarterChartView(_props: any)
{/** Main Quarter Title */}
<div className="inline-flex h-7 w-full justify-between">
<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={{
left: `${SIDEBAR_WIDTH}px`,
}}
>
{quarterBlock?.title}
{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
</span>
)}
</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}
</div>
</div>
@ -60,9 +64,9 @@ export const QuarterChartView = observer(function QuarterChartView(_props: any)
<div
key={`sub-title-${rootIndex}-${index}`}
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` }}
@ -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">
<span
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}
@ -85,8 +90,8 @@ export const QuarterChartView = observer(function QuarterChartView(_props: any)
{quarterBlock?.children?.map((monthBlock, index) => (
<div
key={`column-${rootIndex}-${index}`}
className={cn("h-full overflow-hidden outline-[0.25px] outline-subtle", {
"bg-accent-primary/20": monthBlock.today,
className={cn("nodedc-project-gantt-column h-full overflow-hidden", {
"nodedc-project-gantt-column-today": monthBlock.today,
})}
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;
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 &&
weekBlocks?.map((block, rootIndex) => (
<div
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 */}
<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={{
height: `${HEADER_HEIGHT}px`,
}}
@ -35,14 +35,14 @@ export const WeekChartView = observer(function WeekChartView(_props: any) {
{/** Main Months Title */}
<div className="inline-flex h-7 w-full justify-between">
<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={{
left: `${SIDEBAR_WIDTH}px`,
}}
>
{block?.title}
</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}
</div>
</div>
@ -52,9 +52,9 @@ export const WeekChartView = observer(function WeekChartView(_props: any) {
<div
key={`sub-title-${rootIndex}-${index}`}
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` }}
@ -63,7 +63,8 @@ export const WeekChartView = observer(function WeekChartView(_props: any) {
<div className="space-x-1 text-11 font-medium">
<span
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()}
@ -74,17 +75,17 @@ export const WeekChartView = observer(function WeekChartView(_props: any) {
</div>
</div>
{/** 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) => (
<div
key={`column-${rootIndex}-${index}`}
className={cn("h-full overflow-hidden outline-[0.25px] outline-subtle", {
"bg-accent-primary/20": weekDay.today,
className={cn("nodedc-project-gantt-column h-full overflow-hidden", {
"nodedc-project-gantt-column-today": weekDay.today,
})}
style={{ width: `${currentViewData?.data.dayWidth}px` }}
>
{["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>
))}

View File

@ -4,11 +4,11 @@
* 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;

View File

@ -39,7 +39,7 @@ export const LeftResizable = observer(function LeftResizable(props: LeftResizabl
<>
{(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="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
@ -56,7 +56,7 @@ export const LeftResizable = observer(function LeftResizable(props: LeftResizabl
/>
<div
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,
}

View File

@ -39,7 +39,7 @@ export const RightResizable = observer(function RightResizable(props: RightResiz
<>
{(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="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
@ -54,7 +54,7 @@ export const RightResizable = observer(function RightResizable(props: RightResiz
/>
<div
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,
}

View File

@ -43,7 +43,7 @@ export const ChartDraggable = observer(function ChartDraggable(props: Props) {
} = props;
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 */}
{(typeof enableDependency === "function" ? enableDependency(block.id) : enableDependency) && (
<LeftDependencyDraggable block={block} ganttContainerRef={ganttContainerRef} />
@ -55,7 +55,7 @@ export const ChartDraggable = observer(function ChartDraggable(props: Props) {
position={block.position}
/>
<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,
})}
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 (
<div
className={cn("group/list-block", {
"rounded-sm bg-layer-1": isDragging,
"rounded-l-sm border border-r-0 border-accent-strong": getIsIssuePeeked(block.data.id),
"border border-r-0 border-strong-1": isIssueFocused,
className={cn("nodedc-project-gantt-sidebar-block group/list-block", {
"nodedc-project-gantt-sidebar-block-dragging": isDragging,
"nodedc-project-gantt-sidebar-block-peeked": getIsIssuePeeked(block.data.id),
"nodedc-project-gantt-sidebar-block-focused": isIssueFocused,
})}
onMouseEnter={() => updateActiveBlockId(block.id)}
onMouseLeave={() => updateActiveBlockId(null)}
>
<Row
className={cn(
"group flex w-full items-center gap-2 bg-layer-transparent pr-4 hover:bg-layer-transparent-hover",
{
"bg-layer-transparent-hover": isBlockHoveredOn,
"bg-accent-primary/5 hover:bg-accent-primary/10": isIssueSelected,
"bg-accent-primary/10": isIssueSelected && isBlockHoveredOn,
}
)}
className={cn("nodedc-project-gantt-sidebar-row group flex w-full items-center gap-2 pr-4", {
"nodedc-project-gantt-sidebar-row-hovered": isBlockHoveredOn,
"nodedc-project-gantt-sidebar-row-selected": isIssueSelected,
})}
style={{
height: `${BLOCK_HEIGHT}px`,
}}

View File

@ -77,7 +77,7 @@ export const IssueGanttSidebar = observer(function IssueGanttSidebar(props: Prop
};
return (
<div>
<div className="nodedc-project-gantt-sidebar-list">
{blockIds ? (
<>
{blockIds.map((blockId, index) => {
@ -117,7 +117,7 @@ export const IssueGanttSidebar = observer(function IssueGanttSidebar(props: Prop
})}
{canLoadMoreBlocks && (
<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>
)}
</>

View File

@ -56,14 +56,14 @@ export const GanttChartSidebar = observer(function GanttChartSidebar(props: Prop
<Row
// DO NOT REMOVE THE ID
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={{
width: `${SIDEBAR_WIDTH}px`,
}}
variant={ERowVariant.HUGGING}
>
<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={{
height: `${HEADER_HEIGHT}px`,
}}
@ -88,7 +88,7 @@ export const GanttChartSidebar = observer(function GanttChartSidebar(props: Prop
<h6>{t("common.duration")}</h6>
</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({
title,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -31,6 +31,16 @@ import { HOME_WIDGETS_LIST } from "../../home-dashboard-widgets";
import { WidgetItemDragHandle } from "./widget-item-drag-handle";
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 = {
widgetId: string;
isLastChild: boolean;
@ -53,6 +63,11 @@ export const WidgetItem = observer(function WidgetItem(props: Props) {
// derived values
const widget = widgetsMap[widgetId];
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
useEffect(() => {
@ -76,7 +91,7 @@ export const WidgetItem = observer(function WidgetItem(props: Props) {
getOffset: pointerOutsideOfPreview({ x: "0px", y: "0px" }),
render: ({ 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();
},
nativeSetDragImage,
@ -118,7 +133,7 @@ export const WidgetItem = observer(function WidgetItem(props: Props) {
})
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [elementRef?.current, isDragging, isLastChild, widget.key]);
}, [elementRef?.current, isDragging, isLastChild, widget.key, widgetLabel]);
return (
<div className="">
@ -134,7 +149,7 @@ export const WidgetItem = observer(function WidgetItem(props: Props) {
>
<div className="flex items-center">
<WidgetItemDragHandle sort_order={widget.sort_order} isDragging={isDragging} />
<div>{t(widgetTitle, { count: 1 })}</div>
<div>{widgetLabel}</div>
</div>
<ToggleSwitch
value={widget.is_enabled}

View File

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

View File

@ -10,7 +10,7 @@ import { useTheme } from "next-themes";
import { Combobox } from "@headlessui/react";
// plane imports
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 type { ISearchIssueResponse } from "@plane/types";
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>}
<ul className="text-13 text-primary">
{filteredIssues.map((issue) => {
const stateColor = issue.state__color || "";
const stateColor = getStateGroupColor(issue.state__group, issue.state__color);
return (
<Combobox.Option

View File

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

View File

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

View File

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

View File

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

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