From f5c38bf5728d457866ca37cd7e0d3a6fd3508da0 Mon Sep 17 00:00:00 2001 From: ofried-acutis Date: Thu, 19 Mar 2026 17:19:42 +0100 Subject: [PATCH 1/2] feat: implement case entity linking across API and web --- .gitattributes | 5 +- apps/api/plane/app/serializers/__init__.py | 2 + .../plane/app/serializers/case_entity_link.py | 31 + apps/api/plane/app/urls/__init__.py | 2 + apps/api/plane/app/urls/case_entity_link.py | 25 + apps/api/plane/app/views/__init__.py | 2 + apps/api/plane/app/views/case/__init__.py | 2 + apps/api/plane/app/views/case/entity_link.py | 172 ++ .../api/plane/app/views/case/entity_search.py | 26 + apps/api/plane/app/views/issue/base.py | 8 + .../management/commands/seed_case_config.py | 104 ++ .../0121_caseentitylink_and_more.py | 41 + apps/api/plane/db/models/__init__.py | 2 + apps/api/plane/db/models/case_entity_link.py | 37 + apps/api/plane/utils/core_db_resolver.py | 107 ++ apps/api/plane/utils/entity_search.py | 137 ++ apps/web/ce/components/issues/header.tsx | 2 +- .../components/cases/entity-link-panel.tsx | 65 + .../cases/entity-search-dropdown.tsx | 206 +++ .../components/issues/issue-modal/base.tsx | 44 +- .../components/default-properties.tsx | 35 +- .../components/issues/issue-modal/form.tsx | 96 +- .../issues/peek-overview/properties.tsx | 3 + .../core/services/case/entity-link.service.ts | 88 + apps/web/core/services/case/index.ts | 1 + packages/i18n/src/locales/es/translations.ts | 654 ++++--- packages/types/src/cases/case-entity-link.ts | 47 + packages/types/src/cases/index.ts | 1 + packages/types/src/index.ts | 1 + packages/ui/src/collapsible/collapsible.tsx | 16 +- plans/keyboard-nav-plan.md | 564 ++++++ plans/plan.md | 1238 +++++++++++++ plans/plane-extensibility-research.md | 1647 +++++++++++++++++ plans/policy-claims-searching.md | 544 ++++++ plans/project-case-type-plan.md | 437 +++++ plans/work-model-research.md | 1083 +++++++++++ 36 files changed, 7187 insertions(+), 288 deletions(-) create mode 100644 apps/api/plane/app/serializers/case_entity_link.py create mode 100644 apps/api/plane/app/urls/case_entity_link.py create mode 100644 apps/api/plane/app/views/case/__init__.py create mode 100644 apps/api/plane/app/views/case/entity_link.py create mode 100644 apps/api/plane/app/views/case/entity_search.py create mode 100644 apps/api/plane/db/management/commands/seed_case_config.py create mode 100644 apps/api/plane/db/migrations/0121_caseentitylink_and_more.py create mode 100644 apps/api/plane/db/models/case_entity_link.py create mode 100644 apps/api/plane/utils/core_db_resolver.py create mode 100644 apps/api/plane/utils/entity_search.py create mode 100644 apps/web/core/components/cases/entity-link-panel.tsx create mode 100644 apps/web/core/components/cases/entity-search-dropdown.tsx create mode 100644 apps/web/core/services/case/entity-link.service.ts create mode 100644 apps/web/core/services/case/index.ts create mode 100644 packages/types/src/cases/case-entity-link.ts create mode 100644 packages/types/src/cases/index.ts create mode 100644 plans/keyboard-nav-plan.md create mode 100644 plans/plan.md create mode 100644 plans/plane-extensibility-research.md create mode 100644 plans/policy-claims-searching.md create mode 100644 plans/project-case-type-plan.md create mode 100644 plans/work-model-research.md diff --git a/.gitattributes b/.gitattributes index 526c8a38d4a..8f775a41db8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,4 @@ -*.sh text eol=lf \ No newline at end of file +*.sh text eol=lf +apps/api/plane/db/models/__init__.py merge=ours +apps/api/plane/app/urls/__init__.py merge=ours +apps/api/plane/api/urls/__init__.py merge=ours \ No newline at end of file diff --git a/apps/api/plane/app/serializers/__init__.py b/apps/api/plane/app/serializers/__init__.py index e8a4007ea61..f9f203f481c 100644 --- a/apps/api/plane/app/serializers/__init__.py +++ b/apps/api/plane/app/serializers/__init__.py @@ -133,3 +133,5 @@ DraftIssueSerializer, DraftIssueDetailSerializer, ) + +from .case_entity_link import CaseEntityLinkSerializer, CaseEntityLinkCreateSerializer diff --git a/apps/api/plane/app/serializers/case_entity_link.py b/apps/api/plane/app/serializers/case_entity_link.py new file mode 100644 index 00000000000..fc2bbb57dd3 --- /dev/null +++ b/apps/api/plane/app/serializers/case_entity_link.py @@ -0,0 +1,31 @@ +from rest_framework import serializers +from plane.app.serializers.base import BaseSerializer +from plane.db.models import CaseEntityLink + + +class CaseEntityLinkSerializer(BaseSerializer): + label = serializers.CharField(read_only=True, required=False) + + class Meta: + model = CaseEntityLink + fields = [ + "id", + "issue", + "entity_type", + "entity_id", + "role", + "label", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "issue", "label", "created_at", "updated_at"] + + +class CaseEntityLinkCreateSerializer(BaseSerializer): + class Meta: + model = CaseEntityLink + fields = [ + "entity_type", + "entity_id", + "role", + ] diff --git a/apps/api/plane/app/urls/__init__.py b/apps/api/plane/app/urls/__init__.py index 3fa850b6abd..3b8a799f04c 100644 --- a/apps/api/plane/app/urls/__init__.py +++ b/apps/api/plane/app/urls/__init__.py @@ -22,6 +22,7 @@ from .workspace import urlpatterns as workspace_urls from .timezone import urlpatterns as timezone_urls from .exporter import urlpatterns as exporter_urls +from .case_entity_link import urlpatterns as case_entity_link_urls urlpatterns = [ *analytic_urls, @@ -44,4 +45,5 @@ *webhook_urls, *timezone_urls, *exporter_urls, + *case_entity_link_urls, ] diff --git a/apps/api/plane/app/urls/case_entity_link.py b/apps/api/plane/app/urls/case_entity_link.py new file mode 100644 index 00000000000..f83653e001e --- /dev/null +++ b/apps/api/plane/app/urls/case_entity_link.py @@ -0,0 +1,25 @@ +from django.urls import path +from plane.app.views import CaseEntityLinkViewSet, ResolveEntityEndpoint, EntitySearchEndpoint + +urlpatterns = [ + path( + "workspaces//projects//issues//entity-links/", + CaseEntityLinkViewSet.as_view({"get": "list", "post": "create"}), + name="case-entity-links", + ), + path( + "workspaces//projects//issues//entity-links//", + CaseEntityLinkViewSet.as_view({"patch": "partial_update", "delete": "destroy"}), + name="case-entity-link-detail", + ), + path( + "workspaces//projects//resolve-entity/", + ResolveEntityEndpoint.as_view(), + name="resolve-entity", + ), + path( + "workspaces//projects//search-entities/", + EntitySearchEndpoint.as_view(), + name="search-entities", + ), +] diff --git a/apps/api/plane/app/views/__init__.py b/apps/api/plane/app/views/__init__.py index baa6661b9cc..0773c5b9417 100644 --- a/apps/api/plane/app/views/__init__.py +++ b/apps/api/plane/app/views/__init__.py @@ -238,3 +238,5 @@ from .user.base import AccountEndpoint, ProfileEndpoint, UserSessionEndpoint from .timezone.base import TimezoneEndpoint + +from .case import CaseEntityLinkViewSet, ResolveEntityEndpoint, EntitySearchEndpoint diff --git a/apps/api/plane/app/views/case/__init__.py b/apps/api/plane/app/views/case/__init__.py new file mode 100644 index 00000000000..d1326c6b157 --- /dev/null +++ b/apps/api/plane/app/views/case/__init__.py @@ -0,0 +1,2 @@ +from .entity_link import CaseEntityLinkViewSet, ResolveEntityEndpoint +from .entity_search import EntitySearchEndpoint diff --git a/apps/api/plane/app/views/case/entity_link.py b/apps/api/plane/app/views/case/entity_link.py new file mode 100644 index 00000000000..dcd4a9427b7 --- /dev/null +++ b/apps/api/plane/app/views/case/entity_link.py @@ -0,0 +1,172 @@ +from rest_framework import status +from rest_framework.response import Response + +from plane.app.permissions import ROLE, allow_permission +from plane.app.serializers import ( + CaseEntityLinkSerializer, + CaseEntityLinkCreateSerializer, +) +from plane.app.views.base import BaseViewSet, BaseAPIView +from plane.db.models import CaseEntityLink, Issue +from plane.utils.core_db_resolver import ( + resolve_case_links, + resolve_entity, + resolve_entity_label, + get_claim_policy_id, +) + + +class CaseEntityLinkViewSet(BaseViewSet): + serializer_class = CaseEntityLinkSerializer + model = CaseEntityLink + + def get_queryset(self): + return CaseEntityLink.objects.filter( + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + issue_id=self.kwargs.get("issue_id"), + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def list(self, request, slug, project_id, issue_id): + links = self.get_queryset() + resolved = resolve_case_links(links) + return Response(resolved, status=status.HTTP_200_OK) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def create(self, request, slug, project_id, issue_id): + serializer = CaseEntityLinkCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + entity_type = serializer.validated_data["entity_type"] + entity_id = serializer.validated_data["entity_id"] + role = serializer.validated_data.get("role", "primary") + + resolved = resolve_entity(entity_type, entity_id) + if not resolved: + return Response( + {"error": f"{entity_type} {entity_id} not found in cima"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + issue = Issue.objects.get(id=issue_id) + + if entity_type == "claim": + claim_policy_id = get_claim_policy_id(entity_id) + if claim_policy_id: + existing_policy = self.get_queryset().filter( + entity_type="policy", role="primary" + ).first() + if existing_policy and str(existing_policy.entity_id) != claim_policy_id: + existing_policy.delete() + CaseEntityLink.objects.create( + issue_id=issue_id, + project_id=project_id, + workspace=issue.workspace, + entity_type="policy", + entity_id=claim_policy_id, + role="primary", + ) + elif not existing_policy: + CaseEntityLink.objects.create( + issue_id=issue_id, + project_id=project_id, + workspace=issue.workspace, + entity_type="policy", + entity_id=claim_policy_id, + role="primary", + ) + + link = CaseEntityLink.objects.create( + issue_id=issue_id, + project_id=project_id, + workspace=issue.workspace, + entity_type=entity_type, + entity_id=entity_id, + role=role, + ) + + return Response( + { + "id": str(link.id), + "entity_type": link.entity_type, + "entity_id": str(link.entity_id), + "role": link.role, + "label": resolve_entity_label(entity_type, entity_id), + "detail": resolved, + }, + status=status.HTTP_201_CREATED, + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def partial_update(self, request, slug, project_id, issue_id, pk): + link = self.get_queryset().filter(id=pk).first() + if not link: + return Response(status=status.HTTP_404_NOT_FOUND) + + serializer = CaseEntityLinkCreateSerializer(link, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response( + { + "id": str(link.id), + "entity_type": link.entity_type, + "entity_id": str(link.entity_id), + "role": link.role, + "label": resolve_entity_label(link.entity_type, link.entity_id), + "detail": resolve_entity(link.entity_type, link.entity_id), + }, + status=status.HTTP_200_OK, + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def destroy(self, request, slug, project_id, issue_id, pk): + link = self.get_queryset().filter(id=pk).first() + if not link: + return Response(status=status.HTTP_404_NOT_FOUND) + + sub_issues = Issue.issue_objects.filter(parent_id=issue_id) + started_states = ["started", "completed"] + has_started_work = sub_issues.filter( + state__group__in=started_states + ).exists() + + if has_started_work: + return Response( + {"error": "Cannot remove entity link after work has started on sub-items"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + link.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ResolveEntityEndpoint(BaseAPIView): + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def get(self, request, slug, project_id): + entity_type = request.query_params.get("entity_type") + entity_id = request.query_params.get("entity_id") + + if not entity_type or not entity_id: + return Response( + {"error": "entity_type and entity_id are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + resolved = resolve_entity(entity_type, entity_id) + if not resolved: + return Response( + {"error": f"{entity_type} {entity_id} not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + return Response( + { + "entity_type": entity_type, + "entity_id": entity_id, + "label": resolve_entity_label(entity_type, entity_id), + "detail": resolved, + }, + status=status.HTTP_200_OK, + ) diff --git a/apps/api/plane/app/views/case/entity_search.py b/apps/api/plane/app/views/case/entity_search.py new file mode 100644 index 00000000000..cf8e8681879 --- /dev/null +++ b/apps/api/plane/app/views/case/entity_search.py @@ -0,0 +1,26 @@ +from rest_framework import status +from rest_framework.response import Response + +from plane.app.permissions import ROLE, allow_permission +from plane.app.views.base import BaseAPIView +from plane.utils.entity_search import search_policies, search_claims + + +class EntitySearchEndpoint(BaseAPIView): + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def get(self, request, slug, project_id): + entity_type = request.query_params.get("type", "policy") + query = request.query_params.get("q", "").strip() + limit = min(int(request.query_params.get("limit", "10")), 20) + + if len(query) < 2: + return Response({"results": []}, status=status.HTTP_200_OK) + + if entity_type == "policy": + results = search_policies(query, limit) + elif entity_type == "claim": + results = search_claims(query, limit) + else: + return Response({"results": []}, status=status.HTTP_200_OK) + + return Response({"results": results}, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/issue/base.py b/apps/api/plane/app/views/issue/base.py index 98a59b6481c..24782688715 100644 --- a/apps/api/plane/app/views/issue/base.py +++ b/apps/api/plane/app/views/issue/base.py @@ -269,6 +269,14 @@ def list(self, request, slug, project_id): # Apply legacy filters issue_queryset = issue_queryset.filter(**filters, **extra_filters) + # Filter by linked entity (CaseEntityLink) + entity_id = request.GET.get("entity_id") + if entity_id: + issue_queryset = issue_queryset.filter( + entity_links__entity_id=entity_id, + entity_links__deleted_at__isnull=True, + ) + # Keeping a copy of the queryset before applying annotations filtered_issue_queryset = copy.deepcopy(issue_queryset) diff --git a/apps/api/plane/db/management/commands/seed_case_config.py b/apps/api/plane/db/management/commands/seed_case_config.py new file mode 100644 index 00000000000..07e88fb3856 --- /dev/null +++ b/apps/api/plane/db/management/commands/seed_case_config.py @@ -0,0 +1,104 @@ +from django.core.management.base import BaseCommand +from plane.db.models import ( + State, + Project, + ProjectMember, +) +from plane.db.models.workspace import Workspace, WorkspaceMember + + +CASE_PROJECTS = [ + {"name": "Siniestros", "identifier": "SIN", "description": "Casos de siniestros de seguros", "emoji": "📋"}, + {"name": "Renovaciones", "identifier": "RNV", "description": "Casos de renovación de pólizas", "emoji": "🔄"}, + {"name": "Endosos", "identifier": "END", "description": "Casos de modificación / endoso de pólizas", "emoji": "📝"}, + {"name": "Facturación", "identifier": "FAC", "description": "Casos de facturación / pagos", "emoji": "💳"}, + {"name": "General", "identifier": "GEN", "description": "Solicitudes de servicio general", "emoji": "📩"}, +] + +CASE_STATES = [ + {"name": "Nuevo", "group": "backlog", "color": "#3B82F6"}, + {"name": "Clasificado", "group": "unstarted", "color": "#8B5CF6"}, + {"name": "En Progreso", "group": "started", "color": "#F59E0B"}, + {"name": "Esperando Cliente", "group": "started", "color": "#F97316"}, + {"name": "Esperando Aseguradora", "group": "started", "color": "#F97316"}, + {"name": "Esperando Interno", "group": "started", "color": "#F97316"}, + {"name": "Programado", "group": "started", "color": "#06B6D4"}, + {"name": "Listo para Revisión", "group": "started", "color": "#6366F1"}, + {"name": "Resuelto", "group": "completed", "color": "#22C55E"}, + {"name": "Cerrado", "group": "completed", "color": "#6B7280"}, + {"name": "Cancelado", "group": "cancelled", "color": "#9CA3AF"}, +] + + +class Command(BaseCommand): + help = "Create case type projects with insurance-specific states" + + def add_arguments(self, parser): + parser.add_argument("workspace_slug", type=str) + + def handle(self, *args, **options): + slug = options["workspace_slug"] + + try: + workspace = Workspace.objects.get(slug=slug) + except Workspace.DoesNotExist: + self.stderr.write(self.style.ERROR(f"Workspace '{slug}' not found")) + return + + owner = WorkspaceMember.objects.filter( + workspace=workspace, role=20 + ).first() + if not owner: + self.stderr.write(self.style.ERROR("No workspace admin found")) + return + + for cp in CASE_PROJECTS: + project, created = Project.objects.get_or_create( + workspace=workspace, + identifier=cp["identifier"], + defaults={ + "name": cp["name"], + "description": cp["description"], + "emoji": cp["emoji"], + "network": 2, + }, + ) + verb = "Created" if created else "Already exists" + self.stdout.write(f"{verb}: {cp['name']} ({cp['identifier']})") + + ProjectMember.objects.get_or_create( + project=project, + member=owner.member, + defaults={"role": 20}, + ) + + has_custom_states = State.objects.filter( + project=project, name="New" + ).exists() + if has_custom_states: + self.stdout.write(f" States already configured for {cp['identifier']}") + continue + + State.objects.filter(project=project).delete() + + for i, st in enumerate(CASE_STATES): + State( + project=project, + workspace=workspace, + name=st["name"], + group=st["group"], + color=st["color"], + sequence=float((i + 1) * 15000), + default=(i == 0), + ).save() + + default_state = State.objects.filter( + project=project, default=True + ).first() + if default_state: + project.default_state = default_state + project.save(update_fields=["default_state"]) + + self.stdout.write(f" Configured {len(CASE_STATES)} states for {cp['identifier']}") + + self.stdout.write(self.style.SUCCESS("Seed complete.")) diff --git a/apps/api/plane/db/migrations/0121_caseentitylink_and_more.py b/apps/api/plane/db/migrations/0121_caseentitylink_and_more.py new file mode 100644 index 00000000000..15110aac830 --- /dev/null +++ b/apps/api/plane/db/migrations/0121_caseentitylink_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 4.2.29 on 2026-03-19 13:14 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0120_issueview_archived_at'), + ] + + operations = [ + migrations.CreateModel( + name='CaseEntityLink', + 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)), + ('entity_type', models.CharField(choices=[('policy', 'Policy'), ('claim', 'Claim')], max_length=50)), + ('entity_id', models.UUIDField()), + ('role', models.CharField(choices=[('primary', 'Primary'), ('related', 'Related')], default='primary', max_length=30)), + ('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')), + ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='entity_links', to='db.issue')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', 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.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')), + ], + options={ + 'db_table': 'case_entity_links', + 'ordering': ('-created_at',), + }, + ), + migrations.AddConstraint( + model_name='caseentitylink', + constraint=models.UniqueConstraint(condition=models.Q(('deleted_at__isnull', True)), fields=('issue', 'entity_type', 'entity_id'), name='case_entity_link_unique_active'), + ), + ] diff --git a/apps/api/plane/db/models/__init__.py b/apps/api/plane/db/models/__init__.py index 5cf9dec2a3e..4ab995d805a 100644 --- a/apps/api/plane/db/models/__init__.py +++ b/apps/api/plane/db/models/__init__.py @@ -90,3 +90,5 @@ from .sticky import Sticky from .description import Description, DescriptionVersion + +from .case_entity_link import CaseEntityLink diff --git a/apps/api/plane/db/models/case_entity_link.py b/apps/api/plane/db/models/case_entity_link.py new file mode 100644 index 00000000000..10cf62c4208 --- /dev/null +++ b/apps/api/plane/db/models/case_entity_link.py @@ -0,0 +1,37 @@ +from django.db import models +from django.db.models import Q +from plane.db.models.project import ProjectBaseModel + + +class CaseEntityLink(ProjectBaseModel): + ENTITY_TYPE_CHOICES = ( + ("policy", "Policy"), + ("claim", "Claim"), + ) + ROLE_CHOICES = ( + ("primary", "Primary"), + ("related", "Related"), + ) + + issue = models.ForeignKey( + "db.Issue", + on_delete=models.CASCADE, + related_name="entity_links", + ) + entity_type = models.CharField(max_length=50, choices=ENTITY_TYPE_CHOICES) + entity_id = models.UUIDField() + role = models.CharField(max_length=30, choices=ROLE_CHOICES, default="primary") + + class Meta: + db_table = "case_entity_links" + ordering = ("-created_at",) + constraints = [ + models.UniqueConstraint( + fields=["issue", "entity_type", "entity_id"], + condition=Q(deleted_at__isnull=True), + name="case_entity_link_unique_active", + ) + ] + + def __str__(self): + return f"{self.entity_type}:{self.entity_id} -> Issue {self.issue_id}" diff --git a/apps/api/plane/utils/core_db_resolver.py b/apps/api/plane/utils/core_db_resolver.py new file mode 100644 index 00000000000..906493f5cd8 --- /dev/null +++ b/apps/api/plane/utils/core_db_resolver.py @@ -0,0 +1,107 @@ +from django.db import connection + + +def resolve_policy(policy_id): + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT p.id, p.policy_number, + ic.name AS insurer_name, + pv.policy_status AS status + FROM cima.policies p + JOIN cima.policy_versions pv ON pv.id = p.active_version_id + LEFT JOIN ref.insurance_companies ic ON ic.id = pv.insurance_company_id + WHERE p.id = %s + """, + [str(policy_id)], + ) + row = cursor.fetchone() + if not row: + return None + return { + "id": str(row[0]), + "policy_number": row[1], + "insurer_name": row[2], + "status": row[3], + } + + +def resolve_claim(claim_id): + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT c.id, c.claim_number, c.claim_status, + c.policy_id + FROM cima.claims c + WHERE c.id = %s + """, + [str(claim_id)], + ) + row = cursor.fetchone() + if not row: + return None + return { + "id": str(row[0]), + "claim_number": row[1], + "status": row[2], + "policy_id": str(row[3]) if row[3] else None, + } + + +def resolve_policy_label(policy_id): + data = resolve_policy(policy_id) + if not data: + return f"Unknown policy ({policy_id})" + parts = [data["policy_number"]] + if data.get("insurer_name"): + parts.append(data["insurer_name"]) + if data.get("status"): + parts.append(data["status"]) + return f"{parts[0]} ({', '.join(parts[1:])})" + + +def resolve_claim_label(claim_id): + data = resolve_claim(claim_id) + if not data: + return f"Unknown claim ({claim_id})" + return f"{data['claim_number']} ({data.get('status', 'Unknown')})" + + +def get_claim_policy_id(claim_id): + data = resolve_claim(claim_id) + if not data: + return None + return data.get("policy_id") + + +def resolve_entity(entity_type, entity_id): + if entity_type == "policy": + return resolve_policy(entity_id) + elif entity_type == "claim": + return resolve_claim(entity_id) + return None + + +def resolve_entity_label(entity_type, entity_id): + if entity_type == "policy": + return resolve_policy_label(entity_id) + elif entity_type == "claim": + return resolve_claim_label(entity_id) + return f"Unknown {entity_type}" + + +def resolve_case_links(links): + results = [] + for link in links: + results.append( + { + "id": str(link.id), + "entity_type": link.entity_type, + "entity_id": str(link.entity_id), + "role": link.role, + "label": resolve_entity_label(link.entity_type, link.entity_id), + "detail": resolve_entity(link.entity_type, link.entity_id), + "created_at": link.created_at.isoformat() if link.created_at else None, + } + ) + return results diff --git a/apps/api/plane/utils/entity_search.py b/apps/api/plane/utils/entity_search.py new file mode 100644 index 00000000000..1b55825e40c --- /dev/null +++ b/apps/api/plane/utils/entity_search.py @@ -0,0 +1,137 @@ +from django.db import connection + + +def search_policies(query, limit=10): + q_contains = f"%{query}%" + q_prefix = f"{query}%" + + sql = """ + SELECT DISTINCT ON (p.id) + p.id, + 'policy' AS entity_type, + p.policy_number AS primary_label, + ic.name AS company, + COALESCE(pr.variant_description, pr.insurance_line) AS ramo, + COALESCE( + NULLIF(TRIM(CONCAT_WS(' ', ind_holder.first_name, ind_holder.last_name_1, ind_holder.last_name_2)), ''), + org_holder.legal_name + ) AS secondary_label, + CASE + WHEN p.policy_number ILIKE %(q_prefix)s THEN 1 + WHEN eo_holder.identification_number ILIKE %(q_contains)s THEN 2 + WHEN CONCAT_WS(' ', ind_holder.first_name, ind_holder.last_name_1, ind_holder.last_name_2) ILIKE %(q_contains)s + OR org_holder.legal_name ILIKE %(q_contains)s THEN 3 + WHEN CONCAT_WS(' ', ind_insured.first_name, ind_insured.last_name_1, ind_insured.last_name_2) ILIKE %(q_contains)s + OR org_insured.legal_name ILIKE %(q_contains)s THEN 4 + WHEN vo.license_plate_number ILIKE %(q_contains)s THEN 5 + WHEN vo.vin ILIKE %(q_contains)s THEN 6 + WHEN ic.name ILIKE %(q_contains)s THEN 7 + ELSE 99 + END AS match_priority, + CASE + WHEN p.policy_number ILIKE %(q_prefix)s THEN 'policy_number' + WHEN eo_holder.identification_number ILIKE %(q_contains)s THEN 'holder_id' + WHEN CONCAT_WS(' ', ind_holder.first_name, ind_holder.last_name_1, ind_holder.last_name_2) ILIKE %(q_contains)s + OR org_holder.legal_name ILIKE %(q_contains)s THEN 'holder_name' + WHEN CONCAT_WS(' ', ind_insured.first_name, ind_insured.last_name_1, ind_insured.last_name_2) ILIKE %(q_contains)s + OR org_insured.legal_name ILIKE %(q_contains)s THEN 'insured_name' + WHEN vo.license_plate_number ILIKE %(q_contains)s THEN 'license_plate' + WHEN vo.vin ILIKE %(q_contains)s THEN 'vin' + WHEN ic.name ILIKE %(q_contains)s THEN 'company' + ELSE 'unknown' + END AS match_field + FROM cima.policies p + JOIN cima.policy_versions pv ON pv.id = p.active_version_id + LEFT JOIN cima.entity_observations eo_holder ON eo_holder.id = pv.policy_holder_entity_observation_id + LEFT JOIN cima.individuals ind_holder ON ind_holder.entity_observation_id = eo_holder.id + LEFT JOIN cima.organizations org_holder ON org_holder.entity_observation_id = eo_holder.id + LEFT JOIN cima.entity_observations eo_insured ON eo_insured.id = pv.insured_entity + LEFT JOIN cima.individuals ind_insured ON ind_insured.entity_observation_id = eo_insured.id + LEFT JOIN cima.organizations org_insured ON org_insured.entity_observation_id = eo_insured.id + LEFT JOIN ref.insurance_companies ic ON ic.id = p.insurance_company_id + LEFT JOIN cima.products pr ON pr.id = pv.product_id + LEFT JOIN cima.insured_risks ir ON ir.policy_version_id = pv.id + LEFT JOIN cima.auto_risks ar ON ar.insured_risk_id = ir.id + LEFT JOIN cima.vehicle_observations vo ON vo.id = ar.vehicle_id + WHERE + p.policy_number ILIKE %(q_prefix)s + OR eo_holder.identification_number ILIKE %(q_contains)s + OR CONCAT_WS(' ', ind_holder.first_name, ind_holder.last_name_1, ind_holder.last_name_2) ILIKE %(q_contains)s + OR org_holder.legal_name ILIKE %(q_contains)s + OR CONCAT_WS(' ', ind_insured.first_name, ind_insured.last_name_1, ind_insured.last_name_2) ILIKE %(q_contains)s + OR org_insured.legal_name ILIKE %(q_contains)s + OR vo.license_plate_number ILIKE %(q_contains)s + OR vo.vin ILIKE %(q_contains)s + OR ic.name ILIKE %(q_contains)s + ORDER BY p.id, match_priority + LIMIT %(limit)s + """ + + try: + with connection.cursor() as cursor: + cursor.execute(sql, {"q_prefix": q_prefix, "q_contains": q_contains, "limit": limit}) + columns = [col[0] for col in cursor.description] + return [dict(zip(columns, row)) for row in cursor.fetchall()] + except Exception: + return [] + + +def search_claims(query, limit=10): + q_contains = f"%{query}%" + q_prefix = f"{query}%" + + sql = """ + SELECT DISTINCT ON (c.id) + c.id, + 'claim' AS entity_type, + c.insurance_company_reference_id AS primary_label, + ic.name AS company, + COALESCE(pr.variant_description, pr.insurance_line) AS ramo, + COALESCE( + NULLIF(TRIM(CONCAT_WS(' ', ind_holder.first_name, ind_holder.last_name_1, ind_holder.last_name_2)), ''), + org_holder.legal_name + ) AS secondary_label, + CASE + WHEN c.insurance_company_reference_id ILIKE %(q_prefix)s THEN 1 + WHEN c.broker_reference_id ILIKE %(q_contains)s THEN 2 + WHEN p.policy_number ILIKE %(q_prefix)s THEN 3 + WHEN CONCAT_WS(' ', ind_holder.first_name, ind_holder.last_name_1, ind_holder.last_name_2) ILIKE %(q_contains)s + OR org_holder.legal_name ILIKE %(q_contains)s THEN 4 + WHEN c.description ILIKE %(q_contains)s THEN 5 + ELSE 99 + END AS match_priority, + CASE + WHEN c.insurance_company_reference_id ILIKE %(q_prefix)s THEN 'claim_number' + WHEN c.broker_reference_id ILIKE %(q_contains)s THEN 'broker_ref' + WHEN p.policy_number ILIKE %(q_prefix)s THEN 'policy_number' + WHEN CONCAT_WS(' ', ind_holder.first_name, ind_holder.last_name_1, ind_holder.last_name_2) ILIKE %(q_contains)s + OR org_holder.legal_name ILIKE %(q_contains)s THEN 'holder_name' + WHEN c.description ILIKE %(q_contains)s THEN 'description' + ELSE 'unknown' + END AS match_field + FROM cima.claims c + LEFT JOIN cima.policies p ON p.id = c.policy_id + LEFT JOIN cima.policy_versions pv ON pv.id = p.active_version_id + LEFT JOIN cima.entity_observations eo_holder ON eo_holder.id = pv.policy_holder_entity_observation_id + LEFT JOIN cima.individuals ind_holder ON ind_holder.entity_observation_id = eo_holder.id + LEFT JOIN cima.organizations org_holder ON org_holder.entity_observation_id = eo_holder.id + LEFT JOIN ref.insurance_companies ic ON ic.id = p.insurance_company_id + LEFT JOIN cima.products pr ON pr.id = pv.product_id + WHERE + c.insurance_company_reference_id ILIKE %(q_prefix)s + OR c.broker_reference_id ILIKE %(q_contains)s + OR p.policy_number ILIKE %(q_prefix)s + OR CONCAT_WS(' ', ind_holder.first_name, ind_holder.last_name_1, ind_holder.last_name_2) ILIKE %(q_contains)s + OR org_holder.legal_name ILIKE %(q_contains)s + OR c.description ILIKE %(q_contains)s + ORDER BY c.id, match_priority + LIMIT %(limit)s + """ + + try: + with connection.cursor() as cursor: + cursor.execute(sql, {"q_prefix": q_prefix, "q_contains": q_contains, "limit": limit}) + columns = [col[0] for col in cursor.description] + return [dict(zip(columns, row)) for row in cursor.fetchall()] + except Exception: + return [] diff --git a/apps/web/ce/components/issues/header.tsx b/apps/web/ce/components/issues/header.tsx index 4871217bb99..55393db6d85 100644 --- a/apps/web/ce/components/issues/header.tsx +++ b/apps/web/ce/components/issues/header.tsx @@ -73,7 +73,7 @@ export const IssuesHeader = observer(function IssuesHeader() { } isLast diff --git a/apps/web/core/components/cases/entity-link-panel.tsx b/apps/web/core/components/cases/entity-link-panel.tsx new file mode 100644 index 00000000000..eff6a393292 --- /dev/null +++ b/apps/web/core/components/cases/entity-link-panel.tsx @@ -0,0 +1,65 @@ +import { useEffect, useState, useCallback } from "react"; +import { observer } from "mobx-react"; +import { Link2 } from "lucide-react"; +import type { TCaseEntityLink } from "@plane/types"; +import { EntityLinkService } from "@/services/case"; + +type Props = { + workspaceSlug: string; + projectId: string; + issueId: string; + disabled: boolean; +}; + +const entityLinkService = new EntityLinkService(); + +export const EntityLinkPanel = observer(function EntityLinkPanel({ + workspaceSlug, + projectId, + issueId, + disabled: _disabled, +}: Props) { + const [links, setLinks] = useState([]); + const [loading, setLoading] = useState(true); + + const fetchLinks = useCallback(() => { + if (!workspaceSlug || !projectId || !issueId) return; + setLoading(true); + entityLinkService + .list(workspaceSlug, projectId, issueId) + .then(setLinks) + .catch(() => setLinks([])) + .finally(() => setLoading(false)); + }, [workspaceSlug, projectId, issueId]); + + useEffect(() => { + fetchLinks(); + }, [fetchLinks]); + + if (loading || links.length === 0) return null; + + return ( +
+
+ + Entity Links +
+
+ {links.map((link) => ( +
+
+ {link.entity_type} + {link.label} +
+ + {link.role} + +
+ ))} +
+
+ ); +}); diff --git a/apps/web/core/components/cases/entity-search-dropdown.tsx b/apps/web/core/components/cases/entity-search-dropdown.tsx new file mode 100644 index 00000000000..3f6dd421fbf --- /dev/null +++ b/apps/web/core/components/cases/entity-search-dropdown.tsx @@ -0,0 +1,206 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Combobox } from "@headlessui/react"; +import { usePopper } from "react-popper"; +import { Search, X, Loader2 } from "lucide-react"; +import { cn } from "@plane/utils"; +import type { TEntitySearchResult } from "@plane/types"; +import { EntityLinkService } from "@/services/case"; + +const entityLinkService = new EntityLinkService(); + +type Props = { + entityType: "policy" | "claim"; + onEntityTypeChange: (type: "policy" | "claim") => void; + onSelect: (result: TEntitySearchResult | null) => void; + selected: TEntitySearchResult | null; + workspaceSlug: string; + projectId: string; +}; + +export function EntitySearchDropdown({ + entityType, + onEntityTypeChange, + onSelect, + selected, + workspaceSlug, + projectId, +}: Props) { + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const debounceRef = useRef | null>(null); + const inputRef = useRef(null); + + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: "bottom-start", + modifiers: [{ name: "preventOverflow", options: { padding: 12 } }], + }); + + const doSearch = useCallback( + (q: string) => { + if (q.length < 2 || !workspaceSlug || !projectId) { + setResults([]); + return; + } + setIsLoading(true); + entityLinkService + .search(workspaceSlug, projectId, entityType, q) + .then((data) => setResults(data.results)) + .catch(() => setResults([])) + .finally(() => setIsLoading(false)); + }, + [workspaceSlug, projectId, entityType] + ); + + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + if (query.length < 2) { + setResults([]); + return; + } + debounceRef.current = setTimeout(() => doSearch(query), 300); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [query, doSearch]); + + const handleSelect = (resultId: string) => { + const result = results.find((r) => r.id === resultId); + if (result) { + onSelect(result); + setQuery(""); + setResults([]); + setIsOpen(false); + } + }; + + const handleClear = () => { + onSelect(null); + setQuery(""); + setResults([]); + }; + + if (selected) { + return ( +
+ +
+ {selected.primary_label} + {selected.company && ( + <> + · + {selected.company} + + )} + {selected.ramo && ( + <> + · + {selected.ramo} + + )} + +
+
+ ); + } + + return ( +
+ +
+ +
+ + { + setQuery(e.target.value); + setIsOpen(true); + }} + onFocus={() => setIsOpen(true)} + placeholder={entityType === "policy" ? "Buscar pólizas..." : "Buscar siniestros..."} + autoComplete="off" + /> + {isLoading && } +
+ {isOpen && query.length >= 2 && ( + +
+
+ {isLoading ? ( +

Buscando...

+ ) : results.length > 0 ? ( + results.map((result) => ( + + cn( + "flex w-full cursor-pointer flex-col gap-0.5 truncate rounded-sm px-1.5 py-1.5 select-none", + active ? "bg-layer-transparent-hover" : "" + ) + } + > +
+ {result.primary_label} + {result.company && ( + <> + · + {result.company} + + )} + {result.ramo && ( + <> + · + {result.ramo} + + )} +
+ {result.secondary_label && ( + {result.secondary_label} + )} +
+ )) + ) : ( +

Sin resultados

+ )} +
+
+
+ )} +
+
+
+ ); +} diff --git a/apps/web/core/components/issues/issue-modal/base.tsx b/apps/web/core/components/issues/issue-modal/base.tsx index c9131dcc92a..960203f83c7 100644 --- a/apps/web/core/components/issues/issue-modal/base.tsx +++ b/apps/web/core/components/issues/issue-modal/base.tsx @@ -11,7 +11,7 @@ import { useParams } from "next/navigation"; // Plane imports import { useTranslation } from "@plane/i18n"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; -import type { TBaseIssue, TIssue } from "@plane/types"; +import type { TBaseIssue, TIssue, TCaseEntityLinkCreatePayload } from "@plane/types"; import { EIssuesStoreType } from "@plane/types"; import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; // hooks @@ -24,8 +24,10 @@ import { useProject } from "@/hooks/store/use-project"; import { useIssueStoreType } from "@/hooks/use-issue-layout-store"; import { useIssuesActions } from "@/hooks/use-issues-actions"; // services +import { EntityLinkService } from "@/services/case"; import { FileService } from "@/services/file.service"; const fileService = new FileService(); +const entityLinkService = new EntityLinkService(); // local imports import { CreateIssueToastActionItems } from "../create-issue-toast-action-items"; import { DraftIssueLayout } from "./draft-issue-layout"; @@ -66,9 +68,16 @@ export const CreateUpdateIssueModalBase = observer(function CreateUpdateIssueMod const [description, setDescription] = useState(undefined); const [uploadedAssetIds, setUploadedAssetIds] = useState([]); const [isDuplicateModalOpen, setIsDuplicateModalOpen] = useState(false); + const pendingEntityLinkRef = useRef(null); // store hooks const { t } = useTranslation(); - const { workspaceSlug, projectId: routerProjectId, cycleId, moduleId, workItem } = useParams(); + const { + workspaceSlug, + projectId: routerProjectId, + cycleId: routeCycleId, + moduleId: routeModuleId, + workItem, + } = useParams(); const { fetchCycleDetails } = useCycle(); const { fetchModuleDetails } = useModule(); const { issues } = useIssues(storeType); @@ -173,8 +182,8 @@ export const CreateUpdateIssueModalBase = observer(function CreateUpdateIssueMod // or if the moduleIds in Payload does not match the moduleId in url // use the project issue store to create issues else if ( - (payload.cycle_id !== cycleId && storeType === EIssuesStoreType.CYCLE) || - (!payload.module_ids?.includes(moduleId?.toString()) && storeType === EIssuesStoreType.MODULE) + (payload.cycle_id !== routeCycleId && storeType === EIssuesStoreType.CYCLE) || + (!payload.module_ids?.includes(routeModuleId?.toString()) && storeType === EIssuesStoreType.MODULE) ) { response = await projectIssues.createIssue(workspaceSlug.toString(), payload.project_id, payload); } // else just use the existing store type's create method @@ -202,14 +211,14 @@ export const CreateUpdateIssueModalBase = observer(function CreateUpdateIssueMod if ( payload.cycle_id && payload.cycle_id !== "" && - (payload.cycle_id !== cycleId || storeType !== EIssuesStoreType.CYCLE) + (payload.cycle_id !== routeCycleId || storeType !== EIssuesStoreType.CYCLE) ) { await addIssueToCycle(response, payload.cycle_id); } if ( payload.module_ids && payload.module_ids.length > 0 && - (!payload.module_ids.includes(moduleId?.toString()) || storeType !== EIssuesStoreType.MODULE) + (!payload.module_ids.includes(routeModuleId?.toString()) || storeType !== EIssuesStoreType.MODULE) ) { await addIssueToModule(response, payload.module_ids); } @@ -271,7 +280,7 @@ export const CreateUpdateIssueModalBase = observer(function CreateUpdateIssueMod if ( payload.cycle_id && payload.cycle_id !== "" && - (payload.cycle_id !== cycleId || storeType !== EIssuesStoreType.CYCLE) + (payload.cycle_id !== routeCycleId || storeType !== EIssuesStoreType.CYCLE) ) { await addIssueToCycle(data as TBaseIssue, payload.cycle_id); } @@ -345,6 +354,20 @@ export const CreateUpdateIssueModalBase = observer(function CreateUpdateIssueMod if (beforeFormSubmit) await beforeFormSubmit(); if (!data?.id) response = await handleCreateIssue(payload, is_draft_issue); else response = await handleUpdateIssue(payload); + + if (response && !data?.id && pendingEntityLinkRef.current && workspaceSlug && response.project_id) { + try { + await entityLinkService.create( + workspaceSlug.toString(), + response.project_id, + response.id, + pendingEntityLinkRef.current + ); + } catch { + console.error("Failed to create entity link"); + } + pendingEntityLinkRef.current = null; + } } finally { if (response != undefined && onSubmit) await onSubmit(response); } @@ -364,8 +387,8 @@ export const CreateUpdateIssueModalBase = observer(function CreateUpdateIssueMod data: { ...data, description_html: description, - cycle_id: data?.cycle_id ? data?.cycle_id : cycleId ? cycleId.toString() : null, - module_ids: data?.module_ids ? data?.module_ids : moduleId ? [moduleId.toString()] : null, + cycle_id: data?.cycle_id ? data?.cycle_id : routeCycleId ? routeCycleId.toString() : null, + module_ids: data?.module_ids ? data?.module_ids : routeModuleId ? [routeModuleId.toString()] : null, }, onAssetUpload: handleUpdateUploadedAssetIds, onClose: handleClose, @@ -380,6 +403,9 @@ export const CreateUpdateIssueModalBase = observer(function CreateUpdateIssueMod isDuplicateModalOpen: isDuplicateModalOpen, handleDuplicateIssueModal: handleDuplicateIssueModal, isProjectSelectionDisabled: isProjectSelectionDisabled, + onEntityLinkChange: (linkData) => { + pendingEntityLinkRef.current = linkData; + }, }; return ( diff --git a/apps/web/core/components/issues/issue-modal/components/default-properties.tsx b/apps/web/core/components/issues/issue-modal/components/default-properties.tsx index dee03b21dde..edd6ae78e01 100644 --- a/apps/web/core/components/issues/issue-modal/components/default-properties.tsx +++ b/apps/web/core/components/issues/issue-modal/components/default-properties.tsx @@ -8,11 +8,12 @@ import { useState } from "react"; import { observer } from "mobx-react"; import type { Control } from "react-hook-form"; import { Controller } from "react-hook-form"; +import { Link2 } from "lucide-react"; import { ETabIndices, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { ParentPropertyIcon } from "@plane/propel/icons"; // types -import type { ISearchIssueResponse, TIssue } from "@plane/types"; +import type { ISearchIssueResponse, TIssue, TCaseEntityLinkCreatePayload } from "@plane/types"; // ui import { CustomMenu } from "@plane/ui"; import { getDate, renderFormattedPayloadDate, getTabIndex } from "@plane/utils"; @@ -47,6 +48,7 @@ type TIssueDefaultPropertiesProps = { isDraft: boolean; handleFormChange: () => void; setSelectedParentIssue: (issue: ISearchIssueResponse) => void; + onEntityLinkChange?: (data: TCaseEntityLinkCreatePayload | null) => void; }; export const IssueDefaultProperties = observer(function IssueDefaultProperties(props: TIssueDefaultPropertiesProps) { @@ -62,9 +64,12 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p isDraft, handleFormChange, setSelectedParentIssue, + onEntityLinkChange, } = props; // states const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false); + const [entityLinkInput, setEntityLinkInput] = useState(""); + const [entityLinkType, setEntityLinkType] = useState<"policy" | "claim">("policy"); // store hooks const { t } = useTranslation(); const { areEstimateEnabledByProjectId } = useProjectEstimates(); @@ -338,6 +343,34 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p /> )} /> + {onEntityLinkChange && ( +
+ + { + setEntityLinkInput(e.target.value); + const val = e.target.value.trim(); + if (val) { + onEntityLinkChange({ entity_type: entityLinkType, entity_id: val }); + } else { + onEntityLinkChange(null); + } + }} + /> + {entityLinkInput && } +
+ )} ); }); diff --git a/apps/web/core/components/issues/issue-modal/form.tsx b/apps/web/core/components/issues/issue-modal/form.tsx index 01812b1bca9..ec7c76f85e3 100644 --- a/apps/web/core/components/issues/issue-modal/form.tsx +++ b/apps/web/core/components/issues/issue-modal/form.tsx @@ -4,7 +4,6 @@ * See the LICENSE file for details. */ -import type { FC } from "react"; import React, { useState, useRef, useEffect } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; @@ -16,8 +15,7 @@ import type { EditorRefApi } from "@plane/editor"; import { useTranslation } from "@plane/i18n"; import { Button } from "@plane/propel/button"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; -import type { TIssue, TWorkspaceDraftIssue } from "@plane/types"; -import { EIssuesStoreType } from "@plane/types"; +import type { TIssue, TWorkspaceDraftIssue, TCaseEntityLinkCreatePayload, TEntitySearchResult } from "@plane/types"; // hooks import { ToggleSwitch } from "@plane/ui"; import { @@ -46,6 +44,7 @@ import { useWorkspaceDraftIssues } from "@/hooks/store/workspace-draft"; import { usePlatformOS } from "@/hooks/use-platform-os"; import { useProjectIssueProperties } from "@/hooks/use-project-issue-properties"; // plane web imports +import { EntitySearchDropdown } from "@/components/cases/entity-search-dropdown"; import { DeDupeButtonRoot } from "@/plane-web/components/de-dupe/de-dupe-button"; import { DuplicateModalRoot } from "@/plane-web/components/de-dupe/duplicate-modal"; import { IssueTypeSelect, WorkItemTemplateSelect } from "@/plane-web/components/issues/issue-modal"; @@ -74,7 +73,8 @@ export interface IssueFormProps { handleDraftAndClose?: () => void; isProjectSelectionDisabled?: boolean; showActionButtons?: boolean; - dataResetProperties?: any[]; + dataResetProperties?: string[]; + onEntityLinkChange?: (data: TCaseEntityLinkCreatePayload | null) => void; } export const IssueFormRoot = observer(function IssueFormRoot(props: IssueFormProps) { @@ -102,11 +102,14 @@ export const IssueFormRoot = observer(function IssueFormRoot(props: IssueFormPro isProjectSelectionDisabled = false, showActionButtons = true, dataResetProperties = [], + onEntityLinkChange: onEntityLinkChangeProp, } = props; // states const [gptAssistantModal, setGptAssistantModal] = useState(false); const [isMoving, setIsMoving] = useState(false); + const [entityLinkType, setEntityLinkType] = useState<"policy" | "claim">("claim"); + const [selectedEntity, setSelectedEntity] = useState(null); // refs const editorRef = useRef(null); @@ -164,7 +167,7 @@ export const IssueFormRoot = observer(function IssueFormRoot(props: IssueFormPro }); // derived values - const projectDetails = projectId ? getProjectById(projectId) : undefined; + const currentProjectDetails = projectId ? getProjectById(projectId) : undefined; const isDisabled = isSubmitting || isApplyingTemplate; const { getIndex } = getTabIndex(ETabIndices.ISSUE_FORM, isMobile); @@ -253,29 +256,28 @@ export const IssueFormRoot = observer(function IssueFormRoot(props: IssueFormPro // this condition helps to move the issues from draft to project issues if (formData.hasOwnProperty("is_draft")) submitData.is_draft = formData.is_draft; - await onSubmit(submitData, is_draft_issue) - .then(() => { - setGptAssistantModal(false); - if (isCreateMoreToggleEnabled && workItemTemplateId) { - handleTemplateChange({ - workspaceSlug: workspaceSlug?.toString(), - reset, - editorRef, - }); - } else { - reset({ - ...DEFAULT_WORK_ITEM_FORM_VALUES, - ...(isCreateMoreToggleEnabled ? { ...data } : {}), - project_id: getValues<"project_id">("project_id"), - type_id: getValues<"type_id">("type_id"), - description_html: data?.description_html ?? "

", - }); - editorRef?.current?.clearEditor(); - } - }) - .catch((error) => { - console.error(error); - }); + try { + await onSubmit(submitData, is_draft_issue); + setGptAssistantModal(false); + if (isCreateMoreToggleEnabled && workItemTemplateId) { + handleTemplateChange({ + workspaceSlug: workspaceSlug?.toString(), + reset, + editorRef, + }); + } else { + reset({ + ...DEFAULT_WORK_ITEM_FORM_VALUES, + ...(isCreateMoreToggleEnabled ? { ...data } : {}), + project_id: getValues<"project_id">("project_id"), + type_id: getValues<"type_id">("type_id"), + description_html: data?.description_html ?? "

", + }); + editorRef?.current?.clearEditor(); + } + } catch (error) { + console.error(error); + } }; const handleMoveToProjects = async () => { @@ -318,7 +320,7 @@ export const IssueFormRoot = observer(function IssueFormRoot(props: IssueFormPro // debounced duplicate issues swr const { duplicateIssues } = useDebouncedDuplicateIssues( workspaceSlug?.toString(), - projectDetails?.workspace.toString(), + currentProjectDetails?.workspace.toString(), projectId ?? undefined, { name: watch("name"), @@ -336,15 +338,15 @@ export const IssueFormRoot = observer(function IssueFormRoot(props: IssueFormPro const issue = getIssueById(parentId); if (!issue) return; - const projectDetails = getProjectById(issue.project_id); - if (!projectDetails) return; + const parentProjectDetails = getProjectById(issue.project_id); + if (!parentProjectDetails) return; const stateDetails = getStateById(issue.state_id); setSelectedParentIssue( - convertWorkItemDataToSearchResponse(workspaceSlug?.toString(), issue, projectDetails, stateDetails) + convertWorkItemDataToSearchResponse(workspaceSlug?.toString(), issue, parentProjectDetails, stateDetails) ); - }, [watch, getIssueById, getProjectById, selectedParentIssue, getStateById]); + }, [watch, getIssueById, getProjectById, selectedParentIssue, getStateById, workspaceSlug, setSelectedParentIssue]); // executing this useEffect when isDirty changes useEffect(() => { @@ -382,7 +384,7 @@ export const IssueFormRoot = observer(function IssueFormRoot(props: IssueFormPro
handleFormSubmit(data))} + onSubmit={handleSubmit((formValues) => handleFormSubmit(formValues))} className="flex w-full flex-col" >
@@ -443,6 +445,23 @@ export const IssueFormRoot = observer(function IssueFormRoot(props: IssueFormPro />
)} + {!data?.id && projectId && workspaceSlug && ( +
+ { + setSelectedEntity(result); + onEntityLinkChangeProp?.( + result ? { entity_type: result.entity_type, entity_id: result.id } : null + ); + }} + selected={selectedEntity} + workspaceSlug={workspaceSlug.toString()} + projectId={projectId} + /> +
+ )}
{!data?.id && ( -
onCreateMoreToggleChange(!isCreateMoreToggleEnabled)} - onKeyDown={(e) => { - if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled); - }} - role="button" > {}} size="sm" /> {t("create_more")} -
+ )}
diff --git a/apps/web/core/components/issues/peek-overview/properties.tsx b/apps/web/core/components/issues/peek-overview/properties.tsx index 666f00e918b..d7fd0f708d8 100644 --- a/apps/web/core/components/issues/peek-overview/properties.tsx +++ b/apps/web/core/components/issues/peek-overview/properties.tsx @@ -36,6 +36,7 @@ import { useMember } from "@/hooks/store/use-member"; import { useProject } from "@/hooks/store/use-project"; import { useProjectState } from "@/hooks/store/use-project-state"; // plane web components +import { EntityLinkPanel } from "@/components/cases/entity-link-panel"; import { WorkItemAdditionalSidebarProperties } from "@/plane-web/components/issues/issue-details/additional-properties"; import { IssueParentSelectRoot } from "@/plane-web/components/issues/issue-details/parent-select-root"; import { DateAlert } from "@/plane-web/components/issues/issue-details/sidebar/date-alert"; @@ -268,6 +269,8 @@ export const PeekOverviewProperties = observer(function PeekOverviewProperties(p isEditable={!disabled} isPeekView /> + +
); diff --git a/apps/web/core/services/case/entity-link.service.ts b/apps/web/core/services/case/entity-link.service.ts new file mode 100644 index 00000000000..5b000a84c32 --- /dev/null +++ b/apps/web/core/services/case/entity-link.service.ts @@ -0,0 +1,88 @@ +import { API_BASE_URL } from "@plane/constants"; +import type { TCaseEntityLink, TCaseEntityLinkCreatePayload, TResolvedEntity, TEntitySearchResult } from "@plane/types"; +import { APIService } from "@/services/api.service"; + +export class EntityLinkService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async list(workspaceSlug: string, projectId: string, issueId: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/entity-links/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async create( + workspaceSlug: string, + projectId: string, + issueId: string, + data: TCaseEntityLinkCreatePayload + ): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/entity-links/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async update( + workspaceSlug: string, + projectId: string, + issueId: string, + linkId: string, + data: Partial + ): Promise { + return this.patch( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/entity-links/${linkId}/`, + data + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async remove(workspaceSlug: string, projectId: string, issueId: string, linkId: string): Promise { + return this.delete( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/entity-links/${linkId}/` + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async search( + workspaceSlug: string, + projectId: string, + entityType: "policy" | "claim", + query: string, + limit: number = 10 + ): Promise<{ results: TEntitySearchResult[] }> { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/search-entities/`, { + params: { type: entityType, q: query, limit }, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async resolveEntity( + workspaceSlug: string, + projectId: string, + entityType: string, + entityId: string + ): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/resolve-entity/`, { + params: { entity_type: entityType, entity_id: entityId }, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/apps/web/core/services/case/index.ts b/apps/web/core/services/case/index.ts new file mode 100644 index 00000000000..723f534b08c --- /dev/null +++ b/apps/web/core/services/case/index.ts @@ -0,0 +1 @@ +export { EntityLinkService } from "./entity-link.service"; diff --git a/packages/i18n/src/locales/es/translations.ts b/packages/i18n/src/locales/es/translations.ts index 0d25038f17b..34dcaf487a6 100644 --- a/packages/i18n/src/locales/es/translations.ts +++ b/packages/i18n/src/locales/es/translations.ts @@ -8,14 +8,14 @@ export default { sidebar: { projects: "Proyectos", pages: "Páginas", - new_work_item: "Nuevo elemento de trabajo", + new_work_item: "Nuevo caso", home: "Inicio", your_work: "Tu trabajo", inbox: "Bandeja de entrada", workspace: "Espacio de trabajo", views: "Vistas", analytics: "Análisis", - work_items: "Elementos de trabajo", + work_items: "Casos", cycles: "Ciclos", modules: "Módulos", intake: "Entrada", @@ -224,6 +224,8 @@ export default { activity: "Actividad", appearance: "Apariencia", notifications: "Notificaciones", + preferences: "Preferencias", + language_and_time: "Idioma y hora", connections: "Conexiones", workspaces: "Espacios de trabajo", create_workspace: "Crear espacio de trabajo", @@ -236,6 +238,13 @@ export default { something_went_wrong_please_try_again: "Algo salió mal. Por favor, inténtalo de nuevo.", load_more: "Cargar más", select_or_customize_your_interface_color_scheme: "Selecciona o personaliza el esquema de colores de tu interfaz.", + timezone_setting: "Configuración de zona horaria actual.", + language_setting: "Elige el idioma de la interfaz de usuario.", + settings_moved_to_preferences: "La configuración de zona horaria e idioma se ha movido a preferencias.", + go_to_preferences: "Ir a preferencias", + settings_description: + "Administra tu cuenta, espacio de trabajo y preferencias de proyecto en un solo lugar. Cambia entre pestañas para configurar fácilmente.", + back_to_workspace: "Volver al espacio de trabajo", theme: "Tema", system_preference: "Preferencia del sistema", light: "Claro", @@ -262,7 +271,7 @@ export default { failed_to_update_the_theme: "Error al actualizar el tema", email_notifications: "Notificaciones por correo electrónico", stay_in_the_loop_on_issues_you_are_subscribed_to_enable_this_to_get_notified: - "Mantente al tanto de los elementos de trabajo a los que estás suscrito. Activa esto para recibir notificaciones.", + "Mantente al tanto de los casos a los que estás suscrito. Activa esto para recibir notificaciones.", email_notification_setting_updated_successfully: "Configuración de notificaciones por correo electrónico actualizada exitosamente", failed_to_update_email_notification_setting: @@ -270,13 +279,13 @@ export default { notify_me_when: "Notificarme cuando", property_changes: "Cambios de propiedades", property_changes_description: - "Notificarme cuando cambien las propiedades de los elementos de trabajo como asignados, prioridad, estimaciones o cualquier otra cosa.", + "Notificarme cuando cambien las propiedades de los casos como asignados, prioridad, estimaciones o cualquier otra cosa.", state_change: "Cambio de estado", - state_change_description: "Notificarme cuando los elementos de trabajo se muevan a un estado diferente", - issue_completed: "Elemento de trabajo completado", - issue_completed_description: "Notificarme solo cuando se complete un elemento de trabajo", + state_change_description: "Notificarme cuando los casos se muevan a un estado diferente", + issue_completed: "Caso completado", + issue_completed_description: "Notificarme solo cuando se complete un caso", comments: "Comentarios", - comments_description: "Notificarme cuando alguien deje un comentario en el elemento de trabajo", + comments_description: "Notificarme cuando alguien deje un comentario en el caso", mentions: "Menciones", mentions_description: "Notificarme solo cuando alguien me mencione en los comentarios o descripción", old_password: "Contraseña anterior", @@ -285,7 +294,7 @@ export default { signing_out: "Cerrando sesión", active_cycles: "Ciclos activos", active_cycles_description: - "Monitorea ciclos en todos los proyectos, rastrea elementos de trabajo de alta prioridad y enfócate en los ciclos que necesitan atención.", + "Monitorea ciclos en todos los proyectos, rastrea casos de alta prioridad y enfócate en los ciclos que necesitan atención.", on_demand_snapshots_of_all_your_cycles: "Instantáneas bajo demanda de todos tus ciclos", upgrade: "Actualizar", "10000_feet_view": "Vista panorámica de todos los ciclos activos.", @@ -297,9 +306,9 @@ export default { compare_burndowns: "Compara los burndowns.", compare_burndowns_description: "Monitorea cómo se está desempeñando cada uno de tus equipos con un vistazo al informe de burndown de cada ciclo.", - quickly_see_make_or_break_issues: "Ve rápidamente los elementos de trabajo críticos.", + quickly_see_make_or_break_issues: "Ve rápidamente los casos críticos.", quickly_see_make_or_break_issues_description: - "Previsualiza elementos de trabajo de alta prioridad para cada ciclo contra fechas de vencimiento. Vélos todos por ciclo con un clic.", + "Previsualiza casos de alta prioridad para cada ciclo contra fechas de vencimiento. Vélos todos por ciclo con un clic.", zoom_into_cycles_that_need_attention: "Enfócate en los ciclos que necesitan atención.", zoom_into_cycles_that_need_attention_description: "Investiga el estado de cualquier ciclo que no se ajuste a las expectativas con un clic.", @@ -310,7 +319,7 @@ export default { workspace_invites: "Invitaciones al espacio de trabajo", enter_god_mode: "Entrar en modo dios", workspace_logo: "Logo del espacio de trabajo", - new_issue: "Nuevo elemento de trabajo", + new_issue: "Nuevo caso", your_work: "Tu trabajo", workspace_dashboards: "Paneles de control", drafts: "Borradores", @@ -340,8 +349,7 @@ export default { failed_to_remove_project_from_favorites: "No se pudo eliminar el proyecto de favoritos. Por favor, inténtalo de nuevo.", project_created_successfully: "Proyecto creado exitosamente", - project_created_successfully_description: - "Proyecto creado exitosamente. Ahora puedes comenzar a agregar elementos de trabajo.", + project_created_successfully_description: "Proyecto creado exitosamente. Ahora puedes comenzar a agregar casos.", project_name_already_taken: "El nombre del proyecto ya está en uso.", project_identifier_already_taken: "El identificador del proyecto ya está en uso.", project_cover_image_alt: "Imagen de portada del proyecto", @@ -351,8 +359,7 @@ export default { project_id_must_be_at_least_1_character: "El ID del proyecto debe tener al menos 1 carácter", project_id_must_be_at_most_5_characters: "El ID del proyecto debe tener como máximo 5 caracteres", project_id: "ID del proyecto", - project_id_tooltip_content: - "Te ayuda a identificar elementos de trabajo en el proyecto de manera única. Máximo 10 caracteres.", + project_id_tooltip_content: "Te ayuda a identificar casos en el proyecto de manera única. Máximo 10 caracteres.", description_placeholder: "Descripción", only_alphanumeric_non_latin_characters_allowed: "Solo se permiten caracteres alfanuméricos y no latinos.", project_id_is_required: "El ID del proyecto es requerido", @@ -381,19 +388,20 @@ export default { publish_project: "Publicar proyecto", publish: "Publicar", copy_link: "Copiar enlace", + copy_markdown: "Copiar markdown", leave_project: "Abandonar proyecto", join_the_project_to_rearrange: "Únete al proyecto para reorganizar", drag_to_rearrange: "Arrastra para reorganizar", congrats: "¡Felicitaciones!", open_project: "Abrir proyecto", - issues: "Elementos de trabajo", + issues: "Casos", cycles: "Ciclos", modules: "Módulos", pages: "Páginas", intake: "Entrada", time_tracking: "Seguimiento de tiempo", work_management: "Gestión del trabajo", - projects_and_issues: "Proyectos y elementos de trabajo", + projects_and_issues: "Proyectos y casos", projects_and_issues_description: "Activa o desactiva estos en este proyecto.", cycles_description: "Organiza el trabajo por proyecto en períodos de tiempo y ajusta la duración según sea necesario. Un ciclo puede ser de 2 semanas y el siguiente de 1 semana.", @@ -403,7 +411,7 @@ export default { pages_description: "Crea y edita contenido libre; notas, documentos, lo que sea.", intake_description: "Permite que personas ajenas al equipo compartan errores, comentarios y sugerencias sin interrumpir tu flujo de trabajo.", - time_tracking_description: "Registra el tiempo dedicado a elementos de trabajo y proyectos.", + time_tracking_description: "Registra el tiempo dedicado a casos y proyectos.", work_management_description: "Gestiona tu trabajo y proyectos con facilidad.", documentation: "Documentación", message_support: "Mensaje al soporte", @@ -439,30 +447,30 @@ export default { workspace_name: "nombre-del-espacio-de-trabajo", deactivate_your_account: "Desactivar tu cuenta", deactivate_your_account_description: - "Una vez desactivada, no se te podrán asignar elementos de trabajo ni se te facturará por tu espacio de trabajo. Para reactivar tu cuenta, necesitarás una invitación a un espacio de trabajo con esta dirección de correo electrónico.", + "Una vez desactivada, no se te podrán asignar casos ni se te facturará por tu espacio de trabajo. Para reactivar tu cuenta, necesitarás una invitación a un espacio de trabajo con esta dirección de correo electrónico.", deactivating: "Desactivando", confirm: "Confirmar", confirming: "Confirmando", draft_created: "Borrador creado", - issue_created_successfully: "Elemento de trabajo creado exitosamente", + issue_created_successfully: "Caso creado exitosamente", draft_creation_failed: "Error al crear borrador", - issue_creation_failed: "Error al crear elemento de trabajo", - draft_issue: "Borrador de elemento de trabajo", - issue_updated_successfully: "Elemento de trabajo actualizado exitosamente", - issue_could_not_be_updated: "El elemento de trabajo no pudo ser actualizado", + issue_creation_failed: "Error al crear caso", + draft_issue: "Borrador de caso", + issue_updated_successfully: "Caso actualizado exitosamente", + issue_could_not_be_updated: "El caso no pudo ser actualizado", create_a_draft: "Crear un borrador", save_to_drafts: "Guardar en borradores", save: "Guardar", update: "Actualizar", updating: "Actualizando", - create_new_issue: "Crear nuevo elemento de trabajo", + create_new_issue: "Crear nuevo caso", editor_is_not_ready_to_discard_changes: "El editor no está listo para descartar cambios", - failed_to_move_issue_to_project: "Error al mover elemento de trabajo al proyecto", + failed_to_move_issue_to_project: "Error al mover caso al proyecto", create_more: "Crear más", add_to_project: "Agregar al proyecto", discard: "Descartar", - duplicate_issue_found: "Se encontró un elemento de trabajo duplicado", - duplicate_issues_found: "Se encontraron elementos de trabajo duplicados", + duplicate_issue_found: "Se encontró un caso duplicado", + duplicate_issues_found: "Se encontraron casos duplicados", no_matching_results: "No hay resultados coincidentes", title_is_required: "El título es requerido", title: "Título", @@ -483,8 +491,8 @@ export default { end_date: "Fecha de fin", due_date: "Fecha de vencimiento", estimate: "Estimación", - change_parent_issue: "Cambiar elemento de trabajo padre", - remove_parent_issue: "Eliminar elemento de trabajo padre", + change_parent_issue: "Cambiar caso padre", + remove_parent_issue: "Eliminar caso padre", add_parent: "Agregar padre", loading_members: "Cargando miembros", view_link_copied_to_clipboard: "Enlace de vista copiado al portapapeles.", @@ -507,15 +515,15 @@ export default { show_less: "Mostrar menos", no_data_yet: "Aún no hay datos", syncing: "Sincronizando", - add_work_item: "Agregar elemento de trabajo", + add_work_item: "Agregar caso", advanced_description_placeholder: "Presiona '/' para comandos", - create_work_item: "Crear elemento de trabajo", + create_work_item: "Crear caso", attachments: "Archivos adjuntos", declining: "Rechazando", declined: "Rechazado", decline: "Rechazar", unassigned: "Sin asignar", - work_items: "Elementos de trabajo", + work_items: "Casos", add_link: "Agregar enlace", points: "Puntos", no_assignee: "Sin asignado", @@ -622,14 +630,14 @@ export default { empty: { project: "Tus proyectos recientes aparecerán aquí una vez que visites uno.", page: "Tus páginas recientes aparecerán aquí una vez que visites una.", - issue: "Tus elementos de trabajo recientes aparecerán aquí una vez que visites uno.", + issue: "Tus casos recientes aparecerán aquí una vez que visites uno.", default: "Aún no tienes elementos recientes.", }, filters: { all: "Todos", projects: "Proyectos", pages: "Páginas", - issues: "Elementos de trabajo", + issues: "Casos", }, }, new_at_plane: { @@ -702,9 +710,9 @@ export default { group_by: "Agrupar por", epic: "Epic", epics: "Epics", - work_item: "Elemento de trabajo", - work_items: "Elementos de trabajo", - sub_work_item: "Sub-elemento de trabajo", + work_item: "Caso", + work_items: "Casos", + sub_work_item: "Tarea", add: "Agregar", warning: "Advertencia", updating: "Actualizando", @@ -743,7 +751,7 @@ export default { private: "Privado", }, done: "Hecho", - sub_work_items: "Sub-elementos de trabajo", + sub_work_items: "Tareas", comment: "Comentario", workspace_level: "Nivel de espacio de trabajo", order_by: { @@ -769,8 +777,8 @@ export default { copied: "¡Copiado!", link_copied: "¡Enlace copiado!", link_copied_to_clipboard: "Enlace copiado al portapapeles", - copied_to_clipboard: "Enlace del elemento de trabajo copiado al portapapeles", - is_copied_to_clipboard: "El elemento de trabajo está copiado al portapapeles", + copied_to_clipboard: "Enlace del caso copiado al portapapeles", + is_copied_to_clipboard: "El caso está copiado al portapapeles", no_links_added_yet: "Aún no se han agregado enlaces", add_link: "Agregar enlace", links: "Enlaces", @@ -874,8 +882,8 @@ export default { teams: "Equipos", entity: "Entidad", entities: "Entidades", - task: "Tarea", - tasks: "Tareas", + task: "Caso", + tasks: "Casos", section: "Sección", sections: "Secciones", edit: "Editar", @@ -980,51 +988,50 @@ export default { }, }, issue: { - label: "{count, plural, one {Elemento de trabajo} other {Elementos de trabajo}}", - all: "Todos los elementos de trabajo", - edit: "Editar elemento de trabajo", + label: "{count, plural, one {Caso} other {Casos}}", + all: "Todos los casos", + edit: "Editar caso", title: { - label: "Título del elemento de trabajo", - required: "El título del elemento de trabajo es obligatorio.", + label: "Título del caso", + required: "El título del caso es obligatorio.", }, add: { - press_enter: "Presiona 'Enter' para agregar otro elemento de trabajo", - label: "Agregar elemento de trabajo", + press_enter: "Presiona 'Enter' para agregar otro caso", + label: "Agregar caso", cycle: { - failed: "No se pudo agregar el elemento de trabajo al ciclo. Por favor, inténtalo de nuevo.", - success: - "{count, plural, one {Elemento de trabajo agregado} other {Elementos de trabajo agregados}} al ciclo correctamente.", - loading: "Agregando {count, plural, one {elemento de trabajo} other {elementos de trabajo}} al ciclo", + failed: "No se pudo agregar el caso al ciclo. Por favor, inténtalo de nuevo.", + success: "{count, plural, one {Caso agregado} other {Casos agregados}} al ciclo correctamente.", + loading: "Agregando {count, plural, one {caso} other {casos}} al ciclo", }, assignee: "Agregar asignados", start_date: "Agregar fecha de inicio", due_date: "Agregar fecha de vencimiento", - parent: "Agregar elemento de trabajo padre", - sub_issue: "Agregar sub-elemento de trabajo", + parent: "Agregar caso padre", + sub_issue: "Agregar tarea", relation: "Agregar relación", link: "Agregar enlace", - existing: "Agregar elemento de trabajo existente", + existing: "Agregar caso existente", }, remove: { - label: "Eliminar elemento de trabajo", + label: "Eliminar caso", cycle: { - loading: "Eliminando elemento de trabajo del ciclo", - success: "Elemento de trabajo eliminado del ciclo correctamente.", - failed: "No se pudo eliminar el elemento de trabajo del ciclo. Por favor, inténtalo de nuevo.", + loading: "Eliminando caso del ciclo", + success: "Caso eliminado del ciclo correctamente.", + failed: "No se pudo eliminar el caso del ciclo. Por favor, inténtalo de nuevo.", }, module: { - loading: "Eliminando elemento de trabajo del módulo", - success: "Elemento de trabajo eliminado del módulo correctamente.", - failed: "No se pudo eliminar el elemento de trabajo del módulo. Por favor, inténtalo de nuevo.", + loading: "Eliminando caso del módulo", + success: "Caso eliminado del módulo correctamente.", + failed: "No se pudo eliminar el caso del módulo. Por favor, inténtalo de nuevo.", }, parent: { - label: "Eliminar elemento de trabajo padre", + label: "Eliminar caso padre", }, }, - new: "Nuevo elemento de trabajo", - adding: "Agregando elemento de trabajo", + new: "Nuevo caso", + adding: "Agregando caso", create: { - success: "Elemento de trabajo creado correctamente", + success: "Caso creado correctamente", }, priority: { urgent: "Urgente", @@ -1036,12 +1043,12 @@ export default { properties: { label: "Mostrar propiedades", id: "ID", - issue_type: "Tipo de elemento de trabajo", + issue_type: "Tipo de caso", sub_issue_count: "Cantidad de sub-elementos", attachment_count: "Cantidad de archivos adjuntos", created_on: "Creado el", - sub_issue: "Sub-elemento de trabajo", - work_item_count: "Recuento de elementos de trabajo", + sub_issue: "Tarea", + work_item_count: "Recuento de casos", }, extra: { show_sub_issues: "Mostrar sub-elementos", @@ -1095,36 +1102,36 @@ export default { }, empty_state: { issue_detail: { - title: "El elemento de trabajo no existe", - description: "El elemento de trabajo que buscas no existe, ha sido archivado o ha sido eliminado.", + title: "El caso no existe", + description: "El caso que buscas no existe, ha sido archivado o ha sido eliminado.", primary_button: { - text: "Ver otros elementos de trabajo", + text: "Ver otros casos", }, }, }, sibling: { - label: "Elementos de trabajo hermanos", + label: "Casos hermanos", }, archive: { - description: "Solo los elementos de trabajo completados\no cancelados pueden ser archivados", - label: "Archivar elemento de trabajo", + description: "Solo los casos completados\no cancelados pueden ser archivados", + label: "Archivar caso", confirm_message: - "¿Estás seguro de que quieres archivar el elemento de trabajo? Todos tus elementos archivados pueden ser restaurados más tarde.", + "¿Estás seguro de que quieres archivar el caso? Todos tus elementos archivados pueden ser restaurados más tarde.", success: { label: "Archivo exitoso", message: "Tus archivos se pueden encontrar en los archivos del proyecto.", }, failed: { - message: "No se pudo archivar el elemento de trabajo. Por favor, inténtalo de nuevo.", + message: "No se pudo archivar el caso. Por favor, inténtalo de nuevo.", }, }, restore: { success: { title: "Restauración exitosa", - message: "Tu elemento de trabajo se puede encontrar en los elementos de trabajo del proyecto.", + message: "Tu caso se puede encontrar en los casos del proyecto.", }, failed: { - message: "No se pudo restaurar el elemento de trabajo. Por favor, inténtalo de nuevo.", + message: "No se pudo restaurar el caso. Por favor, inténtalo de nuevo.", }, }, relation: { @@ -1133,25 +1140,25 @@ export default { blocked_by: "Bloqueado por", blocking: "Bloqueando", }, - copy_link: "Copiar enlace del elemento de trabajo", + copy_link: "Copiar enlace del caso", delete: { - label: "Eliminar elemento de trabajo", - error: "Error al eliminar el elemento de trabajo", + label: "Eliminar caso", + error: "Error al eliminar el caso", }, subscription: { actions: { - subscribed: "Suscrito al elemento de trabajo correctamente", - unsubscribed: "Desuscrito del elemento de trabajo correctamente", + subscribed: "Suscrito al caso correctamente", + unsubscribed: "Desuscrito del caso correctamente", }, }, select: { - error: "Por favor selecciona al menos un elemento de trabajo", - empty: "No hay elementos de trabajo seleccionados", + error: "Por favor selecciona al menos un caso", + empty: "No hay casos seleccionados", add_selected: "Agregar elementos seleccionados", select_all: "Seleccionar todo", deselect_all: "Deseleccionar todo", }, - open_in_full_screen: "Abrir elemento de trabajo en pantalla completa", + open_in_full_screen: "Abrir caso en pantalla completa", }, attachment: { error: "No se pudo adjuntar el archivo. Intenta subirlo de nuevo.", @@ -1180,13 +1187,13 @@ export default { }, empty_state: { sub_list_filters: { - title: "No tienes sub-elementos de trabajo que coincidan con los filtros que has aplicado.", - description: "Para ver todos los sub-elementos de trabajo, elimina todos los filtros aplicados.", + title: "No tienes tareas que coincidan con los filtros que has aplicado.", + description: "Para ver todos los tareas, elimina todos los filtros aplicados.", action: "Eliminar filtros", }, list_filters: { - title: "No tienes elementos de trabajo que coincidan con los filtros que has aplicado.", - description: "Para ver todos los elementos de trabajo, elimina todos los filtros aplicados.", + title: "No tienes casos que coincidan con los filtros que has aplicado.", + description: "Para ver todos los casos, elimina todos los filtros aplicados.", action: "Eliminar filtros", }, }, @@ -1225,30 +1232,30 @@ export default { }, modals: { decline: { - title: "Rechazar elemento de trabajo", - content: "¿Estás seguro de que quieres rechazar el elemento de trabajo {value}?", + title: "Rechazar caso", + content: "¿Estás seguro de que quieres rechazar el caso {value}?", }, delete: { - title: "Eliminar elemento de trabajo", - content: "¿Estás seguro de que quieres eliminar el elemento de trabajo {value}?", - success: "Elemento de trabajo eliminado correctamente", + title: "Eliminar caso", + content: "¿Estás seguro de que quieres eliminar el caso {value}?", + success: "Caso eliminado correctamente", }, }, errors: { - snooze_permission: "Solo los administradores del proyecto pueden posponer/desposponer elementos de trabajo", - accept_permission: "Solo los administradores del proyecto pueden aceptar elementos de trabajo", - decline_permission: "Solo los administradores del proyecto pueden rechazar elementos de trabajo", + snooze_permission: "Solo los administradores del proyecto pueden posponer/desposponer casos", + accept_permission: "Solo los administradores del proyecto pueden aceptar casos", + decline_permission: "Solo los administradores del proyecto pueden rechazar casos", }, actions: { accept: "Aceptar", decline: "Rechazar", snooze: "Posponer", unsnooze: "Desposponer", - copy: "Copiar enlace del elemento de trabajo", + copy: "Copiar enlace del caso", delete: "Eliminar", - open: "Abrir elemento de trabajo", + open: "Abrir caso", mark_as_duplicate: "Marcar como duplicado", - move: "Mover {value} a elementos de trabajo del proyecto", + move: "Mover {value} a casos del proyecto", }, source: { "in-app": "en-app", @@ -1261,7 +1268,7 @@ export default { label: "Intake", page_label: "{workspace} - Intake", modal: { - title: "Crear elemento de trabajo de intake", + title: "Crear caso de intake", }, tabs: { open: "Abiertos", @@ -1269,20 +1276,19 @@ export default { }, empty_state: { sidebar_open_tab: { - title: "No hay elementos de trabajo abiertos", - description: "Encuentra elementos de trabajo abiertos aquí. Crea un nuevo elemento de trabajo.", + title: "No hay casos abiertos", + description: "Encuentra casos abiertos aquí. Crea un nuevo caso.", }, sidebar_closed_tab: { - title: "No hay elementos de trabajo cerrados", - description: "Todos los elementos de trabajo, ya sean aceptados o rechazados, se pueden encontrar aquí.", + title: "No hay casos cerrados", + description: "Todos los casos, ya sean aceptados o rechazados, se pueden encontrar aquí.", }, sidebar_filter: { - title: "No hay elementos de trabajo coincidentes", - description: - "Ningún elemento de trabajo coincide con el filtro aplicado en intake. Crea un nuevo elemento de trabajo.", + title: "No hay casos coincidentes", + description: "Ningún caso coincide con el filtro aplicado en intake. Crea un nuevo caso.", }, detail: { - title: "Selecciona un elemento de trabajo para ver sus detalles.", + title: "Selecciona un caso para ver sus detalles.", }, }, }, @@ -1344,7 +1350,7 @@ export default { general: { title: "Resumen de tus proyectos, actividad y métricas", description: - "Bienvenido a Plane, estamos emocionados de tenerte aquí. Crea tu primer proyecto y rastrea tus elementos de trabajo, y esta página se transformará en un espacio que te ayuda a progresar. Los administradores también verán elementos que ayudan a su equipo a progresar.", + "Bienvenido a Plane, estamos emocionados de tenerte aquí. Crea tu primer proyecto y rastrea tus casos, y esta página se transformará en un espacio que te ayuda a progresar. Los administradores también verán elementos que ayudan a su equipo a progresar.", primary_button: { text: "Construye tu primer proyecto", comic: { @@ -1359,28 +1365,28 @@ export default { workspace_analytics: { label: "Análisis", page_label: "{workspace} - Análisis", - open_tasks: "Total de tareas abiertas", + open_tasks: "Total de casos abiertas", error: "Hubo un error al obtener los datos.", - work_items_closed_in: "Elementos de trabajo cerrados en", + work_items_closed_in: "Casos cerrados en", selected_projects: "Proyectos seleccionados", total_members: "Total de miembros", total_cycles: "Total de Ciclos", total_modules: "Total de Módulos", pending_work_items: { - title: "Elementos de trabajo pendientes", - empty_state: "El análisis de elementos de trabajo pendientes por compañeros aparece aquí.", + title: "Casos pendientes", + empty_state: "El análisis de casos pendientes por compañeros aparece aquí.", }, work_items_closed_in_a_year: { - title: "Elementos de trabajo cerrados en un año", - empty_state: "Cierra elementos de trabajo para ver su análisis en forma de gráfico.", + title: "Casos cerrados en un año", + empty_state: "Cierra casos para ver su análisis en forma de gráfico.", }, most_work_items_created: { - title: "Más elementos de trabajo creados", - empty_state: "Los compañeros y el número de elementos de trabajo creados por ellos aparecen aquí.", + title: "Más casos creados", + empty_state: "Los compañeros y el número de casos creados por ellos aparecen aquí.", }, most_work_items_closed: { - title: "Más elementos de trabajo cerrados", - empty_state: "Los compañeros y el número de elementos de trabajo cerrados por ellos aparecen aquí.", + title: "Más casos cerrados", + empty_state: "Los compañeros y el número de casos cerrados por ellos aparecen aquí.", }, tabs: { scope_and_demand: "Alcance y Demanda", @@ -1388,16 +1394,16 @@ export default { }, empty_state: { customized_insights: { - description: "Los elementos de trabajo asignados a ti, desglosados por estado, aparecerán aquí.", + description: "Los casos asignados a ti, desglosados por estado, aparecerán aquí.", title: "Aún no hay datos", }, created_vs_resolved: { - description: "Los elementos de trabajo creados y resueltos con el tiempo aparecerán aquí.", + description: "Los casos creados y resueltos con el tiempo aparecerán aquí.", title: "Aún no hay datos", }, project_insights: { title: "Aún no hay datos", - description: "Los elementos de trabajo asignados a ti, desglosados por estado, aparecerán aquí.", + description: "Los casos asignados a ti, desglosados por estado, aparecerán aquí.", }, general: { title: @@ -1409,7 +1415,7 @@ export default { comic: { title: "Analytics funciona mejor con Ciclos + Módulos", description: - "Primero, encuadra tus elementos de trabajo en Ciclos y, si puedes, agrupa elementos que abarcan más de un ciclo en Módulos. Revisa ambos en la navegación izquierda.", + "Primero, encuadra tus casos en Ciclos y, si puedes, agrupa elementos que abarcan más de un ciclo en Módulos. Revisa ambos en la navegación izquierda.", }, }, }, @@ -1452,7 +1458,7 @@ export default { permission: "No tienes permiso para realizar esta acción.", cycle_delete: "Error al eliminar el ciclo", module_delete: "Error al eliminar el módulo", - issue_delete: "Error al eliminar el elemento de trabajo", + issue_delete: "Error al eliminar el caso", }, state: { backlog: "Pendiente", @@ -1478,7 +1484,7 @@ export default { general: { title: "No hay proyectos activos", description: - "Piensa en cada proyecto como el padre para el trabajo orientado a objetivos. Los proyectos son donde viven las Tareas, Ciclos y Módulos y, junto con tus colegas, te ayudan a alcanzar ese objetivo. Crea un nuevo proyecto o filtra por proyectos archivados.", + "Piensa en cada proyecto como el padre para el trabajo orientado a objetivos. Los proyectos son donde viven las Casos, Ciclos y Módulos y, junto con tus colegas, te ayudan a alcanzar ese objetivo. Crea un nuevo proyecto o filtra por proyectos archivados.", primary_button: { text: "Inicia tu primer proyecto", comic: { @@ -1490,8 +1496,7 @@ export default { }, no_projects: { title: "Sin proyecto", - description: - "Para crear elementos de trabajo o gestionar tu trabajo, necesitas crear un proyecto o ser parte de uno.", + description: "Para crear casos o gestionar tu trabajo, necesitas crear un proyecto o ser parte de uno.", primary_button: { text: "Inicia tu primer proyecto", comic: { @@ -1515,34 +1520,33 @@ export default { add_view: "Agregar vista", empty_state: { "all-issues": { - title: "No hay elementos de trabajo en el proyecto", - description: - "¡Primer proyecto completado! Ahora, divide tu trabajo en piezas rastreables con elementos de trabajo. ¡Vamos!", + title: "No hay casos en el proyecto", + description: "¡Primer proyecto completado! Ahora, divide tu trabajo en piezas rastreables con casos. ¡Vamos!", primary_button: { - text: "Crear nuevo elemento de trabajo", + text: "Crear nuevo caso", }, }, assigned: { - title: "No hay elementos de trabajo aún", - description: "Los elementos de trabajo asignados a ti se pueden rastrear desde aquí.", + title: "No hay casos aún", + description: "Los casos asignados a ti se pueden rastrear desde aquí.", primary_button: { - text: "Crear nuevo elemento de trabajo", + text: "Crear nuevo caso", }, }, created: { - title: "No hay elementos de trabajo aún", - description: "Todos los elementos de trabajo creados por ti vienen aquí, rastréalos aquí directamente.", + title: "No hay casos aún", + description: "Todos los casos creados por ti vienen aquí, rastréalos aquí directamente.", primary_button: { - text: "Crear nuevo elemento de trabajo", + text: "Crear nuevo caso", }, }, subscribed: { - title: "No hay elementos de trabajo aún", - description: "Suscríbete a los elementos de trabajo que te interesan, rastréalos todos aquí.", + title: "No hay casos aún", + description: "Suscríbete a los casos que te interesan, rastréalos todos aquí.", }, "custom-view": { - title: "No hay elementos de trabajo aún", - description: "Elementos de trabajo que aplican a los filtros, rastréalos todos aquí.", + title: "No hay casos aún", + description: "Casos que aplican a los filtros, rastréalos todos aquí.", }, }, delete_view: { @@ -1671,9 +1675,11 @@ export default { exports: { title: "Exportaciones", exporting: "Exportando", + exporting_projects: "Exportando proyecto", + format: "Formato", previous_exports: "Exportaciones anteriores", export_separate_files: "Exportar los datos en archivos separados", - filters_info: "Aplica filtros para exportar elementos de trabajo específicos según tus criterios.", + filters_info: "Aplica filtros para exportar casos específicos según tus criterios.", modal: { title: "Exportar a", toasts: { @@ -1792,16 +1798,16 @@ export default { stats: { workload: "Carga de trabajo", overview: "Resumen", - created: "Elementos de trabajo creados", - assigned: "Elementos de trabajo asignados", - subscribed: "Elementos de trabajo suscritos", + created: "Casos creados", + assigned: "Casos asignados", + subscribed: "Casos suscritos", state_distribution: { - title: "Elementos de trabajo por estado", - empty: "Crea elementos de trabajo para verlos por estados en el gráfico para un mejor análisis.", + title: "Casos por estado", + empty: "Crea casos para verlos por estados en el gráfico para un mejor análisis.", }, priority_distribution: { - title: "Elementos de trabajo por Prioridad", - empty: "Crea elementos de trabajo para verlos por prioridad en el gráfico para un mejor análisis.", + title: "Casos por Prioridad", + empty: "Crea casos para verlos por prioridad en el gráfico para un mejor análisis.", }, recent_activity: { title: "Actividad reciente", @@ -1828,19 +1834,19 @@ export default { activity: { title: "Aún no hay actividades", description: - "¡Comienza creando un nuevo elemento de trabajo! Agrégale detalles y propiedades. Explora más en Plane para ver tu actividad.", + "¡Comienza creando un nuevo caso! Agrégale detalles y propiedades. Explora más en Plane para ver tu actividad.", }, assigned: { - title: "No hay elementos de trabajo asignados a ti", - description: "Los elementos de trabajo asignados a ti se pueden rastrear desde aquí.", + title: "No hay casos asignados a ti", + description: "Los casos asignados a ti se pueden rastrear desde aquí.", }, created: { - title: "Aún no hay elementos de trabajo", - description: "Todos los elementos de trabajo creados por ti aparecen aquí, rastréalos directamente aquí.", + title: "Aún no hay casos", + description: "Todos los casos creados por ti aparecen aquí, rastréalos directamente aquí.", }, subscribed: { - title: "Aún no hay elementos de trabajo", - description: "Suscríbete a los elementos de trabajo que te interesen, rastréalos todos aquí.", + title: "Aún no hay casos", + description: "Suscríbete a los casos que te interesen, rastréalos todos aquí.", }, }, }, @@ -1870,9 +1876,8 @@ export default { project_lead: "Líder del proyecto", default_assignee: "Asignado por defecto", guest_super_permissions: { - title: "Otorgar acceso de visualización a todos los elementos de trabajo para usuarios invitados:", - sub_heading: - "Esto permitirá a los invitados tener acceso de visualización a todos los elementos de trabajo del proyecto.", + title: "Otorgar acceso de visualización a todos los casos para usuarios invitados:", + sub_heading: "Esto permitirá a los invitados tener acceso de visualización a todos los casos del proyecto.", }, invite_members: { title: "Invitar miembros", @@ -1898,7 +1903,8 @@ export default { estimates: { label: "Estimaciones", title: "Activar estimaciones para mi proyecto", - description: "Te ayudan a comunicar la complejidad y la carga de trabajo del equipo.", + description: "Configura sistemas de estimación para rastrear y comunicar el esfuerzo requerido para cada caso.", + enable_description: "Te ayudan a comunicar la complejidad y la carga de trabajo del equipo.", no_estimate: "Sin estimación", new: "Nuevo sistema de estimación", create: { @@ -1982,27 +1988,25 @@ export default { automations: { label: "Automatizaciones", "auto-archive": { - title: "Archivar automáticamente elementos de trabajo cerrados", - description: - "Plane archivará automáticamente los elementos de trabajo que hayan sido completados o cancelados.", - duration: "Archivar automáticamente elementos de trabajo cerrados durante", + title: "Archivar automáticamente casos cerrados", + description: "Plane archivará automáticamente los casos que hayan sido completados o cancelados.", + duration: "Archivar automáticamente casos cerrados durante", }, "auto-close": { - title: "Cerrar automáticamente elementos de trabajo", - description: - "Plane cerrará automáticamente los elementos de trabajo que no hayan sido completados o cancelados.", - duration: "Cerrar automáticamente elementos de trabajo inactivos durante", + title: "Cerrar automáticamente casos", + description: "Plane cerrará automáticamente los casos que no hayan sido completados o cancelados.", + duration: "Cerrar automáticamente casos inactivos durante", auto_close_status: "Estado de cierre automático", }, }, empty_state: { labels: { title: "Aún no hay etiquetas", - description: "Crea etiquetas para organizar y filtrar elementos de trabajo en tu proyecto.", + description: "Crea etiquetas para organizar y filtrar casos en tu proyecto.", }, estimates: { title: "Aún no hay sistemas de estimación", - description: "Crea un conjunto de estimaciones para comunicar el volumen de trabajo por elemento de trabajo.", + description: "Crea un conjunto de estimaciones para comunicar el volumen de trabajo por caso.", primary_button: "Agregar sistema de estimación", }, }, @@ -2060,16 +2064,16 @@ export default { start_date: "Fecha de inicio", end_date: "Fecha de finalización", in_your_timezone: "En tu zona horaria", - transfer_work_items: "Transferir {count} elementos de trabajo", + transfer_work_items: "Transferir {count} casos", date_range: "Rango de fechas", add_date: "Agregar fecha", active_cycle: { label: "Ciclo activo", progress: "Progreso", chart: "Gráfico de avance", - priority_issue: "Elementos de trabajo prioritarios", + priority_issue: "Casos prioritarios", assignees: "Asignados", - issue_burndown: "Avance de elementos de trabajo", + issue_burndown: "Avance de casos", ideal: "Ideal", current: "Actual", labels: "Etiquetas", @@ -2152,19 +2156,19 @@ export default { }, }, no_issues: { - title: "No hay elementos de trabajo agregados al ciclo", - description: "Agrega o crea elementos de trabajo que desees delimitar y entregar dentro de este ciclo", + title: "No hay casos agregados al ciclo", + description: "Agrega o crea casos que desees delimitar y entregar dentro de este ciclo", primary_button: { - text: "Crear nuevo elemento de trabajo", + text: "Crear nuevo caso", }, secondary_button: { - text: "Agregar elemento de trabajo existente", + text: "Agregar caso existente", }, }, completed_no_issues: { - title: "No hay elementos de trabajo en el ciclo", + title: "No hay casos en el ciclo", description: - "No hay elementos de trabajo en el ciclo. Los elementos de trabajo están transferidos u ocultos. Para ver elementos de trabajo ocultos si los hay, actualiza tus propiedades de visualización según corresponda.", + "No hay casos en el ciclo. Los casos están transferidos u ocultos. Para ver casos ocultos si los hay, actualiza tus propiedades de visualización según corresponda.", }, active: { title: "No hay ciclo activo", @@ -2181,28 +2185,28 @@ export default { project_issues: { empty_state: { no_issues: { - title: "Crea un elemento de trabajo y asígnalo a alguien, incluso a ti mismo", + title: "Crea un caso y asígnalo a alguien, incluso a ti mismo", description: - "Piensa en los elementos de trabajo como trabajos, tareas, trabajo o JTBD. Los cuales nos gustan. Un elemento de trabajo y sus sub-elementos de trabajo son generalmente acciones basadas en tiempo asignadas a miembros de tu equipo. Tu equipo crea, asigna y completa elementos de trabajo para mover tu proyecto hacia su objetivo.", + "Piensa en los casos como trabajos, casos, trabajo o JTBD. Los cuales nos gustan. Un caso y sus tareas son generalmente acciones basadas en tiempo asignadas a miembros de tu equipo. Tu equipo crea, asigna y completa casos para mover tu proyecto hacia su objetivo.", primary_button: { - text: "Crea tu primer elemento de trabajo", + text: "Crea tu primer caso", comic: { - title: "Los elementos de trabajo son bloques de construcción en Plane.", + title: "Los casos son bloques de construcción en Plane.", description: - "Rediseñar la interfaz de Plane, Cambiar la marca de la empresa o Lanzar el nuevo sistema de inyección de combustible son ejemplos de elementos de trabajo que probablemente tienen sub-elementos de trabajo.", + "Rediseñar la interfaz de Plane, Cambiar la marca de la empresa o Lanzar el nuevo sistema de inyección de combustible son ejemplos de casos que probablemente tienen tareas.", }, }, }, no_archived_issues: { - title: "Aún no hay elementos de trabajo archivados", + title: "Aún no hay casos archivados", description: - "Manualmente o a través de automatización, puedes archivar elementos de trabajo que estén completados o cancelados. Encuéntralos aquí una vez archivados.", + "Manualmente o a través de automatización, puedes archivar casos que estén completados o cancelados. Encuéntralos aquí una vez archivados.", primary_button: { text: "Establecer automatización", }, }, issues_empty_filter: { - title: "No se encontraron elementos de trabajo que coincidan con los filtros aplicados", + title: "No se encontraron casos que coincidan con los filtros aplicados", secondary_button: { text: "Limpiar todos los filtros", }, @@ -2220,7 +2224,7 @@ export default { general: { title: "Mapea los hitos de tu proyecto a Módulos y rastrea el trabajo agregado fácilmente.", description: - "Un grupo de elementos de trabajo que pertenecen a un padre lógico y jerárquico forman un módulo. Piensa en ellos como una forma de rastrear el trabajo por hitos del proyecto. Tienen sus propios períodos y fechas límite, así como análisis para ayudarte a ver qué tan cerca o lejos estás de un hito.", + "Un grupo de casos que pertenecen a un padre lógico y jerárquico forman un módulo. Piensa en ellos como una forma de rastrear el trabajo por hitos del proyecto. Tienen sus propios períodos y fechas límite, así como análisis para ayudarte a ver qué tan cerca o lejos estás de un hito.", primary_button: { text: "Construye tu primer módulo", comic: { @@ -2231,13 +2235,13 @@ export default { }, }, no_issues: { - title: "No hay elementos de trabajo en el módulo", - description: "Crea o agrega elementos de trabajo que quieras lograr como parte de este módulo", + title: "No hay casos en el módulo", + description: "Crea o agrega casos que quieras lograr como parte de este módulo", primary_button: { - text: "Crear nuevos elementos de trabajo", + text: "Crear nuevos casos", }, secondary_button: { - text: "Agregar un elemento de trabajo existente", + text: "Agregar un caso existente", }, }, archived: { @@ -2274,7 +2278,7 @@ export default { primary_button: { text: "Crea tu primera vista", comic: { - title: "Las vistas funcionan sobre las propiedades de los Elementos de trabajo.", + title: "Las vistas funcionan sobre las propiedades de los Casos.", description: "Puedes crear una vista desde aquí con tantas propiedades como filtros como consideres apropiado.", }, @@ -2297,7 +2301,7 @@ export default { title: "Escribe una nota, un documento o una base de conocimiento completa. Obtén ayuda de Galileo, el asistente de IA de Plane, para comenzar", description: - "Las páginas son espacios para pensamientos en Plane. Toma notas de reuniones, fórmalas fácilmente, integra elementos de trabajo, organízalas usando una biblioteca de componentes y mantenlas todas en el contexto de tu proyecto. Para hacer cualquier documento rápidamente, invoca a Galileo, la IA de Plane, con un atajo o haciendo clic en un botón.", + "Las páginas son espacios para pensamientos en Plane. Toma notas de reuniones, fórmalas fácilmente, integra casos, organízalas usando una biblioteca de componentes y mantenlas todas en el contexto de tu proyecto. Para hacer cualquier documento rápidamente, invoca a Galileo, la IA de Plane, con un atajo o haciendo clic en un botón.", primary_button: { text: "Crea tu primera página", }, @@ -2333,10 +2337,10 @@ export default { issue_relation: { empty_state: { search: { - title: "No se encontraron elementos de trabajo coincidentes", + title: "No se encontraron casos coincidentes", }, no_issues: { - title: "No se encontraron elementos de trabajo", + title: "No se encontraron casos", }, }, }, @@ -2344,8 +2348,7 @@ export default { empty_state: { general: { title: "Aún no hay comentarios", - description: - "Los comentarios pueden usarse como un espacio de discusión y seguimiento para los elementos de trabajo", + description: "Los comentarios pueden usarse como un espacio de discusión y seguimiento para los casos", }, }, }, @@ -2379,12 +2382,12 @@ export default { title: "Selecciona para ver detalles.", }, all: { - title: "No hay elementos de trabajo asignados", - description: "Las actualizaciones de elementos de trabajo asignados a ti se pueden \n ver aquí", + title: "No hay casos asignados", + description: "Las actualizaciones de casos asignados a ti se pueden \n ver aquí", }, mentions: { - title: "No hay elementos de trabajo asignados", - description: "Las actualizaciones de elementos de trabajo asignados a ti se pueden \n ver aquí", + title: "No hay casos asignados", + description: "Las actualizaciones de casos asignados a ti se pueden \n ver aquí", }, }, tabs: { @@ -2408,19 +2411,19 @@ export default { active_cycle: { empty_state: { progress: { - title: "Agrega elementos de trabajo al ciclo para ver su progreso", + title: "Agrega casos al ciclo para ver su progreso", }, chart: { - title: "Agrega elementos de trabajo al ciclo para ver el gráfico de avance.", + title: "Agrega casos al ciclo para ver el gráfico de avance.", }, priority_issue: { - title: "Observa los elementos de trabajo de alta prioridad abordados en el ciclo de un vistazo.", + title: "Observa los casos de alta prioridad abordados en el ciclo de un vistazo.", }, assignee: { - title: "Agrega asignados a los elementos de trabajo para ver un desglose del trabajo por asignados.", + title: "Agrega asignados a los casos para ver un desglose del trabajo por asignados.", }, label: { - title: "Agrega etiquetas a los elementos de trabajo para ver el desglose del trabajo por etiquetas.", + title: "Agrega etiquetas a los casos para ver el desglose del trabajo por etiquetas.", }, }, }, @@ -2429,7 +2432,7 @@ export default { inbox: { title: "Intake no está habilitado para el proyecto.", description: - "Intake te ayuda a gestionar las solicitudes entrantes a tu proyecto y agregarlas como elementos de trabajo en tu flujo de trabajo. Habilita Intake desde la configuración del proyecto para gestionar las solicitudes.", + "Intake te ayuda a gestionar las solicitudes entrantes a tu proyecto y agregarlas como casos en tu flujo de trabajo. Habilita Intake desde la configuración del proyecto para gestionar las solicitudes.", primary_button: { text: "Gestionar funciones", }, @@ -2469,11 +2472,11 @@ export default { }, }, workspace_draft_issues: { - draft_an_issue: "Borrador de elemento de trabajo", + draft_an_issue: "Borrador de caso", empty_state: { - title: "Los elementos de trabajo a medio escribir y pronto los comentarios aparecerán aquí.", + title: "Los casos a medio escribir y pronto los comentarios aparecerán aquí.", description: - "Para probar esto, comienza a agregar un elemento de trabajo y déjalo a medias o crea tu primer borrador a continuación. 😉", + "Para probar esto, comienza a agregar un caso y déjalo a medias o crea tu primer borrador a continuación. 😉", primary_button: { text: "Crea tu primer borrador", }, @@ -2485,7 +2488,7 @@ export default { toasts: { created: { success: "Borrador creado", - error: "No se pudo crear el elemento de trabajo. Por favor, inténtalo de nuevo.", + error: "No se pudo crear el caso. Por favor, inténtalo de nuevo.", }, deleted: { success: "Borrador eliminado", @@ -2506,7 +2509,7 @@ export default { simple: "Anota una idea, captura un momento eureka o registra una inspiración. Agrega una nota adhesiva para comenzar.", general: { - title: "Las notas adhesivas son notas rápidas y tareas pendientes que anotas al vuelo.", + title: "Las notas adhesivas son notas rápidas y casos pendientes que anotas al vuelo.", description: "Captura tus pensamientos e ideas sin esfuerzo creando notas adhesivas a las que puedes acceder en cualquier momento y desde cualquier lugar.", primary_button: { @@ -2581,37 +2584,37 @@ export default { importer: { github: { title: "GitHub", - description: "Importa elementos de trabajo desde repositorios de GitHub y sincronízalos.", + description: "Importa casos desde repositorios de GitHub y sincronízalos.", }, jira: { title: "Jira", - description: "Importa elementos de trabajo y epics desde proyectos y epics de Jira.", + description: "Importa casos y epics desde proyectos y epics de Jira.", }, }, exporter: { csv: { title: "CSV", - description: "Exporta elementos de trabajo a un archivo CSV.", + description: "Exporta casos a un archivo CSV.", short_description: "Exportar como csv", }, excel: { title: "Excel", - description: "Exporta elementos de trabajo a un archivo Excel.", + description: "Exporta casos a un archivo Excel.", short_description: "Exportar como excel", }, xlsx: { title: "Excel", - description: "Exporta elementos de trabajo a un archivo Excel.", + description: "Exporta casos a un archivo Excel.", short_description: "Exportar como excel", }, json: { title: "JSON", - description: "Exporta elementos de trabajo a un archivo JSON.", + description: "Exporta casos a un archivo JSON.", short_description: "Exportar como json", }, }, default_global_view: { - all_issues: "Todos los elementos de trabajo", + all_issues: "Todos los casos", assigned: "Asignados", created: "Creados", subscribed: "Suscritos", @@ -2655,7 +2658,7 @@ export default { order_by: { name: "Nombre", progress: "Progreso", - issues: "Número de elementos de trabajo", + issues: "Número de casos", due_date: "Fecha de vencimiento", created_at: "Fecha de creación", manual: "Manual", @@ -2719,4 +2722,201 @@ export default { close_button: "Cerrar panel de navegación", outline_floating_button: "Abrir esquema", }, + project_members: { + full_name: "Nombre completo", + display_name: "Nombre para mostrar", + email: "Correo electrónico", + joining_date: "Fecha de ingreso", + role: "Rol", + }, + power_k: { + contextual_actions: { + work_item: { + title: "Acciones de caso", + indicator: "Caso", + change_state: "Cambiar estado", + change_priority: "Cambiar prioridad", + change_assignees: "Asignar a", + assign_to_me: "Asignarme", + unassign_from_me: "Desasignarme", + change_estimate: "Cambiar estimación", + add_to_cycle: "Agregar a ciclo", + add_to_modules: "Agregar a módulos", + add_labels: "Agregar etiquetas", + subscribe: "Suscribirse a notificaciones", + unsubscribe: "Cancelar suscripción de notificaciones", + delete: "Eliminar", + copy_id: "Copiar ID", + copy_id_toast_success: "ID del caso copiado al portapapeles.", + copy_id_toast_error: "Ocurrió un error al copiar el ID del caso al portapapeles.", + copy_title: "Copiar título", + copy_title_toast_success: "Título del caso copiado al portapapeles.", + copy_title_toast_error: "Ocurrió un error al copiar el título del caso al portapapeles.", + copy_url: "Copiar URL", + copy_url_toast_success: "URL del caso copiada al portapapeles.", + copy_url_toast_error: "Ocurrió un error al copiar la URL del caso al portapapeles.", + }, + cycle: { + title: "Acciones de ciclo", + indicator: "Ciclo", + add_to_favorites: "Agregar a favoritos", + remove_from_favorites: "Eliminar de favoritos", + copy_url: "Copiar URL", + copy_url_toast_success: "URL del ciclo copiada al portapapeles.", + copy_url_toast_error: "Ocurrió un error al copiar la URL del ciclo al portapapeles.", + }, + module: { + title: "Acciones de módulo", + indicator: "Módulo", + add_remove_members: "Agregar/quitar miembros", + change_status: "Cambiar estado", + add_to_favorites: "Agregar a favoritos", + remove_from_favorites: "Eliminar de favoritos", + copy_url: "Copiar URL", + copy_url_toast_success: "URL del módulo copiada al portapapeles.", + copy_url_toast_error: "Ocurrió un error al copiar la URL del módulo al portapapeles.", + }, + page: { + title: "Acciones de página", + indicator: "Página", + lock: "Bloquear", + unlock: "Desbloquear", + make_private: "Hacer privada", + make_public: "Hacer pública", + archive: "Archivar", + restore: "Restaurar", + add_to_favorites: "Agregar a favoritos", + remove_from_favorites: "Eliminar de favoritos", + copy_url: "Copiar URL", + copy_url_toast_success: "URL de la página copiada al portapapeles.", + copy_url_toast_error: "Ocurrió un error al copiar la URL de la página al portapapeles.", + }, + }, + creation_actions: { + create_work_item: "Nuevo caso", + create_page: "Nueva página", + create_view: "Nueva vista", + create_cycle: "Nuevo ciclo", + create_module: "Nuevo módulo", + create_project: "Nuevo proyecto", + create_workspace: "Nuevo espacio de trabajo", + }, + navigation_actions: { + open_workspace: "Abrir un espacio de trabajo", + nav_home: "Ir a inicio", + nav_inbox: "Ir a bandeja de entrada", + nav_your_work: "Ir a tu trabajo", + nav_account_settings: "Ir a configuración de cuenta", + open_project: "Abrir un proyecto", + nav_projects_list: "Ir a lista de proyectos", + nav_all_workspace_work_items: "Ir a todos los casos", + nav_assigned_workspace_work_items: "Ir a elementos asignados", + nav_created_workspace_work_items: "Ir a elementos creados", + nav_subscribed_workspace_work_items: "Ir a elementos suscritos", + nav_workspace_analytics: "Ir a análisis del espacio de trabajo", + nav_workspace_drafts: "Ir a borradores del espacio de trabajo", + nav_workspace_archives: "Ir a archivos del espacio de trabajo", + open_workspace_setting: "Abrir configuración del espacio de trabajo", + nav_workspace_settings: "Ir a configuración del espacio de trabajo", + nav_project_work_items: "Ir a casos", + open_project_cycle: "Abrir un ciclo", + nav_project_cycles: "Ir a ciclos", + open_project_module: "Abrir un módulo", + nav_project_modules: "Ir a módulos", + open_project_view: "Abrir una vista de proyecto", + nav_project_views: "Ir a vistas del proyecto", + nav_project_pages: "Ir a páginas", + nav_project_intake: "Ir a entrada", + nav_project_archives: "Ir a archivos del proyecto", + open_project_setting: "Abrir configuración del proyecto", + nav_project_settings: "Ir a configuración del proyecto", + }, + account_actions: { + sign_out: "Cerrar sesión", + workspace_invites: "Invitaciones al espacio de trabajo", + }, + miscellaneous_actions: { + toggle_app_sidebar: "Alternar barra lateral", + copy_current_page_url: "Copiar URL de la página actual", + copy_current_page_url_toast_success: "URL de la página actual copiada al portapapeles.", + copy_current_page_url_toast_error: "Ocurrió un error al copiar la URL de la página actual al portapapeles.", + focus_top_nav_search: "Enfocar búsqueda", + }, + preferences_actions: { + update_theme: "Cambiar tema de interfaz", + update_timezone: "Cambiar zona horaria", + update_start_of_week: "Cambiar primer día de la semana", + update_language: "Cambiar idioma de interfaz", + toast: { + theme: { + success: "Tema actualizado exitosamente.", + error: "Error al actualizar el tema. Por favor intenta de nuevo.", + }, + timezone: { + success: "Zona horaria actualizada exitosamente.", + error: "Error al actualizar la zona horaria. Por favor intenta de nuevo.", + }, + generic: { + success: "Preferencias actualizadas exitosamente.", + error: "Error al actualizar las preferencias. Por favor intenta de nuevo.", + }, + }, + }, + help_actions: { + open_keyboard_shortcuts: "Abrir atajos de teclado", + open_plane_documentation: "Abrir documentación de Plane", + join_forum: "Unirse a nuestro foro", + report_bug: "Reportar un error", + chat_with_us: "Chatear con nosotros", + }, + page_placeholders: { + default: "Escribe un comando o busca", + open_workspace: "Abrir un espacio de trabajo", + open_project: "Abrir un proyecto", + open_workspace_setting: "Abrir configuración del espacio de trabajo", + open_project_cycle: "Abrir un ciclo", + open_project_module: "Abrir un módulo", + open_project_view: "Abrir una vista de proyecto", + open_project_setting: "Abrir configuración del proyecto", + update_work_item_state: "Cambiar estado", + update_work_item_priority: "Cambiar prioridad", + update_work_item_assignee: "Asignar a", + update_work_item_estimate: "Cambiar estimación", + update_work_item_cycle: "Agregar a ciclo", + update_work_item_module: "Agregar a módulos", + update_work_item_labels: "Agregar etiquetas", + update_module_member: "Cambiar miembros", + update_module_status: "Cambiar estado", + update_theme: "Cambiar tema", + update_timezone: "Cambiar zona horaria", + update_start_of_week: "Cambiar primer día de la semana", + update_language: "Cambiar idioma", + }, + search_menu: { + no_results: "No se encontraron resultados", + clear_search: "Limpiar búsqueda", + }, + footer: { + workspace_level: "Nivel de espacio de trabajo", + }, + group_titles: { + contextual: "Contextual", + navigation: "Navegar", + create: "Crear", + general: "General", + settings: "Configuración", + account: "Cuenta", + miscellaneous: "Varios", + preferences: "Preferencias", + help: "Ayuda", + }, + }, + customize_navigation: "Personalizar navegación", + personal: "Personal", + accordion_navigation_control: "Navegación de barra lateral tipo acordeón", + horizontal_navigation_bar: "Navegación con pestañas", + show_limited_projects_on_sidebar: "Mostrar proyectos limitados en la barra lateral", + enter_number_of_projects: "Ingresa el número de proyectos", + pin: "Fijar", + unpin: "Desfijar", } as const; diff --git a/packages/types/src/cases/case-entity-link.ts b/packages/types/src/cases/case-entity-link.ts new file mode 100644 index 00000000000..0f083f0018f --- /dev/null +++ b/packages/types/src/cases/case-entity-link.ts @@ -0,0 +1,47 @@ +export type TCaseEntityLink = { + id: string; + entity_type: "policy" | "claim"; + entity_id: string; + role: "primary" | "related"; + label: string; + detail: TResolvedPolicy | TResolvedClaim | null; + created_at: string; +}; + +export type TResolvedPolicy = { + id: string; + policy_number: string; + insurer_name: string | null; + status: string | null; +}; + +export type TResolvedClaim = { + id: string; + claim_number: string; + status: string | null; + policy_id: string | null; +}; + +export type TCaseEntityLinkCreatePayload = { + entity_type: "policy" | "claim"; + entity_id: string; + role?: "primary" | "related"; +}; + +export type TResolvedEntity = { + entity_type: string; + entity_id: string; + label: string; + detail: TResolvedPolicy | TResolvedClaim | null; +}; + +export type TEntitySearchResult = { + id: string; + entity_type: "policy" | "claim"; + primary_label: string; + company: string | null; + ramo: string | null; + secondary_label: string | null; + match_field: string; + match_priority: number; +}; diff --git a/packages/types/src/cases/index.ts b/packages/types/src/cases/index.ts new file mode 100644 index 00000000000..6852776dd08 --- /dev/null +++ b/packages/types/src/cases/index.ts @@ -0,0 +1 @@ +export * from "./case-entity-link"; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 899b5d5119b..4b81498634d 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -52,6 +52,7 @@ export * from "./view-props"; export * from "./views"; export * from "./waitlist"; export * from "./webhook"; +export * from "./cases"; export * from "./workspace"; export * from "./workspace-draft-issues/base"; export * from "./workspace-notifications"; diff --git a/packages/ui/src/collapsible/collapsible.tsx b/packages/ui/src/collapsible/collapsible.tsx index 0831db3bb1a..e539528d420 100644 --- a/packages/ui/src/collapsible/collapsible.tsx +++ b/packages/ui/src/collapsible/collapsible.tsx @@ -4,7 +4,7 @@ * See the LICENSE file for details. */ -import { Disclosure, Transition } from "@headlessui/react"; +import { Transition } from "@headlessui/react"; import React, { useState, useEffect, useCallback } from "react"; export type TCollapsibleProps = { @@ -21,7 +21,7 @@ export type TCollapsibleProps = { export function Collapsible(props: TCollapsibleProps) { const { title, children, buttonRef, className, buttonClassName, isOpen, onToggle, defaultOpen } = props; // state - const [localIsOpen, setLocalIsOpen] = useState(isOpen || defaultOpen ? true : false); + const [localIsOpen, setLocalIsOpen] = useState(!!(isOpen || defaultOpen)); useEffect(() => { if (isOpen !== undefined) { @@ -39,10 +39,10 @@ export function Collapsible(props: TCollapsibleProps) { }, [isOpen, onToggle]); return ( - - +
+ - - {children} - +
{children}
- +
); } diff --git a/plans/keyboard-nav-plan.md b/plans/keyboard-nav-plan.md new file mode 100644 index 00000000000..8fb68ff161a --- /dev/null +++ b/plans/keyboard-nav-plan.md @@ -0,0 +1,564 @@ +# Keyboard Navigation Plan + +## Desired Outcomes + +Linear-style keyboard navigation for work item lists across all layouts (list, board, spreadsheet), with Spanish-first mnemonic shortcuts and minimal upstream conflict risk. + +**Success criteria:** + +1. User can navigate work items with `j`/`k` (or arrow keys) in list, board, and spreadsheet layouts +2. `Enter` opens the focused item (peek overview), `Escape` closes/deselects +3. `x` toggles selection on focused item, `Shift+j`/`Shift+k` extends selection +4. Single-key contextual shortcuts use Spanish mnemonics (e.g., `E` for Estado, not `S` for State) +5. Navigation sequences use Spanish prefixes (`I` for "Ir a" instead of `G` for "Go to") +6. Dynamic project shortcuts: `I` then project initial letter jumps directly to that project +7. All new UI strings are in the i18n system with Spanish translations +8. `git merge upstream/preview` produces zero new conflicts from these changes + +**Testing strategy:** + +- Manual: navigate a 50+ item list with j/k, verify focus ring moves, scroll follows, Enter opens peek, Escape closes +- Manual: board view — j/k within column, h/l across columns +- Manual: spreadsheet — arrow keys still work (no regression in existing `useTableKeyboardNavigation`) +- Manual: verify shortcuts don't fire inside text inputs, search bars, editors +- Manual: verify Spanish mnemonic shortcuts (`E`, `T`, etc.) work on work item detail page +- Manual: press `I` then `T` → navigates to Tareas (work items) in current project +- Manual: press `I` then `S` → navigates to the "Siniestros" project (dynamic shortcut from project identifier) +- Manual: press `I` then a letter shared by two projects → disambiguation picker appears +- Upstream: run `git diff --name-only preview` and verify no file appears that isn't in the "touched files" list below + +--- + +## Upstream Sync Strategy + +**Rules (from upstream-sync-strategy-audit.md):** + +1. New files, not modified files — never modify Plane's existing files where possible +2. Additive changes only in registration/init files (append at bottom) +3. `.gitattributes merge=ours` for known-conflict files + +**How this plan complies:** + +| Change type | Files | Conflict risk | +| ----------------------------------------- | ----------------------------------- | ------------------------------------ | +| **New files** (hooks, components, config) | ~6 new files in `apps/web/` | None — Plane will never create these | +| **Modified files** (minimal) | ~4 existing files, small insertions | Low — described per-file below | + +### Files we will create (NEW — zero conflict risk) + +``` +apps/web/core/hooks/use-issue-list-keyboard-nav.ts # Core navigation hook +apps/web/core/components/issues/issue-layouts/keyboard-nav-provider.tsx # Context provider +apps/web/core/components/power-k/config/clausulo-shortcut-overrides.ts # Spanish mnemonic remaps +apps/web/core/components/power-k/config/keyboard-nav-commands.ts # j/k/Enter/Escape/x commands +packages/i18n/src/locales/es/keyboard-nav.ts # Spanish translations for new strings (if needed) +``` + +### Files we will modify (MINIMAL — low conflict risk) + +Each modification is small and additive. Details in the phases below. + +| File | Change | Risk | +| ---------------------------------------------------------------- | ---------------------------------------------------------- | ----------------------------- | +| `apps/web/core/components/issues/issue-layouts/list/default.tsx` | Wrap content with keyboard nav provider (~3 lines) | Low — wrapper addition | +| `apps/web/core/components/issues/issue-layouts/list/block.tsx` | Add `data-issue-id` attribute to ControlLink (~1 line) | Very low — attribute addition | +| `apps/web/core/components/issues/issue-layouts/kanban/block.tsx` | Add `data-issue-id` attribute (~1 line) | Very low | +| `apps/web/core/components/power-k/config/commands.ts` | Import + spread our override commands (~3 lines at bottom) | Low — append-only | +| `packages/i18n/src/locales/es/translations.ts` | Add new translation keys at bottom of `power_k` section | Low — append-only | + +--- + +## Architecture + +### Focus management approach + +We use a **DOM-based focus manager** rather than React state for the "currently focused" item. This avoids needing to thread focus state through every layout component (which would require heavy upstream file modifications). + +**How it works:** + +1. Each issue block gets a `data-issue-id` attribute (1-line change per layout) +2. A `KeyboardNavProvider` wraps the list container, attaches a `keydown` listener +3. The provider queries DOM for `[data-issue-id]` elements within its container to build the navigation order +4. Focus is tracked as a CSS class (`kb-focused`) applied/removed via direct DOM manipulation +5. The Power-K `ShortcutHandler` is temporarily disabled while keyboard nav is active in a list (to avoid j/k conflicts), re-enabled on Escape + +**Why DOM-based:** + +- Minimal coupling to existing component props/state +- Works across list, board, and spreadsheet layouts with same hook +- No changes to MobX stores +- Easy to remove if upstream adds their own keyboard nav + +### Spanish mnemonic shortcuts + +We create a **shortcut override layer** that replaces upstream single-key shortcuts with Spanish equivalents. This is done via a new config file that registers commands with the same `id` but different `shortcut` values. Since the Power-K registry uses `id` as a unique key, re-registering with the same `id` replaces the command. + +| Action | Upstream key | Spanish key | Mnemonic | +| ----------------- | ------------ | ----------- | -------------------- | +| Cambiar estado | `S` | `E` | **E**stado | +| Cambiar prioridad | `P` | `P` | **P**rioridad (same) | +| Asignar a | `A` | `A` | **A**signado (same) | +| Asignarme | `I` | `Y` | **Y**o | +| Etiquetas | `L` | `T` | e**T**iquetas | +| Suscribirse | `Shift+S` | `Shift+N` | **N**otificaciones | + +Keys that are already Spanish-compatible (`P`, `A`) stay the same. Modifier shortcuts (`Cmd+E`, `Cmd+C`, `Cmd+M`, `Cmd+Shift+,`, etc.) are not remapped — they don't rely on language mnemonics. + +### Navigation sequence remapping (G→I, O→A, N stays N) + +Upstream uses English-mnemonic prefixes for two-key sequences: `G` (Go to), `O` (Open), `N` (New). We remap these to Spanish: + +- `G` → `I` (**I**r a) — works because remapping `I` (Assign to me) to `Y` (Yo) frees up `I` as a sequence prefix +- `O` → `A` (**A**brir) — works because `A` (Assignee) is a contextual shortcut that only fires on work-item detail pages; `A` as a sequence prefix fires everywhere else. However, we need to verify the ShortcutHandler doesn't conflict (see note below) +- `N` → `N` (**N**uevo) — already Spanish-compatible, no change + +**Critical note on `A` prefix conflict:** The current `ShortcutHandler` fires single-key shortcuts immediately on the first keypress (before waiting for a second key). This means `A` can't be both a single-key shortcut AND a sequence prefix without the single-key always winning. Two options: + +1. **Keep `O` for "Open/Abrir"** — simple, no conflict, O is universal enough +2. **Modify handler** to delay single-key execution by ~300ms when a sequence starting with that key exists + +**Decision: Keep `O` for Open.** The `I` → `I` remap is clean (freed by `Y` remap). Remapping `O` → `A` has a real conflict. Not worth the complexity. + +**Full "Ir a" (I then X) sequence mapping:** + +| Upstream | Destination | Spanish | New Sequence | Mnemonic | +| -------- | ------------- | -------------- | ------------ | ------------------------- | +| `gh` | Home | Inicio | `ii` | **I**r **I**nicio | +| `gx` | Inbox | Notificaciones | `in` | **I**r **N**otificaciones | +| `gy` | Your Work | Mi trabajo | `im` | **I**r **M**i trabajo | +| `gp` | Projects list | Proyectos | `ip` | **I**r **P**royectos | +| `ga` | Analytics | Analíticas | `ia` | **I**r **A**nalíticas | +| `gj` | Drafts | Borradores | `ib` | **I**r **B**orradores | +| `gr` | Archives | Archivados | `ih` | **I**r arc**H**ivados | +| `gs` | Settings | Configuración | `ic` | **I**r **C**onfiguración | +| `gi` | Work Items | Tareas | `it` | **I**r **T**areas | +| `gc` | Cycles | Ciclos | `il` | **I**r cic**L**os | +| `gm` | Modules | Módulos | `io` | **I**r m**Ó**dulos | +| `gv` | Views | Vistas | `iv` | **I**r **V**istas | +| `gd` | Pages | Documentos | `id` | **I**r **D**ocumentos | +| `gk` | Intake | Recepción | `ir` | **I**r **R**ecepción | + +**"Open" (O then X) — kept as-is, just second letter remapped where beneficial:** + +| Upstream | Destination | Spanish | New Sequence | Mnemonic | +| -------- | -------------- | -------------- | --------------------------- | -------------------- | +| `ow` | Open Workspace | Abrir espacio | `oe` | **O**pen **E**spacio | +| `op` | Open Project | Abrir Proyecto | `op` | (same) | +| `oc` | Open Cycle | Abrir Ciclo | `oc` | (same) | +| `om` | Open Module | Abrir Módulo | `om` | (same) | +| `ov` | Open View | Abrir Vista | `ov` | (same) | +| `os` | Open Settings | Abrir Config | `oc`... conflict with cycle | keep `os` | + +Most "Open" second letters are already fine (`P`, `C`, `M`, `V` match Spanish). Only `ow` → `oe` (Espacio) is worth changing. + +**"Nuevo" (N then X) — remap second letters to Spanish:** + +| Upstream | Entity | Spanish | New Sequence | Mnemonic | +| -------- | ------------- | --------------- | ------------ | ------------------------------ | +| `ni` | New Work Item | Nueva Tarea | `nt` | **N**ueva **T**area | +| `nd` | New Page | Nuevo Documento | `nd` | **N**uevo **D**ocumento (same) | +| `nv` | New View | Nueva Vista | `nv` | (same) | +| `nc` | New Cycle | Nuevo Ciclo | `nc` | (same) | +| `nm` | New Module | Nuevo Módulo | `nm` | (same) | +| `np` | New Project | Nuevo Proyecto | `np` | (same) | + +Only `ni` → `nt` (Tarea) needs changing. The rest are already Spanish-compatible. + +### Dynamic project shortcuts (I + project letter) + +Beyond the fixed "Ir a" sequences, we register **dynamic shortcuts** that let users jump directly to a project by pressing `I` followed by the first letter of the project's identifier. + +**How it works:** + +1. On workspace load, read all project identifiers (e.g., `SIN` for Siniestros, `VDA` for Vida, `AUT` for Auto) +2. For each project, register a `keySequence` of `i` + lowercase first letter of identifier (e.g., `is`, `iv`, `ia`) +3. If multiple projects share the same first letter, the sequence opens a **disambiguation picker** (reuse the existing Power-K "change-page" pattern — like `op` opens a project picker, but pre-filtered) +4. If a dynamic sequence conflicts with a fixed "Ir a" sequence (e.g., `ia` = Analíticas AND a project starting with A), the fixed sequence wins and the project is only reachable via the picker or `op` + +**Registration:** +Dynamic commands use `type: "action"` when unambiguous (single project for that letter) and `type: "change-page"` when disambiguation is needed (multiple projects share a letter). + +**Implementation:** + +- New file: `apps/web/core/components/power-k/config/dynamic-project-shortcuts.ts` +- Hook: `useDynamicProjectShortcuts()` reads projects from `useProject()` store, returns `TPowerKCommandConfig[]` +- Registered in `commands.ts` alongside other command groups + +**Example for a workspace with projects SIN, VDA, AUT, AHO:** +| Sequence | Action | Note | +|---|---|---| +| `is` | Navigate to SIN (Siniestros) | Unambiguous — direct nav | +| `iv` | Navigate to VDA (Vida) | Unambiguous — direct nav | +| `ia` | Fixed: Ir a Analíticas | Conflict — fixed wins | +| — | AUT and AHO reachable via `op` picker | Dynamic shortcuts shadowed by fixed `ia` | + +--- + +## Phases + +### Phase 1: Tracer Bullet — j/k in list view (single group, no selection) + +The smallest end-to-end slice: pressing `j`/`k` moves a visible focus indicator through work items in the list layout. + +**Step 1.1: Add `data-issue-id` to list blocks** + +In `list/block.tsx`, add one attribute to the `ControlLink`: + +```tsx +// Before: + + +// After: + +``` + +**Step 1.2: Create `use-issue-list-keyboard-nav.ts`** + +Core hook that: + +- Takes a container `ref` +- Queries `[data-issue-id]` elements to build ordered list +- Tracks `focusedIndex` (number) +- On `j`/`ArrowDown`: increment index, apply `kb-focused` class, scroll into view +- On `k`/`ArrowUp`: decrement index, apply `kb-focused` class, scroll into view +- On `Enter`: trigger click on focused element (opens peek overview) +- On `Escape`: clear focus, re-enable Power-K shortcuts +- Skips when `isTypingInInput()` returns true (reuse from `shortcut-handler.ts`) +- Disables Power-K `ShortcutHandler` while nav is active (call `handler.setEnabled(false)`) + +**Step 1.3: Create `keyboard-nav-provider.tsx`** + +Thin wrapper component that: + +- Wraps children with the hook +- Provides keyboard nav state via context (for future phases: "is nav active?", "focused issue id") +- Exposes `focusedIssueId` for other components to consume + +**Step 1.4: Integrate into `list/default.tsx`** + +Wrap the scroll container content: + +```tsx + + {groups.map((group) => )} + +``` + +**Step 1.5: CSS for focus indicator** + +Add a `kb-focused` class rule. This can go in the global CSS or a new CSS module: + +```css +[data-issue-id].kb-focused { + outline: 2px solid var(--color-accent-primary); + outline-offset: -2px; + border-radius: 4px; +} +``` + +**Verify:** Open list view, press `j`/`k`, see focus ring move, press `Enter`, peek opens. + +--- + +### Phase 2: Expand navigation to board and spreadsheet + +**Step 2.1: Board (Kanban) view** + +Add `data-issue-id` to `kanban/block.tsx`. The keyboard nav hook needs 2D navigation: + +- `j`/`k` or `ArrowDown`/`ArrowUp`: move within the current column +- `h`/`l` or `ArrowLeft`/`ArrowRight`: move to the same row position in adjacent column + +The hook detects layout from container attributes (`data-layout="kanban"`) and switches between 1D and 2D navigation modes. + +**Step 2.2: Spreadsheet view** + +The existing `useTableKeyboardNavigation` already handles arrow keys on ``/`` elements. We add `j`/`k` as aliases that call the same logic. No conflict — `j`/`k` only fire when not in an input. + +**Step 2.3: Integrate provider into board and spreadsheet root components** + +Same pattern as list: wrap with `KeyboardNavProvider`, pass container ref. + +**Verify:** Board view — `j`/`k` moves within column, `h`/`l` jumps columns. Spreadsheet — `j`/`k` moves rows, arrow keys still work as before. + +--- + +### Phase 3: Selection and bulk operations + +**Step 3.1: `x` to toggle selection** + +When an item is `kb-focused`, pressing `x` calls `selectionHelpers.handleEntityClick()` with the focused item's ID. This integrates with the existing `MultipleSelectGroup` and `IssueBulkOperationsRoot` system — no new selection logic needed. + +**Step 3.2: `Shift+j`/`Shift+k` for range selection** + +Extend the hook: when Shift is held, moving focus also toggles selection on each item traversed. Uses the same `handleEntityClick` with a synthetic shift-click event. + +**Step 3.3: `Escape` clears selection** + +If selection is active, first `Escape` clears selection. Second `Escape` exits keyboard nav mode. + +**Verify:** Focus item with `j`/`k`, press `x`, see checkbox appear and bulk ops bar. `Shift+j` down 3 items selects all 3. `Escape` clears. + +--- + +### Phase 4: Spanish mnemonic shortcut overrides + +**Step 4.1: Create `clausulo-shortcut-overrides.ts`** + +This file exports a function that returns an array of `TPowerKCommandConfig` objects with the same `id`s as upstream commands but different `shortcut` values: + +```ts +// Overrides upstream shortcuts with Spanish mnemonics +export const getClausuloShortcutOverrides = (): Partial[] => [ + { id: "change_work_item_state", shortcut: "e" }, // Estado + { id: "assign_work_item_to_me", shortcut: "y" }, // Yo + { id: "add_work_item_labels", shortcut: "t" }, // eTiquetas + { id: "subscribe_work_item", modifierShortcut: "shift+n" }, // Notificaciones +]; +``` + +**Step 4.2: Apply overrides in `commands.ts`** + +At the bottom of `useProjectsAppPowerKCommands`, merge overrides: + +```ts +import { getClausuloShortcutOverrides } from "./clausulo-shortcut-overrides"; + +// ... existing code ... +const overrides = getClausuloShortcutOverrides(); +return applyOverrides([...allCommands], overrides); +``` + +The `applyOverrides` utility finds commands by `id` and merges the override fields. This is a ~3-line addition to an existing file. + +**Step 4.3: Update shortcuts display** + +The shortcuts modal (`shortcuts-root.tsx`) reads from the registry, which already reflects the overrides. No change needed — it will automatically show `E` instead of `S`, etc. + +**Step 4.4: Fix i18n gaps in shortcut display** + +The hardcoded `"then"` in key sequence badges (`command-item-shortcut-badge.tsx`) needs i18n: + +- Add `power_k.sequence_separator` key → Spanish: `"luego"`, English: `"then"` +- Replace hardcoded `"then"` with `t("power_k.sequence_separator")` + +Also i18n the shortcuts modal title (`"Keyboard shortcuts"` → `"Atajos de teclado"`) and search placeholder. + +**Verify:** Open work item, press `E`, state selector opens. Press `T`, labels open. `Cmd+/` shows Spanish labels with "luego" instead of "then" in sequences. + +--- + +### Phase 5: Navigation sequence remapping (G→I, N second letters) + +Remap the two-key navigation sequences to Spanish prefixes and mnemonics. + +**Step 5.1: Add sequence overrides to `clausulo-shortcut-overrides.ts`** + +Extend the override file from Phase 4 with `keySequence` remaps for all "Ir a" commands: + +```ts +// Navigation: G → I prefix, Spanish second letters +{ id: "nav_home", keySequence: "ii" }, // Ir a Inicio +{ id: "nav_inbox", keySequence: "in" }, // Ir a Notificaciones +{ id: "nav_your_work", keySequence: "im" }, // Ir a Mi trabajo +{ id: "nav_projects_list", keySequence: "ip" }, // Ir a Proyectos +{ id: "nav_workspace_analytics", keySequence: "ia" }, // Ir a Analíticas +{ id: "nav_workspace_drafts", keySequence: "ib" }, // Ir a Borradores +{ id: "nav_workspace_archives", keySequence: "ih" }, // Ir a arcHivados +{ id: "nav_workspace_settings", keySequence: "ic" }, // Ir a Configuración +{ id: "nav_project_work_items", keySequence: "it" }, // Ir a Tareas +{ id: "nav_project_cycles", keySequence: "il" }, // Ir a cicLos +{ id: "nav_project_modules", keySequence: "io" }, // Ir a módulOs +{ id: "nav_project_views", keySequence: "iv" }, // Ir a Vistas +{ id: "nav_project_pages", keySequence: "id" }, // Ir a Documentos +{ id: "nav_project_intake", keySequence: "ir" }, // Ir a Recepción +// Creation: only ni→nt needs remapping +{ id: "create_work_item", keySequence: "nt" }, // Nueva Tarea +// Open: only ow→oe needs remapping +{ id: "open_workspace", keySequence: "oe" }, // Open Espacio +``` + +The override mechanism (same `id` replaces the command) handles this cleanly — upstream's `gh`, `gx`, etc. are replaced by `ii`, `in`, etc. + +**Step 5.2: Update i18n group titles** + +Add a translation for the "Ir a" group if Power-K groups it separately: + +```ts +"power_k.group_titles.navigation": "Navegación", +"power_k.group_titles.ir_a": "Ir a", +``` + +**Verify:** Press `I` then `T` → navigates to Tareas. Press `I` then `P` → navigates to Projects list. `Cmd+/` shows "I luego T" instead of "G then I" for work items. + +--- + +### Phase 6: Dynamic project shortcuts (I + project letter) + +**Step 6.1: Create `dynamic-project-shortcuts.ts`** + +New file: `apps/web/core/components/power-k/config/dynamic-project-shortcuts.ts` + +Hook `useDynamicProjectShortcuts()`: + +1. Reads `workspaceProjectIds` and `getPartialProjectById` from `useProject()` store +2. Groups projects by first letter of their identifier (lowercased) +3. For each letter with exactly one project: register a `type: "action"` command with `keySequence: "i" + letter` that navigates directly +4. For each letter with multiple projects: register a `type: "change-page"` command that opens a filtered project picker +5. Skip any letter that conflicts with a fixed "Ir a" sequence (`i`, `n`, `m`, `p`, `a`, `b`, `h`, `c`, `t`, `l`, `o`, `v`, `d`, `r`) — those projects are only reachable via `O then P` picker + +```ts +export const useDynamicProjectShortcuts = (): TPowerKCommandConfig[] => { + const { workspaceProjectIds, getPartialProjectById } = useProject(); + + // Reserved second-letters used by fixed "Ir a" sequences + const RESERVED = new Set("inmpabhctlovdr".split("")); + + // Group projects by first letter of identifier + const byLetter = new Map(); + for (const pid of workspaceProjectIds ?? []) { + const proj = getPartialProjectById(pid); + if (!proj?.identifier) continue; + const letter = proj.identifier[0].toLowerCase(); + if (RESERVED.has(letter)) continue; // skip conflicts with fixed sequences + if (!byLetter.has(letter)) byLetter.set(letter, []); + byLetter.get(letter)!.push(proj); + } + + const commands: TPowerKCommandConfig[] = []; + for (const [letter, projects] of byLetter) { + if (projects.length === 1) { + const proj = projects[0]; + commands.push({ + id: `dynamic_nav_project_${proj.id}`, + type: "action", + group: "navigation", + i18n_title: proj.name, // dynamic — not i18n'd, uses project name directly + icon: Briefcase, + keySequence: `i${letter}`, + action: (ctx) => + handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "projects", proj.id, "issues"]), + isEnabled: (ctx) => Boolean(ctx.params.workspaceSlug), + isVisible: (ctx) => Boolean(ctx.params.workspaceSlug), + closeOnSelect: true, + }); + } else { + // Multiple projects share this letter — open picker pre-filtered + commands.push({ + id: `dynamic_nav_projects_${letter}`, + type: "change-page", + group: "navigation", + i18n_title: `Ir a proyecto (${letter.toUpperCase()}...)`, + icon: Briefcase, + keySequence: `i${letter}`, + page: "open-project", // reuse existing project picker + onSelect: (data, ctx) => { + const proj = data as IPartialProject; + handlePowerKNavigate(ctx, [ctx.params.workspaceSlug?.toString(), "projects", proj.id, "issues"]); + }, + isEnabled: (ctx) => Boolean(ctx.params.workspaceSlug), + isVisible: (ctx) => Boolean(ctx.params.workspaceSlug), + closeOnSelect: true, + }); + } + } + return commands; +}; +``` + +**Step 6.2: Register in `commands.ts`** + +Add import and spread: + +```ts +import { useDynamicProjectShortcuts } from "./dynamic-project-shortcuts"; +// ... +const dynamicProjectCommands = useDynamicProjectShortcuts(); +return [...allCommands, ...dynamicProjectCommands]; +``` + +**Step 6.3: Show in shortcuts modal** + +Dynamic commands automatically appear in the shortcuts modal since they're registered in the registry. They show as "I luego S → Siniestros", "I luego V → Vida", etc. + +**Verify:** In a workspace with project identifier `SIN`, press `I` then `S` → navigates directly to Siniestros. If another project `SAL` exists, `I` then `S` opens picker showing both. Projects whose letter conflicts with fixed sequences (e.g., identifier `TEC` → `t` is reserved for Tareas) only appear in the `O then P` picker. + +--- + +### Phase 7: Register keyboard nav as Power-K commands + +**Step 5.1: Create `keyboard-nav-commands.ts`** + +Register j/k/Enter/Escape/x as Power-K commands so they appear in the shortcuts modal: + +```ts +{ + id: "kb_nav_down", + i18n_title: "power_k.keyboard_nav.move_down", + shortcut: "j", // also responds to ArrowDown (handled in hook, not Power-K) + group: "keyboard_nav", + // ... display-only, actual handling is in the nav hook +} +``` + +These are **display-only commands** (no `action`) — they exist so the shortcuts modal shows them. The actual key handling lives in the nav hook, which intercepts before Power-K. + +**Step 5.2: Add translations** + +In `es/translations.ts`, add: + +```ts +"power_k.keyboard_nav.move_down": "Mover abajo", +"power_k.keyboard_nav.move_up": "Mover arriba", +"power_k.keyboard_nav.open_item": "Abrir elemento", +"power_k.keyboard_nav.toggle_select": "Seleccionar/deseleccionar", +"power_k.keyboard_nav.extend_selection_down": "Extender seleccion abajo", +"power_k.keyboard_nav.extend_selection_up": "Extender seleccion arriba", +"power_k.keyboard_nav.clear_focus": "Quitar foco", +"power_k.keyboard_nav.move_left": "Mover izquierda", +"power_k.keyboard_nav.move_right": "Mover derecha", +"power_k.sequence_separator": "luego", +"power_k.shortcuts_modal.title": "Atajos de teclado", +"power_k.shortcuts_modal.search_placeholder": "Buscar atajos", +"power_k.shortcuts_modal.no_results": "No se encontraron atajos para", +``` + +**Verify:** `Cmd+/` shows a "Navegacion" group with all nav shortcuts listed in Spanish. + +--- + +## Summary: all touched files + +### New files (6) + +| File | Purpose | +| ------------------------------------------------------------------------- | ----------------------------------------- | +| `apps/web/core/hooks/use-issue-list-keyboard-nav.ts` | Core j/k/Enter/Escape/x logic | +| `apps/web/core/components/issues/issue-layouts/keyboard-nav-provider.tsx` | Context provider wrapping layouts | +| `apps/web/core/components/power-k/config/clausulo-shortcut-overrides.ts` | Spanish mnemonic remaps | +| `apps/web/core/components/power-k/config/keyboard-nav-commands.ts` | Display-only commands for shortcuts modal | +| `apps/web/core/styles/keyboard-nav.css` | Focus ring styles | +| _(possibly)_ `packages/i18n/src/locales/es/keyboard-nav.ts` | If translations overflow main file | + +### Modified files (7, all minimal) + +| File | Lines changed | Nature | +| -------------------------------------------------- | ------------- | ------------------------------------------ | +| `list/block.tsx` | +1 | Add `data-issue-id` attribute | +| `kanban/block.tsx` | +1 | Add `data-issue-id` attribute | +| `list/default.tsx` | +3 | Wrap with `KeyboardNavProvider` | +| `kanban/default.tsx` or `kanban/root.tsx` | +3 | Wrap with `KeyboardNavProvider` | +| `power-k/config/commands.ts` | +3 | Import + spread nav commands and overrides | +| `packages/i18n/src/locales/es/translations.ts` | +15 | New keys appended to `power_k` section | +| `power-k/ui/modal/command-item-shortcut-badge.tsx` | +2, -1 | Replace hardcoded "then" with i18n | + +--- + +## Unresolved Questions + +1. **j/k vs arrow-only?** — j/k is Linear convention and vim-familiar; arrow keys are more discoverable. Support both, or j/k only to avoid arrow-key conflicts in inputs? +2. **Focus persistence across re-renders?** — When the list re-sorts or filters change, should focus stick to the same issue (by ID) or reset to top? +3. **Board 2D nav** — When pressing `l` (right) on the last item in a column, wrap to top of next column? Or stay at same row index? +4. **Power-K disable scope** — Should j/k completely disable single-key Power-K shortcuts, or only block j/k specifically? (If the former, pressing `s` while navigating won't open state picker — might be surprising.) +5. **Shortcut override persistence** — Should the Spanish remaps be hardcoded, or should we build a locale-to-shortcuts mapping system for future languages? +6. **Upstream's `S` key still works?** — If a user switches locale to English, should shortcuts revert to `S`? Or are our remaps always active regardless of locale? diff --git a/plans/plan.md b/plans/plan.md new file mode 100644 index 00000000000..ad6c8ac334d --- /dev/null +++ b/plans/plan.md @@ -0,0 +1,1238 @@ +# Implementation Plan: Insurance Brokerage Work Model on Plane + +**Principle: reuse existing Plane UI as much as absolutely possible. Avoid creating new UI elements unless there is no alternative.** + +## Desired Outcomes + +Transform a clean Plane clone into an insurance brokerage operations platform with: + +1. **Cases as Issues** — IssueTypes (Claim, Renewal, Endorsement, Billing, General) using existing Plane fields (no custom properties needed) +2. **Entity links** — link cases to policies/claims in the `cima` DB via a new `CaseEntityLink` model +3. **Sidebar navigation** — case type bins replace the project list (projects invisible) +4. **Entity display** — resolved policy/claim data visible in issue peek and creation modal +5. **Entity filtering** — issue list filterable by linked policy/claim +6. **Upstream sync preserved** — all changes additive, new files preferred, merge-friendly + +### Tests / Verification + +- [ ] Sidebar shows case type tabs (Claims, Renewals, etc.) instead of project list; clicking filters issues by IssueType +- [ ] Creating a case with type "Claim" works; entity link selector appears in creation modal +- [ ] Entity link CRUD works: link a policy UUID, see resolved label from cima; edit role; delete (with protection) +- [ ] Peek overview shows entity links section below standard properties +- [ ] Sub-issues (work items) display under each case +- [ ] Issue list can be filtered by linked policy/claim +- [ ] `git merge upstream/preview` produces clean or trivially-resolvable conflicts +- [ ] All native Plane features (kanban, filters, drag-drop, templates, intake) still work + +--- + +## Phase 1: Setup & Git Strategy + +### Step 1.1: Create `.gitattributes` + +> Verify: file exists at repo root + +```gitattributes +apps/api/plane/db/models/__init__.py merge=ours +apps/api/plane/app/urls/__init__.py merge=ours +apps/api/plane/api/urls/__init__.py merge=ours +``` + +### Step 1.2: Verify Plane runs + +```bash +# Docker up, frontend at localhost:3000 +# Workspace exists, at least one project exists +``` + +### Step 1.3: Add upstream remote + +```bash +git remote add upstream https://github.com/makeplane/plane.git +git fetch upstream +``` + +--- + +## Phase 2: Backend — CaseEntityLink Model + +### Step 2.1: Create model + +**New file**: `apps/api/plane/db/models/case_entity_link.py` + +```python +from django.db import models +from django.db.models import Q +from plane.db.models.project import ProjectBaseModel + + +class CaseEntityLink(ProjectBaseModel): + """Links a Plane issue (case) to a policy or claim in the cima DB.""" + + ENTITY_TYPE_CHOICES = ( + ("policy", "Policy"), + ("claim", "Claim"), + ) + ROLE_CHOICES = ( + ("primary", "Primary"), + ("related", "Related"), + ) + + issue = models.ForeignKey( + "db.Issue", + on_delete=models.CASCADE, + related_name="entity_links", + ) + entity_type = models.CharField(max_length=50, choices=ENTITY_TYPE_CHOICES) + entity_id = models.UUIDField() + role = models.CharField(max_length=30, choices=ROLE_CHOICES, default="primary") + + class Meta: + db_table = "case_entity_links" + ordering = ("-created_at",) + constraints = [ + models.UniqueConstraint( + fields=["issue", "entity_type", "entity_id"], + condition=Q(deleted_at__isnull=True), + name="case_entity_link_unique_active", + ) + ] + + def __str__(self): + return f"{self.entity_type}:{self.entity_id} -> Issue {self.issue_id}" +``` + +**Key decisions:** + +- Inherits `ProjectBaseModel` → gets `project`, `workspace`, `created_at`, `updated_at`, `deleted_at`, `created_by`, `updated_by`, UUID `id` +- FK to `db.Issue` (not a custom model) — cases ARE issues +- `entity_id` is a UUID pointing to `cima.policies.id` or `cima.claims.id` +- Unique constraint uses `deleted_at__isnull=True` condition (matches Plane's soft-delete pattern) + +### Step 2.2: Register model + +**Modify**: `apps/api/plane/db/models/__init__.py` + +Append at end of file (after the `Description` import): + +```python +from .case_entity_link import CaseEntityLink +``` + +### Step 2.3: Create migration + +```bash +cd apps/api +python manage.py makemigrations db +python manage.py migrate +``` + +--- + +## Phase 3: Backend — core_db_resolver + +### Step 3.1: Create resolver + +**New file**: `apps/api/plane/utils/core_db_resolver.py` + +```python +from django.db import connection + + +def resolve_policy(policy_id): + """Resolve a cima policy UUID to human-readable data.""" + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT p.id, p.policy_number, + ic.name AS insurer_name, + pv.policy_status AS status + FROM cima.policies p + JOIN cima.policy_versions pv ON pv.id = p.active_version_id + LEFT JOIN ref.insurance_companies ic ON ic.id = pv.insurance_company_id + WHERE p.id = %s + """, + [str(policy_id)], + ) + row = cursor.fetchone() + if not row: + return None + return { + "id": str(row[0]), + "policy_number": row[1], + "insurer_name": row[2], + "status": row[3], + } + + +def resolve_claim(claim_id): + """Resolve a cima claim UUID to human-readable data.""" + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT c.id, c.claim_number, c.claim_status, + c.policy_id + FROM cima.claims c + WHERE c.id = %s + """, + [str(claim_id)], + ) + row = cursor.fetchone() + if not row: + return None + return { + "id": str(row[0]), + "claim_number": row[1], + "status": row[2], + "policy_id": str(row[3]) if row[3] else None, + } + + +def resolve_policy_label(policy_id): + """Short label for sidebar display: 'P-12345 (Mapfre, Active)'.""" + data = resolve_policy(policy_id) + if not data: + return f"Unknown policy ({policy_id})" + parts = [data["policy_number"]] + if data.get("insurer_name"): + parts.append(data["insurer_name"]) + if data.get("status"): + parts.append(data["status"]) + return f"{parts[0]} ({', '.join(parts[1:])})" + + +def resolve_claim_label(claim_id): + """Short label: 'CLM-789 (Open)'.""" + data = resolve_claim(claim_id) + if not data: + return f"Unknown claim ({claim_id})" + return f"{data['claim_number']} ({data.get('status', 'Unknown')})" + + +def get_claim_policy_id(claim_id): + """Get the policy_id for a claim from cima.""" + data = resolve_claim(claim_id) + if not data: + return None + return data.get("policy_id") + + +def resolve_entity(entity_type, entity_id): + """Generic dispatcher.""" + if entity_type == "policy": + return resolve_policy(entity_id) + elif entity_type == "claim": + return resolve_claim(entity_id) + return None + + +def resolve_entity_label(entity_type, entity_id): + """Generic label dispatcher.""" + if entity_type == "policy": + return resolve_policy_label(entity_id) + elif entity_type == "claim": + return resolve_claim_label(entity_id) + return f"Unknown {entity_type}" + + +def resolve_case_links(links): + """Resolve a list of CaseEntityLink objects to labeled dicts.""" + results = [] + for link in links: + results.append( + { + "id": str(link.id), + "entity_type": link.entity_type, + "entity_id": str(link.entity_id), + "role": link.role, + "label": resolve_entity_label(link.entity_type, link.entity_id), + "detail": resolve_entity(link.entity_type, link.entity_id), + "created_at": link.created_at.isoformat() if link.created_at else None, + } + ) + return results +``` + +**Key decisions:** + +- Raw SQL against `cima` schema — no Django models for cima tables (cima is managed by polichat-data-platform) +- Uses `django.db.connection.cursor()` — same DB, different schema +- Label functions for compact display; detail functions for expanded view +- `resolve_case_links()` takes ORM objects, returns JSON-serializable dicts + +--- + +## Phase 4: Backend — Entity Link API + +### Step 4.1: Create serializer + +**New file**: `apps/api/plane/app/serializers/case_entity_link.py` + +```python +from rest_framework import serializers +from plane.app.serializers.base import BaseSerializer +from plane.db.models import CaseEntityLink + + +class CaseEntityLinkSerializer(BaseSerializer): + label = serializers.CharField(read_only=True, required=False) + + class Meta: + model = CaseEntityLink + fields = [ + "id", + "issue", + "entity_type", + "entity_id", + "role", + "label", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "issue", "label", "created_at", "updated_at"] + + +class CaseEntityLinkCreateSerializer(BaseSerializer): + class Meta: + model = CaseEntityLink + fields = [ + "entity_type", + "entity_id", + "role", + ] +``` + +### Step 4.2: Register serializer + +**Modify**: `apps/api/plane/app/serializers/__init__.py` + +Append: + +```python +from .case_entity_link import CaseEntityLinkSerializer, CaseEntityLinkCreateSerializer +``` + +### Step 4.3: Create view + +**New file**: `apps/api/plane/app/views/case/__init__.py` + +```python +from .entity_link import CaseEntityLinkViewSet, ResolveEntityEndpoint +``` + +**New file**: `apps/api/plane/app/views/case/entity_link.py` + +```python +from rest_framework import status +from rest_framework.response import Response + +from plane.app.permissions import ROLE, allow_permission +from plane.app.serializers import ( + CaseEntityLinkSerializer, + CaseEntityLinkCreateSerializer, +) +from plane.app.views.base import BaseViewSet, BaseAPIView +from plane.db.models import CaseEntityLink, Issue +from plane.utils.core_db_resolver import ( + resolve_case_links, + resolve_entity, + resolve_entity_label, + get_claim_policy_id, +) + + +class CaseEntityLinkViewSet(BaseViewSet): + serializer_class = CaseEntityLinkSerializer + model = CaseEntityLink + + def get_queryset(self): + return CaseEntityLink.objects.filter( + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + issue_id=self.kwargs.get("issue_id"), + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def list(self, request, slug, project_id, issue_id): + links = self.get_queryset() + resolved = resolve_case_links(links) + return Response(resolved, status=status.HTTP_200_OK) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def create(self, request, slug, project_id, issue_id): + serializer = CaseEntityLinkCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + entity_type = serializer.validated_data["entity_type"] + entity_id = serializer.validated_data["entity_id"] + role = serializer.validated_data.get("role", "primary") + + # Verify entity exists in cima + resolved = resolve_entity(entity_type, entity_id) + if not resolved: + return Response( + {"error": f"{entity_type} {entity_id} not found in cima"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # If linking a claim, ensure the policy link is consistent + if entity_type == "claim": + claim_policy_id = get_claim_policy_id(entity_id) + if claim_policy_id: + # Check if case already has a policy link + existing_policy = self.get_queryset().filter( + entity_type="policy", role="primary" + ).first() + if existing_policy and str(existing_policy.entity_id) != claim_policy_id: + # Replace with the claim's policy + existing_policy.delete() + CaseEntityLink.objects.create( + issue_id=issue_id, + project_id=project_id, + workspace=Issue.objects.get(id=issue_id).workspace, + entity_type="policy", + entity_id=claim_policy_id, + role="primary", + ) + elif not existing_policy: + CaseEntityLink.objects.create( + issue_id=issue_id, + project_id=project_id, + workspace=Issue.objects.get(id=issue_id).workspace, + entity_type="policy", + entity_id=claim_policy_id, + role="primary", + ) + + link = CaseEntityLink.objects.create( + issue_id=issue_id, + project_id=project_id, + workspace=Issue.objects.get(id=issue_id).workspace, + entity_type=entity_type, + entity_id=entity_id, + role=role, + ) + + return Response( + { + "id": str(link.id), + "entity_type": link.entity_type, + "entity_id": str(link.entity_id), + "role": link.role, + "label": resolve_entity_label(entity_type, entity_id), + "detail": resolved, + }, + status=status.HTTP_201_CREATED, + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def partial_update(self, request, slug, project_id, issue_id, pk): + link = self.get_queryset().filter(id=pk).first() + if not link: + return Response(status=status.HTTP_404_NOT_FOUND) + + serializer = CaseEntityLinkCreateSerializer(link, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response( + { + "id": str(link.id), + "entity_type": link.entity_type, + "entity_id": str(link.entity_id), + "role": link.role, + "label": resolve_entity_label(link.entity_type, link.entity_id), + "detail": resolve_entity(link.entity_type, link.entity_id), + }, + status=status.HTTP_200_OK, + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def destroy(self, request, slug, project_id, issue_id, pk): + link = self.get_queryset().filter(id=pk).first() + if not link: + return Response(status=status.HTTP_404_NOT_FOUND) + + # Deletion protection: block if any sub-issue has work started + sub_issues = Issue.issue_objects.filter(parent_id=issue_id) + started_states = ["started", "completed"] + has_started_work = sub_issues.filter( + state__group__in=started_states + ).exists() + + if has_started_work: + return Response( + {"error": "Cannot remove entity link after work has started on sub-items"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + link.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ResolveEntityEndpoint(BaseAPIView): + """Ad-hoc entity resolution — look up a cima entity by type + UUID.""" + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def get(self, request, slug, project_id): + entity_type = request.query_params.get("entity_type") + entity_id = request.query_params.get("entity_id") + + if not entity_type or not entity_id: + return Response( + {"error": "entity_type and entity_id are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + resolved = resolve_entity(entity_type, entity_id) + if not resolved: + return Response( + {"error": f"{entity_type} {entity_id} not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + return Response( + { + "entity_type": entity_type, + "entity_id": entity_id, + "label": resolve_entity_label(entity_type, entity_id), + "detail": resolved, + }, + status=status.HTTP_200_OK, + ) +``` + +### Step 4.4: Register views + +**Modify**: `apps/api/plane/app/views/__init__.py` + +Append: + +```python +from .case import CaseEntityLinkViewSet, ResolveEntityEndpoint +``` + +### Step 4.5: Create URL routing + +**New file**: `apps/api/plane/app/urls/case_entity_link.py` + +```python +from django.urls import path +from plane.app.views import CaseEntityLinkViewSet, ResolveEntityEndpoint + +urlpatterns = [ + path( + "workspaces//projects//issues//entity-links/", + CaseEntityLinkViewSet.as_view({"get": "list", "post": "create"}), + name="case-entity-links", + ), + path( + "workspaces//projects//issues//entity-links//", + CaseEntityLinkViewSet.as_view({"patch": "partial_update", "delete": "destroy"}), + name="case-entity-link-detail", + ), + path( + "workspaces//projects//resolve-entity/", + ResolveEntityEndpoint.as_view(), + name="resolve-entity", + ), +] +``` + +### Step 4.6: Register URLs + +**Modify**: `apps/api/plane/app/urls/__init__.py` + +Add import (with other imports): + +```python +from .case_entity_link import urlpatterns as case_entity_link_urls +``` + +Add to `urlpatterns` list: + +```python +*case_entity_link_urls, +``` + +--- + +## Phase 5: Seed Script — IssueTypes & States + +### Step 5.1: Create management command + +**New file**: `apps/api/plane/db/management/commands/seed_case_config.py` + +```python +from django.core.management.base import BaseCommand +from plane.db.models import ( + IssueType, + ProjectIssueType, + State, + Project, +) +from plane.db.models.workspace import Workspace + + +CASE_TYPES = [ + {"name": "Claim", "description": "Insurance claim case"}, + {"name": "Renewal", "description": "Policy renewal case"}, + {"name": "Endorsement", "description": "Policy change / endorsement"}, + {"name": "Billing Dispute", "description": "Billing / payment issue"}, + {"name": "General Service", "description": "General service request"}, +] + +CASE_STATES = [ + {"name": "New", "group": "backlog", "color": "#3B82F6"}, + {"name": "Triaged", "group": "unstarted", "color": "#8B5CF6"}, + {"name": "In Progress", "group": "started", "color": "#F59E0B"}, + {"name": "Waiting on Client", "group": "started", "color": "#F97316"}, + {"name": "Waiting on Insurer", "group": "started", "color": "#F97316"}, + {"name": "Waiting on Internal", "group": "started", "color": "#F97316"}, + {"name": "Scheduled", "group": "started", "color": "#06B6D4"}, + {"name": "Ready for Review", "group": "started", "color": "#6366F1"}, + {"name": "Resolved", "group": "completed", "color": "#22C55E"}, + {"name": "Closed", "group": "completed", "color": "#6B7280"}, + {"name": "Cancelled", "group": "cancelled", "color": "#9CA3AF"}, +] + + +class Command(BaseCommand): + help = "Seed IssueTypes and states for brokerage cases" + + def add_arguments(self, parser): + parser.add_argument("workspace_slug", type=str) + parser.add_argument("project_identifier", type=str) + + def handle(self, *args, **options): + slug = options["workspace_slug"] + identifier = options["project_identifier"] + + try: + workspace = Workspace.objects.get(slug=slug) + except Workspace.DoesNotExist: + self.stderr.write(self.style.ERROR(f"Workspace '{slug}' not found")) + return + + try: + project = Project.objects.get( + workspace=workspace, identifier=identifier + ) + except Project.DoesNotExist: + self.stderr.write( + self.style.ERROR(f"Project '{identifier}' not found in workspace '{slug}'") + ) + return + + # --- Create IssueTypes --- + self.stdout.write("Creating IssueTypes...") + for i, ct in enumerate(CASE_TYPES): + issue_type, created = IssueType.objects.get_or_create( + workspace=workspace, + name=ct["name"], + defaults={ + "description": ct["description"], + "is_active": True, + "level": float(i), + }, + ) + ProjectIssueType.objects.get_or_create( + project=project, + issue_type=issue_type, + defaults={ + "level": i, + "is_default": i == 0, + }, + ) + verb = "Created" if created else "Already exists" + self.stdout.write(f" {verb}: {ct['name']}") + + # Enable issue types on project + if not project.is_issue_type_enabled: + project.is_issue_type_enabled = True + project.save(update_fields=["is_issue_type_enabled"]) + self.stdout.write(" Enabled issue types on project") + + # --- Replace default states --- + self.stdout.write("Configuring states...") + + # Delete default states (soft delete) + existing = State.objects.filter(project=project) + deleted_count = existing.count() + existing.delete() + self.stdout.write(f" Removed {deleted_count} default states") + + # Create case-specific states + for i, st in enumerate(CASE_STATES): + state = State( + project=project, + workspace=workspace, + name=st["name"], + group=st["group"], + color=st["color"], + sequence=float((i + 1) * 15000), + default=(i == 0), + ) + state.save() + self.stdout.write(f" Created: {st['name']} ({st['group']})") + + # Set the default state on project + default_state = State.objects.filter( + project=project, default=True + ).first() + if default_state: + project.default_state = default_state + project.save(update_fields=["default_state"]) + + self.stdout.write(self.style.SUCCESS("Seed complete.")) +``` + +### Step 5.2: Run seed + +```bash +cd apps/api +python manage.py seed_case_config +``` + +**Note**: No custom properties needed. Queue = IssueType, SLA = target_date, Owner Type = assignee presence. + +--- + +## Phase 6: Frontend — TypeScript Types + +### Step 6.1: Create types + +**New file**: `packages/types/src/cases/index.ts` + +```typescript +export * from "./case-entity-link"; +``` + +**New file**: `packages/types/src/cases/case-entity-link.ts` + +```typescript +export type TCaseEntityLink = { + id: string; + entity_type: "policy" | "claim"; + entity_id: string; + role: "primary" | "related"; + label: string; + detail: TResolvedPolicy | TResolvedClaim | null; + created_at: string; +}; + +export type TResolvedPolicy = { + id: string; + policy_number: string; + insurer_name: string | null; + status: string | null; +}; + +export type TResolvedClaim = { + id: string; + claim_number: string; + status: string | null; + policy_id: string | null; +}; + +export type TCaseEntityLinkCreatePayload = { + entity_type: "policy" | "claim"; + entity_id: string; + role?: "primary" | "related"; +}; + +export type TResolvedEntity = { + entity_type: string; + entity_id: string; + label: string; + detail: TResolvedPolicy | TResolvedClaim | null; +}; +``` + +### Step 6.2: Export from types index + +**Modify**: `packages/types/src/index.ts` + +Append: + +```typescript +export * from "./cases"; +``` + +--- + +## Phase 7: Frontend — Entity Link Service + +### Step 7.1: Create service + +**New file**: `apps/web/core/services/case/index.ts` + +```typescript +export { EntityLinkService } from "./entity-link.service"; +``` + +**New file**: `apps/web/core/services/case/entity-link.service.ts` + +```typescript +import { API_BASE_URL } from "@plane/constants"; +import type { TCaseEntityLink, TCaseEntityLinkCreatePayload, TResolvedEntity } from "@plane/types"; +import { APIService } from "@/services/api.service"; + +export class EntityLinkService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async list(workspaceSlug: string, projectId: string, issueId: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/entity-links/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async create( + workspaceSlug: string, + projectId: string, + issueId: string, + data: TCaseEntityLinkCreatePayload + ): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/entity-links/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async update( + workspaceSlug: string, + projectId: string, + issueId: string, + linkId: string, + data: Partial + ): Promise { + return this.patch( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/entity-links/${linkId}/`, + data + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async remove(workspaceSlug: string, projectId: string, issueId: string, linkId: string): Promise { + return this.delete( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/entity-links/${linkId}/` + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async resolveEntity( + workspaceSlug: string, + projectId: string, + entityType: string, + entityId: string + ): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/resolve-entity/`, { + params: { entity_type: entityType, entity_id: entityId }, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} +``` + +--- + +## Phase 8: Frontend — Entity Link Panel in Peek + +### Step 8.1: Create entity link panel component + +**New file**: `apps/web/core/components/cases/entity-link-panel.tsx` + +```tsx +import { useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import { Link2 } from "lucide-react"; +import type { TCaseEntityLink } from "@plane/types"; +import { EntityLinkService } from "@/services/case"; + +type Props = { + workspaceSlug: string; + projectId: string; + issueId: string; + disabled: boolean; +}; + +const entityLinkService = new EntityLinkService(); + +export const EntityLinkPanel = observer(function EntityLinkPanel({ + workspaceSlug, + projectId, + issueId, + disabled, +}: Props) { + const [links, setLinks] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!workspaceSlug || !projectId || !issueId) return; + setLoading(true); + entityLinkService + .list(workspaceSlug, projectId, issueId) + .then(setLinks) + .catch(() => setLinks([])) + .finally(() => setLoading(false)); + }, [workspaceSlug, projectId, issueId]); + + if (loading) return null; + if (links.length === 0) return null; + + return ( +
+
+ + Entity Links +
+
+ {links.map((link) => ( +
+
+ {link.entity_type} + {link.label} +
+ + {link.role} + +
+ ))} +
+
+ ); +}); +``` + +### Step 8.2: Insert panel into peek properties + +**Modify**: `apps/web/core/components/issues/peek-overview/properties.tsx` + +Add import at top: + +```tsx +import { EntityLinkPanel } from "@/components/cases/entity-link-panel"; +``` + +Add the panel at the end of the properties container (after `WorkItemAdditionalSidebarProperties`, before the closing `
`): + +```tsx +{ + /* Entity Links */ +} +; +``` + +This is a ~4 line addition to a Plane core file. It renders nothing when there are no entity links (the component returns `null`), so it's invisible in non-brokerage usage. + +--- + +## Phase 9: Frontend — Sidebar Case Type Bins + +### Step 9.1: Replace project list with case type bins + +**Modify**: `apps/web/core/components/workspace/sidebar/projects-list.tsx` + +Replace the project list entirely. Instead of rendering `SidebarProjectsListItem` for each project, render case type links that filter the issue list by IssueType. The "Projects" disclosure becomes a "Cases" disclosure. Projects are invisible to the user. + +The modification replaces the `displayedProjects.map(...)` block with case type items. Each item uses the same `Disclosure` styling that projects currently use (reusing existing Plane sidebar UI patterns — no new components): + +```tsx +// Replace the project list mapping with case type bins +const CASE_TYPES = [ + { name: "Claims", key: "Claim", icon: FileText }, + { name: "Renewals", key: "Renewal", icon: RefreshCw }, + { name: "Endorsements", key: "Endorsement", icon: PenLine }, + { name: "Billing", key: "Billing Dispute", icon: CreditCard }, + { name: "General", key: "General Service", icon: HelpCircle }, +]; + +// Each renders as a sidebar link: +// href = `/${workspaceSlug}/projects/${hiddenProjectId}/issues/?type=${ct.key}` +``` + +This reuses the existing `Disclosure` component, sidebar wrapper styles, and navigation patterns. No new component file needed — the case type list is inlined where the project list used to be. + +The hidden project ID comes from the workspace store or a config constant (set during seed). + +### Step 9.2: Add i18n labels + +**Modify**: `packages/i18n/src/locales/en/core.ts` + +Add within the appropriate section: + +```typescript +cases: "Cases", +claims: "Claims", +renewals: "Renewals", +endorsements: "Endorsements", +billing_disputes: "Billing Disputes", +general_service: "General Service", +entity_links: "Entity Links", +``` + +### Step 9.3: Rebuild i18n + +```bash +pnpm --filter i18n run build +``` + +--- + +## Phase 10: Entity Link in Issue Creation Modal + +The issue creation modal (`apps/web/core/components/issues/issue-modal/form.tsx`) renders default properties via `IssueDefaultProperties`. Rather than creating a new component, we add the entity link selector into the existing form flow. + +### Step 10.1: Add entity link field to creation form + +**Modify**: `apps/web/core/components/issues/issue-modal/components/default-properties.tsx` + +Add an entity link selector below the existing property buttons. This reuses Plane's existing dropdown/combobox patterns. The selector: + +- Shows a "Link Policy" / "Link Claim" button (using existing Plane button styles) +- Opens a UUID input field (or search-as-you-type against the resolve-entity endpoint) +- On selection, stores `entity_type` + `entity_id` in form state +- Displays the resolved label inline + +After the issue is created (POST returns the issue ID), a follow-up call to the entity-links endpoint creates the link. This is handled in the form's `onSubmit` handler. + +```tsx +// In the onSubmit handler, after issue creation: +if (entityLinkData) { + await entityLinkService.create(workspaceSlug, projectId, newIssueId, entityLinkData); +} +``` + +This is a small modification to existing files (~15 lines in the form submit handler + ~10 lines for the selector UI), not a new component. + +--- + +## Phase 11: Filter Issues by Linked Entity + +### Step 11.1: Backend — custom filter + +**Modify**: `apps/api/plane/app/views/issue/base.py` + +Add a filter parameter `entity_id` that joins through `CaseEntityLink`: + +```python +# In the issue list queryset construction, add: +entity_id = request.query_params.get("entity_id") +if entity_id: + queryset = queryset.filter( + entity_links__entity_id=entity_id, + entity_links__deleted_at__isnull=True, + ) +``` + +This is a ~5 line addition to the existing issue list view's `get_queryset` or filter logic. + +### Step 11.2: Frontend — pass filter param + +The issue list already supports arbitrary query params via the filter system. When navigating from an entity context (e.g., "show all cases for policy X"), pass `entity_id` as a query parameter: + +``` +/${workspaceSlug}/projects/${projectId}/issues/?entity_id=${policyUuid} +``` + +The existing filter infrastructure forwards unknown params to the API. No frontend filter UI changes needed for the tracer bullet — filtering by entity is API-driven, triggered by navigation links (e.g., from the entity detail or an external system). + +--- + +## Summary: All Files + +### New Files (11) + +| # | File | Purpose | +| --- | ----------------------------------------------------------- | ------------------------------------------------------------- | +| 1 | `apps/api/plane/db/models/case_entity_link.py` | CaseEntityLink Django model | +| 2 | `apps/api/plane/utils/core_db_resolver.py` | Raw SQL resolver for cima data | +| 3 | `apps/api/plane/app/serializers/case_entity_link.py` | DRF serializers | +| 4 | `apps/api/plane/app/views/case/__init__.py` | View package init | +| 5 | `apps/api/plane/app/views/case/entity_link.py` | Entity link API views (list, create, update, delete, resolve) | +| 6 | `apps/api/plane/app/urls/case_entity_link.py` | URL routing | +| 7 | `apps/api/plane/db/management/commands/seed_case_config.py` | Seed script | +| 8 | `packages/types/src/cases/case-entity-link.ts` | TypeScript types | +| 9 | `packages/types/src/cases/index.ts` | Type exports | +| 10 | `apps/web/core/services/case/entity-link.service.ts` | Frontend API service | +| 11 | `apps/web/core/services/case/index.ts` | Service exports | + +### New UI Components (1) + +| # | File | Purpose | +| --- | ------------------------------------------------------ | --------------------------- | +| 12 | `apps/web/core/components/cases/entity-link-panel.tsx` | Entity link display in peek | + +### Modified Files (8, all additive) + +| # | File | Change | +| --- | ------------------------------------------------------------------------------- | ---------------------------------------- | +| 1 | `apps/api/plane/db/models/__init__.py` | Append `CaseEntityLink` import | +| 2 | `apps/api/plane/app/serializers/__init__.py` | Append serializer imports | +| 3 | `apps/api/plane/app/views/__init__.py` | Append view imports | +| 4 | `apps/api/plane/app/urls/__init__.py` | Append URL pattern | +| 5 | `apps/web/core/components/issues/peek-overview/properties.tsx` | Add EntityLinkPanel (~4 lines) | +| 6 | `packages/types/src/index.ts` | Append `export * from "./cases"` | +| 7 | `apps/web/core/components/workspace/sidebar/projects-list.tsx` | Replace project list with case type bins | +| 8 | `apps/web/core/components/issues/issue-modal/components/default-properties.tsx` | Add entity link selector | + +### Additional Modified Files (small, additive) + +| # | File | Change | +| --- | ---------------------------------------- | --------------------------------------------- | +| 9 | `apps/api/plane/app/views/issue/base.py` | Add `entity_id` filter (~5 lines) | +| 10 | `.gitattributes` | New — merge strategy for conflict-prone files | +| 11 | `packages/i18n/src/locales/en/core.ts` | Append case-related labels | + +### Migrations (1) + +| # | Creates | +| --- | ------------------------- | +| 1 | `case_entity_links` table | + +### Configuration (via seed script, not code) + +| What | How | +| ---------------- | ----------------------------------- | +| 5 IssueTypes | `python manage.py seed_case_config` | +| 11 States | Same seed command | +| 1 hidden project | Manual or seed | + +--- + +## Resolved Questions + +| # | Question | Decision | +| --- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| 1 | Sidebar approach | Replace project list entirely. Projects invisible. | +| 2 | Custom properties | Not needed. Queue=IssueType, SLA=target_date, Owner=assignee. Custom properties are EE/Pro only anyway. | +| 3 | Entity link editing | Yes — PATCH endpoint added to API (Phase 4). | +| 4 | Entity link in creation modal | Yes — selector added to creation form (Phase 10). | +| 5 | Filter by entity | Yes — `entity_id` query param on issue list (Phase 11). | +| 6 | cima schema | Confirmed: same PostgreSQL instance in all environments. | +| 7 | AI agents | Not assignees. Invoked via @mention in comments. Agent system documented but not yet implemented in CE codebase. | + +--- + +## TODO + +### Phase 1: Setup & Git Strategy + +- [x] 1.1 Create `.gitattributes` at repo root with merge=ours for `__init__.py` files +- [ ] 1.2 Verify Plane runs (Docker up, frontend loads, workspace + project exist) — _requires Docker_ +- [ ] 1.3 Add upstream remote (`git remote add upstream ...`) — _origin already points to plane_ +- [ ] 1.4 Fetch upstream and verify merge is clean (`git fetch upstream`) + +### Phase 2: Backend — CaseEntityLink Model + +- [x] 2.1 Create `apps/api/plane/db/models/case_entity_link.py` +- [x] 2.2 Append `CaseEntityLink` import to `apps/api/plane/db/models/__init__.py` +- [ ] 2.3 Run `makemigrations db` and verify migration file is created — _requires Docker_ +- [ ] 2.4 Run `migrate` and verify `case_entity_links` table exists — _requires Docker_ +- [ ] 2.5 Smoke test: create a CaseEntityLink via Django shell — _requires Docker_ + +### Phase 3: Backend — core_db_resolver + +- [x] 3.1 Create `apps/api/plane/utils/core_db_resolver.py` with all resolver functions +- [ ] 3.2 Smoke test: call `resolve_policy()` from Django shell with a known cima policy UUID — _requires Docker + cima_ +- [ ] 3.3 Smoke test: call `resolve_claim()` with a known cima claim UUID — _requires Docker + cima_ +- [ ] 3.4 Smoke test: `resolve_policy_label()` and `resolve_claim_label()` return readable strings — _requires Docker + cima_ + +### Phase 4: Backend — Entity Link API + +- [x] 4.1 Create `apps/api/plane/app/serializers/case_entity_link.py` +- [x] 4.2 Append serializer imports to `apps/api/plane/app/serializers/__init__.py` +- [x] 4.3 Create `apps/api/plane/app/views/case/__init__.py` +- [x] 4.4 Create `apps/api/plane/app/views/case/entity_link.py` (list, create, partial_update, destroy, resolve) +- [x] 4.5 Append view imports to `apps/api/plane/app/views/__init__.py` +- [x] 4.6 Create `apps/api/plane/app/urls/case_entity_link.py` +- [x] 4.7 Append URL pattern to `apps/api/plane/app/urls/__init__.py` +- [ ] 4.8 Test: `GET /api/workspaces/{slug}/projects/{pid}/issues/{iid}/entity-links/` returns `[]` — _requires Docker_ +- [ ] 4.9 Test: `POST` with valid cima policy UUID → 201, link created, label resolved — _requires Docker + cima_ +- [ ] 4.10 Test: `POST` with invalid UUID → 400 — _requires Docker_ +- [ ] 4.11 Test: `POST` claim → auto-creates/corrects policy link — _requires Docker + cima_ +- [ ] 4.12 Test: `PATCH` → role change works — _requires Docker_ +- [ ] 4.13 Test: `DELETE` with no started sub-issues → 204 — _requires Docker_ +- [ ] 4.14 Test: `DELETE` with started sub-issues → 400 (deletion protection) — _requires Docker_ +- [ ] 4.15 Test: `GET /resolve-entity/?entity_type=policy&entity_id={uuid}` → resolved data — _requires Docker + cima_ + +### Phase 5: Seed Script — IssueTypes & States + +- [x] 5.1 Create `apps/api/plane/db/management/commands/seed_case_config.py` +- [ ] 5.2 Run `python manage.py seed_case_config ` — _requires Docker_ +- [ ] 5.3 Verify: 5 IssueTypes created — _requires Docker_ +- [ ] 5.4 Verify: `is_issue_type_enabled = True` on project — _requires Docker_ +- [ ] 5.5 Verify: 11 states created with correct groups and colors — _requires Docker_ +- [ ] 5.6 Verify: default state is "New" (backlog group) — _requires Docker_ +- [ ] 5.7 Verify: idempotent — running seed again doesn't duplicate — _requires Docker_ + +### Phase 6: Frontend — TypeScript Types + +- [x] 6.1 Create `packages/types/src/cases/case-entity-link.ts` +- [x] 6.2 Create `packages/types/src/cases/index.ts` +- [x] 6.3 Append `export * from "./cases"` to `packages/types/src/index.ts` +- [x] 6.4 Verify: `pnpm build` in types package succeeds, no type errors + +### Phase 7: Frontend — Entity Link Service + +- [x] 7.1 Create `apps/web/core/services/case/entity-link.service.ts` +- [x] 7.2 Create `apps/web/core/services/case/index.ts` +- [x] 7.3 Verify: imports resolve, no build errors + +### Phase 8: Frontend — Entity Link Panel in Peek + +- [x] 8.1 Create `apps/web/core/components/cases/entity-link-panel.tsx` +- [x] 8.2 Add import + `` to `peek-overview/properties.tsx` (~4 lines) +- [ ] 8.3 Test: open peek on issue with no entity links → nothing extra renders — _requires running app_ +- [ ] 8.4 Test: open peek on issue with entity links → policy/claim cards visible — _requires running app_ + +### Phase 9: Frontend — Sidebar Case Type Bins + +- [x] 9.1 Modify `projects-list.tsx` to replace project list with case type bins +- [x] 9.2 Case type bins link to `/${ws}/projects/${pid}/issues/?type=${typeName}` +- [x] 9.3 Add i18n labels to `packages/i18n/src/locales/en/translations.ts` +- [x] 9.4 Run `pnpm --filter i18n run build` +- [ ] 9.5 Test: sidebar shows Claims, Renewals, Endorsements, Billing, General — _requires running app_ +- [ ] 9.6 Test: clicking a case type filters the issue list by that IssueType — _requires running app_ +- [ ] 9.7 Test: no project list visible anywhere in sidebar — _requires running app_ + +### Phase 10: Entity Link in Issue Creation Modal + +- [x] 10.1 Modify `default-properties.tsx` to add entity link selector UI +- [x] 10.2 Add entity link creation call in form submit handler (after issue is created via `base.tsx`) +- [ ] 10.3 Test: create issue → entity link selector visible — _requires running app_ +- [ ] 10.4 Test: enter policy UUID → resolved label appears inline — _requires running app_ +- [ ] 10.5 Test: submit → issue created AND entity link created in one flow — _requires running app_ + +### Phase 11: Filter Issues by Linked Entity + +- [x] 11.1 Add `entity_id` filter to issue list view in `views/issue/base.py` (~5 lines) +- [ ] 11.2 Test: `GET /issues/?entity_id={uuid}` → returns only issues linked to that entity — _requires Docker_ +- [ ] 11.3 Test: no `entity_id` param → all issues returned (no regression) — _requires Docker_ + +### Final Verification + +- [x] All code written and type-checked (0 new type errors) +- [ ] Run full Plane frontend — no console errors — _requires running app_ +- [ ] Run `python manage.py test` — no regressions — _requires Docker_ +- [ ] `git fetch upstream && git merge upstream/preview` — clean or trivially-resolvable +- [ ] Kanban, list, calendar, spreadsheet views all work with case data — _requires running app_ +- [ ] Drag-drop, sub-issues, activity tracking, comments all work — _requires running app_ +- [ ] Intake, templates, workflows features still functional — _requires running app_ diff --git a/plans/plane-extensibility-research.md b/plans/plane-extensibility-research.md new file mode 100644 index 00000000000..99ad4628706 --- /dev/null +++ b/plans/plane-extensibility-research.md @@ -0,0 +1,1647 @@ +# Plane Extensibility - Comprehensive Research + +> Complete analysis of every extensibility mechanism Plane offers: API, OAuth apps, webhooks, agents, MCP, Compose, integrations, custom properties, workflows, automations, templates, intake, deploy/publish, self-hosting configuration, and **code-level modification patterns**. + +--- + +## Table of Contents + +1. [Extensibility Overview](#1-extensibility-overview) +2. [REST API](#2-rest-api) +3. [Authentication: API Keys & OAuth 2.0](#3-authentication-api-keys--oauth-20) +4. [OAuth App Development](#4-oauth-app-development) +5. [Webhooks](#5-webhooks) +6. [Agents (AI-Powered Apps)](#6-agents-ai-powered-apps) +7. [MCP Server (Model Context Protocol)](#7-mcp-server-model-context-protocol) +8. [Plane Compose (Infrastructure as Code)](#8-plane-compose-infrastructure-as-code) +9. [Official SDKs](#9-official-sdks) +10. [Native Integrations](#10-native-integrations) +11. [Custom Properties & Work Item Types](#11-custom-properties--work-item-types) +12. [Workflows & Approvals](#12-workflows--approvals) +13. [Automations](#13-automations) +14. [Templates](#14-templates) +15. [Intake (External Input Channels)](#15-intake-external-input-channels) +16. [Deploy & Publish (Public Sharing)](#16-deploy--publish-public-sharing) +17. [Export & Analytics](#17-export--analytics) +18. [Permissions & Roles](#18-permissions--roles) +19. [Self-Hosting & Configuration](#19-self-hosting--configuration) +20. [Tier Matrix](#20-tier-matrix) +21. [Code-Level Modification: CE/EE Architecture](#21-code-level-modification-ceee-architecture) +22. [Frontend Extension Patterns](#22-frontend-extension-patterns) +23. [Backend Extension Patterns](#23-backend-extension-patterns) +24. [How To: Add Custom Features](#24-how-to-add-custom-features) + +--- + +## 1. Extensibility Overview + +Plane provides extensibility at multiple layers: + +``` +External Integration Layer +├── REST API (180+ endpoints, 25+ resource categories) +├── OAuth 2.0 Apps (bot + user token flows) +├── Webhooks (real-time event notifications) +├── Agents (AI apps that respond to @mentions) +├── MCP Server (Model Context Protocol for AI tools) +├── Plane Compose (YAML-based IaC, bidirectional sync) +└── SDKs (Node.js, Python) + +Native Integration Layer +├── GitHub / GitHub Enterprise (bidirectional issue + PR sync) +├── GitLab / GitLab Self-managed (issue + MR sync) +├── Slack (thread sync, DM notifications, AI agent) +├── Sentry (auto-create work items from errors) +└── Draw.io (diagram embedding in Pages) + +Internal Customization Layer +├── Custom Work Item Types (unlimited per project) +├── Custom Properties (6 field types per type) +├── Workflows & Approvals (state transition rules + gates) +├── Automations (trigger-condition-action rules) +├── Templates (work item, page, project) +├── Custom States, Labels, Estimates +└── Intake Channels (in-app, forms, email) + +Public Sharing Layer +├── Deploy (publish project as public board) +├── Publish Views (share filtered views publicly) +├── Publish Pages (share documentation publicly) +└── Export (CSV, Excel, JSON) +``` + +--- + +## 2. REST API + +**Base URL**: `https://api.plane.so/api/v1/` (cloud) or `https:///api/v1/` (self-hosted) + +### Endpoint Categories (25+) + +| Category | Endpoints | Key Operations | +| -------------------- | --------- | -------------------------------------------- | +| Projects | 8+ | CRUD, members, features, states | +| Work Items (Issues) | 7+ | CRUD, search, custom fields | +| States | 4+ | CRUD workflow states | +| Labels | 4+ | CRUD project labels | +| Cycles | 8+ | CRUD, add/remove items, archive, transfer | +| Modules | 8+ | CRUD, add/remove items, archive | +| Epics | 3+ | List, read details | +| Initiatives | 5+ | CRUD with projects/epics/labels | +| Customers | 6+ | CRUD with properties and requests | +| Work Item Types | 4+ | Manage types + properties + dropdown options | +| Work Item Properties | 5+ | Custom field management | +| Comments | 4+ | CRUD on work item comments | +| Attachments | 4+ | Upload, get credentials, complete, delete | +| Links | 3+ | CRUD external links | +| Activities | 2+ | Track change history | +| Worklogs | 3+ | Time tracking entries | +| Members | 3+ | Workspace/project member management | +| Pages | 4+ | Workspace and project pages | +| Stickies | 4+ | Quick notes CRUD | +| Teamspaces | 4+ | Team management (beta) | +| Inbox/Intake | 3+ | Triage queue management | +| User | 1 | Current user profile | + +### Pagination + +Cursor-based with format `value:offset:is_prev`: + +``` +GET /api/v1/.../work-items/?per_page=50&cursor=50:1:0 + +Response: +{ + "next_cursor": "50:2:0", + "prev_cursor": "50:0:1", + "next_page_results": true, + "prev_page_results": false, + "count": 50, + "total_pages": 5, + "total_results": 234, + "results": [...] +} +``` + +- `per_page`: max 100 (default 100) +- `cursor`: navigate pages + +### Field Selection & Expansion + +``` +# Select specific fields +GET /work-items/?fields=name,priority,state + +# Expand related objects +GET /work-items/?expand=assignees,state,project,type +``` + +### Rate Limiting + +- **60 requests/minute** per API key +- Headers: `X-RateLimit-Remaining`, `X-RateLimit-Reset` +- Exceeding returns `429 Throttling Error` + +### Error Codes + +| Code | Meaning | +| ------- | ------------------- | +| 200 | OK (GET, PATCH) | +| 201 | Created (POST) | +| 204 | No Content (DELETE) | +| 400 | Bad Request | +| 401 | Unauthorized | +| 404 | Not Found | +| 429 | Rate Limited | +| 500-504 | Server errors | + +--- + +## 3. Authentication: API Keys & OAuth 2.0 + +### Personal Access Tokens (API Keys) + +Simple authentication for scripts and personal tools: + +``` +Header: X-API-Key: plane_api_ +``` + +- Created in workspace settings +- Scoped to the user's permissions +- Best for: personal scripts, CI/CD, quick integrations + +### OAuth 2.0 + +For building apps that act on behalf of users or as bots: + +``` +Header: Authorization: Bearer +``` + +**Two token flows:** + +#### Bot Token Flow (Client Credentials) + +For autonomous apps, agents, webhooks, background automation: + +``` +1. Redirect user to consent: + GET /auth/o/authorize-app/?client_id=...&response_type=code&redirect_uri=...&scope=... + +2. User approves → you receive app_installation_id + +3. Exchange for bot token: + POST /auth/o/token/ + { "grant_type": "client_credentials", "client_id": ..., "client_secret": ..., "app_installation_id": ... } + +4. Response: { "bot_token": "pln_bot_...", "expires_in": 86400 } + +5. Refresh: same POST with stored app_installation_id (no refresh_token needed) +``` + +#### User Token Flow (Authorization Code) + +For user-specific actions: + +``` +1. Redirect with state parameter for CSRF: + GET /auth/o/authorize-app/?...&state=random_string + +2. User approves → you receive code + state + +3. Exchange code: + POST /auth/o/token/ + { "grant_type": "authorization_code", "code": ..., "client_id": ..., "client_secret": ..., "redirect_uri": ... } + +4. Response: { "access_token": "pln_...", "refresh_token": "pln_refresh_...", "expires_in": 86400 } + +5. Refresh: + POST /auth/o/token/ + { "grant_type": "refresh_token", "refresh_token": ..., "client_id": ..., "client_secret": ... } +``` + +--- + +## 4. OAuth App Development + +### Creating an OAuth App + +Navigate to `https://app.plane.so//settings/integrations/` → "Build your own" + +**Configure:** + +- **App Name**: Display name shown to users during consent +- **Setup URL**: Entry point for installation +- **Redirect URI**: Callback after OAuth approval +- **Webhook URL**: Endpoint for event notifications +- **Enable App Mentions**: For @mention support (agents) + +**Store securely**: Client ID + Client Secret + +### OAuth Scopes (55+) + +Granular permission scopes organized by resource: + +**Project Scopes:** + +``` +projects:read/write +projects.features:read/write +projects.members:read/write +projects.states:read/write +projects.labels:read/write +projects.intakes:read/write +projects.epics:read/write +projects.cycles:read/write +projects.modules:read/write +projects.pages:read/write +projects.milestones:read/write +projects.work_items:read/write +projects.work_item_types:read/write +projects.work_item_properties:read/write +projects.work_item_property_options:read/write +projects.work_item_property_values:read/write +``` + +**Work Item Sub-Scopes:** + +``` +projects.work_items.comments:read/write +projects.work_items.attachments:read/write +projects.work_items.links:read/write +projects.work_items.relations:read/write +projects.work_items.activities:read/write +projects.work_items.worklogs:read/write +``` + +**Other Scopes:** + +``` +wiki.pages:read/write +customers:read/write (+requests, properties, property_values, work_items) +initiatives:read/write (+projects, epics, labels) +workspaces.members:read +workspaces.features:read/write +stickies:read/write +teamspaces:read/write (+projects, members) +profile:read +assets:read/write +agents.runs:read/write +agents.run_activities:read/write +``` + +--- + +## 5. Webhooks + +### Supported Events + +| Event | Actions | Payload | +| ----------------------- | ---------------------- | --------------------- | +| `project` | create, update, delete | Full project object | +| `issue` | create, update, delete | Full work item object | +| `issue_comment` | create, update, delete | Full comment object | +| `cycle` | create, update, delete | Full cycle object | +| `module` | create, update, delete | Full module object | +| `agent_run_create` | create | Agent run details | +| `agent_run_user_prompt` | create | User prompt content | + +### Delivery Format + +``` +POST + +Headers: + Content-Type: application/json + User-Agent: Autopilot + X-Plane-Delivery: + X-Plane-Event: + X-Plane-Signature: + +Body: +{ + "event": "issue", + "action": "created", + "webhook_id": "uuid", + "workspace_id": "uuid", + "data": { /* full resource object */ }, + "activity": { + "actor": { "id": "user-uuid", "display_name": "Name" } + } +} +``` + +### Signature Verification + +HMAC-SHA256 using the webhook secret: + +**Python:** + +```python +import hmac, hashlib +expected = hmac.new(secret.encode(), request.body, hashlib.sha256).hexdigest() +hmac.compare_digest(expected, request.headers['X-Plane-Signature']) +``` + +**TypeScript:** + +```typescript +const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); +crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature)); +``` + +### Retry Policy + +- Must respond with HTTP 200 +- Failed deliveries retry with exponential backoff (~10 min, ~30 min, ...) +- DELETE events only include the resource ID, not full data + +--- + +## 6. Agents (AI-Powered Apps) + +Agents are AI apps that respond to @mentions in work item comments. This is Plane's most sophisticated extensibility mechanism. + +### How Agents Work + +1. User @mentions the agent in a work item comment +2. Plane creates an "Agent Run" and sends a webhook to the agent's URL +3. Agent processes the request and posts activities (thoughts, actions, responses) +4. Activities appear in the work item comment thread + +### Agent Run Lifecycle + +``` +User @mentions agent + → Agent Run created (status: "created") + → Webhook sent to agent URL (event: "agent_run_create") + → Agent posts thought activities (status: "in_progress") + → Agent processes and posts response (status: "completed") + +User sends follow-up message + → Webhook sent (event: "agent_run_user_prompt") + → Agent continues conversation +``` + +### Agent Run Statuses + +| Status | Meaning | +| ---------------------- | ------------------------------------------ | +| `created` | Initiated, not started | +| `in_progress` | Actively processing | +| `awaiting` | Waiting for user input (after elicitation) | +| `completed` | Finished successfully | +| `stopping` / `stopped` | Stop requested/completed | +| `failed` | Encountered error | +| `stale` | No update in 5 minutes | + +### Activity Types + +| Type | Permanence | Purpose | +| ------------- | ---------- | ---------------------------------------------- | +| `prompt` | Permanent | User's message (cannot be created by agent) | +| `thought` | Ephemeral | Internal reasoning (shows processing) | +| `action` | Ephemeral | Tool invocation / external call | +| `response` | Permanent | Final answer (creates visible comment) | +| `elicitation` | Permanent | Request user input (sets status to `awaiting`) | +| `error` | Permanent | Error message (sets status to `failed`) | + +### Activity Signals + +| Signal | Meaning | +| -------------- | --------------------------------- | +| `continue` | Default, normal flow | +| `stop` | User requested stop | +| `auth_request` | Needs external authentication | +| `select` | Presenting options to choose from | + +### Activity Content Structure + +```json +{ + "type": "response", + "content": { + "type": "response", + "body": "Here's what I found..." + }, + "signal": "continue", + "signal_metadata": {}, + "content_metadata": {}, + "ephemeral": false +} +``` + +### Best Practices + +- **Send a thought activity immediately** on webhook receipt (prevents 5-minute stale timeout) +- Always check for `stop` signal before processing +- Send heartbeat thoughts for long operations +- Respond to webhooks within seconds, process async +- Wrap everything in try/catch — always send error activity on failure +- Use friendly error messages (no stack traces) +- Maintain conversation context from previous activities +- Agents do NOT count as billable users + +--- + +## 7. MCP Server (Model Context Protocol) + +Plane exposes an MCP server that enables AI tools (Claude, Cursor, VSCode, etc.) to interact with Plane. + +### Transport Methods + +#### 1. HTTP with OAuth (recommended for cloud) + +```json +{ + "type": "http", + "url": "https://mcp.plane.so/http/mcp" +} +``` + +Browser-based authentication. Works with Claude.ai, Claude Desktop, Cursor, VSCode, Windsurf, Zed. + +#### 2. HTTP with PAT Token (CI/CD, automation) + +```json +{ + "type": "http", + "url": "https://mcp.plane.so/http/api-key/mcp", + "headers": { + "Authorization": "Bearer ", + "X-Workspace-slug": "" + } +} +``` + +No browser interaction needed. + +#### 3. Local Stdio (self-hosted) + +```bash +uvx plane-mcp-server stdio +# Env vars: PLANE_API_KEY, PLANE_WORKSPACE_SLUG, PLANE_BASE_URL +``` + +#### 4. SSE Transport (legacy) + +```json +{ + "type": "sse", + "url": "https://mcp.plane.so/sse" +} +``` + +### Available MCP Tools (55+) + +| Category | Tools | Examples | +| -------------------- | ----- | -------------------------------------------------- | +| Projects | 9 | List, create, update, delete, get members/features | +| Work Items | 7 | Create, list, search, update, delete | +| Cycles | 12 | Manage, add/remove items, transfer, archive | +| Modules | 11 | Manage, add/remove items, archive | +| Initiatives | 5 | Create, manage strategic initiatives | +| Intake | 5 | Manage triage items | +| Work Item Properties | 5 | Manage custom fields | +| Users | 1 | Get current user info | + +### Claude Code Integration + +```bash +# OAuth (browser auth) +claude mcp add --transport http plane https://mcp.plane.so/http/mcp + +# PAT token +claude mcp add-json plane '{ + "type": "http", + "url": "https://mcp.plane.so/http/api-key/mcp", + "headers": { + "Authorization": "Bearer ", + "X-Workspace-slug": "" + } +}' + +# Stdio (self-hosted) +claude mcp add-json plane '{ + "type": "stdio", + "command": "uvx", + "args": ["plane-mcp-server", "stdio"], + "env": { + "PLANE_API_KEY": "...", + "PLANE_WORKSPACE_SLUG": "...", + "PLANE_BASE_URL": "..." + } +}' +``` + +--- + +## 8. Plane Compose (Infrastructure as Code) + +Define Plane projects, workflows, and work items in YAML. Version control them. Sync bidirectionally. + +### Installation + +```bash +pipx install plane-compose +``` + +### Project Structure + +``` +my-project/ +├── plane.yaml # Configuration +├── schema/ +│ ├── types.yaml # Work item types +│ ├── workflows.yaml # State machines +│ └── labels.yaml # Label definitions +├── work/ +│ └── inbox.yaml # Work items +└── .plane/ + └── state.json # Sync state (Terraform-style) +``` + +### Configuration (plane.yaml) + +```yaml +workspace: my-workspace +project: + key: PROJ + name: My Project +defaults: + type: task + workflow: standard +apply_scope: # Optional, for declarative mode + labels: ["automated"] + id_prefix: "AUTO-" +``` + +### Commands + +| Command | Purpose | +| -------------------------------- | --------------------------------------------- | +| `plane init [path]` | Initialize project | +| `plane auth login/logout/whoami` | Authentication | +| `plane schema validate` | Validate YAML schema | +| `plane schema push` | Push schema to Plane | +| `plane push` | Collaborative sync (create/update, no delete) | +| `plane pull` | Pull from Plane to local YAML | +| `plane sync` | Bidirectional sync | +| `plane apply` | Declarative sync (with delete support) | +| `plane status` | Show sync status | +| `plane clone ` | Clone existing project | +| `plane rate stats/reset` | Monitor rate limits | + +### Two Sync Modes + +**Collaborative (`plane push`)**: Create/update only, never deletes. Safe for shared projects. + +**Declarative (`plane apply`)**: Full sync including deletes. Terraform-style — local YAML is source of truth. Uses `apply_scope` to limit blast radius. + +### Work Item YAML Format + +```yaml +- id: task-001 + title: Implement login + type: task + priority: high + state: in-progress + labels: [backend, auth] + description: | + Implement OAuth login flow + assignee: user@example.com +``` + +### Key Features + +- Content-based diffing (intelligent change detection) +- State tracking in `.plane/state.json` +- Built-in rate limiting (50 requests/min default) +- Auto-create projects from YAML +- Project cloning by UUID + +--- + +## 9. Official SDKs + +### Node.js / TypeScript + +```bash +npm install @makeplane/plane-node-sdk +``` + +```typescript +import { OAuthClient, PlaneClient } from '@makeplane/plane-node-sdk'; + +// OAuth +const oauth = new OAuthClient({ clientId, clientSecret, redirectUri, baseUrl }); +const authUrl = oauth.getAuthorizationUrl({ scope: [...], state: '...' }); +const token = await oauth.getBotToken(appInstallationId); +const userToken = await oauth.exchangeCodeForToken(code); +const refreshed = await oauth.getRefreshToken(refreshToken); + +// API Client +const client = new PlaneClient({ apiKey: '...', baseUrl: '...' }); +``` + +### Python + +```bash +pip install plane-sdk +``` + +```python +from plane_sdk import OAuthClient, PlaneClient + +# OAuth +oauth = OAuthClient(client_id=..., client_secret=..., redirect_uri=..., base_url=...) +auth_url = oauth.get_authorization_url(scope=[...], state='...') +token = oauth.get_client_credentials_token(app_installation_id=...) +user_token = oauth.exchange_code(code=...) +refreshed = oauth.refresh_token(refresh_token=...) + +# API Client +client = PlaneClient(api_key='...', base_url='...') +``` + +--- + +## 10. Native Integrations + +### GitHub / GitHub Enterprise + +**Capabilities:** + +- Bidirectional or unidirectional issue sync +- PR automation — state mapping based on PR lifecycle (draft → opened → review → approved → merged → closed) +- Label-based triggering: `Plane` label syncs GitHub → Plane; `GitHub` label syncs Plane → GitHub +- Synced properties: title, description, assignees, labels, states, comments, mentions, links +- PR reference: `[WEB-344]` for state automation, `WEB-344` for link-only +- Personal account support for commenting under user identity + +**Setup:** Create GitHub App with permissions (Issues R/W, PRs R/W, Metadata R/O, Email R/O). Subscribe to: Installation, Issues, Pull requests, Reviews, Push events. + +**Self-hosted env vars:** `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `GITHUB_APP_NAME`, `GITHUB_APP_ID`, `GITHUB_PRIVATE_KEY` + +### GitLab / GitLab Self-Managed + +**Capabilities:** + +- Bidirectional or unidirectional issue sync +- MR automation and state mapping +- Label-based triggering: `Plane` label for GitLab → Plane; `gitlab` label for Plane → GitLab +- Synced properties: title, description, labels, states, comments, mentions, links +- MR reference: `[WEB-344]` for state automation + +**Setup:** Create OAuth application with scopes: `api`, `read_api`, `read_user`, `read_repository`, `profile`, `email`. + +**Self-hosted env vars:** `GITLAB_CLIENT_ID`, `GITLAB_CLIENT_SECRET` + +### Slack + +**Capabilities:** + +- Create work items from Slack messages via shortcut or `/plane` command +- Link existing work items to Slack threads +- Thread sync between Slack and Plane +- Project notifications: configurable events (creation, state changes, comments, completion) +- DM notifications for mentions and assignments +- Plane AI agent via @Plane mentions +- Link previews with quick action buttons +- Edit work items directly from Slack previews + +**Setup:** Create Slack app via Slack API console. Workspace admin connects. + +**Self-hosted env vars:** `SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET` + +### Sentry + +**Capabilities:** + +- Auto-create work items from Sentry issues +- Alert rules: auto-create work items when conditions are met +- Bidirectional state sync (Unresolved ↔ Open/In Progress, Resolved ↔ Done/Closed) +- Manual linking of existing issues +- Configurable assignees, priority, labels on auto-created items + +**Setup:** Connect via OAuth. Configure state mappings per project. Set up Sentry alert rules. + +**Self-hosted env vars:** `SENTRY_BASE_URL`, `SENTRY_CLIENT_ID`, `SENTRY_CLIENT_SECRET`, `SENTRY_INTEGRATION_SLUG` + +### Draw.io + +**Capabilities:** + +- Embed diagrams directly in Plane Pages +- Create and edit diagrams within Plane interface +- No external switching needed + +**Tier:** Business + +--- + +## 11. Custom Properties & Work Item Types + +### Work Item Types (Pro+) + +Types classify work items (e.g., Bug, Feature, Task, Epic). Once enabled per-project, cannot be disabled. + +**Default type**: "Issue" (always available) + +**Creating types:** + +- Name, description, background color, icon +- Must be activated via toggle to be selectable +- Each type can have its own custom properties + +### Custom Properties (6 Field Types) + +Each work item type can define custom properties: + +| Type | Variants | Mandatory? | Notes | +| ----------------- | --------------------------------- | ----------------------------- | --------------------------------- | +| **Text** | Single line, Paragraph, Read-only | Yes (except read-only) | Read-only stores static text | +| **Number** | Default value optional | Yes | Supports all comparison operators | +| **Dropdown** | Single select, Multi-select | Yes | Define options upfront | +| **Boolean** | True/False | No (always defaults to false) | Toggle field | +| **Date** | Consistent format | Yes | Temporal operators for filtering | +| **Member Picker** | Single, Multi-select | Yes | Lists all project members | + +### Property Management + +- Mark as mandatory (enforced on creation) +- Toggle Active/Inactive to hide without losing data +- All custom property changes tracked in activity trail +- Switching work item types preserves data where properties match +- Bulk update types in spreadsheet view +- Custom properties are filterable with type-specific operators + +### Filtering Operators by Property Type + +| Property Type | Operators | +| ------------- | ------------------------------------------------------------------- | +| Text | is, is not, contains, does not contain, is empty | +| Number | is, is not, less than, greater than, between, not between, is empty | +| Dropdown | is, is any of, is not, is not any of, is empty | +| Boolean | is, is not | +| Date | is, before, after, between, not between, is empty | +| Member Picker | is, is any of, is not, is not any of, is empty | + +**Free plan**: limited to `is` and `between` operators. **Paid plans**: full operator set. + +--- + +## 12. Workflows & Approvals + +### Workflows (Business+) + +Workflows control state transitions through defined guardrails. + +**Components:** + +- **States**: Customizable per project (default: Backlog, Todo, In Progress, In Review, Done, Cancelled) +- **Allow new work items**: Toggle per state — controls where items can be created +- **Transition flows**: Define which state changes are allowed and who can make them +- **Approval flows** (Enterprise only): Gate transitions requiring designated approvers + +**Behavior:** + +- Items can only be created in states marked "Allow new work items" +- Unauthorized transition attempts show a blocker message +- Items pending approval show Approve/Reject buttons in the detail view +- Type-specific workflows override the default workflow for matching item types + +**Configuration:** + +- One default workflow per project +- Type-specific workflows on Enterprise Grid +- Toggle on/off without losing configuration +- View change history with audit trail + +### Approvals (Enterprise) + +Approval gates on state transitions: + +- Designate approvers (specific people) +- Approve/Reject buttons appear in work item detail +- Blocked until approved — cannot bypass + +--- + +## 13. Automations + +### Trigger-Condition-Action Model (Business+) + +``` +When [trigger] happens + → If [conditions] are met + → Then perform [actions] +``` + +### Triggers (5) + +| Trigger | Fires When | +| ----------------- | --------------------------- | +| Work item created | New item created in project | +| Work item updated | Any field changes | +| State changed | State transitions | +| Assignee changed | Assignment modifications | +| Comment created | New comment posted | + +### Conditions (6) + +| Condition | Matches On | +| ---------- | ------------------------ | +| State | Specific workflow status | +| Type | Work item type | +| Label | Project tags | +| Assignees | Specific team members | +| Created by | Item creator | +| Priority | Priority level | + +Multiple conditions per automation (AND logic). + +### Actions + +| Action | Effect | +| ----------------- | ---------------------------- | +| Add comment | Post automated comment | +| Change State | Transition to specific state | +| Change Priority | Set priority level | +| Change Assignee | Assign/reassign members | +| Change Labels | Add/modify labels | +| Change Start Date | Set start date | +| Change Due Date | Set due date | + +Multiple actions per automation (sequential execution). + +### Use Cases + +- **State management**: Auto-transition items on certain triggers +- **Team assignment**: Auto-assign based on type/priority/stage +- **Priority escalation**: Auto-adjust priorities based on conditions +- **Communication**: Auto-post comments on key events + +--- + +## 14. Templates + +### Work Item Templates (Pro+) + +- **Scope**: Project-level or workspace-level +- **Pre-populated fields**: Title format, description, labels, assignees, modules, dates +- **Sub-work items**: Define child items that auto-create with parent +- **Access**: Via templates icon when creating work items + +### Page Templates (Pro+) + +- **Scope**: Workspace-level only +- **Content**: Pre-defined layouts, headers, tables, checklists, placeholder text +- **Access**: When creating new pages + +### Project Templates (Business+) + +- **Scope**: Workspace-level +- **Pre-configured**: States, Labels, Work item types, Epics toggle +- **Initial work items**: Define starter tasks that auto-create +- **Access**: When creating new projects via "Pick a project template" + +--- + +## 15. Intake (External Input Channels) + +Three channels for collecting work from outside the project team: + +### In-App Intake + +- Guests create work items directly in the Plane UI +- Guests can set: Priority, Assignees, Labels, Due date +- Admin/Member actions: Accept, Decline, Snooze, Mark as duplicate, Delete +- All items enter the "Triage" state (separate from project states) +- Supports attachments, comments, activity tracking + +### Intake Forms (Business) + +- Public web forms — no login required +- Default form structure OR custom forms using work item types +- Unique regenerable URL per form +- Fields: Name, Email, work item details +- Custom forms: choose which properties to collect + +### Intake Email (Business) + +- Dedicated project email address (auto-generated) +- Email subject → work item title +- Email body → work item description +- Attachments preserved +- Can regenerate email address for security + +### Common Intake Features + +- Intake Responsible person (auto-assigned to all new items) +- Filter & sort by Status, State, Priority, Assignee, Created by, Labels, Dates +- Triage workflow: Pending → Accepted / Rejected / Snoozed / Duplicate + +--- + +## 16. Deploy & Publish (Public Sharing) + +### Deploy (Publish Project Board) + +- Turn any project into a public website +- Zero setup — free domain hosting +- Layout options: Kanban, List +- Public features: Commenting, Upvoting/Downvoting, Emoji reactions +- **Exposed attributes**: Work item ID, title, description, state, priority, due date, comments, reactions, votes +- **Hidden attributes**: Attachments, links, relations, cycles, modules, members +- Only Admins can publish + +### Publish Views (Pro) + +- Share filtered views publicly via link +- Anyone with link can access (no login) +- Publisher controls: comments, reactions, voting toggles + +### Publish Pages (Pro) + +- Share documentation pages publicly via link +- Viewers can leave comments +- Perfect for stakeholder updates + +--- + +## 17. Export & Analytics + +### Export + +- **Workspace-level** (Admins): All projects or specific project +- **Formats**: CSV, Excel, JSON +- **Output**: ZIP file with 7-day expiration +- **Custom exports** (Pro): Export filtered work items, cycles, modules, or views directly + +### Analytics + +- **Workspace level** (Admins only): Overview of users, projects, items, cycles, intake stats +- **Project level** (Admins/Members): Work item analysis, Created vs Resolved charts +- **Cycle/Module level**: Progress tracking and completion analysis +- **Pro analytics**: Projects analysis, Cycles analysis, Modules analysis, Intake analysis +- **Dashboards** (Pro): Custom widgets (bar, line, area, donut, pie, number) with property grouping + +--- + +## 18. Permissions & Roles + +### Three-Tier Role System + +| Role | Code | Workspace Access | Project Access | +| ---------- | ---- | ----------------------------------------------------------- | ----------------------------------- | +| **Admin** | 20 | Full governance: settings, members, integrations, billing | Full: settings, members, features | +| **Member** | 15 | Execution: create projects, collaborate. No settings access | Create items, manage cycles/modules | +| **Guest** | 5 | Extremely restricted: project-specific visibility only | View access only (unless expanded) | + +### Guest Access Nuances + +- Cannot see workspace members, settings, or project landscape +- Project-specific visibility only (isolated) +- Project setting `guest_view_all_features` controls whether guests see all items or only their own +- Useful for clients, contractors, stakeholders + +### Teamspace Role Elevation + +When a user has both teamspace and direct project access, they get the **highest privilege** of either: + +- Teamspace membership auto-grants `Member` access to linked projects +- Existing higher roles (Admin) are preserved + +### Project Visibility + +- **Public**: Members can self-join +- **Private**: Invite-only + +--- + +## 19. Self-Hosting & Configuration + +### Deployment Methods + +| Method | Best For | +| -------------------- | ---------------------------- | +| Docker Compose | Small-to-medium teams | +| Docker AIO | Single-container quick start | +| Kubernetes (Helm) | Production-grade | +| Docker Swarm | Distributed orchestration | +| Podman Quadlets | Podman alternative | +| Coolify / Portainer | Platform-based deployment | +| Airgapped Docker/K8s | Isolated networks | + +### System Requirements + +- CPU: 2 cores (x64/ARM64) +- RAM: 4GB minimum (8GB+ recommended) +- OS: Ubuntu, Debian, CentOS, Amazon Linux, macOS, Windows WSL2 + +### Authentication Options (Self-Hosted) + +- Google OAuth +- GitHub OAuth +- GitLab OAuth +- Custom OIDC / SAML +- LDAP + +### External Services + +- PostgreSQL (managed or self-hosted) +- Redis +- S3 / MinIO / GCS (cloud storage) +- OpenSearch (advanced search) +- Sentry (error tracking) +- SMTP (SendGrid, AWS SES, custom) + +### God Mode (Instance Admin) + +Instance-wide settings panel for: + +- User management +- Authentication configuration +- Feature toggles +- License management +- Advanced controls + +--- + +## 20. Tier Matrix + +What extensibility features are available at each plan level: + +| Feature | Free | Pro | Business | Enterprise | +| -------------------------- | ---- | --- | -------- | ---------- | +| REST API | Yes | Yes | Yes | Yes | +| API Keys | Yes | Yes | Yes | Yes | +| OAuth Apps | Yes | Yes | Yes | Yes | +| Webhooks | Yes | Yes | Yes | Yes | +| Agents | Yes | Yes | Yes | Yes | +| MCP Server | Yes | Yes | Yes | Yes | +| Plane Compose | Yes | Yes | Yes | Yes | +| SDKs | Yes | Yes | Yes | Yes | +| GitHub/GitLab Integration | Yes | Yes | Yes | Yes | +| Slack Integration | Yes | Yes | Yes | Yes | +| Sentry Integration | Yes | Yes | Yes | Yes | +| Draw.io Integration | - | - | Yes | Yes | +| Custom Work Item Types | - | Yes | Yes | Yes | +| Custom Properties | - | Yes | Yes | Yes | +| Templates (Work Item/Page) | - | Yes | Yes | Yes | +| Templates (Project) | - | - | Yes | Yes | +| Workflows | - | - | Yes | Yes | +| Approval Flows | - | - | - | Yes | +| Automations | - | - | Yes | Yes | +| Intake Forms | - | - | Yes | Yes | +| Intake Email | - | - | Yes | Yes | +| Publish Views/Pages | - | Yes | Yes | Yes | +| Custom Export | - | Yes | Yes | Yes | +| Dashboards | - | Yes | Yes | Yes | +| Analytics (Advanced) | - | Yes | Yes | Yes | +| Epics | - | Yes | Yes | Yes | +| Initiatives | - | Yes | Yes | Yes | +| Milestones | - | Yes | Yes | Yes | +| Teamspaces | - | Yes | Yes | Yes | +| Recurring Work Items | - | - | Yes | Yes | +| Time Tracking | - | Yes | Yes | Yes | +| Dependencies (Timeline) | - | Yes | Yes | Yes | +| OIDC/SAML SSO | - | - | - | Yes | +| Audit Logs | - | - | Yes | Yes | + +--- + +## Key Documentation Sources + +### Developer Docs (`plane-docs/developer-docs/`) + +| Section | Path | Content | +| ------------- | --------------------------------- | -------------------------------------- | +| API Reference | `docs/api-reference/` | 25+ categories, 180+ endpoints | +| Build Apps | `docs/dev-tools/build-plane-app/` | OAuth, webhooks, SDKs, examples | +| Agents | `docs/dev-tools/agents/` | Agent runs, activities, best practices | +| MCP Server | `docs/dev-tools/mcp-server.md` | Transport methods, tools, setup | +| Plane Compose | `docs/dev-tools/plane-compose.md` | IaC, YAML schema, sync modes | +| Self-Hosting | `docs/self-hosting/` | Deployment, config, integrations | + +### Product Docs (`plane-docs/docs/`) + +| Section | Path | Content | +| ------------ | ------------------------------------------ | -------------------------------------- | +| Integrations | `docs/integrations/` | GitHub, GitLab, Slack, Sentry, Draw.io | +| Workflows | `docs/workflows-and-approvals/` | State transitions, approvals | +| Automations | `docs/automations/` | Trigger-condition-action rules | +| Templates | `docs/templates/` | Work item, page, project templates | +| Intake | `docs/intake/` | Forms, email, in-app intake | +| Issue Types | `docs/core-concepts/issues/issue-types.md` | Custom types and properties | +| Deploy | `docs/core-concepts/deploy.md` | Public project boards | +| Export | `docs/core-concepts/export.md` | Data export | +| Analytics | `docs/core-concepts/analytics.md` | Dashboards and reports | + +--- + +## 21. Code-Level Modification: CE/EE Architecture + +The Plane codebase uses a **build-time alias swapping** pattern to separate Community Edition (CE) from Enterprise Edition (EE). This is the fundamental mechanism that enables code-level customization. + +### The Key Mechanism: Path Aliases + +**Frontend** (`apps/web/tsconfig.json`): + +```json +{ + "paths": { + "@/*": ["./core/*"], + "@/plane-web/*": ["./ce/*"] + } +} +``` + +**Editor** (`packages/editor/tsconfig.json`): + +```json +{ + "paths": { + "@/plane-editor/*": ["./src/ce/*"] + } +} +``` + +**How it works:** + +- All core code imports feature-specific implementations via `@/plane-web/*` +- This alias resolves to `./ce/*` (Community Edition) at build time +- For EE builds, remapping to `./ee/*` swaps in all enterprise features +- Vite resolves these via `vite-tsconfig-paths` plugin + +**One line change** in `tsconfig.json` swaps the entire edition: + +```json +// CE build +"@/plane-web/*": ["./ce/*"] + +// EE build (hypothetical) +"@/plane-web/*": ["./ee/*"] +``` + +### Frontend vs Backend Split + +| Aspect | Frontend | Backend | +| ----------------- | ------------------------------------------------- | ------------------------------------------------------ | +| CE/EE split | Yes — `ce/` directory with stubs/implementations | **No** — monolithic, all code always available | +| Feature gating | Build-time path alias swap | Instance `edition` field (currently unused for gating) | +| Extension pattern | Component stubs, store inheritance, hook wrappers | Standard Django: add model → serializer → view → URL | +| License check | `InstanceStore` reads config from API | `Instance.edition` field exists but not enforced | + +### Directory Layout + +``` +apps/web/ +├── core/ # Shared engine (100% of Plane logic) +│ ├── components/ # Shared UI components +│ ├── store/ # CoreRootStore + all base stores +│ ├── hooks/ # Shared hooks +│ ├── lib/ # Providers, contexts, wrappers +│ └── constants/ # Shared constants +├── ce/ # Community Edition (extension point) +│ ├── components/ # CE-specific components (stubs or full) +│ ├── store/ # RootStore extends CoreRootStore +│ ├── hooks/ # CE hook wrappers +│ └── types/ # CE type definitions +└── app/ # React Router entry points (layouts, routes) + +apps/api/plane/ +├── app/ # Core API (internal endpoints under /api/) +│ ├── views/ # ViewSets and APIViews +│ ├── serializers/ # DRF Serializers +│ ├── urls/ # URL routing +│ └── permissions/ # Permission classes +├── api/ # v1 API (external endpoints under /api/v1/) +│ ├── views/ # v1 APIViews +│ ├── serializers/ # v1 Serializers +│ └── urls/ # v1 URL routing +├── db/ # Database models + migrations +│ ├── models/ # 30+ model files +│ ├── migrations/ # Auto-generated migrations +│ └── mixins.py # Base model mixins (AuditModel, SoftDeleteModel) +├── license/ # Instance/edition management +├── authentication/ # Auth system +├── bgtasks/ # Celery background tasks +├── settings/ # Django configuration +└── urls.py # Root URL router +``` + +--- + +## 22. Frontend Extension Patterns + +### Pattern 1: Empty Stubs (Most Common) + +CE provides a component that returns empty JSX. EE replaces it with the real implementation. + +```tsx +// ce/components/issues/issue-modal/issue-type-select.tsx +export function IssueTypeSelect>(props: TIssueTypeSelectProps) { + return <>; // Empty in CE +} + +// core/ imports it without knowing it's a stub: +import { IssueTypeSelect } from "@/plane-web/components/issues/issue-modal"; +``` + +**Examples of stubs:** + +- `IssueTypeSelect` — returns `<>` +- `FilterIssueTypes` — returns `null` +- `IssueTypeSwitcher` — returns `<>` +- `IssueTypeActivity` — returns `<>` +- `CreateUpdateEpicModal` — returns `<>` +- `DeDupeButtonRoot` — returns `<>` +- `DuplicateModalRoot` — returns `<>` +- `WorkItemAdditionalSidebarProperties` — returns `<>` +- `CycleAdditionalActions` — returns `<>` +- `UpdateEstimateModal` — returns `<>` + +### Pattern 2: Provider/Context Wrappers + +CE wraps a core provider with CE-specific configuration: + +```tsx +// core/lib/app-rail/provider.tsx (base provider) +export const AppRailVisibilityProvider = observer(function({ children, isEnabled = false }) { + return {children}; +}); + +// ce/hooks/app-rail/provider.tsx (CE wrapper) +import { AppRailVisibilityProvider as CoreProvider } from "@/lib/app-rail/provider"; + +export const AppRailVisibilityProvider = observer(function({ children }) { + return {children}; + // ^^^^^^^^^^^^^^^^ Hardcoded for CE +}); +``` + +### Pattern 3: Store Inheritance + +CE stores extend core stores with additional state: + +```tsx +// core/store/root.store.ts +export class CoreRootStore { + cycle: ICycleStore; + issue: IIssueRootStore; + state: IStateStore; + // ... all base stores +} + +// ce/store/root.store.ts +import { CoreRootStore } from "@/store/root.store"; + +export class RootStore extends CoreRootStore { + timelineStore: ITimelineStore; // CE-only store + + constructor() { + super(); + this.timelineStore = new TimeLineStore(this); + } +} +``` + +### Pattern 4: Functional Delegation + +CE implements logic but delegates rendering to core: + +```tsx +// ce/components/issues/issue-details/parent-select-root.tsx +export const IssueParentSelectRoot = observer(function (props) { + // Full logic: hooks, state management, operations + return ( + + ); +}); +``` + +### Pattern 5: Re-exports (Pass-through) + +CE simply re-exports core implementations when no CE-specific behavior is needed: + +```tsx +// ce/components/common/quick-actions-factory.tsx +export { useQuickActionsFactory } from "@/components/common/quick-actions-factory"; + +// ce/store/state.store.ts +export * from "@/store/state.store"; +``` + +### Pattern 6: Type-Only Stubs + +Minimal type definitions for compile-time satisfaction: + +```tsx +// ce/types/issue-types/issue-property-values.d.ts +export type TIssuePropertyValues = object; +export type TIssuePropertyValueErrors = object; +``` + +### How Core Composes CE Components + +The app layout shows how CE components integrate: + +``` +app/(all)/[workspaceSlug]/layout.tsx + └→ AppRailVisibilityProvider (ce/hooks — wraps core provider) + └→ WorkspaceContentWrapper (ce/components/workspace) + ├→ AppRailRoot (core/components/navigation) + ├→ TopNavigationRoot (ce/components/navigations) + ├→ GlobalModals (ce/components/common/modal) + └→ (route components) +``` + +### CE Component Inventory + +~154 component files across categories: + +| Category | Files | Pattern | +| ---------------------------------- | ----- | ----------------------------- | +| Issues (filters, modals, details) | 30+ | Stubs + functional delegation | +| Gantt chart (dependencies, layers) | 10+ | Full implementations | +| Cycles (analytics, actions) | 8+ | Stubs + analytics components | +| Analytics | 5+ | Tab configs + helpers | +| Command palette | 5+ | Search group configs | +| Automations | 3+ | Wrappers | +| De-duplicate | 4+ | Stubs | +| Estimates | 3+ | Stubs | +| Epics | 2+ | Stubs | +| Pages (editor, AI) | 4+ | Stubs + editor integration | +| Home/onboarding | 3+ | Feature components | + +--- + +## 23. Backend Extension Patterns + +The backend is a standard Django REST Framework application. There is **no CE/EE build-time swapping** — all code is always available. Extension follows Django conventions. + +### App Registration + +`apps/api/plane/settings/common.py`: + +```python +INSTALLED_APPS = [ + "plane.analytics", + "plane.app", + "plane.space", + "plane.bgtasks", + "plane.db", + "plane.utils", + "plane.web", + "plane.middleware", + "plane.license", + "plane.api", + "plane.authentication", + "rest_framework", + "corsheaders", + "django_celery_beat", +] +``` + +### Model Registration + +All models centrally exported from `plane/db/models/__init__.py`: + +```python +from .project import Project, ProjectMember, ProjectMemberInvite, ... +from .issue import Issue, IssueActivity, IssueComment, ... +from .cycle import Cycle, CycleIssue, CycleUserProperties +from .workspace import Workspace, WorkspaceMember, ... +# ... 30+ model files +``` + +### View Architecture + +Base classes in `plane/app/views/base.py`: + +```python +class BaseViewSet(TimezoneMixin, ReadReplicaControlMixin, ModelViewSet, BasePaginator): + model = None + permission_classes = [IsAuthenticated] + filter_backends = (DjangoFilterBackend, SearchFilter) + # Properties: workspace_slug, project_id, fields, expand +``` + +Views organized by domain: `plane/app/views/issue/`, `plane/app/views/project/`, etc. + +### URL Routing + +Root router (`plane/urls.py`): + +```python +urlpatterns = [ + path("api/", include("plane.app.urls")), # Internal API + path("api/public/", include("plane.space.urls")), # Public API + path("api/v1/", include("plane.api.urls")), # v1 External API + path("auth/", include("plane.authentication.urls")), +] +``` + +Each domain has its own URL file, aggregated in `plane/app/urls/__init__.py`. + +### Permission System + +Decorator-based with role hierarchy: + +```python +from plane.app.permissions import allow_permission, ROLE + +@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") +def my_view_method(self, request, *args, **kwargs): + ... +``` + +Roles: `ADMIN = 20`, `MEMBER = 15`, `GUEST = 5` + +### Two API Versions + +| Version | Path | Purpose | Location | +| -------- | ---------- | -------------------- | ------------------ | +| Internal | `/api/` | Frontend consumption | `plane/app/views/` | +| v1 | `/api/v1/` | External/public API | `plane/api/views/` | + +Both share the same models (`plane/db/models/`) but have separate serializers and views. + +--- + +## 24. How To: Add Custom Features + +### Adding a New Model + +1. **Create model file** `plane/db/models/my_feature.py`: + +```python +from plane.db.models.base import ProjectBaseModel + +class MyFeature(ProjectBaseModel): + name = models.CharField(max_length=255) + config = models.JSONField(default=dict) + + class Meta: + db_table = "my_features" +``` + +2. **Export** from `plane/db/models/__init__.py`: + +```python +from .my_feature import MyFeature +``` + +3. **Create and run migration**: + +```bash +python manage.py makemigrations db +python manage.py migrate +``` + +### Adding a New API Endpoint + +1. **Create serializer** `plane/app/serializers/my_feature.py`: + +```python +from plane.app.serializers.base import BaseSerializer +from plane.db.models import MyFeature + +class MyFeatureSerializer(BaseSerializer): + class Meta: + model = MyFeature + fields = "__all__" +``` + +2. **Create view** `plane/app/views/my_feature.py`: + +```python +from plane.app.views.base import BaseViewSet +from plane.app.serializers import MyFeatureSerializer +from plane.db.models import MyFeature +from plane.app.permissions import allow_permission, ROLE + +class MyFeatureViewSet(BaseViewSet): + serializer_class = MyFeatureSerializer + model = MyFeature + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="PROJECT") + def list(self, request, slug, project_id): + features = MyFeature.objects.filter(workspace__slug=slug, project_id=project_id) + serializer = MyFeatureSerializer(features, many=True) + return Response(serializer.data) +``` + +3. **Create URLs** `plane/app/urls/my_feature.py`: + +```python +from django.urls import path +from plane.app.views import MyFeatureViewSet + +urlpatterns = [ + path( + "workspaces//projects//my-features/", + MyFeatureViewSet.as_view({"get": "list", "post": "create"}), + ), +] +``` + +4. **Register** in `plane/app/urls/__init__.py`: + +```python +from .my_feature import urlpatterns as my_feature_urls +urlpatterns = [*my_feature_urls, ...] +``` + +5. **Export** from `plane/app/views/__init__.py` and `plane/app/serializers/__init__.py`. + +### Adding a Frontend Feature + +1. **Create CE component** `apps/web/ce/components/my-feature/root.tsx`: + +```tsx +export const MyFeatureRoot = observer(function MyFeatureRoot() { + return
My custom feature
; +}); +``` + +2. **Export** from `apps/web/ce/components/my-feature/index.ts`: + +```tsx +export * from "./root"; +``` + +3. **Import in core** using the alias: + +```tsx +import { MyFeatureRoot } from "@/plane-web/components/my-feature"; +``` + +4. **Add store** if needed `apps/web/ce/store/my-feature.store.ts`, extend `RootStore`. + +5. **Add API service** in `apps/web/core/services/` to call your backend endpoint. + +### Adding a Frontend Store + +```tsx +// ce/store/my-feature.store.ts +import { makeObservable, observable, action } from "mobx"; + +export class MyFeatureStore { + items: Record = {}; + + constructor(private rootStore: RootStore) { + makeObservable(this, { + items: observable, + fetchItems: action, + }); + } + + fetchItems = async (workspaceSlug: string, projectId: string) => { + const response = await myFeatureService.list(workspaceSlug, projectId); + runInAction(() => { + this.items = response; + }); + }; +} + +// ce/store/root.store.ts +export class RootStore extends CoreRootStore { + myFeature: MyFeatureStore; + constructor() { + super(); + this.myFeature = new MyFeatureStore(this); + } +} +``` + +### Key Principles + +1. **Never modify `core/` directly** if you want to stay merge-compatible — put customizations in `ce/` +2. **Always use `@/plane-web/` imports** in core — never import directly from `ce/` or `ee/` +3. **Backend is monolithic** — add features directly to `plane/app/` or `plane/api/` +4. **Follow the domain pattern**: model file → serializer → view → URL → export from `__init__.py` +5. **Use `ProjectBaseModel`** for project-scoped entities, `WorkspaceBaseModel` for workspace-scoped +6. **All models get soft-delete** for free via `AuditModel` inheritance + +### Key Files for Custom Modifications + +| Task | Frontend File | Backend File | +| ---------------------- | ------------------------------------- | ----------------------------------- | +| Path alias config | `apps/web/tsconfig.json` | N/A | +| Root store | `apps/web/ce/store/root.store.ts` | N/A | +| App registration | N/A | `plane/settings/common.py` | +| Model registry | N/A | `plane/db/models/__init__.py` | +| View registry | N/A | `plane/app/views/__init__.py` | +| Serializer registry | N/A | `plane/app/serializers/__init__.py` | +| URL aggregation | N/A | `plane/app/urls/__init__.py` | +| Permission definitions | N/A | `plane/app/permissions/base.py` | +| Vite build config | `apps/web/vite.config.ts` | N/A | +| Store provider | `apps/web/core/lib/store-context.tsx` | N/A | diff --git a/plans/policy-claims-searching.md b/plans/policy-claims-searching.md new file mode 100644 index 00000000000..8c86942e4eb --- /dev/null +++ b/plans/policy-claims-searching.md @@ -0,0 +1,544 @@ +# Plan: Policy & Claim Search in Creation Modal + +## What We're Building + +Replace the raw UUID input with a **search-as-you-type dropdown** for linking policies and claims when creating a case. The user types a name, number, plate, DNI, or any identifier → the system searches the cima DB across all relevant tables → shows prioritized results → user selects one → it appears as a compact tag. + +### User Flow + +1. Below the title in the creation modal, a row appears: `[Policy ▾] [🔍 Search policies...]` +2. User switches the dropdown to "Policy" or "Claim" (defaults to "Claim" in Claims project, "Policy" in others) +3. User types into the search box (e.g., "Oliver", "P-12345", "4532AB", "12345678Z") +4. Debounced (300ms) search hits the backend → returns prioritized results +5. Results show as a dropdown list: primary identifier + person/company name + ramo +6. User clicks a result → search box collapses into a **tag**: `P-12345 · Mapfre · Auto` +7. Clicking the tag opens a detail panel showing the full policy or claim (deferred to v2) +8. User can clear the tag (X button) to search again + +### Visual Layout + +``` +┌─────────────────────────────────────────────┐ +│ Create new work item │ +│ │ +│ ◉ Claims │ +│ │ +│ [Policy ▾] [🔍 Search policies... ] │ ← NEW ROW +│ │ +│ Title │ +│ ┌─────────────────────────────────────────┐ │ +│ │ │ │ +│ └─────────────────────────────────────────┘ │ +│ │ +│ [New] [None] [Assignees] [Labels] ... │ +└─────────────────────────────────────────────┘ +``` + +After selection: + +``` +│ [Policy ▾] [ P-12345 · Mapfre · Auto ✕ ] │ +``` + +--- + +## Backend: Search SQL (Raw Tables, No Views) + +The search uses raw `cima.*` tables with explicit JOINs. This captures one-to-many relationships (multiple vehicles per policy, multiple risks, etc.) that views flatten away. + +### Base Policy Query (Common Joins) + +Every policy search starts with this join chain: + +```sql +FROM cima.policies p +JOIN cima.policy_versions pv ON pv.id = p.active_version_id + +-- Policyholder (tomador) +LEFT JOIN cima.entity_observations eo_holder + ON eo_holder.id = pv.policy_holder_entity_observation_id +LEFT JOIN cima.individuals ind_holder + ON ind_holder.entity_observation_id = eo_holder.id +LEFT JOIN cima.organizations org_holder + ON org_holder.entity_observation_id = eo_holder.id + +-- Insured party (asegurado) +LEFT JOIN cima.entity_observations eo_insured + ON eo_insured.id = pv.insured_entity +LEFT JOIN cima.individuals ind_insured + ON ind_insured.entity_observation_id = eo_insured.id +LEFT JOIN cima.organizations org_insured + ON org_insured.entity_observation_id = eo_insured.id + +-- Insurance company +LEFT JOIN ref.insurance_companies ic + ON ic.id = p.insurance_company_id + +-- Product / Ramo +LEFT JOIN cima.products pr + ON pr.id = pv.product_id +``` + +**Derived columns from these joins:** + +```sql +p.policy_number AS primary_label, +ic.name AS company, +COALESCE(pr.variant_description, pr.insurance_line) AS ramo, + +-- Policyholder name +COALESCE( + NULLIF(TRIM(CONCAT_WS(' ', ind_holder.first_name, ind_holder.last_name_1, ind_holder.last_name_2)), ''), + org_holder.legal_name +) AS holder_name, + +-- Insured name +COALESCE( + NULLIF(TRIM(CONCAT_WS(' ', ind_insured.first_name, ind_insured.last_name_1, ind_insured.last_name_2)), ''), + org_insured.legal_name +) AS insured_name, + +-- Holder ID number +eo_holder.identification_number AS holder_id_number +``` + +### Search Priority Tiers (Policy) + +Each tier is a separate query block in a `UNION ALL`, deduplicating with `WHERE p.id NOT IN (...)`. Ordered by priority. + +| Tier | What We're Matching | WHERE Clause | match_field | +| ---- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | --------------- | +| 1 | Policy number | `p.policy_number ILIKE '%query%'` | `policy_number` | +| 2 | Holder ID number (DNI/CIF) | `eo_holder.identification_number ILIKE '%query%'` | `holder_id` | +| 3 | Policyholder name | `CONCAT_WS(' ', ind_holder.first_name, ind_holder.last_name_1, ind_holder.last_name_2) ILIKE '%query%' OR org_holder.legal_name ILIKE '%query%'` | `holder_name` | +| 4 | Insured person name | same pattern with `ind_insured`/`org_insured` | `insured_name` | +| 5 | Vehicle license plate | requires additional join (see below) | `license_plate` | +| 6 | Vehicle VIN | same join as tier 5 | `vin` | +| 7 | Insurance company name | `ic.name ILIKE '%query%'` | `company` | + +### Vehicle Join (Tiers 5-6) + +Vehicles are linked through the risk chain: + +```sql +-- Additional joins for vehicle search +LEFT JOIN cima.insured_risks ir ON ir.policy_version_id = pv.id +LEFT JOIN cima.auto_risks ar ON ar.insured_risk_id = ir.id +LEFT JOIN cima.vehicle_observations vo ON vo.id = ar.vehicle_id +``` + +Then match: `vo.license_plate_number ILIKE '%query%'` or `vo.vin ILIKE '%query%'` + +**Important**: A policy can have multiple vehicles. The search should match if ANY vehicle matches, but the result should still show the policy (not the vehicle). The vehicle info appears as a hint in the secondary label. + +### Search Priority Tiers (Claim) + +| Tier | What We're Matching | WHERE Clause | match_field | +| ---- | -------------------------------- | -------------------------------------------------- | --------------- | +| 1 | Claim number | `c.insurance_company_reference_id ILIKE '%query%'` | `claim_number` | +| 2 | Broker reference | `c.broker_reference_id ILIKE '%query%'` | `broker_ref` | +| 3 | Policy number (of linked policy) | `p.policy_number ILIKE '%query%'` | `policy_number` | +| 4 | Policyholder name (via policy) | same holder name pattern | `holder_name` | +| 5 | Claim description | `c.description ILIKE '%query%'` | `description` | + +### Base Claim Query (Common Joins) + +```sql +FROM cima.claims c +LEFT JOIN cima.policies p ON p.id = c.policy_id +LEFT JOIN cima.policy_versions pv ON pv.id = p.active_version_id + +-- Policyholder (via policy) +LEFT JOIN cima.entity_observations eo_holder + ON eo_holder.id = pv.policy_holder_entity_observation_id +LEFT JOIN cima.individuals ind_holder + ON ind_holder.entity_observation_id = eo_holder.id +LEFT JOIN cima.organizations org_holder + ON org_holder.entity_observation_id = eo_holder.id + +-- Insurance company (via policy) +LEFT JOIN ref.insurance_companies ic + ON ic.id = p.insurance_company_id + +-- Product/ramo (via policy) +LEFT JOIN cima.products pr ON pr.id = pv.product_id + +-- Latest claim status +LEFT JOIN LATERAL ( + SELECT cs.status::text AS status + FROM cima.claim_statuses cs + WHERE cs.claim_id = c.id + ORDER BY cs.date DESC, cs.id DESC + LIMIT 1 +) latest_status ON TRUE +``` + +**Claim result columns:** + +```sql +c.id, +c.insurance_company_reference_id AS primary_label, -- claim number +p.policy_number, +ic.name AS company, +COALESCE(pr.variant_description, pr.insurance_line) AS ramo, +holder_name, -- same COALESCE pattern +latest_status.status +``` + +### Practical Implementation: Single Query with CASE Priority + +Instead of `UNION ALL` (complex, hard to deduplicate), use a single query with `CASE` for priority: + +```sql +-- Policy search +SELECT DISTINCT ON (p.id) + p.id, + 'policy' AS entity_type, + p.policy_number AS primary_label, + ic.name AS company, + COALESCE(pr.variant_description, pr.insurance_line) AS ramo, + COALESCE( + NULLIF(TRIM(CONCAT_WS(' ', ind_holder.first_name, ind_holder.last_name_1, ind_holder.last_name_2)), ''), + org_holder.legal_name + ) AS secondary_label, + CASE + WHEN p.policy_number ILIKE %(q_prefix)s THEN 1 + WHEN eo_holder.identification_number ILIKE %(q_contains)s THEN 2 + WHEN CONCAT_WS(' ', ind_holder.first_name, ind_holder.last_name_1, ind_holder.last_name_2) ILIKE %(q_contains)s + OR org_holder.legal_name ILIKE %(q_contains)s THEN 3 + WHEN CONCAT_WS(' ', ind_insured.first_name, ind_insured.last_name_1, ind_insured.last_name_2) ILIKE %(q_contains)s + OR org_insured.legal_name ILIKE %(q_contains)s THEN 4 + WHEN vo.license_plate_number ILIKE %(q_contains)s THEN 5 + WHEN vo.vin ILIKE %(q_contains)s THEN 6 + WHEN ic.name ILIKE %(q_contains)s THEN 7 + ELSE 99 + END AS match_priority, + CASE + WHEN p.policy_number ILIKE %(q_prefix)s THEN 'policy_number' + WHEN eo_holder.identification_number ILIKE %(q_contains)s THEN 'holder_id' + WHEN CONCAT_WS(' ', ind_holder.first_name, ind_holder.last_name_1, ind_holder.last_name_2) ILIKE %(q_contains)s + OR org_holder.legal_name ILIKE %(q_contains)s THEN 'holder_name' + WHEN CONCAT_WS(' ', ind_insured.first_name, ind_insured.last_name_1, ind_insured.last_name_2) ILIKE %(q_contains)s + OR org_insured.legal_name ILIKE %(q_contains)s THEN 'insured_name' + WHEN vo.license_plate_number ILIKE %(q_contains)s THEN 'license_plate' + WHEN vo.vin ILIKE %(q_contains)s THEN 'vin' + WHEN ic.name ILIKE %(q_contains)s THEN 'company' + ELSE 'unknown' + END AS match_field +FROM cima.policies p +JOIN cima.policy_versions pv ON pv.id = p.active_version_id +LEFT JOIN cima.entity_observations eo_holder ON eo_holder.id = pv.policy_holder_entity_observation_id +LEFT JOIN cima.individuals ind_holder ON ind_holder.entity_observation_id = eo_holder.id +LEFT JOIN cima.organizations org_holder ON org_holder.entity_observation_id = eo_holder.id +LEFT JOIN cima.entity_observations eo_insured ON eo_insured.id = pv.insured_entity +LEFT JOIN cima.individuals ind_insured ON ind_insured.entity_observation_id = eo_insured.id +LEFT JOIN cima.organizations org_insured ON org_insured.entity_observation_id = eo_insured.id +LEFT JOIN ref.insurance_companies ic ON ic.id = p.insurance_company_id +LEFT JOIN cima.products pr ON pr.id = pv.product_id +LEFT JOIN cima.insured_risks ir ON ir.policy_version_id = pv.id +LEFT JOIN cima.auto_risks ar ON ar.insured_risk_id = ir.id +LEFT JOIN cima.vehicle_observations vo ON vo.id = ar.vehicle_id +WHERE + p.policy_number ILIKE %(q_prefix)s + OR eo_holder.identification_number ILIKE %(q_contains)s + OR CONCAT_WS(' ', ind_holder.first_name, ind_holder.last_name_1, ind_holder.last_name_2) ILIKE %(q_contains)s + OR org_holder.legal_name ILIKE %(q_contains)s + OR CONCAT_WS(' ', ind_insured.first_name, ind_insured.last_name_1, ind_insured.last_name_2) ILIKE %(q_contains)s + OR org_insured.legal_name ILIKE %(q_contains)s + OR vo.license_plate_number ILIKE %(q_contains)s + OR vo.vin ILIKE %(q_contains)s + OR ic.name ILIKE %(q_contains)s +ORDER BY p.id, match_priority +LIMIT %(limit)s +``` + +**Parameters:** + +- `q_prefix` = `'query%'` (starts with — for policy/claim numbers) +- `q_contains` = `'%query%'` (contains — for names, plates) +- `limit` = 10 + +The `DISTINCT ON (p.id)` with `ORDER BY p.id, match_priority` ensures each policy appears once, at its highest priority match. + +--- + +## Result Format + +```json +{ + "results": [ + { + "id": "uuid", + "entity_type": "policy", + "primary_label": "P-12345", + "company": "Mapfre", + "ramo": "Auto", + "secondary_label": "Oliver Freed Martinez", + "match_field": "policy_number", + "match_priority": 1 + } + ] +} +``` + +For claims, `primary_label` = claim number, and `secondary_label` = policyholder name. + +--- + +## Backend Files + +### New: `apps/api/plane/utils/entity_search.py` + +Contains `search_policies(query, limit)` and `search_claims(query, limit)`. + +- Raw SQL with parameterized queries +- Joins through the full chain: policies → policy_versions → entity_observations → individuals/organizations + insured_risks → auto_risks → vehicle_observations +- Wrapped in try/except — returns empty list if cima schema doesn't exist +- Uses `django.db.connection.cursor()` + +### New: `apps/api/plane/app/views/case/entity_search.py` + +```python +class EntitySearchEndpoint(BaseAPIView): + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def get(self, request, slug, project_id): + entity_type = request.query_params.get("type", "policy") + query = request.query_params.get("q", "").strip() + limit = min(int(request.query_params.get("limit", "10")), 20) + + if len(query) < 2: + return Response({"results": []}) + + if entity_type == "policy": + results = search_policies(query, limit) + elif entity_type == "claim": + results = search_claims(query, limit) + else: + return Response({"results": []}) + + return Response({"results": results}) +``` + +### URL + +```python +path( + "workspaces//projects//search-entities/", + EntitySearchEndpoint.as_view(), + name="search-entities", +), +``` + +--- + +## Frontend: Entity Search Dropdown Component + +### New: `apps/web/core/components/cases/entity-search-dropdown.tsx` + +Matches Plane's dropdown styling exactly. Uses: + +- `Combobox` from `@headlessui/react` for keyboard nav + selection +- `usePopper` for dropdown positioning +- `useDropdown` hook for outside-click and keyboard handling +- Debounced input (300ms) via `setTimeout`/`clearTimeout` +- Plane's exact CSS classes for button, search input, options list, option items + +**States:** + +1. **Empty** — shows search input placeholder +2. **Searching** — shows loading indicator in dropdown +3. **Results** — shows option list with matches +4. **No results** — shows "No matching results" italic text +5. **Selected** — shows tag: `primary_label · company · ramo [✕]` + +### Component Props + +```typescript +type EntitySearchDropdownProps = { + entityType: "policy" | "claim"; + onEntityTypeChange: (type: "policy" | "claim") => void; + onSelect: (result: TEntitySearchResult | null) => void; + selected: TEntitySearchResult | null; + workspaceSlug: string; + projectId: string; +}; +``` + +### Tag Display (After Selection) + +Uses Plane's existing tag/chip styling: + +```tsx +
+ {result.primary_label} + · + {result.company} + · + {result.ramo} + +
+``` + +--- + +## Frontend: Types + +### Add to `packages/types/src/cases/case-entity-link.ts` + +```typescript +export type TEntitySearchResult = { + id: string; + entity_type: "policy" | "claim"; + primary_label: string; + company: string | null; + ramo: string | null; + secondary_label: string | null; + match_field: string; + match_priority: number; +}; +``` + +### Add to `EntityLinkService` + +```typescript +async search( + workspaceSlug: string, + projectId: string, + entityType: "policy" | "claim", + query: string, + limit?: number +): Promise<{ results: TEntitySearchResult[] }> +``` + +--- + +## Frontend: Form Integration + +### Modify: `form.tsx` + +Insert `EntitySearchDropdown` between the project/type selector row and the title input. Currently the layout is: + +``` +line ~392:
project selector row
+line ~447: {watch("parent_id") && } +line ~452:
+``` + +Insert between the parent tag and title: + +```tsx +{ + /* Entity search — between project selector and title */ +} +
+ { + setSelectedEntity(result); + onEntityLinkChangeProp?.(result ? { entity_type: result.entity_type, entity_id: result.id } : null); + }} + selected={selectedEntity} + workspaceSlug={workspaceSlug?.toString() ?? ""} + projectId={projectId} + /> +
; +``` + +### Modify: `default-properties.tsx` + +**Remove** the entity link input (select + UUID text box) from the bottom properties bar. The search dropdown above the title replaces it entirely. + +--- + +## Files Summary + +### New Files + +| File | Purpose | +| ----------------------------------------------------------- | --------------------------------------------------------- | +| `apps/api/plane/utils/entity_search.py` | Raw SQL search functions (search_policies, search_claims) | +| `apps/api/plane/app/views/case/entity_search.py` | Search API endpoint | +| `apps/web/core/components/cases/entity-search-dropdown.tsx` | Search dropdown component | + +### Modified Files + +| File | Change | +| ------------------------------------------------------------------------------- | ------------------------------------ | +| `apps/api/plane/app/views/case/__init__.py` | Add `EntitySearchEndpoint` | +| `apps/api/plane/app/views/__init__.py` | Add `EntitySearchEndpoint` import | +| `apps/api/plane/app/urls/case_entity_link.py` | Add search URL | +| `apps/web/core/services/case/entity-link.service.ts` | Add `search()` method | +| `packages/types/src/cases/case-entity-link.ts` | Add `TEntitySearchResult` | +| `apps/web/core/components/issues/issue-modal/form.tsx` | Add EntitySearchDropdown above title | +| `apps/web/core/components/issues/issue-modal/components/default-properties.tsx` | Remove entity link UUID input | + +--- + +## TODO + +### Phase 1: Backend — Search Functions + +- [x] 1.1 Create `apps/api/plane/utils/entity_search.py` +- [x] 1.2 `search_policies(query, limit)`: raw SQL joining policies → policy_versions → entity_observations → individuals/organizations → insurance_companies → products → insured_risks → auto_risks → vehicle_observations +- [x] 1.3 Priority ordering via CASE: policy_number(1) > holder_id(2) > holder_name(3) > insured_name(4) > license_plate(5) > vin(6) > company(7) +- [x] 1.4 `search_claims(query, limit)`: raw SQL joining claims → policies → policy_versions → entity_observations → individuals/organizations → insurance_companies → products + lateral claim_statuses +- [x] 1.5 Priority ordering: claim_number(1) > broker_ref(2) > policy_number(3) > holder_name(4) > description(5) +- [x] 1.6 DISTINCT ON to deduplicate (one result per policy/claim) +- [x] 1.7 Parameterized queries — no SQL injection +- [x] 1.8 try/except wrapper — empty list if cima schema missing + +### Phase 2: Backend — Search Endpoint + +- [x] 2.1 Create `apps/api/plane/app/views/case/entity_search.py` +- [x] 2.2 GET params: type (policy|claim), q (search string), limit (max 20) +- [x] 2.3 Minimum 2 characters before searching +- [x] 2.4 Add to `case/__init__.py` +- [x] 2.5 Add to `views/__init__.py` +- [x] 2.6 Add URL to `case_entity_link.py` + +### Phase 3: Frontend — Types & Service + +- [x] 3.1 Add `TEntitySearchResult` to types +- [x] 3.2 Add `search()` method to `EntityLinkService` +- [x] 3.3 Build types package +- [x] 3.4 Typecheck: 0 errors + +### Phase 4: Frontend — Search Dropdown Component + +- [x] 4.1 Create `entity-search-dropdown.tsx` +- [x] 4.2 Entity type selector (Póliza/Siniestro dropdown) — matches Plane styling +- [x] 4.3 Search input with 300ms debounce — matches Plane search input styling +- [x] 4.4 Results dropdown with Combobox.Options — matches Plane option list +- [x] 4.5 Each result: primary_label bold, secondary_label muted, company + ramo +- [x] 4.6 Selected state: tag with `primary_label · company · ramo` + X clear +- [x] 4.7 Loading state (spinner + "Buscando...") +- [x] 4.8 Empty/no results state ("Sin resultados") +- [x] 4.9 Typecheck: 0 errors + +### Phase 5: Frontend — Form Integration + +- [x] 5.1 Add EntitySearchDropdown to `form.tsx` between project selector and title +- [x] 5.2 Wire onSelect → onEntityLinkChangeProp +- [x] 5.3 Reverted `default-properties.tsx` to stock (entity link UUID input removed) +- [x] 5.4 Default entity type: "claim" +- [x] 5.5 Typecheck: 0 errors +- [ ] 5.6 Test: dropdown appears, search works, selection shows tag, save creates entity link — _requires running app + cima data_ + +### Phase 6: Detail Panel (Deferred) + +- [ ] 6.1 Click tag → open policy/claim detail view (v2) + +--- + +## Resolved Questions + +| # | Question | Decision | +| --- | ------------------------------- | ---------------------------------------------------------------------- | +| 1 | Vehicle/plate search | Yes — join through insured_risks → auto_risks → vehicle_observations | +| 2 | ID number (DNI/CIF) search | Yes — via entity_observations.identification_number | +| 3 | Default entity type per project | Claims project → "claim", all others → "policy" | +| 4 | Use views or raw tables | Raw tables with explicit joins — views can't capture 1:N relationships | +| 5 | Detail panel on tag click | Deferred to v2 | diff --git a/plans/project-case-type-plan.md b/plans/project-case-type-plan.md new file mode 100644 index 00000000000..604c89f2ee9 --- /dev/null +++ b/plans/project-case-type-plan.md @@ -0,0 +1,437 @@ +# Implementation Plan: Projects as Case Types + +**Principle: reuse existing Plane UI as much as absolutely possible. Avoid creating new UI elements unless there is no alternative.** + +## Key Insight + +Instead of one hidden project with IssueTypes, use **one Plane project per case type**. This eliminates all UI hacks (sidebar, breadcrumb, creation modal) because Plane's native project UI already does what we need. + +| Concept | Plane Feature | +| -------------------------------- | ---------------------------------- | +| Case type (Claim, Renewal, etc.) | Project | +| Case | Issue (work item) within a project | +| Work item | Sub-issue under a case | +| Case queue/bin | Project in the sidebar | +| Entity link | CaseEntityLink model (custom) | + +## What Changes vs. Previous Plan + +| Previous (hidden project) | New (project per case type) | Impact | +| ------------------------------------------- | ---------------------------------------- | ---------------- | +| 1 hidden project + 5 IssueTypes | 5 projects, no IssueTypes needed | Simpler seed | +| Sidebar hacked to show case type bins | Sidebar shows projects natively | **Revert hack** | +| Project selector hidden in creation modal | Project selector = case type selector | **Revert hack** | +| Breadcrumb shows hidden project name | Breadcrumb shows "Claims > Work Items" | **Revert hack** | +| "Projects" link hidden in workspace sidebar | "Projects" link shows case type projects | **Revert hack** | +| Issue IDs: `CASES-42` | `CLM-42`, `RNW-15`, `END-8` | More descriptive | + +## What Stays the Same + +These are unchanged from the previous plan — already implemented: + +- **CaseEntityLink model** (`apps/api/plane/db/models/case_entity_link.py`) +- **core_db_resolver** (`apps/api/plane/utils/core_db_resolver.py`) +- **Entity link API** (serializer, views, URLs) +- **Entity link panel in peek** (`apps/web/core/components/cases/entity-link-panel.tsx`) +- **Entity link service** (`apps/web/core/services/case/entity-link.service.ts`) +- **Entity link types** (`packages/types/src/cases/case-entity-link.ts`) +- **Entity link filter** (`entity_id` param on issue list) +- **Entity link in creation modal** (policy/claim UUID input in default properties) +- **Backend registrations** (model, serializer, view, URL `__init__.py` additions) +- **`.gitattributes`** merge strategy + +## Desired Outcomes + +1. **5 projects** — Claims (`CLM`), Renewals (`RNW`), Endorsements (`END`), Billing (`BIL`), General (`GEN`) +2. **Each project** has the same 11 insurance-specific states +3. **Sidebar** shows the 5 projects natively (no modification) +4. **Breadcrumbs** show project name natively (no modification) +5. **Creation modal** project selector works as case type selector (no modification) +6. **Entity links** — link cases to policies/claims in cima DB (already implemented) +7. **Upstream sync** — all Plane UI reverted to stock; only backend additions remain + +### Verification + +- [ ] Sidebar shows: Claims, Renewals, Endorsements, Billing, General as projects +- [ ] Creating a case: project selector shows case types, entity link field works +- [ ] Breadcrumb: "Claims > Work Items" (not "test1") +- [ ] Issue IDs: `CLM-1`, `RNW-1`, etc. +- [ ] Entity link CRUD works in peek +- [ ] Entity link filter works (`?entity_id=...`) +- [ ] All native Plane features work unmodified (kanban, filters, drag-drop, sub-issues, etc.) +- [ ] `git diff` shows zero changes to Plane frontend components (only backend additions) + +--- + +## Phase 1: Revert All Frontend Hacks + +These files were modified to hide projects. All modifications must be reverted to stock Plane code. + +### Step 1.1: Revert sidebar + +**File**: `apps/web/core/components/workspace/sidebar/projects-list.tsx` + +Revert to original — remove CASE_TYPES constant, restore project list mapping, restore "Projects" header. + +```bash +git checkout -- apps/web/core/components/workspace/sidebar/projects-list.tsx +``` + +### Step 1.2: Revert creation modal form + +**File**: `apps/web/core/components/issues/issue-modal/form.tsx` + +Revert: restore `IssueProjectSelect` (was hidden), remove `joinedProjectIds` usage, remove `onEntityLinkChange` prop passthrough. + +```bash +git checkout -- apps/web/core/components/issues/issue-modal/form.tsx +``` + +### Step 1.3: Revert creation modal base + +**File**: `apps/web/core/components/issues/issue-modal/base.tsx` + +Revert: remove `EntityLinkService` import, remove `pendingEntityLinkRef`, remove post-create entity link call, remove `onEntityLinkChange` prop. + +```bash +git checkout -- apps/web/core/components/issues/issue-modal/base.tsx +``` + +### Step 1.4: Revert default properties + +**File**: `apps/web/core/components/issues/issue-modal/components/default-properties.tsx` + +Revert: remove entity link selector UI (select + input), remove `onEntityLinkChange` prop, remove `Link2` import, remove `TCaseEntityLinkCreatePayload` import. + +```bash +git checkout -- apps/web/core/components/issues/issue-modal/components/default-properties.tsx +``` + +### Step 1.5: Keep peek properties modification + +**File**: `apps/web/core/components/issues/peek-overview/properties.tsx` + +**DO NOT revert** — the `EntityLinkPanel` insertion stays. This is the only frontend modification we keep. + +### Step 1.6: Revert i18n labels + +**File**: `packages/i18n/src/locales/en/translations.ts` + +Revert: remove the case-related labels (cases, claims, renewals, etc.) since the sidebar no longer needs them. + +```bash +git checkout -- packages/i18n/src/locales/en/translations.ts +``` + +Then rebuild i18n: + +```bash +pnpm --filter "@plane/i18n" run build +``` + +### Summary of Phase 1 + +After this phase: + +- **4 files reverted** to stock Plane (sidebar, form.tsx, base.tsx, default-properties.tsx, translations.ts) +- **1 file keeps modification** (peek properties.tsx — entity link panel) +- Frontend `git diff` shows only 2 changed files: `properties.tsx` and `packages/types/src/index.ts` + +--- + +## Phase 2: Re-add Entity Link to Creation Modal (Clean Approach) + +Now that projects = case types, the creation modal needs the entity link input back — but this time without hiding the project selector. + +### Step 2.1: Add entity link input to default-properties.tsx + +Same modification as before but without the project-hiding hack: + +**Modify**: `apps/web/core/components/issues/issue-modal/components/default-properties.tsx` + +- Add `Link2` import from lucide-react +- Add `TCaseEntityLinkCreatePayload` import from `@plane/types` +- Add `onEntityLinkChange` optional prop +- Add entity link select + UUID input at end of properties div + +### Step 2.2: Wire entity link creation in base.tsx + +**Modify**: `apps/web/core/components/issues/issue-modal/base.tsx` + +- Add `EntityLinkService` import +- Add `pendingEntityLinkRef` +- After `handleCreateIssue`, create entity link if pending +- Pass `onEntityLinkChange` callback in `commonIssueModalProps` + +### Step 2.3: Pass callback through form.tsx + +**Modify**: `apps/web/core/components/issues/issue-modal/form.tsx` + +- Add `TCaseEntityLinkCreatePayload` import +- Add `onEntityLinkChange` optional prop to `IssueFormProps` +- Pass it through to `IssueDefaultProperties` + +--- + +## Phase 3: Update Seed Script + +### Step 3.1: Modify seed script to create 5 projects + +**Modify**: `apps/api/plane/db/management/commands/seed_case_config.py` + +Instead of creating IssueTypes on one project, create 5 projects each with the same states: + +```python +CASE_PROJECTS = [ + {"name": "Claims", "identifier": "CLM", "description": "Insurance claim cases", "emoji": "📋"}, + {"name": "Renewals", "identifier": "RNW", "description": "Policy renewal cases", "emoji": "🔄"}, + {"name": "Endorsements", "identifier": "END", "description": "Policy change / endorsement cases", "emoji": "📝"}, + {"name": "Billing", "identifier": "BIL", "description": "Billing / payment cases", "emoji": "💳"}, + {"name": "General", "identifier": "GEN", "description": "General service requests", "emoji": "📩"}, +] + +CASE_STATES = [ + {"name": "New", "group": "backlog", "color": "#3B82F6"}, + {"name": "Triaged", "group": "unstarted", "color": "#8B5CF6"}, + {"name": "In Progress", "group": "started", "color": "#F59E0B"}, + {"name": "Waiting on Client", "group": "started", "color": "#F97316"}, + {"name": "Waiting on Insurer", "group": "started", "color": "#F97316"}, + {"name": "Waiting on Internal", "group": "started", "color": "#F97316"}, + {"name": "Scheduled", "group": "started", "color": "#06B6D4"}, + {"name": "Ready for Review", "group": "started", "color": "#6366F1"}, + {"name": "Resolved", "group": "completed", "color": "#22C55E"}, + {"name": "Closed", "group": "completed", "color": "#6B7280"}, + {"name": "Cancelled", "group": "cancelled", "color": "#9CA3AF"}, +] +``` + +Usage: + +```bash +python manage.py seed_case_config +``` + +No project identifier argument needed — it creates all 5. + +For each project: create the project, add the requesting user as admin, replace default states with case-specific states, set default state to "New". + +### Step 3.2: Remove IssueType creation from seed + +Since projects ARE the case types, no IssueType creation is needed. Remove `IssueType` and `ProjectIssueType` imports and logic. + +--- + +## Phase 4: Typecheck & Verify + +### Step 4.1: Build packages + +```bash +pnpm --filter "@plane/types" run build +pnpm --filter "@plane/i18n" run build +``` + +### Step 4.2: Typecheck + +```bash +node_modules/.bin/tsc --noEmit +``` + +Verify: 0 new errors. + +--- + +## Summary: All Files + +### New Files (kept from previous implementation) + +| # | File | Purpose | +| --- | ----------------------------------------------------------- | ------------------------------ | +| 1 | `apps/api/plane/db/models/case_entity_link.py` | CaseEntityLink Django model | +| 2 | `apps/api/plane/utils/core_db_resolver.py` | Raw SQL resolver for cima data | +| 3 | `apps/api/plane/app/serializers/case_entity_link.py` | DRF serializers | +| 4 | `apps/api/plane/app/views/case/__init__.py` | View package init | +| 5 | `apps/api/plane/app/views/case/entity_link.py` | Entity link API views | +| 6 | `apps/api/plane/app/urls/case_entity_link.py` | URL routing | +| 7 | `apps/api/plane/db/management/commands/seed_case_config.py` | Seed script (updated) | +| 8 | `packages/types/src/cases/case-entity-link.ts` | TypeScript types | +| 9 | `packages/types/src/cases/index.ts` | Type exports | +| 10 | `apps/web/core/services/case/entity-link.service.ts` | Frontend API service | +| 11 | `apps/web/core/services/case/index.ts` | Service exports | +| 12 | `apps/web/core/components/cases/entity-link-panel.tsx` | Entity link display in peek | + +### Modified Plane Files (minimal) + +| # | File | Change | +| --- | ------------------------------------------------------------------------------- | --------------------------------- | +| 1 | `apps/api/plane/db/models/__init__.py` | Append `CaseEntityLink` import | +| 2 | `apps/api/plane/app/serializers/__init__.py` | Append serializer imports | +| 3 | `apps/api/plane/app/views/__init__.py` | Append view imports | +| 4 | `apps/api/plane/app/urls/__init__.py` | Append URL pattern | +| 5 | `apps/api/plane/app/views/issue/base.py` | Add `entity_id` filter (~5 lines) | +| 6 | `apps/web/core/components/issues/peek-overview/properties.tsx` | Add EntityLinkPanel (~4 lines) | +| 7 | `packages/types/src/index.ts` | Append `export * from "./cases"` | +| 8 | `apps/web/core/components/issues/issue-modal/components/default-properties.tsx` | Entity link input | +| 9 | `apps/web/core/components/issues/issue-modal/form.tsx` | Pass entity link callback | +| 10 | `apps/web/core/components/issues/issue-modal/base.tsx` | Entity link post-create | +| 11 | `.gitattributes` | Merge strategy | + +### Files Reverted to Stock (no longer modified) + +| # | File | Was | +| --- | -------------------------------------------------------------- | ------------------- | +| 1 | `apps/web/core/components/workspace/sidebar/projects-list.tsx` | Case type bins hack | +| 2 | `packages/i18n/src/locales/en/translations.ts` | Case sidebar labels | + +### Configuration (via seed script) + +| What | How | +| ------------------------------------ | ---------------------------------------------------- | +| 5 projects (CLM, RNW, END, BIL, GEN) | `python manage.py seed_case_config ` | +| 11 states per project | Same seed command | + +--- + +## TODO + +### Phase 1: Revert Frontend Hacks + +Restore 5 files to byte-for-byte upstream state. Zero git diff on these files after this phase. + +- [x] 1.1 Revert sidebar: `git checkout -- apps/web/core/components/workspace/sidebar/projects-list.tsx` +- [x] 1.2 Revert creation modal form: `git checkout -- apps/web/core/components/issues/issue-modal/form.tsx` +- [x] 1.3 Revert creation modal base: `git checkout -- apps/web/core/components/issues/issue-modal/base.tsx` +- [x] 1.4 Revert default properties: `git checkout -- apps/web/core/components/issues/issue-modal/components/default-properties.tsx` +- [x] 1.5 Revert i18n translations: `git checkout -- packages/i18n/src/locales/en/translations.ts` +- [x] 1.6 Rebuild i18n package: `pnpm --filter "@plane/i18n" run build` +- [x] 1.7 Verify reverts: `git diff --name-only` does NOT include: `projects-list.tsx`, `translations.ts` +- [x] 1.8 Verify reverts: `git diff --name-only` DOES still include: `properties.tsx`, `__init__.py` files, `issue/base.py`, `types/src/index.ts`, `.gitattributes` +- [x] 1.9 Typecheck: 0 new errors + +### Phase 2: Re-add Entity Link to Creation Modal (Clean) + +Re-apply entity link input to the creation modal, without the project-hiding hacks. + +#### 2A: default-properties.tsx + +- [x] 2.1 Add `Link2` import from `lucide-react` +- [x] 2.2 Add `TCaseEntityLinkCreatePayload` import from `@plane/types` +- [x] 2.3 Add `onEntityLinkChange?: (data: TCaseEntityLinkCreatePayload | null) => void` to props type +- [x] 2.4 Add `onEntityLinkChange` to destructured props +- [x] 2.5 Add `entityLinkInput` and `entityLinkType` local state +- [x] 2.6 Add entity link select + UUID input JSX at end of properties div (before closing `
`) +- [x] 2.7 Wrap in `{onEntityLinkChange && (...)}` guard so it only renders when callback provided + +#### 2B: form.tsx + +- [x] 2.8 Add `TCaseEntityLinkCreatePayload` to imports from `@plane/types` +- [x] 2.9 Add `onEntityLinkChange?: (data: TCaseEntityLinkCreatePayload | null) => void` to `IssueFormProps` +- [x] 2.10 Destructure `onEntityLinkChange` from props (rename to `onEntityLinkChangeProp` to avoid shadowing) +- [x] 2.11 Pass `onEntityLinkChange={!data?.id ? onEntityLinkChangeProp : undefined}` to `IssueDefaultProperties` +- [x] 2.12 IssueProjectSelect left fully visible (stock behavior) + +#### 2C: base.tsx + +- [x] 2.13 Add `TCaseEntityLinkCreatePayload` to imports from `@plane/types` +- [x] 2.14 Add `EntityLinkService` import from `@/services/case` +- [x] 2.15 Instantiate `const entityLinkService = new EntityLinkService()` at module level +- [x] 2.16 Add `pendingEntityLinkRef = useRef(null)` in component +- [x] 2.17 Post-create hook: after `handleCreateIssue`, call `entityLinkService.create()` if ref set +- [x] 2.18 Clear `pendingEntityLinkRef.current = null` after creation attempt +- [x] 2.19 Add `onEntityLinkChange` callback to `commonIssueModalProps` + +#### 2D: Verify + +- [x] 2.20 Typecheck: 0 errors +- [x] 2.21 `git diff form.tsx` shows NO project selector changes +- [x] 2.22 `git diff projects-list.tsx` shows NO changes (stock) + +### Phase 3: Update Seed Script + +Rewrite to create 5 projects instead of IssueTypes on one project. + +- [x] 3.1 Replaced with `CASE_PROJECTS` list: Claims/CLM, Renewals/RNW, Endorsements/END, Billing/BIL, General/GEN +- [x] 3.2 `CASE_STATES` unchanged (same 11 states) +- [x] 3.3 Command accepts only `workspace_slug` +- [x] 3.4 Uses `Project.objects.get_or_create()` per case project +- [x] 3.5 Adds workspace admin as `ProjectMember` with role=20 +- [x] 3.6 Deletes default states, creates 11 case-specific states per project +- [x] 3.7 Sets `default_state` to "New" per project +- [x] 3.8 Removed all `IssueType` and `ProjectIssueType` imports/logic +- [x] 3.9 Removed `is_issue_type_enabled` logic +- [x] 3.10 Idempotent: `get_or_create` for projects, skips states if "New" already exists + +### Phase 4: Build & Typecheck + +- [x] 4.1 Build types package: success +- [x] 4.2 Build i18n package: success +- [x] 4.3 Typecheck: 0 errors +- [x] 4.4 Modified files: 11 (matches expected — `.gitattributes` + 5 backend `__init__.py`/views + 3 modal files + peek properties + types index) + +### Phase 5: Runtime Testing (requires Docker) + +#### 5A: Seed & Projects + +- [ ] 5.1 Run migration: `python manage.py makemigrations db && python manage.py migrate` +- [ ] 5.2 Run seed: `python manage.py seed_case_config ` +- [ ] 5.3 Verify: 5 projects visible in sidebar (Claims, Renewals, Endorsements, Billing, General) +- [ ] 5.4 Verify: each project has 11 states (New, Triaged, In Progress, etc.) +- [ ] 5.5 Verify: default state is "New" in each project +- [ ] 5.6 Verify: issue identifiers are CLM-1, RNW-1, END-1, BIL-1, GEN-1 +- [ ] 5.7 Verify: running seed again is idempotent (no duplicates) + +#### 5B: Entity Link CRUD + +- [ ] 5.8 Test: `GET /api/.../issues/{id}/entity-links/` returns `[]` +- [ ] 5.9 Test: `POST` with valid cima policy UUID → 201, link created, label resolved +- [ ] 5.10 Test: `POST` with invalid UUID → 400 +- [ ] 5.11 Test: `POST` claim → auto-creates/corrects policy link +- [ ] 5.12 Test: `PATCH` → role change works +- [ ] 5.13 Test: `DELETE` with no started sub-issues → 204 +- [ ] 5.14 Test: `DELETE` with started sub-issues → 400 (deletion protection) +- [ ] 5.15 Test: `GET /resolve-entity/?entity_type=policy&entity_id={uuid}` → resolved data + +#### 5C: Entity Link UI + +- [ ] 5.16 Test: open peek on issue with no entity links → nothing extra renders +- [ ] 5.17 Test: open peek on issue with entity links → policy/claim cards visible with resolved labels +- [ ] 5.18 Test: create new issue → entity link selector visible in creation modal +- [ ] 5.19 Test: enter policy UUID + save → issue created AND entity link created +- [ ] 5.20 Test: entity link appears in peek after creation + +#### 5D: Entity Filter + +- [ ] 5.21 Test: `GET /issues/?entity_id={uuid}` → returns only issues linked to that entity +- [ ] 5.22 Test: no `entity_id` param → all issues returned (no regression) + +#### 5E: Native Plane Features + +- [ ] 5.23 Sidebar: projects listed, expandable, sub-nav works (Work Items, Cycles, etc.) +- [ ] 5.24 Breadcrumbs: "Claims > Work Items", "Renewals > Work Items", etc. +- [ ] 5.25 Project selector in creation modal: shows all 5 case type projects +- [ ] 5.26 Kanban view works per project +- [ ] 5.27 List, calendar, spreadsheet, gantt views work +- [ ] 5.28 Drag-drop works +- [ ] 5.29 Sub-issues (work items) work +- [ ] 5.30 Comments, activity tracking work +- [ ] 5.31 Filters, grouping, ordering work +- [ ] 5.32 "Your Work" page shows issues across all case type projects + +#### 5F: Upstream Compatibility + +- [ ] 5.33 `git fetch upstream && git merge upstream/preview` — clean or trivially-resolvable +- [ ] 5.34 Verify conflict-prone files are only the `__init__.py` registrations (additive, at end of file) + +--- + +## Resolved Questions (carried forward) + +| # | Question | Decision | +| --- | ----------------------------- | ----------------------------------------------------------- | +| 1 | Sidebar approach | Projects = case types. Sidebar is stock Plane. | +| 2 | Custom properties | Not needed. Queue=Project, SLA=target_date, Owner=assignee. | +| 3 | Entity link editing | Yes — PATCH endpoint in API. | +| 4 | Entity link in creation modal | Yes — entity link input in default properties. | +| 5 | Filter by entity | Yes — `entity_id` query param on issue list. | +| 6 | cima schema | Same PostgreSQL instance. | +| 7 | AI agents | Not assignees. Invoked via @mention. Not yet in CE. | +| 8 | Project per case type | Yes — eliminates all UI hacks, uses Plane natively. | diff --git a/plans/work-model-research.md b/plans/work-model-research.md new file mode 100644 index 00000000000..790ca230e74 --- /dev/null +++ b/plans/work-model-research.md @@ -0,0 +1,1083 @@ +# Plane Work Model - Comprehensive Research + +> Deep analysis of how Plane's work item system operates across data models, API, frontend state, UI, and organizational features. + +--- + +## Table of Contents + +1. [Architecture Overview](#1-architecture-overview) +2. [Core Data Model: Issue (Work Item)](#2-core-data-model-issue-work-item) +3. [States & Workflow](#3-states--workflow) +4. [Issue Types](#4-issue-types) +5. [Organizational Containers: Cycles & Modules](#5-organizational-containers-cycles--modules) +6. [Labels & Estimates](#6-labels--estimates) +7. [Relations & Sub-Issues](#7-relations--sub-issues) +8. [Activity Tracking & Versioning](#8-activity-tracking--versioning) +9. [Comments, Reactions & Engagement](#9-comments-reactions--engagement) +10. [Attachments & Links](#10-attachments--links) +11. [Views & Filters](#11-views--filters) +12. [Intake / Triage](#12-intake--triage) +13. [Archives & Soft Deletion](#13-archives--soft-deletion) +14. [Permissions & Roles](#14-permissions--roles) +15. [Notifications](#15-notifications) +16. [API Layer](#16-api-layer) +17. [Frontend State Management](#17-frontend-state-management) +18. [Frontend UI Components](#18-frontend-ui-components) +19. [Drag-and-Drop System](#19-drag-and-drop-system) +20. [Key Design Patterns](#20-key-design-patterns) + +--- + +## 1. Architecture Overview + +Plane is a project management app built on: + +- **Backend**: Django (Python) with PostgreSQL, Celery for background tasks +- **Frontend**: Next.js (React) with MobX for state management +- **Types**: Shared TypeScript type definitions in `packages/types/` + +### Model Inheritance Chain + +All models inherit from a common chain: + +``` +TimeAuditModel (created_at, updated_at) + + UserAuditModel (created_by, updated_by) + + SoftDeleteModel (deleted_at) + = AuditModel + → BaseModel (id: UUID) + → WorkspaceBaseModel (workspace FK) + → ProjectBaseModel (project FK + workspace FK) +``` + +Every entity gets: UUID primary key, timestamps, soft-delete support, and user audit trail. + +### Scope Hierarchy + +``` +Workspace + └── Project + ├── Issues (Work Items) + ├── States + ├── Cycles + ├── Modules + ├── Views + ├── Labels (also at Workspace level) + └── Estimates +``` + +--- + +## 2. Core Data Model: Issue (Work Item) + +**File**: `apiserver/plane/db/models/issue.py` +**TS Type**: `packages/types/src/issues/issue.ts` + +The `Issue` model is the central entity. All fields: + +| Field | Type | Description | Default | +| ---------------------- | ------------------ | ------------------------------------- | ---------------------------- | +| `id` | UUID | Primary key | auto | +| `name` | CharField(255) | Title | required | +| `description_json` | JSONField | Rich text (JSON) | `{}` | +| `description_html` | TextField | HTML version | `"

"` | +| `description_stripped` | TextField | Plain text (auto-generated) | None | +| `description_binary` | BinaryField | Binary format | None | +| `state` | FK → State | Workflow state | auto (project default) | +| `priority` | CharField(30) | Priority level | `"none"` | +| `parent` | FK → self | Parent issue (sub-issues) | None | +| `point` | IntegerField | Legacy estimate (0-12) | None | +| `estimate_point` | FK → EstimatePoint | Linked estimate | None | +| `start_date` | DateField | Start date | None | +| `target_date` | DateField | Due date | None | +| `sequence_id` | IntegerField | Human-readable ID (e.g. "WEB-123") | auto-incremented per project | +| `sort_order` | FloatField | Ordering within state | 65535 | +| `completed_at` | DateTimeField | Auto-set when state group = completed | None | +| `archived_at` | DateField | Archive timestamp | None | +| `is_draft` | BooleanField | Draft flag | False | +| `type` | FK → IssueType | Issue type (Epic, Bug, etc.) | None | +| `external_source` | CharField(255) | Integration source | None | +| `external_id` | CharField(255) | External system ID | None | + +### Priority Choices + +`"urgent"` > `"high"` > `"medium"` > `"low"` > `"none"` + +### Many-to-Many Relations (via through tables) + +- **Assignees** → `IssueAssignee(issue, assignee)` — unique per (issue, assignee) +- **Labels** → `IssueLabel(issue, label)` + +### Automatic Behaviors + +- **State default**: If no state specified on save, the project's default state is assigned. +- **completed_at**: Auto-set when state changes to a "completed" group state; cleared when moved away. +- **sequence_id**: Auto-incremented per project using PostgreSQL advisory locks for concurrency safety. +- **sort_order**: New issues get `max(sort_order) + 10000` within their state group. + +### Manager: IssueManager + +The default manager `Issue.issue_objects` excludes: + +- Issues in the **triage** state group +- **Archived** issues (`archived_at` not null) +- **Draft** issues +- Issues in **archived projects** + +This means default queries never return triage/archived/draft items unless explicitly requested. + +--- + +## 3. States & Workflow + +**File**: `apiserver/plane/db/models/state.py` +**TS Type**: `packages/types/src/state.ts` + +### State Groups (Enum) + +Every state belongs to one of 6 groups: + +| Group | Value | Purpose | +| --------- | ------------- | ---------------------------------------------- | +| BACKLOG | `"backlog"` | Not yet prioritized | +| UNSTARTED | `"unstarted"` | Prioritized but not started | +| STARTED | `"started"` | In progress | +| COMPLETED | `"completed"` | Done — triggers `completed_at` | +| CANCELLED | `"cancelled"` | Cancelled/won't do | +| TRIAGE | `"triage"` | Incoming items (excluded from default queries) | + +### Default States per Project + +When a project is created, 6 states are auto-generated: + +| Name | Group | Sequence | Color | Default? | +| ----------- | --------- | -------- | ------- | -------- | +| Backlog | backlog | 15000 | #60646C | Yes | +| Todo | unstarted | 25000 | #60646C | No | +| In Progress | started | 35000 | #F59E0B | No | +| Done | completed | 45000 | #46A758 | No | +| Cancelled | cancelled | 55000 | #9AA4BC | No | +| Triage | triage | 65000 | #4E5355 | No | + +### State Model Fields + +- `name`, `description`, `color`, `slug` (auto from name) +- `sequence` (float, for ordering — increments by 15000) +- `group` (StateGroup enum) +- `is_triage` (boolean) +- `default` (boolean — marks the default state for new issues) + +### State Managers + +- `State.objects` — excludes triage states +- `State.all_state_objects` — all states including triage +- `State.triage_objects` — triage states only + +### Constraints + +- State names must be unique per project (when not soft-deleted). + +--- + +## 4. Issue Types + +**File**: `apiserver/plane/db/models/issue_type.py` + +Issue types allow classifying work items (e.g., Epic, Bug, Feature, Task). + +### IssueType Model + +- `workspace` (FK) — types are workspace-level +- `name` (CharField) +- `description` (TextField) +- `logo_props` (JSONField) — icon/color configuration +- `is_epic` (BooleanField) — marks epics specifically +- `is_default` (BooleanField) +- `is_active` (BooleanField) +- `level` (FloatField) — ordering + +### ProjectIssueType (Per-Project Configuration) + +- `project` (FK) +- `issue_type` (FK) +- `level` (FloatField) — project-specific ordering +- `is_default` (BooleanField) — default type for this project + +This two-tier system means: types are defined at the workspace level, then enabled/configured per project. + +### Impact on UI + +- The creation modal shows a type selector before the title +- Issue detail sidebar shows type with switch capability +- Activity tracking records type changes +- Types can carry additional custom properties (via `WorkItemModalAdditionalProperties`) + +--- + +## 5. Organizational Containers: Cycles & Modules + +### Cycles (Sprints/Iterations) + +**File**: `apiserver/plane/db/models/cycle.py` +**TS Type**: `packages/types/src/cycle/cycle.ts` + +Cycles are time-boxed iterations for planning work. + +**Fields:** + +- `name`, `description` +- `start_date`, `end_date` (DateTimeField, nullable) +- `owned_by` (FK → User) +- `sort_order` (float, new cycles decrement: `smallest - 10000`) +- `view_props` (JSONField — saved filter configuration) +- `progress_snapshot` (JSONField — cached analytics) +- `logo_props`, `timezone` (default UTC) +- `archived_at` (nullable) + +**CycleIssue**: Through table linking issues to cycles. An issue belongs to **at most one cycle** (unique constraint on issue+cycle). + +**CycleUserProperties**: Per-user view preferences per cycle — `filters`, `display_filters`, `display_properties`, `rich_filters`. + +**Progress Tracking (TProgressSnapshot):** + +``` +total_issues, completed_issues, pending_issues +backlog_issues, started_issues, unstarted_issues, cancelled_issues +total_estimate_points, completed_estimate_points +distribution: { assignees: [...], labels: [...] } +``` + +### Modules (Feature Groups) + +**File**: `apiserver/plane/db/models/module.py` +**TS Type**: `packages/types/src/module/modules.ts` + +Modules are thematic groups of work (features, epics, etc.) — not time-boxed. + +**Fields:** + +- `name`, `description`, `description_html` +- `start_date`, `target_date` (DateField) +- `status` (enum: backlog, planned, in-progress, paused, completed, cancelled) +- `lead` (FK → User, nullable) +- `members` (M2M → User via ModuleMember) +- `sort_order`, `view_props`, `archived_at`, `logo_props`, `timezone` + +**ModuleIssue**: Through table. An issue can belong to **multiple modules** (`module_ids` array on the TypeScript type). + +**ModuleLink**: External URLs associated with a module. + +**ModuleUserProperties**: Same structure as CycleUserProperties. + +### Key Difference: Cycles vs Modules + +| Aspect | Cycles | Modules | +| ----------------- | --------------------- | ----------------------------- | +| Time-boxed | Yes (start/end dates) | Optional (start/target dates) | +| Issue cardinality | Issue in max 1 cycle | Issue in many modules | +| Status tracking | Implicit (date-based) | Explicit (status enum) | +| Members | Owned by 1 user | Lead + M2M members | +| Purpose | Sprint planning | Feature/scope grouping | + +--- + +## 6. Labels & Estimates + +### Labels + +**File**: `apiserver/plane/db/models/label.py` + +- **Scope**: `WorkspaceBaseModel` — labels exist at workspace or project level +- `name`, `description`, `color` +- `parent` (FK → self, nullable) — enables **hierarchical labels** +- `sort_order` (float, increments by 10000) + +**Two scopes:** + +- **Workspace labels**: `project IS NULL` — available across all projects +- **Project labels**: `project IS NOT NULL` — scoped to one project + +**Constraints**: Label names unique per scope (workspace-only or project+name). + +### Estimates + +**File**: `apiserver/plane/db/models/estimate.py` +**TS Type**: `packages/types/src/estimate.ts` + +**Estimate Model:** + +- `name`, `description` +- `type` — one of: `"points"`, `"categories"`, `"time"` +- `last_used` (boolean — which estimate system is active) + +**EstimatePoint Model:** + +- FK → Estimate +- `key` (int, >= 0) — ordering index +- `value` (string) — display value (e.g., "1", "2", "3", "5", "8", "13") +- `description` (text) + +**Issue Connection:** + +- `Issue.estimate_point` (FK → EstimatePoint) — the modern link +- `Issue.point` (IntegerField, 0-12) — legacy direct value + +--- + +## 7. Relations & Sub-Issues + +### Issue Relations + +**File**: `apiserver/plane/db/models/issue.py` (IssueRelation class) +**TS Type**: `packages/types/src/issues/issue_relation.ts` + +Relations are stored as directed edges between issues: + +| Forward Relation | Reverse (Computed) | Semantics | +| ---------------- | ------------------ | ---------------------- | +| `blocked_by` | `blocking` | Directional dependency | +| `relates_to` | `relates_to` | Symmetric | +| `duplicate` | `duplicate` | Symmetric | +| `start_before` | `start_after` | Directional scheduling | +| `finish_before` | `finish_after` | Directional scheduling | +| `implemented_by` | `implements` | Directional | + +**Storage**: `IssueRelation(issue, related_issue, relation_type)` — unique on (issue, related_issue). + +### Sub-Issues (Parent-Child) + +The `Issue.parent` FK creates a tree structure: + +- An issue with `parent = NULL` is a top-level issue +- An issue with `parent = ` is a sub-issue +- Sub-issues can have their own sub-issues (recursive) +- `sub_issues_count` is tracked on the TypeScript type +- Bulk sub-issue assignment is supported via `SubIssuesEndpoint` + +The UI renders sub-issues as a collapsible tree in the detail view, with inline property editors (state, priority, assignees, dates) on each sub-issue row. + +--- + +## 8. Activity Tracking & Versioning + +### IssueActivity (Audit Log) + +**File**: `apiserver/plane/db/models/issue.py` (line ~405) + +Every change to an issue generates an activity record: + +- `issue` (FK) +- `verb` — action type ("created", "updated", etc.) +- `field` — which field changed (e.g., "state", "priority", "assignees") +- `old_value`, `new_value` — before/after (text) +- `old_identifier`, `new_identifier` — UUIDs for FK changes +- `actor` (FK → User) +- `epoch` (float) — Unix timestamp for ordering +- `issue_comment` (FK, nullable) — linked comment if any + +**Recording mechanism**: Background task `issue_activity.delay()` records changes asynchronously. Triggered on create, update, and delete operations. + +### IssueVersion (State Snapshots) + +Full issue state snapshots at the time of each activity: + +- Captures: parent, state, estimate_point, name, priority, dates, assignees, labels, sort_order, completed_at, archived_at, is_draft, type, cycle, modules +- `properties`, `meta` (JSONField) — additional context +- `activity` (FK → IssueActivity) +- `owned_by` (FK → User) + +### IssueDescriptionVersion (Description History) + +Tracks description changes specifically: + +- `description_binary`, `description_html`, `description_stripped`, `description_json` +- `last_saved_at`, `owned_by` +- Created via `issue_description_version_task.delay()` on description changes + +--- + +## 9. Comments, Reactions & Engagement + +### IssueComment + +- `comment_json`, `comment_html`, `comment_stripped` — three formats +- `description` (OneToOneField → Description model) +- `attachments` (ArrayField of URLs) +- `actor` (FK → User) +- `access` — `"INTERNAL"` or `"EXTERNAL"` +- `parent` (FK → self) — **threaded comments** +- `edited_at` — tracks edits + +### Reactions + +**IssueReaction**: `(actor, issue, reaction)` — emoji reactions on issues +**CommentReaction**: `(actor, comment, reaction)` — emoji reactions on comments + +### IssueVote + +- `(issue, actor, vote)` — vote is -1 (downvote) or 1 (upvote) + +### IssueSubscriber + +- `(issue, subscriber)` — tracks notification subscriptions per issue + +### IssueMention + +- `(issue, mention)` — tracks @mentions of users in issue descriptions/comments + +--- + +## 10. Attachments & Links + +### IssueAttachment + +- `attributes` (JSONField) — metadata (name, size, type) +- `asset` (FileField) — uploaded file +- Two API versions: v1 and v2 + +### IssueLink + +- `title` (CharField, nullable) +- `url` (TextField) +- `metadata` (JSONField) + +Both support `external_source` and `external_id` for integration tracking. + +--- + +## 11. Views & Filters + +**File**: `apiserver/plane/db/models/view.py` +**TS Type**: `packages/types/src/views.ts` + +### Saved Views (IssueView) + +- `name`, `description` +- `query` (JSONField — auto-generated from filters) +- `filters` (JSONField — legacy format) +- `rich_filters` (JSONField — new structured format) +- `display_filters` (JSONField — group_by, order_by, layout, show_empty_groups, sub_issue) +- `display_properties` (JSONField — boolean toggles for visible properties) +- `access` — 0 (private) or 1 (public) +- `is_locked` (boolean) +- `owned_by` (FK → User) + +**Scopes**: Project views (`project` not null) and Workspace views (`project` is null). + +### Filter Properties + +Available filter keys: + +``` +assignees, mentions, created_by, labels, priority, +cycle, module, project, team_project, +start_date, state, state_group, subscriber, target_date, +issue_type +``` + +### Display Filters + +``` +group_by: state | priority | labels | created_by | state_detail.group | + project | assignees | cycle | module | target_date | team_project +sub_group_by: (same options) +order_by: -created_at | created_at | priority | state__name | + assignees__first_name | labels__name | target_date | start_date | + estimate_point__key | link_count | sub_issues_count +layout: list | kanban | calendar | spreadsheet | gantt_chart +show_empty_groups: boolean +sub_issue: boolean +``` + +### Display Properties (Column Visibility) + +``` +assignee, start_date, due_date, labels, key, priority, state, +sub_issue_count, link, attachment_count, estimate, created_on, +updated_on, modules, cycle, issue_type +``` + +### Rich Filters (New System) + +Structured filter expressions using logical operators: + +- Type: `TWorkItemFilterExpression` +- Structure: AND/OR operators + conditions +- Conditions: `${property}__${operator}` (e.g., `priority__is`, `state__is_not`) + +--- + +## 12. Intake / Triage + +**File**: `apiserver/plane/db/models/intake.py` + +### Intake Model + +- `name`, `description` +- `is_default` (boolean) +- `view_props`, `logo_props` (JSON) + +### IntakeIssue (Triage Workflow) + +Links issues to intake channels with a status workflow: + +| Status | Code | Meaning | +| --------- | ---- | ---------------------------------------------- | +| PENDING | -2 | Awaiting review | +| REJECTED | -1 | Rejected/declined | +| SNOOZED | 0 | Deferred (with `snoozed_till` timestamp) | +| ACCEPTED | 1 | Accepted into project | +| DUPLICATE | 2 | Marked as duplicate (links via `duplicate_to`) | + +Additional fields: + +- `source` (default `"IN_APP"`) +- `source_email` — for email-based intake +- `extra` (JSONField) — metadata + +### Triage State Group + +The special `triage` state group works with intake: + +- Issues in triage are **excluded from default queries** by `IssueManager` +- They appear only in the intake/triage view +- Once accepted, issues transition to a regular state group + +--- + +## 13. Archives & Soft Deletion + +### Archives + +- `Issue.archived_at` (DateField) — set when archived +- **Rule**: Only issues in "completed" or "cancelled" state groups can be archived +- Archived issues are excluded from default queries by `IssueManager` +- Operations: `archive()`, `unarchive()`, `bulkArchiveIssues()` +- Bulk archive requires all target issues to be in completed/cancelled states + +### Soft Deletion + +All models use soft deletion via `SoftDeleteModel`: + +- `deleted_at` (DateTimeField, nullable) — set on delete, NULL when active +- `SoftDeletionManager` — default manager filters `deleted_at__isnull=True` +- `all_objects` manager — returns everything including deleted +- `.delete()` performs soft delete by default +- `.delete(soft=False)` forces hard delete +- Background task `soft_delete_related_objects` handles cascading soft deletes +- Unique constraints are conditional: `Q(deleted_at__isnull=True)` — allows re-creating items with same name after deletion + +### Deleted Issues Retrieval + +- `DeletedIssuesListViewSet` uses `Issue.all_objects` to list deleted/archived items +- Filters: `archived_at__isnull=False OR deleted_at__isnull=False` + +--- + +## 14. Permissions & Roles + +**File**: `apiserver/plane/app/permissions/project.py` + +### Role Levels + +| Role | Code | Capabilities | +| ------ | ---- | ----------------------------------------------------------------------------- | +| Admin | 20 | Full control — CRUD, bulk ops, delete, settings | +| Member | 15 | Create, edit, archive, comment | +| Guest | 5 | Read-only; sees only own created issues unless `guest_view_all_features=true` | + +### Permission Classes + +- `ProjectBasePermission` — project endpoint access +- `ProjectMemberPermission` — admin/member only for modifications +- `ProjectEntityPermission` — item-level (read for all, write for admin/member) +- `ProjectAdminPermission` — admin-only +- `ProjectLitePermission` — any project member (e.g., subscribe/unsubscribe) + +### Enforcement + +Decorator-based: `@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="PROJECT")` + +**Guest access control:** + +- Project-level setting: `guest_view_all_features` +- When `false`: guests see only issues they created (`created_by = request.user`) +- When `true`: guests see all issues but cannot edit + +### Operation Permissions + +| Operation | Allowed Roles | +| ----------------- | -------------------------------- | +| List issues | Admin, Member, Guest | +| Create issue | Admin, Member | +| Update issue | Admin, Member (+ creator bypass) | +| Delete issue | Admin (+ creator bypass) | +| Archive/Unarchive | Admin, Member | +| Bulk delete | Admin only | +| Bulk archive | Admin, Member | + +--- + +## 15. Notifications + +**File**: `apiserver/plane/db/models/notification.py` + +### Notification Model + +- `triggered_by` (FK → User) — who caused it +- `receiver` (FK → User) — who gets it +- `entity_identifier` (UUID), `entity_name` (string) — what entity it's about +- `read_at`, `snoozed_till`, `archived_at` — notification lifecycle + +### UserNotificationPreference + +Per-user toggleable notification types: + +- `property_change` — field changes on subscribed issues +- `state_change` — state transitions +- `comment` — new comments +- `mention` — @mentions +- `issue_completed` — completion events + +### Notification Triggers + +- Issue property changes (via `IssueActivity`) +- @mentions (via `IssueMention`) +- New comments +- State transitions +- Issue completion +- Subscriptions (via `IssueSubscriber`) + +--- + +## 16. API Layer + +### Endpoint Structure + +All issue endpoints are under: `/api/workspaces//projects//` + +**Core CRUD:** + +``` +GET/POST /issues/ — List/Create +GET/PATCH /issues// — Retrieve/Update +DELETE /issues// — Delete +GET /issues-detail/ — List with rich detail +GET /v2/issues/ — Cursor-paginated list +``` + +**Sub-resources:** + +``` +/issues//sub-issues/ — Sub-issues +/issues//issue-links/ — External links +/issues//issue-attachments/ — File attachments +/issues//history/ — Activity log +/issues//comments/ — Comments +/issues//reactions/ — Emoji reactions +/issues//issue-subscribers/ — Subscribers +/issues//versions/ — Version history +``` + +**Bulk operations:** + +``` +POST /bulk-delete-issues/ — Bulk delete (admin) +POST /bulk-archive-issues/ — Bulk archive +POST /issue-dates/ — Bulk update dates +``` + +**Archives & Deleted:** + +``` +GET /archived-issues/ — Archived issues +GET /deleted-issues/ — Deleted issues +``` + +**v1 API** (uses "work-items" terminology): + +``` +/work-items/ — List/Create +/work-items// — Get/Update/Delete +/work-items//links/ — Links +/work-items//comments/ — Comments +/work-items//activities/ — Activities +/work-items//attachments/ — Attachments +``` + +### Filtering & Ordering + +**Server-side filtering** via `ComplexFilterBackend` with `IssueFilterSet`: + +- Supports: state, priority, assignees, labels, created_by, cycles, modules, dates, state_group, subscriber + +**Ordering** via `order_issue_queryset()`: + +- Priority: custom order (urgent > high > medium > low > none) +- State group: custom order +- Default: `-created_at` +- Supports: any field, ascending/descending + +**Grouping** via `issue_queryset_grouper()`: + +- Group by: state, priority, assignees, labels, modules, cycles, project +- Sub-group by: same options +- Paginated grouping with `GroupedOffsetPaginator` and `SubGroupedOffsetPaginator` + +### Serializers + +| Serializer | Purpose | +| --------------------------- | ---------------------------------------- | +| `IssueCreateSerializer` | Full write serializer | +| `IssueSerializer` | Read-only, all fields | +| `IssueDetailSerializer` | Extended with description + subscription | +| `IssueLiteSerializer` | Minimal (id, sequence_id, project_id) | +| `IssueListDetailSerializer` | Computed fields, optional expand | +| `IssueFlatSerializer` | Flat fields only | +| `IssuePublicSerializer` | Public view with reactions/votes | + +### Webhook Triggers + +On issue changes, background tasks fire: + +- `issue_activity.delay()` — logs field changes +- `model_activity.delay()` — webhook events for integrations +- Event types: `issue.activity.created`, `issue.activity.updated`, `issue.activity.deleted` + +--- + +## 17. Frontend State Management + +**Location**: `web/core/store/issue/` + +### Store Architecture (MobX) + +``` +IssueRootStore (orchestrator) +├── IssueStore — Global issuesMap: Record +├── IssueDetail — Detail/peek modal state +├── Context Stores: +│ ├── ProjectIssues — Project-scoped collection +│ ├── CycleIssues — Cycle-scoped collection +│ ├── ModuleIssues — Module-scoped collection +│ ├── ArchivedIssues — Archived items +│ ├── WorkspaceIssues — Workspace-level view +│ ├── ProfileIssues — User profile items +│ ├── ProjectViewIssues — Custom saved views +│ ├── TeamIssues — Team-scoped items +│ ├── WorkspaceDraftIssues — Drafts +│ └── ProjectEpics — Epics +├── View Stores: +│ ├── KanbanView — Collapsed headers, drag state +│ ├── CalendarView — Active month/week, date layout +│ └── GanttView — Timeline view state +└── Filter Stores: + ├── ProjectIssuesFilter + ├── CycleIssuesFilter + ├── ModuleIssuesFilter + └── ... (one per context) +``` + +### Normalized Data Store + +All issues are stored in a single flat map: `issuesMap: Record`. + +Context stores only hold **references** (arrays of issue IDs), not issue data. This prevents data duplication when the same issue appears in multiple views. + +### BaseIssuesStore (Abstract Pattern) + +**File**: `web/core/store/issue/helpers/base-issues.store.ts` (~1965 lines) + +All context stores (Project, Cycle, Module, etc.) extend this abstract class, which provides: + +- **Pagination**: cursor-based, per-group tracking +- **Grouping**: `groupedIssueIds` — issues grouped/sub-grouped by property +- **Sorting**: `issuesSortWithOrderBy()` — client-side re-sorting +- **CRUD**: create, update, archive, delete with optimistic updates +- **Bulk ops**: bulk update properties, bulk archive, bulk delete +- **Group movement**: when an issue property changes, move it between groups + +### Grouping Data Structures + +```typescript +// Ungrouped +{ ALL_ISSUES: ["id1", "id2", ...] } + +// Grouped +{ "state-uuid-1": ["id1", "id2"], "state-uuid-2": ["id3"] } + +// Grouped + Sub-grouped +{ "state-uuid-1": { "priority-high": ["id1"], "priority-low": ["id2"] } } +``` + +Issues can appear in **multiple groups** when grouped by array properties (assignees, labels, modules). + +### Optimistic Updates + +``` +1. Store previous state +2. Update MobX store immediately (instant UI feedback) +3. Fire API call +4. On error: revert to previous state +``` + +### Pagination + +- Cursor format: `"{perPageCount}:{pageNum}:{offset}"` +- Per-group cursors allow loading different groups independently +- `fetchIssues()` — first page (clears store) +- `fetchNextIssues()` — subsequent pages + +--- + +## 18. Frontend UI Components + +**Location**: `web/core/components/issues/` + +### Five Layout Views + +All layouts live in `web/core/components/issues/issue-layouts/`: + +#### 1. List View (`/list/`) + +- Rows with inline properties +- Expandable sub-issue tree +- Drag-and-drop between groups +- `block.tsx` — individual row, `list-group.tsx` — group container + +#### 2. Kanban View (`/kanban/`) + +- Cards in columns (grouped by state, priority, etc.) +- Swimlanes for sub-grouping +- Drag-and-drop between columns +- `block.tsx` — card, `kanban-group.tsx` — column + +#### 3. Spreadsheet View (`/spreadsheet/`) + +- Table with editable cells +- 18 column types: assignee, state, priority, cycle, module, label, estimate, dates, links, attachments, sub-issues, created/updated dates +- `issue-row.tsx` — row, `issue-column.tsx` — cell editor + +#### 4. Calendar View (`/calendar/`) + +- Monthly grid with issues on their due dates +- Drag-and-drop to change dates +- `calendar.tsx` — grid, `day-tile.tsx` — drop target + +#### 5. Gantt View (`/gantt/`) + +- Timeline bars +- `blocks.tsx` — bar rendering + +### Issue Creation Modal + +**File**: `web/core/components/issues/issue-modal/form.tsx` + +Form structure (React Hook Form): + +1. Project selector + Issue type selector + Template selector +2. Parent issue tag (optional) +3. Title input +4. Rich text description editor (with AI assistant) +5. Type-specific additional properties +6. Default properties: assignee, state, priority, dates, parent, labels, etc. +7. Action buttons: Create More toggle, Discard, Submit + +### Issue Detail / Peek Overview + +**Two modes:** + +- **Full page** (`issue-detail/root.tsx`): Split layout — main content + sidebar +- **Peek modal** (`peek-overview/root.tsx`): Lightweight modal overlay + +Both render: + +- **Main content**: description, sub-issues, links, attachments, activity/comments +- **Sidebar**: state, assignees, priority, dates, cycles, modules, labels, estimate, relations + +### Property Selectors + +Located in `web/core/components/dropdowns/`: + +| Dropdown | File | Features | +| --------------- | --------------------- | --------------------------------- | +| State | `state/dropdown.tsx` | Groups by state type | +| Priority | `priority.tsx` | 5 levels with color indicators | +| Member/Assignee | `member/dropdown.tsx` | Multi-select with avatars, search | +| Cycle | `cycle/dropdown.tsx` | Active/archived cycles | +| Module | `module/dropdown.tsx` | Multi-select | +| Date | `date.tsx` | Date picker, overdue highlighting | +| Date Range | `date-range.tsx` | Combined start/due date | +| Estimate | `estimate.tsx` | Points/categories/time | + +### Sub-Issues Widget + +**Location**: `web/core/components/issues/issue-detail-widgets/sub-issues/` + +- Collapsible section in issue detail +- Tree structure with recursive nesting +- Each sub-issue row shows: identifier, title, state, priority, assignees, dates +- Operations: create, edit, delete, reorder + +### Activity & Comments + +**Location**: `web/core/components/issues/issue-detail/issue-activity/` + +- Activity feed with filter controls (toggle by type: state, assignments, labels, etc.) +- Sort ascending/descending +- 15+ activity type renderers in `/activity/actions/`: state, priority, assignee, label, cycle, module, dates, description, attachment, link, name, parent, relation, archive, inbox +- Comment creation with rich text editor +- Threaded comments (parent FK) +- Emoji reactions on comments + +--- + +## 19. Drag-and-Drop System + +**Library**: `@atlaskit/pragmatic-drag-and-drop` + +### Supported In + +- **List view**: drag items between groups +- **Kanban view**: drag cards between columns, reorder within columns +- **Calendar view**: drag issues to change dates + +### Drag Payload + +```typescript +IPragmaticDropPayload { + columnId, groupId, subGroupId, issueId +} +``` + +### Drop Behavior + +When dropped, the system: + +1. Determines the target group/subgroup +2. Updates the relevant issue property (state, cycle, module, date, etc.) +3. Recalculates sort order within the target group +4. Fires an optimistic update + +### Restrictions + +`getCanUserDragDrop()` validates whether dragging is allowed: + +- Only when grouped by **state** or **priority** (meaningful property change) +- Not allowed for read-only users (guests) + +--- + +## 20. Key Design Patterns + +### 1. Soft Delete Everywhere + +Every model uses `deleted_at` for soft deletion. Unique constraints include `Q(deleted_at__isnull=True)` conditions, allowing re-creation of items after deletion. + +### 2. External Integration Support + +All major models carry `external_source` and `external_id` fields for mapping to external systems (Jira, GitHub, etc.). Upsert by external ID is supported on the v1 API (returns 409 CONFLICT on duplicates). + +### 3. Sequence Numbering + +Issues get human-readable IDs (e.g., "WEB-123") via `sequence_id`, auto-incremented per project using PostgreSQL advisory locks for concurrency safety. + +### 4. Float-Based Ordering + +Sort orders use floats throughout: + +- States: increment by 15000 +- Labels/Cycles/Modules: increment by 10000 +- Issues within state: increment by 10000 +- This allows inserting items between existing ones without reindexing. + +### 5. JSON-Based User Preferences + +Display preferences (filters, display properties, grouping, ordering) are stored as JSONFields, not as relational data. This enables: + +- Per-user, per-project display settings +- Per-user, per-cycle/module display settings +- Rich filter expressions without schema changes + +### 6. Three-Format Descriptions + +Descriptions exist in three parallel formats: + +- `description_json` — structured rich text +- `description_html` — HTML rendering +- `description_stripped` — plain text (auto-generated, for search) + +### 7. Background Task Activity Recording + +All issue changes trigger asynchronous Celery tasks: + +- `issue_activity.delay()` — field change logging +- `model_activity.delay()` — webhook dispatch +- `issue_description_version_task.delay()` — description versioning + +This keeps the request-response cycle fast while ensuring complete audit trails. + +### 8. Normalized Frontend Store + +The MobX store normalizes all issue data into a single flat map (`issuesMap`). Context stores hold only ID arrays. This prevents stale data when the same issue appears in multiple views (project, cycle, module) simultaneously. + +--- + +## Key File Index + +### Backend (Django) + +| Area | Path | +| ------------------ | -------------------------------------------- | +| Issue model | `apiserver/plane/db/models/issue.py` | +| State model | `apiserver/plane/db/models/state.py` | +| Issue type model | `apiserver/plane/db/models/issue_type.py` | +| Cycle model | `apiserver/plane/db/models/cycle.py` | +| Module model | `apiserver/plane/db/models/module.py` | +| Label model | `apiserver/plane/db/models/label.py` | +| Estimate model | `apiserver/plane/db/models/estimate.py` | +| View model | `apiserver/plane/db/models/view.py` | +| Intake model | `apiserver/plane/db/models/intake.py` | +| Notification model | `apiserver/plane/db/models/notification.py` | +| Draft model | `apiserver/plane/db/models/draft.py` | +| Base mixins | `apiserver/plane/db/mixins.py` | +| Issue views | `apiserver/plane/app/views/issue/` | +| Issue serializers | `apiserver/plane/app/serializers/issue.py` | +| Permissions | `apiserver/plane/app/permissions/project.py` | +| URL routing | `apiserver/plane/app/urls/issue.py` | +| v1 API views | `apiserver/plane/api/views/issue.py` | +| v1 URL routing | `apiserver/plane/api/urls/work_item.py` | + +### Frontend (React/MobX) + +| Area | Path | +| ---------------------- | -------------------------------------------------------------------------- | +| Store root | `web/core/store/issue/root.store.ts` | +| Issue map store | `web/core/store/issue/issue.store.ts` | +| Base issues store | `web/core/store/issue/helpers/base-issues.store.ts` | +| Filter helper | `web/core/store/issue/helpers/issue-filter-helper.store.ts` | +| Grouping/sorting utils | `web/core/store/issue/helpers/base-issues-utils.ts` | +| Kanban view store | `web/core/store/issue/issue_kanban_view.store.ts` | +| Calendar view store | `web/core/store/issue/issue_calendar_view.store.ts` | +| Detail store | `web/core/store/issue/issue-details/root.store.ts` | +| Layout components | `web/core/components/issues/issue-layouts/` | +| Issue modal | `web/core/components/issues/issue-modal/form.tsx` | +| Detail components | `web/core/components/issues/issue-detail/` | +| Peek overview | `web/core/components/issues/peek-overview/` | +| Dropdowns | `web/core/components/dropdowns/` | +| Sub-issues widget | `web/core/components/issues/issue-detail-widgets/sub-issues/` | +| Activity renderers | `web/core/components/issues/issue-detail/issue-activity/activity/actions/` | + +### Shared Types + +| Area | Path | +| -------------- | --------------------------------------------- | +| Issue types | `packages/types/src/issues/issue.ts` | +| Activity types | `packages/types/src/issues/activity/` | +| Relation types | `packages/types/src/issues/issue_relation.ts` | +| State types | `packages/types/src/state.ts` | +| Cycle types | `packages/types/src/cycle/cycle.ts` | +| Module types | `packages/types/src/module/modules.ts` | +| View types | `packages/types/src/views.ts` | +| Estimate types | `packages/types/src/estimate.ts` | From fd05e8e26a9fae27dbed37e79d69aad295a487e6 Mon Sep 17 00:00:00 2001 From: ofried-acutis Date: Thu, 19 Mar 2026 18:47:17 +0100 Subject: [PATCH 2/2] fix: Collapsible content never renders in controlled mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Transition component with grid-rows-[0fr]/[1fr] animation prevents collapsible content from appearing. This affects sub-work items on issue detail pages — clicking the toggle arrow changes the arrow direction but the content never shows. Replace Transition with simple conditional rendering. The Transition animation classes (grid-rows-[0fr] to grid-rows-[1fr]) don't produce a visible expand/collapse effect, so removing them has no visual regression while fixing the broken toggle. Steps to reproduce: 1. Create an issue with sub-work items (sub-issues) 2. Open the issue detail page 3. Click the sub-work items toggle arrow 4. Content never appears despite arrow toggling --- packages/ui/src/collapsible/collapsible.tsx | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/ui/src/collapsible/collapsible.tsx b/packages/ui/src/collapsible/collapsible.tsx index e539528d420..81d0e386b8f 100644 --- a/packages/ui/src/collapsible/collapsible.tsx +++ b/packages/ui/src/collapsible/collapsible.tsx @@ -4,7 +4,6 @@ * See the LICENSE file for details. */ -import { Transition } from "@headlessui/react"; import React, { useState, useEffect, useCallback } from "react"; export type TCollapsibleProps = { @@ -21,7 +20,7 @@ export type TCollapsibleProps = { export function Collapsible(props: TCollapsibleProps) { const { title, children, buttonRef, className, buttonClassName, isOpen, onToggle, defaultOpen } = props; // state - const [localIsOpen, setLocalIsOpen] = useState(!!(isOpen || defaultOpen)); + const [localIsOpen, setLocalIsOpen] = useState(isOpen !== undefined ? isOpen : !!defaultOpen); useEffect(() => { if (isOpen !== undefined) { @@ -43,18 +42,11 @@ export function Collapsible(props: TCollapsibleProps) { - -
{children}
-
+ {localIsOpen && ( +
+ {children} +
+ )} ); }