Compare commits
27 Commits
e5036fc95b
...
8b230d2670
| Author | SHA1 | Date |
|---|---|---|
|
|
8b230d2670 | |
|
|
8fde5e9502 | |
|
|
8b5f15333a | |
|
|
ba996998e8 | |
|
|
7d520c7aaf | |
|
|
f060d4dedd | |
|
|
eac010d3d4 | |
|
|
4ed63cac4e | |
|
|
c4032e3040 | |
|
|
5f2d543cab | |
|
|
21a9d2b809 | |
|
|
4fe6d091e0 | |
|
|
a9f2c53e89 | |
|
|
7209d2caab | |
|
|
597480adb9 | |
|
|
d3b47326da | |
|
|
1a20e19a93 | |
|
|
3c19c3175f | |
|
|
237c7964cd | |
|
|
b3c6b37399 | |
|
|
eff71d7258 | |
|
|
ad1d9c34ea | |
|
|
52bd017d82 | |
|
|
cf6fca20aa | |
|
|
2642a522f2 | |
|
|
655ff7fc4a | |
|
|
a8b6f9f9ce |
|
|
@ -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
|
||||
- текст локализован
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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:-}
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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__"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",),
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
),
|
||||
]),
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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` }}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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` }}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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`,
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
|
|
|
|||
|
|
@ -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} / реальные даты задач` : "Выберите проект для календарного окна"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue