Add task links for feed posts and board defaults
This commit is contained in:
parent
0cb40e992f
commit
c0cff00e82
@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from rest_framework import serializers
|
||||
|
||||
from feed.constants import TYPE_DEPARTMENT, TYPE_EMPLOYEE
|
||||
@ -13,6 +14,53 @@ from ..utils import build_media_url
|
||||
Employee = get_user_model()
|
||||
|
||||
|
||||
def _linked_task_payloads(post: Post, user) -> list[dict]:
|
||||
if not user or not getattr(user, "is_authenticated", False):
|
||||
return []
|
||||
|
||||
try:
|
||||
from tasks.access import task_board_access_q
|
||||
from tasks.models import (
|
||||
TaskBoard,
|
||||
TaskLinkedObject,
|
||||
TaskLinkedObjectKind,
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
content_type = ContentType.objects.get_for_model(Post)
|
||||
accessible_boards = TaskBoard.objects.filter(
|
||||
is_archived=False,
|
||||
).filter(task_board_access_q(user))
|
||||
|
||||
links = (
|
||||
TaskLinkedObject.objects.filter(
|
||||
kind=TaskLinkedObjectKind.POST,
|
||||
content_type=content_type,
|
||||
object_id=post.id,
|
||||
task__board__in=accessible_boards,
|
||||
)
|
||||
.select_related("task", "task__board", "task__column")
|
||||
.order_by("task__title", "task_id")
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"link_id": link.id,
|
||||
"id": link.task_id,
|
||||
"title": link.task.title,
|
||||
"board_id": link.task.board_id,
|
||||
"board_name": link.task.board.name,
|
||||
"column_id": link.task.column_id,
|
||||
"column_name": link.task.column.name,
|
||||
"column_color": link.task.column.color,
|
||||
"priority": link.task.priority,
|
||||
"priority_display": link.task.get_priority_display(),
|
||||
}
|
||||
for link in links
|
||||
]
|
||||
|
||||
|
||||
class AuthorMiniSerializer(serializers.ModelSerializer):
|
||||
full_name = serializers.SerializerMethodField()
|
||||
avatar = serializers.SerializerMethodField()
|
||||
@ -68,6 +116,7 @@ class PostListSerializer(serializers.ModelSerializer):
|
||||
# Переопределяем поля для полных URL
|
||||
image = serializers.SerializerMethodField()
|
||||
attachment = serializers.SerializerMethodField()
|
||||
linked_tasks = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Post
|
||||
@ -91,6 +140,7 @@ class PostListSerializer(serializers.ModelSerializer):
|
||||
"author_id",
|
||||
"author",
|
||||
"is_liked",
|
||||
"linked_tasks",
|
||||
]
|
||||
read_only_fields = (
|
||||
"created_at",
|
||||
@ -105,6 +155,7 @@ class PostListSerializer(serializers.ModelSerializer):
|
||||
"department_id",
|
||||
"department_name",
|
||||
"is_liked",
|
||||
"linked_tasks",
|
||||
)
|
||||
|
||||
@extend_schema_field(OpenApiTypes.URI)
|
||||
@ -126,6 +177,11 @@ class PostListSerializer(serializers.ModelSerializer):
|
||||
return bool(getattr(obj, "pinned_department", False))
|
||||
return bool(getattr(obj, "pinned_global", False))
|
||||
|
||||
def get_linked_tasks(self, obj):
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
return _linked_task_payloads(obj, user)
|
||||
|
||||
|
||||
class PostSerializer(PostListSerializer):
|
||||
"""Сериализатор для деталей поста"""
|
||||
|
||||
@ -16,6 +16,7 @@ from communications.utils import user_can_access_chat
|
||||
from documents.models import Document
|
||||
from employees.models import Department, EmployeeDepartment
|
||||
from employees.services.personnel_state import resolve_employee_personnel_state
|
||||
from feed.models import Post
|
||||
from guests.models import Guest, GuestVisit
|
||||
from procurement.models import ProcurementRequest
|
||||
from requests_app.models import Request as EmployeeRequest
|
||||
@ -26,6 +27,7 @@ from tasks.access import (
|
||||
user_can_access_document,
|
||||
user_can_access_employee,
|
||||
user_can_access_employee_request,
|
||||
user_can_access_feed_post,
|
||||
user_can_access_guest,
|
||||
user_can_access_guest_visit,
|
||||
user_can_access_attendance_record,
|
||||
@ -148,6 +150,7 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
board_name = serializers.CharField(source="board.name", read_only=True)
|
||||
column_name = serializers.CharField(source="column.name", read_only=True)
|
||||
column_color = serializers.CharField(source="column.color", read_only=True)
|
||||
linked_posts_count = serializers.SerializerMethodField()
|
||||
linked_messages_count = serializers.SerializerMethodField()
|
||||
linked_events_count = serializers.SerializerMethodField()
|
||||
linked_documents_count = serializers.SerializerMethodField()
|
||||
@ -181,6 +184,7 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
"due_date",
|
||||
"position",
|
||||
"completed_at",
|
||||
"linked_posts_count",
|
||||
"linked_messages_count",
|
||||
"linked_events_count",
|
||||
"linked_documents_count",
|
||||
@ -203,6 +207,7 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
"column_color",
|
||||
"priority_display",
|
||||
"completed_at",
|
||||
"linked_posts_count",
|
||||
"linked_messages_count",
|
||||
"linked_events_count",
|
||||
"linked_documents_count",
|
||||
@ -218,6 +223,14 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
@extend_schema_field(serializers.IntegerField())
|
||||
def get_linked_posts_count(self, obj):
|
||||
if hasattr(obj, "linked_posts_count"):
|
||||
return obj.linked_posts_count
|
||||
return obj.linked_objects.filter(
|
||||
kind=TaskLinkedObjectKind.POST,
|
||||
).count()
|
||||
|
||||
@extend_schema_field(serializers.IntegerField())
|
||||
def get_linked_messages_count(self, obj):
|
||||
if hasattr(obj, "linked_messages_count"):
|
||||
@ -380,6 +393,7 @@ class TaskBoardSerializer(serializers.ModelSerializer):
|
||||
labels = TaskLabelSerializer(many=True, read_only=True)
|
||||
tasks = TaskSerializer(many=True, read_only=True)
|
||||
tasks_count = serializers.IntegerField(read_only=True, default=0)
|
||||
is_default_for_current_user = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = TaskBoard
|
||||
@ -397,10 +411,94 @@ class TaskBoardSerializer(serializers.ModelSerializer):
|
||||
"labels",
|
||||
"tasks",
|
||||
"tasks_count",
|
||||
"is_default_for_current_user",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
@extend_schema_field(serializers.BooleanField())
|
||||
def get_is_default_for_current_user(self, obj):
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
if not user or not getattr(user, "is_authenticated", False):
|
||||
return False
|
||||
settings_obj = getattr(user, "task_settings", None)
|
||||
return bool(settings_obj and settings_obj.default_board_id == obj.id)
|
||||
|
||||
|
||||
class TaskLinkedPostSerializer(serializers.ModelSerializer):
|
||||
created_by = EmployeeBriefSerializer(read_only=True)
|
||||
post = serializers.SerializerMethodField()
|
||||
post_id = serializers.IntegerField(source="object_id", read_only=True)
|
||||
can_open = serializers.SerializerMethodField()
|
||||
object_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = TaskLinkedObject
|
||||
fields = [
|
||||
"id",
|
||||
"kind",
|
||||
"post_id",
|
||||
"post",
|
||||
"can_open",
|
||||
"object_url",
|
||||
"created_by",
|
||||
"created_at",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||
def get_post(self, obj):
|
||||
if obj.kind != TaskLinkedObjectKind.POST:
|
||||
return None
|
||||
|
||||
post = getattr(obj, "content_object", None)
|
||||
if post is None:
|
||||
return None
|
||||
|
||||
request = self.context.get("request")
|
||||
from api.v1.utils import build_media_url
|
||||
|
||||
return {
|
||||
"id": post.id,
|
||||
"type": post.type,
|
||||
"department": post.department_id,
|
||||
"department_id": post.department_id,
|
||||
"department_name": getattr(post.department, "name", None),
|
||||
"title": post.title,
|
||||
"body": post.body,
|
||||
"image": build_media_url(post.image, request),
|
||||
"attachment": build_media_url(post.attachment, request),
|
||||
"created_at": post.created_at,
|
||||
"pinned": post.pinned_global or post.pinned_department,
|
||||
"pinned_global": post.pinned_global,
|
||||
"pinned_department": post.pinned_department,
|
||||
"likes_count": post.likes_count,
|
||||
"comments_count": get_comment_count(post),
|
||||
"author_id": post.author_id,
|
||||
"author": EmployeeBriefSerializer(post.author).data,
|
||||
}
|
||||
|
||||
@extend_schema_field(serializers.BooleanField())
|
||||
def get_can_open(self, obj):
|
||||
post = getattr(obj, "content_object", None)
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
if obj.kind != TaskLinkedObjectKind.POST or post is None or not user:
|
||||
return False
|
||||
return user_can_access_feed_post(user, post)
|
||||
|
||||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||
def get_object_url(self, obj):
|
||||
post = getattr(obj, "content_object", None)
|
||||
if post is None or not self.get_can_open(obj):
|
||||
return None
|
||||
return f"/?post={post.id}"
|
||||
|
||||
|
||||
def get_post_content_type():
|
||||
return ContentType.objects.get_for_model(Post)
|
||||
|
||||
|
||||
class TaskLinkedMessageSerializer(serializers.ModelSerializer):
|
||||
created_by = EmployeeBriefSerializer(read_only=True)
|
||||
|
||||
@ -15,6 +15,7 @@ from communications import comments_helpers
|
||||
from communications.models import Message
|
||||
from communications.utils import user_can_access_chat
|
||||
from documents.models import Document
|
||||
from feed.models import Post
|
||||
from guests.models import Guest, GuestVisit
|
||||
from procurement.models import ProcurementRequest
|
||||
from requests_app.models import Request as EmployeeRequest
|
||||
@ -30,6 +31,7 @@ from tasks.access import (
|
||||
user_can_access_document,
|
||||
user_can_access_employee,
|
||||
user_can_access_employee_request,
|
||||
user_can_access_feed_post,
|
||||
user_can_access_guest,
|
||||
user_can_access_guest_visit,
|
||||
user_can_access_attendance_record,
|
||||
@ -44,6 +46,7 @@ from tasks.models import (
|
||||
TaskLabel,
|
||||
TaskLinkedObject,
|
||||
TaskLinkedObjectKind,
|
||||
TaskUserSettings,
|
||||
)
|
||||
from tasks.realtime import (
|
||||
get_task_board_recipient_ids,
|
||||
@ -63,6 +66,7 @@ from .serializers import (
|
||||
TaskLinkedGuestSerializer,
|
||||
TaskLinkedGuestVisitSerializer,
|
||||
TaskLinkedMessageSerializer,
|
||||
TaskLinkedPostSerializer,
|
||||
TaskLinkedProcurementRequestSerializer,
|
||||
TaskLinkedRequestSerializer,
|
||||
TaskSerializer,
|
||||
@ -74,6 +78,7 @@ from .serializers import (
|
||||
get_guest_content_type,
|
||||
get_guest_visit_content_type,
|
||||
get_message_content_type,
|
||||
get_post_content_type,
|
||||
get_procurement_request_content_type,
|
||||
get_request_content_type,
|
||||
)
|
||||
@ -168,7 +173,21 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="default")
|
||||
def default(self, request):
|
||||
board = self.get_queryset().order_by("id").first()
|
||||
queryset = self.get_queryset()
|
||||
settings_obj = (
|
||||
TaskUserSettings.objects.select_related("default_board")
|
||||
.filter(user=request.user)
|
||||
.first()
|
||||
)
|
||||
board = None
|
||||
if settings_obj and settings_obj.default_board_id:
|
||||
board = queryset.filter(id=settings_obj.default_board_id).first()
|
||||
if board is None:
|
||||
settings_obj.default_board = None
|
||||
settings_obj.save(update_fields=["default_board", "updated_at"])
|
||||
|
||||
if board is None:
|
||||
board = queryset.order_by("id").first()
|
||||
if not board:
|
||||
board = TaskBoard.objects.create(
|
||||
name="Рабочая доска",
|
||||
@ -179,6 +198,16 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
|
||||
serializer = self.get_serializer(self.get_queryset().get(id=board.id))
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="set-default")
|
||||
def set_default(self, request, pk=None):
|
||||
board = self.get_object()
|
||||
TaskUserSettings.objects.update_or_create(
|
||||
user=request.user,
|
||||
defaults={"default_board": board},
|
||||
)
|
||||
serializer = self.get_serializer(self.get_queryset().get(id=board.id))
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class TaskColumnViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
@ -281,6 +310,11 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
.select_related("board", "column", "created_by", "assignee")
|
||||
.prefetch_related("labels")
|
||||
.annotate(
|
||||
linked_posts_count=Count(
|
||||
"linked_objects",
|
||||
filter=Q(linked_objects__kind=TaskLinkedObjectKind.POST),
|
||||
distinct=True,
|
||||
),
|
||||
linked_messages_count=Count(
|
||||
"linked_objects",
|
||||
filter=Q(linked_objects__kind=TaskLinkedObjectKind.MESSAGE),
|
||||
@ -799,6 +833,45 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="linked-post-tasks")
|
||||
def linked_post_tasks(self, request):
|
||||
post_id = request.query_params.get("post_id")
|
||||
try:
|
||||
post_id = int(post_id)
|
||||
except (TypeError, ValueError):
|
||||
return Response(
|
||||
{"post_id": ["Укажите ID новости."]},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
post = Post.objects.select_related("author", "department").get(
|
||||
id=post_id,
|
||||
)
|
||||
except Post.DoesNotExist:
|
||||
return Response(
|
||||
{"post_id": ["Новость не найдена."]},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
if not user_can_access_feed_post(request.user, post):
|
||||
return Response(
|
||||
{"detail": "Нет доступа к этой новости."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
queryset = (
|
||||
self.get_queryset()
|
||||
.filter(
|
||||
linked_objects__kind=TaskLinkedObjectKind.POST,
|
||||
linked_objects__content_type=get_post_content_type(),
|
||||
linked_objects__object_id=post.id,
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="linked-guest-tasks")
|
||||
def linked_guest_tasks(self, request):
|
||||
guest_id = request.query_params.get("guest_id")
|
||||
@ -1004,6 +1077,121 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
.order_by("-created_at", "-id")
|
||||
)
|
||||
|
||||
def _post_links_queryset(self, task):
|
||||
return (
|
||||
TaskLinkedObject.objects.filter(
|
||||
task=task,
|
||||
kind=TaskLinkedObjectKind.POST,
|
||||
)
|
||||
.select_related("created_by", "content_type")
|
||||
.order_by("-created_at", "-id")
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["get", "post"], url_path="linked-posts")
|
||||
def linked_posts(self, request, pk=None):
|
||||
task = self.get_object()
|
||||
if request.method == "GET":
|
||||
serializer = TaskLinkedPostSerializer(
|
||||
self._post_links_queryset(task),
|
||||
many=True,
|
||||
context={"request": request},
|
||||
)
|
||||
return Response(serializer.data)
|
||||
|
||||
post_id = request.data.get("post_id")
|
||||
try:
|
||||
post_id = int(post_id)
|
||||
except (TypeError, ValueError):
|
||||
return Response(
|
||||
{"post_id": ["Укажите ID новости."]},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
post = Post.objects.select_related("author", "department").get(
|
||||
id=post_id,
|
||||
)
|
||||
except Post.DoesNotExist:
|
||||
return Response(
|
||||
{"post_id": ["Новость не найдена."]},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
if not user_can_access_feed_post(request.user, post):
|
||||
return Response(
|
||||
{"detail": "Нет доступа к этой новости."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
link, created = TaskLinkedObject.objects.get_or_create(
|
||||
task=task,
|
||||
kind=TaskLinkedObjectKind.POST,
|
||||
content_type=get_post_content_type(),
|
||||
object_id=post.id,
|
||||
defaults={"created_by": request.user},
|
||||
)
|
||||
serializer = TaskLinkedPostSerializer(
|
||||
link,
|
||||
context={"request": request},
|
||||
)
|
||||
if created:
|
||||
create_task_activity(
|
||||
task,
|
||||
request.user,
|
||||
TaskActivityAction.LINKED,
|
||||
object_kind=TaskLinkedObjectKind.POST,
|
||||
object_id=post.id,
|
||||
metadata={
|
||||
"object_label": post.title,
|
||||
"object_type": "Новость",
|
||||
},
|
||||
)
|
||||
send_task_board_update(
|
||||
task.board,
|
||||
"linked",
|
||||
"post",
|
||||
post.id,
|
||||
extra={"task_id": task.id, "link_id": link.id},
|
||||
)
|
||||
return Response(
|
||||
serializer.data,
|
||||
status=status.HTTP_201_CREATED if created else status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@action(
|
||||
detail=True,
|
||||
methods=["delete"],
|
||||
url_path=r"linked-posts/(?P<link_id>\d+)",
|
||||
)
|
||||
def unlink_post(self, request, pk=None, link_id=None):
|
||||
task = self.get_object()
|
||||
try:
|
||||
link = self._post_links_queryset(task).get(id=link_id)
|
||||
except TaskLinkedObject.DoesNotExist:
|
||||
return Response(
|
||||
{"detail": "Связь не найдена."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
post_id = link.object_id
|
||||
link.delete()
|
||||
create_task_activity(
|
||||
task,
|
||||
request.user,
|
||||
TaskActivityAction.UNLINKED,
|
||||
object_kind=TaskLinkedObjectKind.POST,
|
||||
object_id=post_id,
|
||||
metadata={"object_type": "Новость"},
|
||||
)
|
||||
send_task_board_update(
|
||||
task.board,
|
||||
"unlinked",
|
||||
"post",
|
||||
post_id,
|
||||
extra={"task_id": task.id, "link_id": int(link_id)},
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=True, methods=["get", "post"], url_path="linked-messages")
|
||||
def linked_messages(self, request, pk=None):
|
||||
task = self.get_object()
|
||||
|
||||
@ -2,6 +2,7 @@ from django.db.models import Q
|
||||
from attendance.models import AttendanceRecord
|
||||
from documents.models import Document
|
||||
from employees.models import Department, EmployeeDepartment, RoleAssignment
|
||||
from feed.models import Post
|
||||
from requests_app.enums import RequestStatus
|
||||
from requests_app.models import Request as EmployeeRequest
|
||||
from schedule.models import Event
|
||||
@ -175,3 +176,7 @@ def user_can_access_attendance_record(
|
||||
or user.is_superuser
|
||||
or record.employee_id == user.id
|
||||
)
|
||||
|
||||
|
||||
def user_can_access_feed_post(user, post: Post | None) -> bool:
|
||||
return bool(user and user.is_authenticated and post is not None)
|
||||
|
||||
@ -7,6 +7,7 @@ from .models import (
|
||||
TaskColumn,
|
||||
TaskLabel,
|
||||
TaskLinkedObject,
|
||||
TaskUserSettings,
|
||||
)
|
||||
|
||||
|
||||
@ -73,3 +74,10 @@ class TaskActivityAdmin(admin.ModelAdmin):
|
||||
list_display = ["task", "action", "actor", "object_kind", "object_id", "created_at"]
|
||||
list_filter = ["action", "object_kind", "created_at"]
|
||||
search_fields = ["task__title", "actor__email", "object_id"]
|
||||
|
||||
|
||||
@admin.register(TaskUserSettings)
|
||||
class TaskUserSettingsAdmin(admin.ModelAdmin):
|
||||
list_display = ["user", "default_board", "updated_at"]
|
||||
search_fields = ["user__email", "user__first_name", "user__last_name", "default_board__name"]
|
||||
autocomplete_fields = ["user", "default_board"]
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.4 on 2026-07-06 19:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tasks', '0010_alter_taskactivity_object_kind_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='taskactivity',
|
||||
name='object_kind',
|
||||
field=models.CharField(blank=True, choices=[('post', 'Новость'), ('message', 'Сообщение'), ('calendar_event', 'Календарное событие'), ('document', 'Документ'), ('request', 'Заявление'), ('procurement_request', 'Заявка на закупку'), ('employee', 'Сотрудник'), ('guest', 'Гость'), ('guest_visit', 'Заявка на гостевой визит'), ('attendance_record', 'Запись посещаемости')], max_length=32, verbose_name='Тип объекта'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tasklinkedobject',
|
||||
name='kind',
|
||||
field=models.CharField(choices=[('post', 'Новость'), ('message', 'Сообщение'), ('calendar_event', 'Календарное событие'), ('document', 'Документ'), ('request', 'Заявление'), ('procurement_request', 'Заявка на закупку'), ('employee', 'Сотрудник'), ('guest', 'Гость'), ('guest_visit', 'Заявка на гостевой визит'), ('attendance_record', 'Запись посещаемости')], max_length=32, verbose_name='Тип связи'),
|
||||
),
|
||||
]
|
||||
30
backend/tasks/migrations/0012_taskusersettings.py
Normal file
30
backend/tasks/migrations/0012_taskusersettings.py
Normal file
@ -0,0 +1,30 @@
|
||||
# Generated by Django 5.2.4 on 2026-07-06 20:13
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tasks', '0011_alter_taskactivity_object_kind_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='TaskUserSettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
|
||||
('default_board', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_for_user_settings', to='tasks.taskboard', verbose_name='Доска по умолчанию')),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='task_settings', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Настройки задач пользователя',
|
||||
'verbose_name_plural': 'Настройки задач пользователей',
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -47,6 +47,32 @@ class TaskBoard(models.Model):
|
||||
return self.name
|
||||
|
||||
|
||||
class TaskUserSettings(models.Model):
|
||||
user = models.OneToOneField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="task_settings",
|
||||
verbose_name="Пользователь",
|
||||
)
|
||||
default_board = models.ForeignKey(
|
||||
TaskBoard,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="default_for_user_settings",
|
||||
verbose_name="Доска по умолчанию",
|
||||
)
|
||||
created_at = models.DateTimeField("Создано", auto_now_add=True)
|
||||
updated_at = models.DateTimeField("Обновлено", auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Настройки задач пользователя"
|
||||
verbose_name_plural = "Настройки задач пользователей"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} - задачи"
|
||||
|
||||
|
||||
class TaskColumn(models.Model):
|
||||
board = models.ForeignKey(
|
||||
TaskBoard,
|
||||
@ -174,6 +200,7 @@ class Task(models.Model):
|
||||
|
||||
|
||||
class TaskLinkedObjectKind(models.TextChoices):
|
||||
POST = "post", "Новость"
|
||||
MESSAGE = "message", "Сообщение"
|
||||
CALENDAR_EVENT = "calendar_event", "Календарное событие"
|
||||
DOCUMENT = "document", "Документ"
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from employees.models import DepartmentPermission
|
||||
from employees.models import (
|
||||
@ -10,6 +11,13 @@ from employees.models import (
|
||||
)
|
||||
from feed.models import Post
|
||||
from rest_framework import status
|
||||
from tasks.models import (
|
||||
Task,
|
||||
TaskBoard,
|
||||
TaskColumn,
|
||||
TaskLinkedObject,
|
||||
TaskLinkedObjectKind,
|
||||
)
|
||||
|
||||
from tests.test_config import API_POSTS_URL
|
||||
|
||||
@ -336,6 +344,91 @@ def test_list_department_posts_filtered_by_department(
|
||||
assert payload[0]["department_id"] == department_a.id
|
||||
|
||||
|
||||
def test_post_list_includes_visible_linked_tasks_only(
|
||||
auth_client_factory,
|
||||
make_user,
|
||||
):
|
||||
viewer = make_user("post-linked-task-viewer@example.com")
|
||||
hidden_member = make_user("post-linked-task-hidden@example.com")
|
||||
post = Post.objects.create(
|
||||
author=viewer,
|
||||
type="company",
|
||||
title="Новость со связью",
|
||||
body="body",
|
||||
)
|
||||
visible_board = TaskBoard.objects.create(
|
||||
name="Доступная доска новости",
|
||||
created_by=viewer,
|
||||
)
|
||||
visible_column = TaskColumn.objects.create(
|
||||
board=visible_board,
|
||||
name="Новые",
|
||||
position=1000,
|
||||
color="#38bdf8",
|
||||
)
|
||||
visible_task = Task.objects.create(
|
||||
board=visible_board,
|
||||
column=visible_column,
|
||||
title="Видимая задача новости",
|
||||
created_by=viewer,
|
||||
priority="high",
|
||||
)
|
||||
hidden_board = TaskBoard.objects.create(
|
||||
name="Скрытая доска новости",
|
||||
created_by=hidden_member,
|
||||
)
|
||||
hidden_board.members.add(hidden_member)
|
||||
hidden_column = TaskColumn.objects.create(
|
||||
board=hidden_board,
|
||||
name="Скрытые",
|
||||
position=1000,
|
||||
color="#ef4444",
|
||||
)
|
||||
hidden_task = Task.objects.create(
|
||||
board=hidden_board,
|
||||
column=hidden_column,
|
||||
title="Скрытая задача новости",
|
||||
created_by=hidden_member,
|
||||
priority="critical",
|
||||
)
|
||||
content_type = ContentType.objects.get_for_model(Post)
|
||||
visible_link = TaskLinkedObject.objects.create(
|
||||
task=visible_task,
|
||||
kind=TaskLinkedObjectKind.POST,
|
||||
content_type=content_type,
|
||||
object_id=post.id,
|
||||
created_by=viewer,
|
||||
)
|
||||
TaskLinkedObject.objects.create(
|
||||
task=hidden_task,
|
||||
kind=TaskLinkedObjectKind.POST,
|
||||
content_type=content_type,
|
||||
object_id=post.id,
|
||||
created_by=hidden_member,
|
||||
)
|
||||
|
||||
client = auth_client_factory(viewer)
|
||||
response = client.get(API_POSTS_URL)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
payload = response.data["results"] if isinstance(response.data, dict) else response.data
|
||||
post_payload = next(item for item in payload if item["id"] == post.id)
|
||||
assert post_payload["linked_tasks"] == [
|
||||
{
|
||||
"link_id": visible_link.id,
|
||||
"id": visible_task.id,
|
||||
"title": "Видимая задача новости",
|
||||
"board_id": visible_board.id,
|
||||
"board_name": "Доступная доска новости",
|
||||
"column_id": visible_column.id,
|
||||
"column_name": "Новые",
|
||||
"column_color": "#38bdf8",
|
||||
"priority": "high",
|
||||
"priority_display": "Высокий",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_department_pin_scope_is_separate_from_global_feed(
|
||||
auth_client_factory,
|
||||
make_user,
|
||||
|
||||
@ -13,12 +13,13 @@ from attendance.models import AttendanceAnalysisRun, AttendanceRecord
|
||||
from communications.models import Chat, ChatMembership, Message
|
||||
from documents.models import Document
|
||||
from employees.models import Department, EmployeeDepartment, RoleAssignment
|
||||
from feed.models import Post
|
||||
from guests.models import Guest, GuestVisit
|
||||
from procurement.constants import ProcurementStatus, UrgencyLevel
|
||||
from procurement.models import ProcurementRequest
|
||||
from requests_app.models import Request as EmployeeRequest
|
||||
from schedule.models import Calendar, CalendarRelation, Event
|
||||
from tasks.models import Task, TaskBoard, TaskColumn
|
||||
from tasks.models import Task, TaskBoard, TaskColumn, TaskUserSettings
|
||||
from tasks.realtime import get_task_board_recipient_ids
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@ -77,6 +78,61 @@ def test_default_board_creates_work_columns(api_client, user):
|
||||
assert TaskColumn.objects.count() == 4
|
||||
|
||||
|
||||
def test_user_can_set_personal_default_board(api_client, user):
|
||||
api_client.force_authenticate(user=user)
|
||||
first_board = api_client.get(
|
||||
reverse("api:v1:tasks:task-board-default")
|
||||
).data
|
||||
second_board = TaskBoard.objects.create(
|
||||
name="Персональная доска по умолчанию",
|
||||
created_by=user,
|
||||
)
|
||||
TaskColumn.objects.create(
|
||||
board=second_board,
|
||||
name="Новые",
|
||||
position=1000,
|
||||
)
|
||||
|
||||
set_response = api_client.post(
|
||||
reverse("api:v1:tasks:task-board-set-default", kwargs={"pk": second_board.id})
|
||||
)
|
||||
|
||||
assert set_response.status_code == status.HTTP_200_OK
|
||||
assert set_response.data["id"] == second_board.id
|
||||
assert set_response.data["is_default_for_current_user"] is True
|
||||
assert TaskUserSettings.objects.get(user=user).default_board_id == second_board.id
|
||||
|
||||
default_response = api_client.get(reverse("api:v1:tasks:task-board-default"))
|
||||
assert default_response.status_code == status.HTTP_200_OK
|
||||
assert default_response.data["id"] == second_board.id
|
||||
assert default_response.data["is_default_for_current_user"] is True
|
||||
|
||||
other_user = make_user("task-default-other@example.com", "+79994440090")
|
||||
api_client.force_authenticate(user=other_user)
|
||||
other_default_response = api_client.get(reverse("api:v1:tasks:task-board-default"))
|
||||
assert other_default_response.status_code == status.HTTP_200_OK
|
||||
assert other_default_response.data["id"] == first_board["id"]
|
||||
assert other_default_response.data["is_default_for_current_user"] is False
|
||||
|
||||
|
||||
def test_user_cannot_set_inaccessible_board_as_default(api_client):
|
||||
owner = make_user("task-default-owner@example.com", "+79994440091")
|
||||
outsider = make_user("task-default-outsider@example.com", "+79994440092")
|
||||
board = TaskBoard.objects.create(
|
||||
name="Закрытая доска",
|
||||
created_by=owner,
|
||||
)
|
||||
board.members.add(owner)
|
||||
|
||||
api_client.force_authenticate(user=outsider)
|
||||
response = api_client.post(
|
||||
reverse("api:v1:tasks:task-board-set-default", kwargs={"pk": board.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert not TaskUserSettings.objects.filter(user=outsider).exists()
|
||||
|
||||
|
||||
def test_create_task_and_move_to_done_column(api_client, user):
|
||||
api_client.force_authenticate(user=user)
|
||||
board_response = api_client.get(
|
||||
@ -820,6 +876,75 @@ def test_task_linked_guest_respects_task_board_access(api_client):
|
||||
assert reverse_linked_response.data == []
|
||||
|
||||
|
||||
def test_task_linked_post_respects_task_board_access(api_client):
|
||||
owner = make_user("task-post-owner@example.com", "+79994440045")
|
||||
board_member = make_user("task-post-member@example.com", "+79994440046")
|
||||
outsider = make_user("task-post-outsider@example.com", "+79994440047")
|
||||
post = Post.objects.create(
|
||||
author=owner,
|
||||
type="company",
|
||||
title="Новости задач",
|
||||
body="Связать публикацию с задачей",
|
||||
)
|
||||
board = TaskBoard.objects.create(
|
||||
name="Доска с новостями",
|
||||
created_by=owner,
|
||||
)
|
||||
board.members.add(board_member)
|
||||
column = TaskColumn.objects.create(
|
||||
board=board,
|
||||
name="Новые",
|
||||
position=1000,
|
||||
color="#38bdf8",
|
||||
)
|
||||
task = Task.objects.create(
|
||||
board=board,
|
||||
column=column,
|
||||
title="Обработать новость",
|
||||
created_by=owner,
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=board_member)
|
||||
link_response = api_client.post(
|
||||
reverse("api:v1:tasks:task-linked-posts", kwargs={"pk": task.id}),
|
||||
{"post_id": post.id},
|
||||
format="json",
|
||||
)
|
||||
assert link_response.status_code == status.HTTP_201_CREATED
|
||||
assert link_response.data["post_id"] == post.id
|
||||
assert link_response.data["post"]["title"] == "Новости задач"
|
||||
assert link_response.data["post"]["body"] == "Связать публикацию с задачей"
|
||||
assert link_response.data["can_open"] is True
|
||||
assert link_response.data["object_url"] == f"/?post={post.id}"
|
||||
|
||||
linked_response = api_client.get(
|
||||
reverse("api:v1:tasks:task-linked-posts", kwargs={"pk": task.id})
|
||||
)
|
||||
assert linked_response.status_code == status.HTTP_200_OK
|
||||
assert linked_response.data[0]["post"]["author"]["id"] == owner.id
|
||||
|
||||
reverse_linked_response = api_client.get(
|
||||
reverse("api:v1:tasks:task-linked-post-tasks"),
|
||||
{"post_id": post.id},
|
||||
)
|
||||
assert reverse_linked_response.status_code == status.HTTP_200_OK
|
||||
assert reverse_linked_response.data[0]["id"] == task.id
|
||||
assert reverse_linked_response.data[0]["linked_posts_count"] == 1
|
||||
|
||||
api_client.force_authenticate(user=outsider)
|
||||
task_detail_response = api_client.get(
|
||||
reverse("api:v1:tasks:task-linked-posts", kwargs={"pk": task.id})
|
||||
)
|
||||
assert task_detail_response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
reverse_linked_response = api_client.get(
|
||||
reverse("api:v1:tasks:task-linked-post-tasks"),
|
||||
{"post_id": post.id},
|
||||
)
|
||||
assert reverse_linked_response.status_code == status.HTTP_200_OK
|
||||
assert reverse_linked_response.data == []
|
||||
|
||||
|
||||
def test_task_linked_guest_visit_respects_visit_access(api_client):
|
||||
owner = make_user("task-guest-visit-owner@example.com", "+79994440039")
|
||||
inviter = make_user("task-guest-visit-inviter@example.com", "+79994440040")
|
||||
|
||||
@ -492,6 +492,16 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
|
||||
background: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.app-menu-danger-item {
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--danger-foreground);
|
||||
}
|
||||
|
||||
.app-menu-danger-item:hover {
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.app-action-danger {
|
||||
border: 1px solid color-mix(in srgb, var(--danger-foreground) 18%, var(--border-strong));
|
||||
background: var(--surface-elevated);
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ChevronRight, Pencil, Pin, Plus, Trash2 } from "lucide-react";
|
||||
import { ChevronRight, Link2, Pencil, Pin, Plus, Trash2 } from "lucide-react";
|
||||
import { AppShell } from "../components/AppShell";
|
||||
import { FeedPostCard } from "@/components/feed/FeedPostCard";
|
||||
import { Modal } from "@/components/ui";
|
||||
import { PostCommentsModal } from "@/components/feed/PostCommentsModal";
|
||||
import { RequestAvatar } from "@/components/requests/RequestAvatar";
|
||||
import { RelatedTaskLinks } from "@/components/tasks/RelatedTaskLinks";
|
||||
import TaskLinkPill from "@/components/tasks/TaskLinkPill";
|
||||
import { useNotifications } from "@/contexts/NotificationsContext";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { userProfileLink } from "@/lib/shared";
|
||||
@ -76,6 +78,7 @@ function HomePageContent() {
|
||||
const [likesUsersEndpointUnavailable, setLikesUsersEndpointUnavailable] = useState(false);
|
||||
const [imageCacheBuster, setImageCacheBuster] = useState<Record<number, number>>({});
|
||||
const [postMenuOpenId, setPostMenuOpenId] = useState<number | null>(null);
|
||||
const [taskLinkPost, setTaskLinkPost] = useState<Post | null>(null);
|
||||
const postMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const formatUserName = (u: LikeUser) => {
|
||||
@ -208,6 +211,18 @@ function HomePageContent() {
|
||||
);
|
||||
}, []);
|
||||
|
||||
const getPostTaskSubtitle = (post: Post) => {
|
||||
if (post.type === "department") {
|
||||
return post.department_name ? `Отдел: ${post.department_name}` : "Отдел";
|
||||
}
|
||||
return "Компания";
|
||||
};
|
||||
|
||||
const getPostDefaultTaskTitle = (post: Post) => {
|
||||
const title = (post.title || "").trim();
|
||||
return title ? `Задача по новости: ${title}` : `Задача по новости #${post.id}`;
|
||||
};
|
||||
|
||||
const refreshPosts = useCallback(async () => {
|
||||
const response = await apiClient.getPosts();
|
||||
setPosts(sortPostsPinnedFirst(response.results));
|
||||
@ -476,6 +491,11 @@ function HomePageContent() {
|
||||
setCommentsOpen(true);
|
||||
};
|
||||
|
||||
const openTaskLinkModal = (post: Post) => {
|
||||
setPostMenuOpenId(null);
|
||||
setTaskLinkPost(post);
|
||||
};
|
||||
|
||||
const applyCommentCountDelta = (postId: number, delta: number) => {
|
||||
setPosts((prev) =>
|
||||
prev.map((item) =>
|
||||
@ -686,6 +706,7 @@ function HomePageContent() {
|
||||
: diffHours < 24
|
||||
? `${diffHours} ч. назад`
|
||||
: `${Math.floor(diffHours / 24)} дн. назад`;
|
||||
const linkedTasks = post.linked_tasks || [];
|
||||
|
||||
return (
|
||||
<FeedPostCard
|
||||
@ -699,71 +720,93 @@ function HomePageContent() {
|
||||
</>
|
||||
}
|
||||
headerActions={(
|
||||
(canEditPost(post) || canDeletePost(post)) ? (
|
||||
<div
|
||||
ref={postMenuOpenId === post.id ? postMenuRef : null}
|
||||
className="relative shrink-0"
|
||||
<div
|
||||
ref={postMenuOpenId === post.id ? postMenuRef : null}
|
||||
className="relative shrink-0"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPostMenuOpenId((prev) => (prev === post.id ? null : post.id))}
|
||||
className="app-action-ghost flex h-8 w-8 items-center justify-center rounded-md"
|
||||
title="Действия с публикацией"
|
||||
aria-label="Действия с публикацией"
|
||||
aria-expanded={postMenuOpenId === post.id}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPostMenuOpenId((prev) => (prev === post.id ? null : post.id))}
|
||||
className="app-action-ghost flex h-8 w-8 items-center justify-center rounded-md"
|
||||
title="Действия с публикацией"
|
||||
aria-label="Действия с публикацией"
|
||||
aria-expanded={postMenuOpenId === post.id}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<ChevronRight
|
||||
size={15}
|
||||
className={`transition-transform duration-200 ${postMenuOpenId === post.id ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
<ChevronRight
|
||||
size={15}
|
||||
className={`transition-transform duration-200 ${postMenuOpenId === post.id ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{postMenuOpenId === post.id ? (
|
||||
<div className="app-menu absolute right-0 top-full z-20 mt-2 w-48 rounded-xl py-1.5">
|
||||
{canPinPost ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={postActionId === post.id}
|
||||
onClick={() => void handlePinToggle(post)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)] disabled:opacity-50"
|
||||
>
|
||||
<Pin size={14} className={post.pinned ? "fill-current app-accent-text" : ""} />
|
||||
{post.pinned ? "Открепить в общей ленте" : "Закрепить в общей ленте"}
|
||||
</button>
|
||||
) : null}
|
||||
{canEditPost(post) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPostMenuOpenId(null);
|
||||
openEditPostModal(post);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
Редактировать
|
||||
</button>
|
||||
) : null}
|
||||
{canDeletePost(post) ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={postActionId === post.id}
|
||||
onClick={() => {
|
||||
setPostMenuOpenId(null);
|
||||
void removePost(post);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--danger-foreground)] transition hover:bg-[var(--danger-soft)] disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Удалить
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null
|
||||
{postMenuOpenId === post.id ? (
|
||||
<div className="app-menu absolute right-0 top-full z-20 mt-2 w-56 rounded-xl py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openTaskLinkModal(post)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<Link2 size={14} />
|
||||
Связать с задачей
|
||||
</button>
|
||||
{canPinPost ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={postActionId === post.id}
|
||||
onClick={() => void handlePinToggle(post)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)] disabled:opacity-50"
|
||||
>
|
||||
<Pin size={14} className={post.pinned ? "fill-current app-accent-text" : ""} />
|
||||
{post.pinned ? "Открепить в общей ленте" : "Закрепить в общей ленте"}
|
||||
</button>
|
||||
) : null}
|
||||
{canEditPost(post) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPostMenuOpenId(null);
|
||||
openEditPostModal(post);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
Редактировать
|
||||
</button>
|
||||
) : null}
|
||||
{canDeletePost(post) ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={postActionId === post.id}
|
||||
onClick={() => {
|
||||
setPostMenuOpenId(null);
|
||||
void removePost(post);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--danger-foreground)] transition hover:bg-[var(--danger-soft)] disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Удалить
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
footerAction={linkedTasks.length > 0 ? (
|
||||
<div className="flex max-w-full flex-wrap justify-end gap-1.5">
|
||||
{linkedTasks.slice(0, 3).map((task) => (
|
||||
<TaskLinkPill
|
||||
key={task.link_id || task.id}
|
||||
task={task}
|
||||
maxTitleClassName="max-w-40"
|
||||
/>
|
||||
))}
|
||||
{linkedTasks.length > 3 ? (
|
||||
<span className="app-badge rounded-full px-2 py-0.5 text-[11px] font-medium">
|
||||
+{linkedTasks.length - 3}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
imageSrc={withImageCacheBuster(resolveMediaUrl(post.image), post.id)}
|
||||
isLikeActive={Boolean(post.is_liked)}
|
||||
likeDisabled={likeBusyId === post.id}
|
||||
@ -935,6 +978,24 @@ function HomePageContent() {
|
||||
onCommentCountChange={applyCommentCountDelta}
|
||||
post={activePost}
|
||||
/>
|
||||
|
||||
{taskLinkPost ? (
|
||||
<RelatedTaskLinks
|
||||
key={`post-task-link-${taskLinkPost.id}`}
|
||||
entityLabel="Новость"
|
||||
entityTitle={taskLinkPost.title || `Новость #${taskLinkPost.id}`}
|
||||
entitySubtitle={getPostTaskSubtitle(taskLinkPost)}
|
||||
defaultTaskTitle={getPostDefaultTaskTitle(taskLinkPost)}
|
||||
defaultTaskDescription={(taskLinkPost.body || taskLinkPost.content || "").trim()}
|
||||
successMessage="Новость связана с задачей"
|
||||
variant="dialog"
|
||||
open
|
||||
loadLinkedTasks={() => apiClient.getPostLinkedTasks(taskLinkPost.id)}
|
||||
linkTask={(taskId) => apiClient.linkTaskPost(taskId, taskLinkPost.id)}
|
||||
onClose={() => setTaskLinkPost(null)}
|
||||
onLinked={() => void refreshPosts()}
|
||||
/>
|
||||
) : null}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -27,12 +27,14 @@ import {
|
||||
Link2,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Newspaper,
|
||||
Paperclip,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShoppingCart,
|
||||
Star,
|
||||
Tag,
|
||||
Trash2,
|
||||
UserRound,
|
||||
@ -63,6 +65,7 @@ import type {
|
||||
TaskLinkedGuest,
|
||||
TaskLinkedGuestVisit,
|
||||
TaskLinkedMessage,
|
||||
TaskLinkedPost,
|
||||
TaskLinkedProcurementRequest,
|
||||
TaskLinkedRequest,
|
||||
TaskPriority,
|
||||
@ -272,11 +275,11 @@ function TaskCardView({
|
||||
onClick={() => onOpen(task)}
|
||||
className="min-w-0 flex-1 text-left"
|
||||
>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="app-badge shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-semibold">
|
||||
#{task.id}
|
||||
</span>
|
||||
<h3 className="app-text-wrap min-w-0 text-sm font-semibold text-[var(--foreground)]">
|
||||
<h3 className="min-w-0 max-w-[9rem] truncate text-sm font-semibold text-[var(--foreground)]">
|
||||
{task.title}
|
||||
</h3>
|
||||
</div>
|
||||
@ -384,11 +387,11 @@ function TaskDragOverlayCard({ task }: { task: TaskCard }) {
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="app-badge shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-semibold">
|
||||
#{task.id}
|
||||
</span>
|
||||
<h3 className="app-text-wrap min-w-0 text-sm font-semibold text-[var(--foreground)]">
|
||||
<h3 className="min-w-0 max-w-[9rem] truncate text-sm font-semibold text-[var(--foreground)]">
|
||||
{task.title}
|
||||
</h3>
|
||||
</div>
|
||||
@ -487,13 +490,13 @@ function BoardColumn({
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[var(--border-subtle)] px-3 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: column.color || "#38bdf8" }}
|
||||
/>
|
||||
<h2 className="truncate text-sm font-semibold text-[var(--foreground)]">
|
||||
<h2 className="min-w-0 truncate text-sm font-semibold text-[var(--foreground)]">
|
||||
{column.name}
|
||||
</h2>
|
||||
</div>
|
||||
@ -550,6 +553,117 @@ function AddColumnCard({ onClick }: { onClick: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
function getLinkedPostOriginLabel(post: TaskLinkedPost["post"]) {
|
||||
if (!post) return "";
|
||||
if (post.type === "department") {
|
||||
return post.department_name ? `Отдел: ${post.department_name}` : "Отдел";
|
||||
}
|
||||
return "Компания";
|
||||
}
|
||||
|
||||
function LinkedPostCard({
|
||||
link,
|
||||
onUnlink,
|
||||
disabled,
|
||||
}: {
|
||||
link: TaskLinkedPost;
|
||||
onUnlink: (linkId: number) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const post = link.post;
|
||||
const canOpen = Boolean(link.can_open && link.object_url);
|
||||
const postText = (post?.body || post?.content || "").trim();
|
||||
const originLabel = getLinkedPostOriginLabel(post);
|
||||
const mainContent = (
|
||||
<>
|
||||
<div className="mb-2 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="app-pill inline-flex items-center gap-1.5 rounded-full px-2 py-1 text-xs font-medium">
|
||||
<Newspaper size={12} />
|
||||
Новость
|
||||
</span>
|
||||
{originLabel ? (
|
||||
<span className="app-badge rounded-full px-2 py-1 text-[11px]">
|
||||
{originLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{canOpen ? (
|
||||
<span className="app-badge rounded-full px-2 py-1 text-[11px]">
|
||||
Открыть
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-2 text-sm font-semibold text-[var(--foreground)]">
|
||||
{post?.title || "Новость не найдена"}
|
||||
</p>
|
||||
{post?.created_at ? (
|
||||
<p className="app-text-muted mt-0.5 text-xs">
|
||||
{formatDateTime(post.created_at)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{postText ? (
|
||||
<p className="app-text-wrap line-clamp-4 whitespace-pre-wrap text-sm leading-5 text-[var(--foreground)]">
|
||||
{postText}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{post?.author ? (
|
||||
<span className="app-badge inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 text-[11px]">
|
||||
<span className="truncate">{displayUserName(post.author)}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{typeof post?.likes_count === "number" ? (
|
||||
<span className="app-badge rounded-full px-2 py-1 text-[11px]">
|
||||
{post.likes_count} лайк.
|
||||
</span>
|
||||
) : null}
|
||||
{typeof post?.comments_count === "number" ? (
|
||||
<span className="app-badge rounded-full px-2 py-1 text-[11px]">
|
||||
{post.comments_count} комм.
|
||||
</span>
|
||||
) : null}
|
||||
<span className="app-text-muted text-[11px]">
|
||||
Связал: {link.created_by ? displayUserName(link.created_by) : "не указано"}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<article className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
|
||||
<div className="relative">
|
||||
{canOpen && link.object_url ? (
|
||||
<Link
|
||||
href={link.object_url}
|
||||
className="block rounded-lg pr-10 transition hover:bg-[var(--surface-elevated)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-primary)]"
|
||||
title="Открыть связанную новость"
|
||||
>
|
||||
{mainContent}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="pr-10">
|
||||
{mainContent}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUnlink(link.id)}
|
||||
disabled={disabled}
|
||||
className="app-icon-button absolute right-0 top-0 z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg disabled:opacity-50"
|
||||
title="Убрать связь"
|
||||
aria-label="Убрать связь с новостью"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkedMessageCard({
|
||||
link,
|
||||
onUnlink,
|
||||
@ -1569,6 +1683,7 @@ export default function TasksPage() {
|
||||
const [labelName, setLabelName] = useState("");
|
||||
const [labelColor, setLabelColor] = useState("#38bdf8");
|
||||
const [linkedObjectsOpen, setLinkedObjectsOpen] = useState(false);
|
||||
const [linkedPosts, setLinkedPosts] = useState<TaskLinkedPost[]>([]);
|
||||
const [linkedMessages, setLinkedMessages] = useState<TaskLinkedMessage[]>([]);
|
||||
const [linkedEvents, setLinkedEvents] = useState<TaskLinkedCalendarEvent[]>([]);
|
||||
const [linkedDocuments, setLinkedDocuments] = useState<TaskLinkedDocument[]>([]);
|
||||
@ -1623,6 +1738,7 @@ export default function TasksPage() {
|
||||
setLinkedObjectsLoading(true);
|
||||
try {
|
||||
const [
|
||||
posts,
|
||||
messages,
|
||||
events,
|
||||
documents,
|
||||
@ -1633,6 +1749,7 @@ export default function TasksPage() {
|
||||
guestVisits,
|
||||
attendanceRecords,
|
||||
] = await Promise.all([
|
||||
apiClient.getTaskLinkedPosts(taskId),
|
||||
apiClient.getTaskLinkedMessages(taskId),
|
||||
apiClient.getTaskLinkedEvents(taskId),
|
||||
apiClient.getTaskLinkedDocuments(taskId),
|
||||
@ -1643,6 +1760,7 @@ export default function TasksPage() {
|
||||
apiClient.getTaskLinkedGuestVisits(taskId),
|
||||
apiClient.getTaskLinkedAttendanceRecords(taskId),
|
||||
]);
|
||||
setLinkedPosts(posts);
|
||||
setLinkedMessages(messages);
|
||||
setLinkedEvents(events);
|
||||
setLinkedDocuments(documents);
|
||||
@ -1984,7 +2102,7 @@ export default function TasksPage() {
|
||||
() => activeColumns.find((column) => column.id === viewTask?.column) || null,
|
||||
[activeColumns, viewTask?.column],
|
||||
);
|
||||
const linkedObjectsCount = linkedMessages.length + linkedEvents.length + linkedDocuments.length + linkedRequests.length + linkedProcurementRequests.length + linkedEmployees.length + linkedGuests.length + linkedGuestVisits.length + linkedAttendanceRecords.length;
|
||||
const linkedObjectsCount = linkedPosts.length + linkedMessages.length + linkedEvents.length + linkedDocuments.length + linkedRequests.length + linkedProcurementRequests.length + linkedEmployees.length + linkedGuests.length + linkedGuestVisits.length + linkedAttendanceRecords.length;
|
||||
const linkedObjectsBadgeCount = viewTask?.linked_objects_count ?? linkedObjectsCount;
|
||||
const commentsBadgeCount = viewTask?.comments_count ?? taskComments.length;
|
||||
|
||||
@ -1999,6 +2117,7 @@ export default function TasksPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewTaskId) {
|
||||
setLinkedPosts([]);
|
||||
setLinkedMessages([]);
|
||||
setLinkedEvents([]);
|
||||
setLinkedDocuments([]);
|
||||
@ -2206,6 +2325,33 @@ export default function TasksPage() {
|
||||
}
|
||||
}, [board, loadBoard, loadBoards]);
|
||||
|
||||
const setDefaultSelectedBoard = useCallback(async () => {
|
||||
if (!board || saving || board.is_default_for_current_user) return;
|
||||
|
||||
setBoardActionsMenuOpen(false);
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updatedBoard = await apiClient.setDefaultTaskBoard(board.id);
|
||||
setBoard((current) => (
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
is_default_for_current_user: current.id === updatedBoard.id,
|
||||
}
|
||||
: current
|
||||
));
|
||||
setBoards((current) => current.map((item) => ({
|
||||
...item,
|
||||
is_default_for_current_user: item.id === updatedBoard.id,
|
||||
})));
|
||||
} catch (defaultError) {
|
||||
setError(getTaskError(defaultError, "Не удалось выбрать доску по умолчанию"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [board, saving]);
|
||||
|
||||
const deleteSelectedBoard = useCallback(async () => {
|
||||
if (!board || saving) return;
|
||||
if (!window.confirm(`Удалить доску "${board.name}"? Все задачи этой доски будут удалены.`)) return;
|
||||
@ -2413,6 +2559,23 @@ export default function TasksPage() {
|
||||
}
|
||||
}, [board, loadBoard, saving, viewTask]);
|
||||
|
||||
const unlinkPostFromViewedTask = useCallback(async (linkId: number) => {
|
||||
if (!board || !viewTask || saving) return;
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiClient.unlinkTaskPost(viewTask.id, linkId);
|
||||
await loadTaskLinkedObjects(viewTask.id);
|
||||
await loadBoard(board.id);
|
||||
if (activityOpen) await loadTaskActivity(viewTask.id);
|
||||
} catch (unlinkError) {
|
||||
setError(getTaskError(unlinkError, "Не удалось убрать связь с новостью"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [activityOpen, board, loadBoard, loadTaskActivity, loadTaskLinkedObjects, saving, viewTask]);
|
||||
|
||||
const unlinkMessageFromViewedTask = useCallback(async (linkId: number) => {
|
||||
if (!board || !viewTask || saving) return;
|
||||
|
||||
@ -2680,6 +2843,26 @@ export default function TasksPage() {
|
||||
<RefreshCw size={14} className="app-text-muted shrink-0" />
|
||||
<span>Обновить</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void setDefaultSelectedBoard()}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)] disabled:cursor-default disabled:opacity-70 disabled:hover:bg-transparent"
|
||||
disabled={saving || Boolean(board?.is_default_for_current_user)}
|
||||
>
|
||||
<Star
|
||||
size={14}
|
||||
className={`shrink-0 ${
|
||||
board?.is_default_for_current_user
|
||||
? "fill-current app-accent-text"
|
||||
: "app-text-muted"
|
||||
}`}
|
||||
/>
|
||||
<span>
|
||||
{board?.is_default_for_current_user
|
||||
? "Доска по умолчанию"
|
||||
: "Сделать доской по умолчанию"}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void deleteSelectedBoard()}
|
||||
@ -2753,6 +2936,13 @@ export default function TasksPage() {
|
||||
>
|
||||
<Kanban size={15} className="shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{item.name}</span>
|
||||
{item.is_default_for_current_user ? (
|
||||
<Star
|
||||
size={12}
|
||||
className="app-accent-text shrink-0 fill-current"
|
||||
aria-label="Доска по умолчанию"
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@ -3352,6 +3542,14 @@ export default function TasksPage() {
|
||||
</div>
|
||||
) : linkedObjectsCount > 0 ? (
|
||||
<>
|
||||
{linkedPosts.map((link) => (
|
||||
<LinkedPostCard
|
||||
key={link.id}
|
||||
link={link}
|
||||
onUnlink={unlinkPostFromViewedTask}
|
||||
disabled={saving}
|
||||
/>
|
||||
))}
|
||||
{linkedMessages.map((link) => (
|
||||
<LinkedMessageCard
|
||||
key={link.id}
|
||||
|
||||
@ -253,7 +253,7 @@ function Header({ mobileNavPlacement, suppressMobileChrome = false, onOpenLeftNa
|
||||
) : null}
|
||||
<div className="app-divider my-1 border-t" />
|
||||
<button
|
||||
className="app-action-danger w-full px-4 py-2 text-left text-sm transition"
|
||||
className="app-menu-danger-item w-full px-4 py-2 text-left text-sm transition"
|
||||
onClick={() => { setUserMenuOpen(false); logout(); }}
|
||||
>Выйти</button>
|
||||
</div>
|
||||
|
||||
@ -86,7 +86,7 @@ export function MobileLeftDrawer({ isOpen, onClose, children }: LeftNavDrawerPro
|
||||
onClick={() => { onClose(); router.push("/settings"); }}
|
||||
>Настройки</button>
|
||||
<button
|
||||
className="app-action-danger w-full rounded-lg px-3 py-2 text-left text-sm transition"
|
||||
className="app-menu-danger-item w-full rounded-lg px-3 py-2 text-left text-sm transition"
|
||||
onClick={() => { onClose(); logout(); }}
|
||||
>Выйти</button>
|
||||
</div>
|
||||
|
||||
@ -13,6 +13,7 @@ import type {
|
||||
TaskLinkedGuest,
|
||||
TaskLinkedGuestVisit,
|
||||
TaskLinkedMessage,
|
||||
TaskLinkedPost,
|
||||
TaskLinkedProcurementRequest,
|
||||
TaskLinkedRequest,
|
||||
} from "@/types/api";
|
||||
@ -38,6 +39,8 @@ export function createTasksApi(request: RequestFn) {
|
||||
}),
|
||||
deleteTaskBoard: (id: number): Promise<void> =>
|
||||
request(`/api/v1/tasks/boards/${id}/`, { method: "DELETE" }),
|
||||
setDefaultTaskBoard: (id: number): Promise<TaskBoard> =>
|
||||
request(`/api/v1/tasks/boards/${id}/set-default/`, { method: "POST" }),
|
||||
createTaskColumn: (data: Record<string, any>): Promise<TaskColumn> =>
|
||||
request("/api/v1/tasks/columns/", {
|
||||
method: "POST",
|
||||
@ -79,6 +82,8 @@ export function createTasksApi(request: RequestFn) {
|
||||
request(`/api/v1/tasks/${id}/comments/${commentId}/`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
getPostLinkedTasks: (postId: number): Promise<TaskCard[]> =>
|
||||
request(`/api/v1/tasks/linked-post-tasks/?post_id=${postId}`),
|
||||
getCalendarEventLinkedTasks: (eventId: number): Promise<TaskCard[]> =>
|
||||
request(`/api/v1/tasks/linked-event-tasks/?event_id=${eventId}`),
|
||||
getDocumentLinkedTasks: (documentId: number): Promise<TaskCard[]> =>
|
||||
@ -95,6 +100,17 @@ export function createTasksApi(request: RequestFn) {
|
||||
request(`/api/v1/tasks/linked-guest-visit-tasks/?guest_visit_id=${guestVisitId}`),
|
||||
getAttendanceRecordLinkedTasks: (attendanceRecordId: number): Promise<TaskCard[]> =>
|
||||
request(`/api/v1/tasks/linked-attendance-record-tasks/?attendance_record_id=${attendanceRecordId}`),
|
||||
getTaskLinkedPosts: (id: number): Promise<TaskLinkedPost[]> =>
|
||||
request(`/api/v1/tasks/${id}/linked-posts/`),
|
||||
linkTaskPost: (id: number, postId: number): Promise<TaskLinkedPost> =>
|
||||
request(`/api/v1/tasks/${id}/linked-posts/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ post_id: postId }),
|
||||
}),
|
||||
unlinkTaskPost: (id: number, linkId: number): Promise<void> =>
|
||||
request(`/api/v1/tasks/${id}/linked-posts/${linkId}/`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
getTaskLinkedMessages: (id: number): Promise<TaskLinkedMessage[]> =>
|
||||
request(`/api/v1/tasks/${id}/linked-messages/`),
|
||||
linkTaskMessage: (id: number, messageId: number): Promise<TaskLinkedMessage> =>
|
||||
|
||||
@ -180,6 +180,7 @@ export interface TaskCard {
|
||||
due_date?: string | null;
|
||||
position: number;
|
||||
completed_at?: string | null;
|
||||
linked_posts_count?: number;
|
||||
linked_messages_count?: number;
|
||||
linked_events_count?: number;
|
||||
linked_documents_count?: number;
|
||||
@ -209,10 +210,22 @@ export interface TaskBoard {
|
||||
labels: TaskLabel[];
|
||||
tasks: TaskCard[];
|
||||
tasks_count?: number;
|
||||
is_default_for_current_user?: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TaskLinkedPost {
|
||||
id: number;
|
||||
kind: "post";
|
||||
post_id: number;
|
||||
post: Post | null;
|
||||
can_open?: boolean;
|
||||
object_url?: string | null;
|
||||
created_by?: User;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TaskLinkedMessage {
|
||||
id: number;
|
||||
kind: "message";
|
||||
@ -412,7 +425,7 @@ export interface TaskActivity {
|
||||
action: "created" | "updated" | "moved" | "linked" | "unlinked";
|
||||
action_display?: string;
|
||||
actor?: User | null;
|
||||
object_kind?: "message" | "calendar_event" | "document" | "request" | "procurement_request" | "employee" | "guest" | "guest_visit" | "attendance_record" | "";
|
||||
object_kind?: "post" | "message" | "calendar_event" | "document" | "request" | "procurement_request" | "employee" | "guest" | "guest_visit" | "attendance_record" | "";
|
||||
object_id?: number | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
created_at: string;
|
||||
@ -561,6 +574,7 @@ export interface Post {
|
||||
likes_count: number;
|
||||
comments_count: number;
|
||||
is_liked?: boolean;
|
||||
linked_tasks?: MessageLinkedTask[];
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user