From beb4b8b86b0714f5ad80ef290604c26e608df80b Mon Sep 17 00:00:00 2001
From: lizerk
Date: Tue, 14 Apr 2026 16:14:52 +0300
Subject: [PATCH] refactor(departments): unify department feed and discussion
flows
---
backend/api/v1/employees/views/departments.py | 40 +
backend/api/v1/feed/serializers.py | 24 +
backend/api/v1/feed/views.py | 110 ++-
backend/communications/api/viewsets.py | 14 +-
backend/communications/consumers.py | 5 +
.../communications/notifications/handlers.py | 35 +-
backend/communications/rules.py | 18 +
backend/employees/signals/common.py | 41 +-
backend/employees/utils.py | 6 +
backend/feed/admin.py | 5 +-
.../feed/migrations/0008_alter_post_body.py | 18 +
.../0009_split_post_pins_by_scope.py | 62 ++
backend/feed/models.py | 27 +-
backend/feed/notifications/handlers.py | 30 +-
.../employees/test_department_discussion.py | 263 ++++++
.../employees/test_department_user_perms.py | 80 ++
backend/tests/api/v1/feed/test_posts.py | 340 ++++++++
backend/tests/realtime/test_integration.py | 50 +-
.../guides/frontend/06-COMPONENT-INVENTORY.md | 3 +-
docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md | 2 +-
frontend/src/app/departments/[id]/page.tsx | 395 +++++----
frontend/src/app/messages/page.tsx | 2 +
frontend/src/app/page.tsx | 594 +++-----------
.../departments/DepartmentFeedSection.tsx | 770 ++++++++++++++++++
frontend/src/components/feed/FeedPostCard.tsx | 203 +++++
.../src/components/feed/PostCommentsModal.tsx | 625 ++++++++++++++
.../src/components/shared/CommentControls.tsx | 9 +-
frontend/src/hooks/useDepartmentPage.ts | 2 +
frontend/src/lib/api/employees.ts | 2 +
frontend/src/lib/api/feed.ts | 46 +-
frontend/src/types/api.ts | 5 +
31 files changed, 3095 insertions(+), 731 deletions(-)
create mode 100644 backend/feed/migrations/0008_alter_post_body.py
create mode 100644 backend/feed/migrations/0009_split_post_pins_by_scope.py
create mode 100644 backend/tests/api/v1/employees/test_department_discussion.py
create mode 100644 backend/tests/api/v1/employees/test_department_user_perms.py
create mode 100644 backend/tests/api/v1/feed/test_posts.py
create mode 100644 frontend/src/components/departments/DepartmentFeedSection.tsx
create mode 100644 frontend/src/components/feed/FeedPostCard.tsx
create mode 100644 frontend/src/components/feed/PostCommentsModal.tsx
diff --git a/backend/api/v1/employees/views/departments.py b/backend/api/v1/employees/views/departments.py
index 266aa03c..bf036c6b 100644
--- a/backend/api/v1/employees/views/departments.py
+++ b/backend/api/v1/employees/views/departments.py
@@ -109,6 +109,7 @@ class DepartmentViewSet(viewsets.ModelViewSet):
return [AdminOrActionOrModelPerms()]
if self.action in {
"members",
+ "discussion_chat",
"user_perms",
"list",
"retrieve",
@@ -449,6 +450,39 @@ class DepartmentViewSet(viewsets.ModelViewSet):
links = _build_links_for_dept(dept, EmployeeBriefSerializer)
return Response({"count": len(links), "results": links}, status=200)
+ @action(detail=True, methods=["get"], url_path="discussion-chat")
+ def discussion_chat(self, request, pk=None):
+ """Возвращает comments-chat отдела для входа из detail page."""
+ from communications.comments_helpers import get_or_create_comments_chat
+
+ dept = self.get_object()
+ chat = get_or_create_comments_chat(
+ dept,
+ created_by=None,
+ name=dept.name,
+ )
+ next_flags = dict(chat.flags or {})
+ if next_flags.get("show_in_messages") is not True:
+ next_flags["show_in_messages"] = True
+
+ if (
+ chat.created_by_id is not None
+ or chat.name != dept.name
+ or chat.flags != next_flags
+ ):
+ chat.created_by = None
+ chat.name = dept.name
+ chat.flags = next_flags
+ chat.save(update_fields=["created_by", "name", "flags"])
+ return Response(
+ {
+ "chat_id": chat.id,
+ "chat_type": chat.type,
+ "name": chat.name,
+ },
+ status=status.HTTP_200_OK,
+ )
+
@action(detail=True, methods=["get"], url_path="user-perms")
def user_perms(self, request, pk=None):
"""GET /api/v1/departments/{id}/user-perms/.
@@ -466,6 +500,12 @@ class DepartmentViewSet(viewsets.ModelViewSet):
"can_assign_roles": has_dept_perm(
request.user, dept.id, DeptPerm.ASSIGN_ROLE
),
+ "can_publish_posts": has_dept_perm(
+ request.user, dept.id, DeptPerm.CREATE_POST
+ ),
+ "can_manage_feed": has_dept_perm(
+ request.user, dept.id, DeptPerm.MANAGE_FEED
+ ),
}
return Response(data, status=200)
diff --git a/backend/api/v1/feed/serializers.py b/backend/api/v1/feed/serializers.py
index ead29630..20a2c185 100644
--- a/backend/api/v1/feed/serializers.py
+++ b/backend/api/v1/feed/serializers.py
@@ -49,6 +49,9 @@ class PostListSerializer(serializers.ModelSerializer):
source="created_at", format="%d.%m.%Y %H:%M", read_only=True
)
+ pinned = serializers.SerializerMethodField()
+ pinned_global = serializers.BooleanField(read_only=True, default=False)
+ pinned_department = serializers.BooleanField(read_only=True, default=False)
# ожидания фронта
is_liked = serializers.BooleanField(read_only=True, default=False)
comments_count = serializers.IntegerField(
@@ -75,6 +78,8 @@ class PostListSerializer(serializers.ModelSerializer):
"created_at",
"created_at_display",
"pinned",
+ "pinned_global",
+ "pinned_department",
"likes_count",
"comments_count",
"author_id",
@@ -85,6 +90,8 @@ class PostListSerializer(serializers.ModelSerializer):
"created_at",
"created_at_display",
"pinned",
+ "pinned_global",
+ "pinned_department",
"likes_count",
"comments_count",
"author_id",
@@ -105,10 +112,20 @@ class PostListSerializer(serializers.ModelSerializer):
request = self.context.get("request")
return build_media_url(obj.attachment, request)
+ @extend_schema_field(serializers.BooleanField())
+ def get_pinned(self, obj):
+ scope = self.context.get("pin_scope") or "global"
+ if scope == "department":
+ return bool(getattr(obj, "pinned_department", False))
+ return bool(getattr(obj, "pinned_global", False))
+
class PostSerializer(PostListSerializer):
"""Сериализатор для деталей поста"""
+ body = serializers.CharField(required=False, allow_blank=True)
+ image = serializers.ImageField(required=False, allow_null=True, write_only=True)
+ attachment = serializers.FileField(required=False, allow_null=True, write_only=True)
user_has_liked = serializers.SerializerMethodField()
class Meta(PostListSerializer.Meta):
@@ -122,6 +139,13 @@ class PostSerializer(PostListSerializer):
return PostLike.objects.filter(post=obj, user=request.user).exists()
return False
+ def to_representation(self, instance):
+ data = super().to_representation(instance)
+ request = self.context.get("request")
+ data["image"] = build_media_url(instance.image, request)
+ data["attachment"] = build_media_url(instance.attachment, request)
+ return data
+
def validate(self, attrs):
# значения с учётом partial
t = attrs.get("type", getattr(self.instance, "type", None))
diff --git a/backend/api/v1/feed/views.py b/backend/api/v1/feed/views.py
index fe588946..0d3dd9a6 100644
--- a/backend/api/v1/feed/views.py
+++ b/backend/api/v1/feed/views.py
@@ -66,13 +66,18 @@ class PostViewSet(viewsets.ModelViewSet):
"""
queryset = Post.objects.select_related("author", "department").order_by(
- "-pinned", "-created_at"
+ "-pinned_global", "-created_at"
)
parser_classes = [MultiPartParser, FormParser, JSONParser]
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
search_fields = ["title", "body"]
- ordering_fields = ["created_at", "pinned", "likes_count"]
- ordering = ["-pinned", "-created_at"]
+ ordering_fields = [
+ "created_at",
+ "pinned_global",
+ "pinned_department",
+ "likes_count",
+ ]
+ ordering = ["-pinned_global", "-created_at"]
class PostDeptPerm(AdminOrDeptAllowed):
"""Департаментные права для операций над постами отдела.
@@ -153,6 +158,30 @@ class PostViewSet(viewsets.ModelViewSet):
def get_serializer_class(self):
return PostListSerializer if self.action == "list" else PostSerializer
+ def get_serializer_context(self):
+ context = super().get_serializer_context()
+ context["pin_scope"] = self._resolve_pin_scope()
+ return context
+
+ def _resolve_pin_scope(self, post: Post | None = None) -> str:
+ request_data = self.request.data if self.request.method not in {"GET", "HEAD"} else {}
+ scope = (
+ self.request.query_params.get("pin_scope")
+ or self.request.query_params.get("scope")
+ or request_data.get("scope")
+ or ""
+ ).strip()
+ if scope in {"global", "department"}:
+ return scope
+
+ post_type = (self.request.query_params.get("type") or "").strip()
+ department_id = _to_id(self.request.query_params.get("department"))
+ if post_type == TYPE_DEPARTMENT and department_id is not None:
+ return "department"
+ if post is not None and getattr(post, "type", None) == TYPE_DEPARTMENT:
+ return "department"
+ return "global"
+
def get_queryset(self):
qs = super().get_queryset()
user = self.request.user
@@ -197,11 +226,20 @@ class PostViewSet(viewsets.ModelViewSet):
author_id = _to_id(qp.get("author"))
if author_id is not None:
qs = qs.filter(author_id=author_id)
+ pin_scope = self._resolve_pin_scope()
+ pinned_field = (
+ "pinned_department" if pin_scope == "department" else "pinned_global"
+ )
pinned = qp.get("pinned")
if pinned in ("true", "1"):
- qs = qs.filter(pinned=True)
+ qs = qs.filter(**{pinned_field: True})
elif pinned in ("false", "0"):
- qs = qs.filter(pinned=False)
+ qs = qs.filter(**{pinned_field: False})
+
+ if pin_scope == "department" and dept_id is not None:
+ qs = qs.order_by("-pinned_department", "-created_at")
+ else:
+ qs = qs.order_by("-pinned_global", "-created_at")
return qs
@@ -231,17 +269,67 @@ class PostViewSet(viewsets.ModelViewSet):
def pin(self, request, pk=None):
"""Пинует пост (только staff/superuser)."""
post = self.get_object()
- if not post.pinned:
- Post.objects.filter(pk=post.pk).update(pinned=True)
- return Response({"pinned": True})
+ scope = self._resolve_pin_scope(post=post)
+ if scope == "department":
+ if post.type != TYPE_DEPARTMENT or post.department_id is None:
+ return Response(
+ {"detail": "Закрепление в ленте отдела доступно только для публикаций отдела."},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ if not post.pinned_department:
+ Post.objects.filter(pk=post.pk).update(pinned_department=True)
+ return Response(
+ {
+ "scope": "department",
+ "pinned": True,
+ "pinned_global": bool(post.pinned_global),
+ "pinned_department": True,
+ }
+ )
+
+ if not post.pinned_global:
+ Post.objects.filter(pk=post.pk).update(pinned_global=True)
+ return Response(
+ {
+ "scope": "global",
+ "pinned": True,
+ "pinned_global": True,
+ "pinned_department": bool(post.pinned_department),
+ }
+ )
@action(detail=True, methods=["post"], permission_classes=[IsAdminUser])
def unpin(self, request, pk=None):
"""Снимает пин (только staff/superuser)."""
post = self.get_object()
- if post.pinned:
- Post.objects.filter(pk=post.pk).update(pinned=False)
- return Response({"pinned": False})
+ scope = self._resolve_pin_scope(post=post)
+ if scope == "department":
+ if post.type != TYPE_DEPARTMENT or post.department_id is None:
+ return Response(
+ {"detail": "Закрепление в ленте отдела доступно только для публикаций отдела."},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ if post.pinned_department:
+ Post.objects.filter(pk=post.pk).update(pinned_department=False)
+ return Response(
+ {
+ "scope": "department",
+ "pinned": False,
+ "pinned_global": bool(post.pinned_global),
+ "pinned_department": False,
+ }
+ )
+
+ if post.pinned_global:
+ Post.objects.filter(pk=post.pk).update(pinned_global=False)
+ return Response(
+ {
+ "scope": "global",
+ "pinned": False,
+ "pinned_global": False,
+ "pinned_department": bool(post.pinned_department),
+ }
+ )
@action(detail=True, methods=["post"], permission_classes=[IsAuthenticated])
def like(self, request, pk=None):
diff --git a/backend/communications/api/viewsets.py b/backend/communications/api/viewsets.py
index 24a21c35..e5db6be3 100644
--- a/backend/communications/api/viewsets.py
+++ b/backend/communications/api/viewsets.py
@@ -174,8 +174,9 @@ class ChatViewSet(viewsets.ModelViewSet):
ОПТИМИЗИРОВАНО: Используем денормализованное поле unread_count из ChatReadState
вместо подзапросов COUNT(*). Это убирает N+1 проблему и ускоряет в ~100x.
- ФИЛЬТРАЦИЯ: Чаты типа 'comments' исключены из списка (list action),
- но доступны по прямой ссылке (retrieve action).
+ ФИЛЬТРАЦИЯ: Чаты типа 'comments' по умолчанию исключены из списка,
+ кроме явно помеченных object-chats (`flags.show_in_messages=true`).
+ По прямой ссылке retrieve доступны все comments-чаты.
"""
user = self.request.user
@@ -184,6 +185,7 @@ class ChatViewSet(viewsets.ModelViewSet):
| Q(participants=user)
| Q(include_all_users=True)
| Q(created_by=user) # Создатель всегда видит свои чаты
+ | Q(type="comments")
).select_related(
'created_by', 'context_content_type'
).prefetch_related(
@@ -215,10 +217,12 @@ class ChatViewSet(viewsets.ModelViewSet):
),
).distinct()
- # Исключаем чаты-комментарии из общего списка
- # Они доступны только через прямой запрос (retrieve) или через контекст поста
+ # В список попадают только явно выставленные comments-чаты.
+ # Остальные object-comment threads доступны по прямому retrieve.
if self.action == 'list':
- queryset = queryset.exclude(type='comments')
+ queryset = queryset.exclude(
+ Q(type='comments') & ~Q(flags__show_in_messages=True)
+ )
return queryset.order_by('-created_at')
diff --git a/backend/communications/consumers.py b/backend/communications/consumers.py
index 7c9314bd..8fbcb48d 100644
--- a/backend/communications/consumers.py
+++ b/backend/communications/consumers.py
@@ -18,6 +18,7 @@ from django.utils import timezone
from .models import Chat, ChatMembership, ChatReadState, Message, MessageReaction
from .serialization import serialize_message
+from .utils import user_can_access_chat
logger = logging.getLogger(__name__)
@@ -597,6 +598,7 @@ class ChatConsumerMixin:
Q(type="global")
| Q(id__in=membership_chat_ids)
| Q(include_all_users=True)
+ | Q(type="comments", flags__show_in_messages=True)
)
.values_list("id", flat=True)
.distinct()
@@ -625,6 +627,9 @@ class ChatConsumerMixin:
@database_sync_to_async
def _user_can_access(self, chat, user):
"""Проверка доступа пользователя к чату"""
+ if chat.type == "comments":
+ return user_can_access_chat(chat, user)
+
if chat.type == "global":
return True
diff --git a/backend/communications/notifications/handlers.py b/backend/communications/notifications/handlers.py
index 5bcc67e0..39ab2fb6 100644
--- a/backend/communications/notifications/handlers.py
+++ b/backend/communications/notifications/handlers.py
@@ -232,7 +232,7 @@ def _notify_comment(message):
# Нет привязанного объекта - обычная обработка
return
- recipients_set = set()
+ recipient_ids = set()
context_obj = chat.context_object
# Определяем автора объекта в зависимости от типа
@@ -251,6 +251,23 @@ def _notify_comment(message):
object_author = getattr(context_obj, 'uploaded_by', None)
object_url = f"/documents?document={context_obj.id}"
object_type = "документа"
+ elif model_name == 'Department':
+ from employees.models import EmployeeDepartment, RoleAssignment
+
+ object_url = ActionURLs.chat_detail(chat.id)
+ object_type = "отдела"
+
+ member_ids = EmployeeDepartment.objects.filter(
+ department_id=context_obj.id,
+ is_active=True,
+ ).values_list("employee_id", flat=True)
+ recipient_ids.update(member_ids)
+
+ role_only_ids = RoleAssignment.objects.filter(
+ role__department_id=context_obj.id,
+ is_active=True,
+ ).values_list("employee_id", flat=True)
+ recipient_ids.update(role_only_ids)
elif hasattr(context_obj, 'employee'): # Request
object_author = getattr(context_obj, 'employee', None)
object_url = f"/requests?request={context_obj.id}"
@@ -258,7 +275,7 @@ def _notify_comment(message):
# 1. Уведомляем автора объекта
if object_author and object_author.id != author.id:
- recipients_set.add(object_author)
+ recipient_ids.add(object_author.id)
# 2. Уведомляем всех, кто участвовал в обсуждении
from ..models import Message
@@ -270,16 +287,18 @@ def _notify_comment(message):
).values_list('author_id', flat=True).distinct()
if previous_commenters:
- from django.contrib.auth import get_user_model
- User = get_user_model()
- recipients_set.update(
- User.objects.filter(id__in=previous_commenters, is_active=True)
- )
+ recipient_ids.update(previous_commenters)
+
+ recipient_ids.discard(author.id)
+ if not recipient_ids:
+ return
+
+ recipients = User.objects.filter(id__in=recipient_ids, is_active=True)
# Отправляем уведомления
author_name = author.get_full_name() or author.username
- for recipient in recipients_set:
+ for recipient in recipients:
_send_notification(
sender=author,
recipient=recipient,
diff --git a/backend/communications/rules.py b/backend/communications/rules.py
index 226f4aed..55b86b15 100644
--- a/backend/communications/rules.py
+++ b/backend/communications/rules.py
@@ -35,6 +35,15 @@ def is_chat_member(user, chat):
if chat.type == "global":
return True
+ if chat.type == "comments" and chat.context_object_id:
+ try:
+ return chat.get_participants().filter(pk=user.pk).exists()
+ except Exception:
+ logger.exception(
+ "[is_chat_member] failed to resolve comments chat participants"
+ )
+ return False
+
return (
ChatMembership.objects.filter(
chat=chat, user=user, is_active=True
@@ -173,6 +182,15 @@ def has_send_messages_permission(user, chat):
if chat.type in ["private", "global"]:
return True
+ if chat.type == "comments" and chat.context_object_id:
+ try:
+ return chat.get_participants().filter(pk=user.pk).exists()
+ except Exception:
+ logger.exception(
+ "[has_send_messages_permission] failed to resolve comments chat participants"
+ )
+ return False
+
# Для чатов с ChatMembership проверяем флаг can_send_messages
if hasattr(chat, "memberships"):
from .models import ChatMembership
diff --git a/backend/employees/signals/common.py b/backend/employees/signals/common.py
index 1ae77a27..5009b583 100644
--- a/backend/employees/signals/common.py
+++ b/backend/employees/signals/common.py
@@ -3,20 +3,18 @@
Обработчики:
- Создание кадровых событий (EmployeeAction)
-- Создание главных чатов отделов
- Автоматизация из заявок (requests_app)
"""
import logging
from datetime import datetime
-from django.contrib.contenttypes.models import ContentType
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
from django.utils import timezone
from employees.constants import ACTION_HIRED
-from employees.models import Employee, EmployeeAction, Department
+from employees.models import Employee, EmployeeAction
logger = logging.getLogger(__name__)
@@ -52,43 +50,6 @@ def create_hired_action(sender, instance: Employee, created, **kwargs):
)
-@receiver(post_save, sender=Department)
-def create_main_department_chat(sender, instance, created, **kwargs):
- """
- Создает главный чат для нового отдела (EUSRR-specific logic).
-
- Использует GenericFK (context_object) и flags['is_primary'].
- """
- if not created:
- return
-
- try:
- from communications.models import Chat
-
- dept_ct = ContentType.objects.get_for_model(Department)
-
- # Проверка: существует ли уже чат для этого отдела
- existing = Chat.objects.filter(
- type="channel",
- context_content_type=dept_ct,
- context_object_id=instance.id,
- flags__is_primary=True
- ).exists()
-
- if not existing:
- Chat.objects.create(
- type="channel",
- context_content_type=dept_ct,
- context_object_id=instance.id,
- flags={'is_primary': True},
- is_main=True,
- name=f"Основной чат {instance.name}"
- )
- except ImportError:
- # communications app не установлен - пропускаем
- pass
-
-
# ============================================================
# Обработчики заявок (requests_app)
# ============================================================
diff --git a/backend/employees/utils.py b/backend/employees/utils.py
index 8e209847..34225fd0 100644
--- a/backend/employees/utils.py
+++ b/backend/employees/utils.py
@@ -341,6 +341,12 @@ def resolve_chat_participants_for_department(context_object, **kwargs):
return Employee.objects.none()
department = context_object
+ chat = kwargs.get("chat")
+
+ # Основной discussion/comments chat отдела доступен всем активным
+ # сотрудникам сайта, а не только физическому составу отдела.
+ if getattr(chat, "type", None) == "comments":
+ return Employee.objects.filter(is_active=True)
# Получаем активных сотрудников отдела
employee_ids = EmployeeDepartment.objects.filter(
diff --git a/backend/feed/admin.py b/backend/feed/admin.py
index 9b9ba4e4..0e825a7f 100644
--- a/backend/feed/admin.py
+++ b/backend/feed/admin.py
@@ -10,7 +10,8 @@ class PostAdmin(admin.ModelAdmin):
"author",
"department",
"created_at",
- "pinned",
+ "pinned_global",
+ "pinned_department",
]
- list_filter = ["type", "department", "pinned"]
+ list_filter = ["type", "department", "pinned_global", "pinned_department"]
search_fields = ["title", "body", "author__last_name"]
diff --git a/backend/feed/migrations/0008_alter_post_body.py b/backend/feed/migrations/0008_alter_post_body.py
new file mode 100644
index 00000000..ee5187ee
--- /dev/null
+++ b/backend/feed/migrations/0008_alter_post_body.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.4 on 2026-04-14 07:54
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('feed', '0007_delete_comment'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='post',
+ name='body',
+ field=models.TextField(blank=True, verbose_name='Текст'),
+ ),
+ ]
diff --git a/backend/feed/migrations/0009_split_post_pins_by_scope.py b/backend/feed/migrations/0009_split_post_pins_by_scope.py
new file mode 100644
index 00000000..f6457f0a
--- /dev/null
+++ b/backend/feed/migrations/0009_split_post_pins_by_scope.py
@@ -0,0 +1,62 @@
+from django.db import migrations, models
+
+
+def copy_legacy_pin_to_global(apps, schema_editor):
+ Post = apps.get_model("feed", "Post")
+ Post.objects.filter(pinned=True).update(pinned_global=True)
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("feed", "0008_alter_post_body"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="post",
+ name="pinned_global",
+ field=models.BooleanField(
+ default=False,
+ verbose_name="Закреплено в общей ленте",
+ ),
+ ),
+ migrations.AddField(
+ model_name="post",
+ name="pinned_department",
+ field=models.BooleanField(
+ default=False,
+ verbose_name="Закреплено в ленте отдела",
+ ),
+ ),
+ migrations.RunPython(
+ copy_legacy_pin_to_global,
+ migrations.RunPython.noop,
+ ),
+ migrations.AlterModelOptions(
+ name="post",
+ options={
+ "ordering": ["-pinned_global", "-created_at"],
+ "verbose_name": "Публикация",
+ "verbose_name_plural": "Публикации",
+ },
+ ),
+ migrations.RemoveIndex(
+ model_name="post",
+ name="feed_post_pinned_e86c9a_idx",
+ ),
+ migrations.AddIndex(
+ model_name="post",
+ index=models.Index(
+ fields=["pinned_global", "created_at"],
+ name="feed_post_pin_global_idx",
+ ),
+ ),
+ migrations.AddIndex(
+ model_name="post",
+ index=models.Index(
+ fields=["pinned_department", "created_at"],
+ name="feed_post_pin_department_idx",
+ ),
+ ),
+ ]
diff --git a/backend/feed/models.py b/backend/feed/models.py
index 3233a5d2..08d457bb 100644
--- a/backend/feed/models.py
+++ b/backend/feed/models.py
@@ -82,9 +82,21 @@ class Post(models.Model):
),
)
title = models.CharField("Заголовок", max_length=200)
- body = models.TextField("Текст")
+ body = models.TextField("Текст", blank=True)
created_at = models.DateTimeField("Создано", auto_now_add=True)
- pinned = models.BooleanField("Закреплено", default=False)
+ pinned = models.BooleanField(
+ "Закреплено (legacy)",
+ default=False,
+ editable=False,
+ )
+ pinned_global = models.BooleanField(
+ "Закреплено в общей ленте",
+ default=False,
+ )
+ pinned_department = models.BooleanField(
+ "Закреплено в ленте отдела",
+ default=False,
+ )
type = models.CharField(
"Тип публикации", max_length=20, choices=TYPE_CHOICES
)
@@ -105,10 +117,17 @@ class Post(models.Model):
class Meta:
verbose_name = "Публикация"
verbose_name_plural = "Публикации"
- ordering = ["-pinned", "-created_at"]
+ ordering = ["-pinned_global", "-created_at"]
indexes = [
models.Index(fields=["type", "created_at"]),
- models.Index(fields=["pinned", "created_at"]),
+ models.Index(
+ fields=["pinned_global", "created_at"],
+ name="feed_post_pin_global_idx",
+ ),
+ models.Index(
+ fields=["pinned_department", "created_at"],
+ name="feed_post_pin_department_idx",
+ ),
models.Index(fields=["created_at"]),
models.Index(fields=["department", "-created_at"]),
models.Index(fields=["author", "-created_at"]),
diff --git a/backend/feed/notifications/handlers.py b/backend/feed/notifications/handlers.py
index c76f6a65..f1cbf2c3 100644
--- a/backend/feed/notifications/handlers.py
+++ b/backend/feed/notifications/handlers.py
@@ -20,6 +20,32 @@ from .config import (
Employee = get_user_model()
+def _department_post_recipients(department):
+ """Получатели новостей отдела: физический состав + role-only сотрудники."""
+ if department is None:
+ return []
+
+ recipients: dict[int, Employee] = {}
+
+ for employee in department.active_employees:
+ if getattr(employee, "is_active", False):
+ recipients[employee.id] = employee
+
+ role_only_qs = (
+ Employee.objects.filter(
+ is_active=True,
+ role_assignments__role__department=department,
+ role_assignments__is_active=True,
+ )
+ .distinct()
+ .order_by("id")
+ )
+ for employee in role_only_qs:
+ recipients[employee.id] = employee
+
+ return list(recipients.values())
+
+
def notify_new_post(post):
"""
Отправляет уведомления о новой публикации.
@@ -114,8 +140,8 @@ def get_post_recipients(post):
return list(Employee.objects.filter(is_active=True))
elif post.type == TYPE_DEPARTMENT and post.department:
- # Новость отдела - сотрудникам отдела
- return list(post.department.employees.filter(is_active=True))
+ # Новость отдела — физическому составу + role-only участникам
+ return _department_post_recipients(post.department)
elif post.type == TYPE_EMPLOYEE:
# Личная публикация - подписчикам
diff --git a/backend/tests/api/v1/employees/test_department_discussion.py b/backend/tests/api/v1/employees/test_department_discussion.py
new file mode 100644
index 00000000..7ffa37aa
--- /dev/null
+++ b/backend/tests/api/v1/employees/test_department_discussion.py
@@ -0,0 +1,263 @@
+import itertools
+
+import pytest
+from communications.comments_helpers import create_comment
+from communications.models import Chat, Message
+from django.contrib.auth import get_user_model
+from django.contrib.contenttypes.models import ContentType
+from django.urls import reverse
+from employees.models import (
+ Department,
+ DepartmentRole,
+ EmployeeDepartment,
+ RoleAssignment,
+)
+from rest_framework import status
+from rest_framework.test import APIClient
+
+pytestmark = pytest.mark.django_db
+
+User = get_user_model()
+
+_phone_seq = itertools.count(3000)
+
+
+def _unique_phone() -> str:
+ return f"+7999555{next(_phone_seq):04d}"
+
+
+@pytest.fixture
+def api_client():
+ return APIClient()
+
+
+def make_user(
+ email: str,
+ *,
+ first_name: str = "FN",
+ last_name: str = "LN",
+ active: bool = True,
+):
+ user = User.objects.create(
+ email=email,
+ phone_number=_unique_phone(),
+ first_name=first_name,
+ last_name=last_name,
+ is_active=active,
+ email_verified=True,
+ )
+ user.set_password("pass")
+ user.save()
+ return user
+
+
+def discussion_chat_url(department_id: int) -> str:
+ base = reverse("api:v1:departments-detail", args=[department_id])
+ return f"{base}discussion-chat/"
+
+
+def test_department_creation_does_not_create_auto_channel_chat():
+ department = Department.objects.create(name="Finance")
+ dept_ct = ContentType.objects.get_for_model(Department)
+
+ assert not Chat.objects.filter(
+ type="channel",
+ context_content_type=dept_ct,
+ context_object_id=department.id,
+ ).exists()
+
+
+def test_department_discussion_chat_endpoint_returns_comments_chat(
+ api_client: APIClient,
+):
+ user = make_user("user@example.com")
+ department = Department.objects.create(name="Support")
+ api_client.force_authenticate(user=user)
+
+ response = api_client.get(discussion_chat_url(department.id))
+
+ assert response.status_code == status.HTTP_200_OK
+ payload = response.json()
+ assert payload["chat_type"] == "comments"
+ assert payload["name"] == department.name
+
+ chat = Chat.objects.get(id=payload["chat_id"])
+ assert chat.type == "comments"
+ assert chat.context_object == department
+ assert chat.created_by is None
+ assert chat.flags.get("show_in_messages") is True
+
+
+def test_department_discussion_chat_endpoint_clears_legacy_owner(
+ api_client: APIClient,
+):
+ user = make_user("user@example.com")
+ department = Department.objects.create(name="Support")
+ api_client.force_authenticate(user=user)
+
+ chat = Chat.objects.create(
+ type="comments",
+ name=f"Комментарии: {department.name}",
+ created_by=user,
+ context_content_type=ContentType.objects.get_for_model(Department),
+ context_object_id=department.id,
+ )
+
+ response = api_client.get(discussion_chat_url(department.id))
+
+ assert response.status_code == status.HTTP_200_OK
+ chat.refresh_from_db()
+ assert chat.created_by is None
+ assert chat.name == department.name
+ assert chat.flags.get("show_in_messages") is True
+
+
+def test_any_active_user_can_retrieve_department_comments_chat(
+ api_client: APIClient,
+):
+ department = Department.objects.create(name="HR")
+ member = make_user("member@example.com")
+ outsider = make_user("outsider@example.com")
+ EmployeeDepartment.objects.create(
+ employee=member,
+ department=department,
+ is_active=True,
+ )
+
+ chat = create_comment(
+ department,
+ author=member,
+ content="Первое сообщение",
+ ).chat
+
+ api_client.force_authenticate(user=outsider)
+ response = api_client.get(f"/api/v1/communications/chats/{chat.id}/")
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["id"] == chat.id
+ assert response.data["type"] == "comments"
+
+
+def test_any_active_user_can_send_message_to_department_comments_chat(
+ api_client: APIClient,
+):
+ department = Department.objects.create(name="Accounting")
+ member = make_user("member@example.com")
+ outsider = make_user("outsider@example.com")
+ EmployeeDepartment.objects.create(
+ employee=member,
+ department=department,
+ is_active=True,
+ )
+
+ chat = create_comment(
+ department,
+ author=member,
+ content="Старт обсуждения",
+ ).chat
+
+ api_client.force_authenticate(user=outsider)
+ bootstrap_response = api_client.get(discussion_chat_url(department.id))
+ assert bootstrap_response.status_code == status.HTTP_200_OK
+ chat.refresh_from_db()
+ assert chat.created_by is None
+ assert chat.name == department.name
+
+ response = api_client.post(
+ "/api/v1/communications/messages/upload/",
+ {"chat_id": chat.id, "content": "Пишу в чат отдела"},
+ format="json",
+ )
+
+ assert response.status_code == status.HTTP_201_CREATED
+ assert Message.objects.filter(
+ chat=chat,
+ author=outsider,
+ content="Пишу в чат отдела",
+ ).exists()
+ chat.refresh_from_db()
+ assert chat.created_by is None
+ assert chat.name == department.name
+ assert chat.flags.get("show_in_messages") is True
+
+
+def test_department_comment_notifications_keep_chat_routing_and_include_role_only(
+ monkeypatch,
+):
+ department = Department.objects.create(name="Backoffice")
+ member = make_user("member@example.com")
+ role_only = make_user("role-only@example.com")
+ previous_commenter = make_user("previous@example.com")
+
+ EmployeeDepartment.objects.create(
+ employee=member,
+ department=department,
+ is_active=True,
+ )
+ dept_role = DepartmentRole.objects.create(
+ department=department,
+ name="Consultant",
+ )
+ RoleAssignment.objects.create(
+ employee=role_only,
+ role=dept_role,
+ is_active=True,
+ )
+
+ create_comment(
+ department,
+ author=previous_commenter,
+ content="Первый комментарий",
+ )
+
+ sent_notifications = []
+
+ def capture_notification(**kwargs):
+ sent_notifications.append(kwargs)
+
+ monkeypatch.setattr(
+ "communications.notifications.handlers._send_notification",
+ capture_notification,
+ )
+
+ message = create_comment(
+ department,
+ author=member,
+ content="Новый комментарий",
+ )
+
+ recipient_ids = {payload["recipient"].id for payload in sent_notifications}
+
+ assert role_only.id in recipient_ids
+ assert previous_commenter.id in recipient_ids
+ assert member.id not in recipient_ids
+ assert sent_notifications
+ for payload in sent_notifications:
+ assert payload["action_url"] == f"/messages/{message.chat_id}"
+ assert payload["data"]["chat_id"] == message.chat_id
+
+
+def test_department_chat_visible_in_messages_list_but_generic_comments_hidden(
+ api_client: APIClient,
+):
+ user = make_user("user@example.com")
+ department = Department.objects.create(name="Finance")
+ api_client.force_authenticate(user=user)
+
+ visible_chat_response = api_client.get(discussion_chat_url(department.id))
+ assert visible_chat_response.status_code == status.HTTP_200_OK
+ visible_chat_id = visible_chat_response.json()["chat_id"]
+
+ hidden_comments_chat = Chat.objects.create(
+ type="comments",
+ name="Скрытый object chat",
+ )
+
+ response = api_client.get("/api/v1/communications/chats/")
+
+ assert response.status_code == status.HTTP_200_OK
+ payload = response.data
+ items = payload["results"] if isinstance(payload, dict) and "results" in payload else payload
+ returned_ids = {item["id"] for item in items}
+ assert visible_chat_id in returned_ids
+ assert hidden_comments_chat.id not in returned_ids
diff --git a/backend/tests/api/v1/employees/test_department_user_perms.py b/backend/tests/api/v1/employees/test_department_user_perms.py
new file mode 100644
index 00000000..d4a0306b
--- /dev/null
+++ b/backend/tests/api/v1/employees/test_department_user_perms.py
@@ -0,0 +1,80 @@
+import pytest
+from django.contrib.auth import get_user_model
+from django.urls import reverse
+from rest_framework import status
+from rest_framework.test import APIClient
+
+from employees.constants import DeptPerm
+from employees.models import (
+ Department,
+ DepartmentPermission,
+ DepartmentRole,
+ RoleAssignment,
+)
+from tests.conftest import _unique_phone
+from tests.test_config import DEFAULT_PASSWORD
+
+User = get_user_model()
+
+
+def _unique_email() -> str:
+ return f"user{User.objects.count()}@example.com"
+
+
+def make_user(*, staff: bool = False, superuser: bool = False) -> User:
+ user = User.objects.create_user(
+ email=_unique_email(),
+ password=DEFAULT_PASSWORD,
+ phone_number=_unique_phone(),
+ send_activation_email=False,
+ first_name="Dept",
+ last_name="User",
+ )
+ user.is_staff = staff
+ user.is_superuser = superuser
+ user.email_verified = True
+ user.is_active = True
+ user.save(
+ update_fields=["is_staff", "is_superuser", "email_verified", "is_active"]
+ )
+ return user
+
+
+@pytest.fixture()
+def api_client() -> APIClient:
+ return APIClient()
+
+
+@pytest.mark.django_db
+def test_department_user_perms_includes_feed_flags_for_head(api_client: APIClient):
+ head = make_user()
+ dept = Department.objects.create(name="Бухгалтерия", head=head)
+
+ api_client.force_authenticate(user=head)
+ url = reverse("api:v1:departments-user-perms", args=[dept.id])
+ resp = api_client.get(url)
+
+ assert resp.status_code == status.HTTP_200_OK
+ assert resp.json()["can_publish_posts"] is True
+ assert resp.json()["can_manage_feed"] is True
+
+
+@pytest.mark.django_db
+def test_department_user_perms_resolves_role_based_feed_rights(api_client: APIClient):
+ employee = make_user()
+ dept = Department.objects.create(name="Закупки")
+ role = DepartmentRole.objects.create(department=dept, name="Редактор ленты")
+ publish_perm, _ = DepartmentPermission.objects.get_or_create(
+ code=DeptPerm.CREATE_POST,
+ defaults={"name": "Публиковать новости на странице отдела"},
+ )
+ role.scoped_permissions.add(publish_perm)
+ RoleAssignment.objects.create(employee=employee, role=role, is_active=True)
+
+ api_client.force_authenticate(user=employee)
+ url = reverse("api:v1:departments-user-perms", args=[dept.id])
+ resp = api_client.get(url)
+
+ assert resp.status_code == status.HTTP_200_OK
+ assert resp.json()["can_publish_posts"] is True
+ assert resp.json()["can_manage_feed"] is False
diff --git a/backend/tests/api/v1/feed/test_posts.py b/backend/tests/api/v1/feed/test_posts.py
new file mode 100644
index 00000000..5a6611c2
--- /dev/null
+++ b/backend/tests/api/v1/feed/test_posts.py
@@ -0,0 +1,340 @@
+from __future__ import annotations
+
+import pytest
+from django.core.files.uploadedfile import SimpleUploadedFile
+from employees.constants import DeptPerm
+from employees.models import (
+ DepartmentPermission,
+ DepartmentRole,
+ EmployeeDepartment,
+ RoleAssignment,
+)
+from feed.models import Post
+from rest_framework import status
+
+from tests.test_config import API_POSTS_URL
+
+pytestmark = pytest.mark.django_db
+
+
+def _department_post_payload(department_id: int, **extra):
+ payload = {
+ "type": "department",
+ "department": department_id,
+ "title": "Новость отдела",
+ "body": "Важное объявление",
+ }
+ payload.update(extra)
+ return payload
+
+
+def _grant_department_post_role(user, department):
+ permission, _ = DepartmentPermission.objects.get_or_create(
+ code=DeptPerm.CREATE_POST,
+ defaults={"name": "Публиковать новости на странице отдела"},
+ )
+ role = DepartmentRole.objects.create(
+ department=department,
+ name=f"Publisher {user.id}",
+ )
+ role.scoped_permissions.add(permission)
+ RoleAssignment.objects.create(
+ employee=user,
+ role=role,
+ is_active=True,
+ )
+ return role
+
+
+def test_create_department_post_notifies_active_members_and_role_only(
+ monkeypatch,
+ auth_client_factory,
+ make_user,
+ department_factory,
+):
+ author = make_user("author@example.com")
+ department = department_factory(name="Finance", head=author)
+ member = make_user("member@example.com")
+ role_only = make_user("roleonly@example.com")
+ inactive_member = make_user("inactive@example.com", active=False)
+
+ EmployeeDepartment.objects.create(
+ employee=member,
+ department=department,
+ is_active=True,
+ )
+ EmployeeDepartment.objects.create(
+ employee=inactive_member,
+ department=department,
+ is_active=True,
+ )
+
+ role = DepartmentRole.objects.create(
+ department=department,
+ name="Observer",
+ )
+ RoleAssignment.objects.create(
+ employee=role_only,
+ role=role,
+ is_active=True,
+ )
+
+ captured = []
+
+ def _capture_notify(*args, **kwargs):
+ captured.append(kwargs)
+
+ monkeypatch.setattr("feed.notifications.handlers.notify.send", _capture_notify)
+
+ client = auth_client_factory(author)
+ response = client.post(
+ API_POSTS_URL,
+ _department_post_payload(department.id),
+ format="json",
+ )
+
+ assert response.status_code == status.HTTP_201_CREATED
+ post = Post.objects.get(id=response.data["id"])
+ assert post.department_id == department.id
+
+ recipient_ids = {payload["recipient"].id for payload in captured}
+ assert member.id in recipient_ids
+ assert role_only.id in recipient_ids
+ assert inactive_member.id not in recipient_ids
+ assert author.id not in recipient_ids
+
+
+def test_create_department_post_with_attachment_and_empty_body(
+ auth_client_factory,
+ make_user,
+ department_factory,
+):
+ author = make_user("head@example.com")
+ department = department_factory(name="HR", head=author)
+ attachment = SimpleUploadedFile(
+ "memo.txt",
+ b"department announcement",
+ content_type="text/plain",
+ )
+
+ client = auth_client_factory(author)
+ response = client.post(
+ API_POSTS_URL,
+ _department_post_payload(
+ department.id,
+ body="",
+ attachment=attachment,
+ ),
+ format="multipart",
+ )
+
+ assert response.status_code == status.HTTP_201_CREATED
+ post = Post.objects.get(id=response.data["id"])
+ assert post.attachment.name
+ assert post.body == ""
+
+
+def test_create_department_post_requires_department_not_403(
+ auth_client_factory,
+ make_user,
+):
+ author = make_user("author@example.com", staff=True)
+ client = auth_client_factory(author)
+
+ response = client.post(
+ API_POSTS_URL,
+ {
+ "type": "department",
+ "title": "Без отдела",
+ "body": "Тело",
+ },
+ format="json",
+ )
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert "department" in response.data
+
+
+def test_department_post_requires_publish_permission(
+ auth_client_factory,
+ make_user,
+ department_factory,
+):
+ user = make_user("plain@example.com")
+ department = department_factory(name="Legal")
+ client = auth_client_factory(user)
+
+ response = client.post(
+ API_POSTS_URL,
+ _department_post_payload(department.id),
+ format="json",
+ )
+
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+
+def test_role_assignment_can_create_department_post(
+ auth_client_factory,
+ make_user,
+ department_factory,
+):
+ user = make_user("publisher@example.com")
+ department = department_factory(name="Accounting")
+ _grant_department_post_role(user, department)
+
+ client = auth_client_factory(user)
+ response = client.post(
+ API_POSTS_URL,
+ _department_post_payload(department.id),
+ format="json",
+ )
+
+ assert response.status_code == status.HTTP_201_CREATED
+ assert Post.objects.filter(id=response.data["id"], department=department).exists()
+
+
+def test_list_department_posts_filtered_by_department(
+ auth_client_factory,
+ make_user,
+ department_factory,
+):
+ viewer = make_user("viewer@example.com")
+ department_a = department_factory(name="Dept A")
+ department_b = department_factory(name="Dept B")
+
+ Post.objects.create(
+ author=viewer,
+ department=department_a,
+ type="department",
+ title="A1",
+ body="one",
+ )
+ Post.objects.create(
+ author=viewer,
+ department=department_b,
+ type="department",
+ title="B1",
+ body="two",
+ )
+
+ client = auth_client_factory(viewer)
+ response = client.get(API_POSTS_URL, {"type": "department", "department": department_a.id})
+
+ assert response.status_code == status.HTTP_200_OK
+ payload = response.data["results"] if isinstance(response.data, dict) else response.data
+ assert len(payload) == 1
+ assert payload[0]["department_id"] == department_a.id
+
+
+def test_department_pin_scope_is_separate_from_global_feed(
+ auth_client_factory,
+ make_user,
+ department_factory,
+):
+ moderator = make_user("moderator@example.com", staff=True)
+ department = department_factory(name="Finance")
+ post = Post.objects.create(
+ author=moderator,
+ department=department,
+ type="department",
+ title="Scoped pin",
+ body="body",
+ )
+
+ client = auth_client_factory(moderator)
+ response = client.post(f"{API_POSTS_URL}{post.id}/pin/?scope=department")
+
+ assert response.status_code == status.HTTP_200_OK
+ post.refresh_from_db()
+ assert post.pinned_department is True
+ assert post.pinned_global is False
+
+ global_list = client.get(API_POSTS_URL)
+ global_payload = (
+ global_list.data["results"]
+ if isinstance(global_list.data, dict)
+ else global_list.data
+ )
+ scoped_global = next(item for item in global_payload if item["id"] == post.id)
+ assert scoped_global["pinned"] is False
+ assert scoped_global["pinned_global"] is False
+ assert scoped_global["pinned_department"] is True
+
+ department_list = client.get(
+ API_POSTS_URL,
+ {
+ "type": "department",
+ "department": department.id,
+ "pin_scope": "department",
+ },
+ )
+ department_payload = (
+ department_list.data["results"]
+ if isinstance(department_list.data, dict)
+ else department_list.data
+ )
+ scoped_department = next(
+ item for item in department_payload if item["id"] == post.id
+ )
+ assert scoped_department["pinned"] is True
+
+
+def test_global_pin_scope_is_separate_from_department_feed(
+ auth_client_factory,
+ make_user,
+ department_factory,
+):
+ moderator = make_user("chief@example.com", staff=True)
+ department = department_factory(name="HR")
+ post = Post.objects.create(
+ author=moderator,
+ department=department,
+ type="department",
+ title="Global scoped pin",
+ body="body",
+ )
+
+ client = auth_client_factory(moderator)
+ response = client.post(f"{API_POSTS_URL}{post.id}/pin/?scope=global")
+
+ assert response.status_code == status.HTTP_200_OK
+ post.refresh_from_db()
+ assert post.pinned_global is True
+ assert post.pinned_department is False
+
+ department_list = client.get(
+ API_POSTS_URL,
+ {
+ "type": "department",
+ "department": department.id,
+ "pin_scope": "department",
+ },
+ )
+ department_payload = (
+ department_list.data["results"]
+ if isinstance(department_list.data, dict)
+ else department_list.data
+ )
+ scoped_department = next(
+ item for item in department_payload if item["id"] == post.id
+ )
+ assert scoped_department["pinned"] is False
+
+
+def test_department_pin_scope_rejected_for_non_department_post(
+ auth_client_factory,
+ make_user,
+):
+ moderator = make_user("staff@example.com", staff=True)
+ post = Post.objects.create(
+ author=moderator,
+ type="company",
+ title="Company post",
+ body="body",
+ )
+
+ client = auth_client_factory(moderator)
+ response = client.post(f"{API_POSTS_URL}{post.id}/pin/?scope=department")
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert "detail" in response.data
diff --git a/backend/tests/realtime/test_integration.py b/backend/tests/realtime/test_integration.py
index b43c72f4..27ae9404 100644
--- a/backend/tests/realtime/test_integration.py
+++ b/backend/tests/realtime/test_integration.py
@@ -6,8 +6,11 @@
import pytest
from channels.layers import get_channel_layer
from channels.testing import WebsocketCommunicator
+from channels.db import database_sync_to_async
+from django.contrib.contenttypes.models import ContentType
from communications.models import Chat, ChatMembership, Message
+from employees.models import Department
pytestmark = pytest.mark.django_db(transaction=True)
@@ -134,8 +137,53 @@ class TestRealtimeIntegration:
# Должны получить обновление списка (list_update)
update = await communicator.receive_json_from(timeout=5)
-
+
assert update["type"] == "list_update"
assert update["chat_id"] == test_chat.id
await communicator.disconnect()
+
+ async def test_visible_department_comments_chat_updates_list_for_active_user(
+ self, ws_communicator, user
+ ):
+ """
+ Видимый comments-chat отдела должен попадать в realtime-подписки
+ и присылать list_update для страницы списка чатов.
+ """
+ department = await database_sync_to_async(Department.objects.create)(
+ name="Finance",
+ )
+ department_ct = await database_sync_to_async(ContentType.objects.get_for_model)(Department)
+ comments_chat = await database_sync_to_async(Chat.objects.create)(
+ type="comments",
+ name=department.name,
+ flags={"show_in_messages": True},
+ context_content_type=department_ct,
+ context_object_id=department.id,
+ )
+
+ communicator = await ws_communicator(user=user)
+
+ connected, _ = await communicator.connect()
+ assert connected
+
+ channel_layer = get_channel_layer()
+ await channel_layer.group_send(
+ f"chat_{comments_chat.id}",
+ {
+ "type": "chat_message",
+ "chat_id": comments_chat.id,
+ "payload": {
+ "id": 1,
+ "content": "Department chat message",
+ "author_id": user.id,
+ },
+ },
+ )
+
+ update = await communicator.receive_json_from(timeout=5)
+
+ assert update["type"] == "list_update"
+ assert update["chat_id"] == comments_chat.id
+
+ await communicator.disconnect()
diff --git a/docs/guides/frontend/06-COMPONENT-INVENTORY.md b/docs/guides/frontend/06-COMPONENT-INVENTORY.md
index cee778bd..43838df5 100644
--- a/docs/guides/frontend/06-COMPONENT-INVENTORY.md
+++ b/docs/guides/frontend/06-COMPONENT-INVENTORY.md
@@ -233,13 +233,14 @@
- detail screen, который должен уметь переключаться между `showcase` и `management` без смены route и без ощущения двух разных страниц
- page-level controller для domain actions вокруг одного объекта: overview, members и inline management actions
+- entry-point с detail page в уже существующий object-chat, когда объекту нужен access к реальной message history без локального duplicate UI
- richer detail layout, где есть сильный header и один основной roster/workspace-блок, без раздувания служебных summary-panels и вторичных management-panels
- member roster с role assignment и person-chip подачей вместо сырых строк участников
- role management на уровне названий ролей и назначений участникам, встроенный в roster, без фронтового permission-editor
С оговоркой:
-- `departments/[id]` должен оставаться привязанным к реальным backend-capabilities; не наращивать UI, если под него нет соответствующих endpoint/actions
+- `departments/[id]` должен оставаться привязанным к реальным backend-capabilities; если у отдела уже есть живой object-chat, не дублировать его локальной discussion-панелью на странице
### Messages
diff --git a/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md b/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md
index 4a72b785..20267ae4 100644
--- a/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md
+++ b/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md
@@ -34,7 +34,7 @@
| `users/directory` | `mixed` | `users/page.tsx` и `employees/page.tsx` уже живут в app-surface language, сценарии user/employee navigation выровнены и читаются как один cluster | список людей всё ещё проще и беднее по interaction density, чем основные рабочие модули, поэтому это не полноценный stylistic reference | считать baseline-pass завершённым и трогать дальше только при появлении richer directory requirements |
| `home/feed` | `mixed` | сильный app-shell fit, хорошая operational density, уже много app primitives | page очень большой, комментарии и post-compose logic перегружены, встречаются legacy `rounded-md` fragments | использовать как reference по feed density, но разрезать modal/comments/post flows и дочистить geometry |
| `notifications` | `mixed` | близок к operational app-style, фильтры и list hierarchy уже читаются системно | page-level logic пока монолитная, нет выделенного feature controller, есть место для уплотнения и симметрии с messages/requests | после people cluster сделать короткий structural pass без крупного редизайна |
-| `departments` | `mixed` | detail screen уже работает как dual-mode page: showcase-first для обычного просмотра и встроенный inline management-layer для управленцев, page-level controller вынесен в hook | список отделов всё ещё проще detail screen, а сам модуль ещё не проверен длинным visual-pass и живым backend-сценарием | считать `departments/[id]` baseline reference для object-detail pages с mixed audience и затем при желании подтянуть list screen до той же richness |
+| `departments` | `mixed` | detail screen уже работает как dual-mode page: showcase-first для обычного просмотра и встроенный inline management-layer для управленцев, page-level controller вынесен в hook, вход в discussion идёт через реальный object-chat отдела | список отделов всё ещё проще detail screen, а сам модуль ещё не проверен длинным visual-pass и живым backend-сценарием | считать `departments/[id]` baseline reference для object-detail pages с mixed audience и object-chat entry points; list screen при желании подтянуть до той же richness |
| `auth/legal` | `mixed` | изолированные flows уже отделены от app-shell, общая подача аккуратная | `rounded-[2rem]` и локальная hero-card геометрия образуют мини-систему отдельно от app shell | не смешивать с operational refactor; сделать отдельный compact pass по auth/legal geometry |
| `documents/dashboard` | `legacy-heavy` | есть готовые data blocks | заметны старые rounded patterns и мелкие utility-решения | включить в documents refactor pass |
diff --git a/frontend/src/app/departments/[id]/page.tsx b/frontend/src/app/departments/[id]/page.tsx
index 2ad882fc..e37aedbf 100644
--- a/frontend/src/app/departments/[id]/page.tsx
+++ b/frontend/src/app/departments/[id]/page.tsx
@@ -6,12 +6,12 @@ import { useEffect, useMemo, useRef, useState, type ReactNode, type RefObject }
import {
ArrowLeft,
BadgePlus,
- Building2,
Check,
ChevronDown,
ChevronRight,
Crown,
Link2,
+ MessageCircle,
PencilLine,
Plus,
Search,
@@ -22,6 +22,7 @@ import {
} from "lucide-react";
import { AppShell } from "@/components/AppShell";
+import { DepartmentFeedSection } from "@/components/departments/DepartmentFeedSection";
import {
DEPARTMENT_MEMBERS_EMPTY_STATE_CLASSNAME,
getDepartmentMembersListClassName,
@@ -38,8 +39,10 @@ import { RequestAvatar } from "@/components/requests/RequestAvatar";
import { SearchableSelectSingle } from "@/components/shared/SearchableSelect";
import { Modal } from "@/components/ui/Modal";
import { useDepartmentPage } from "@/hooks/useDepartmentPage";
+import { apiClient } from "@/lib/api";
import { displayUserName, userProfileLink } from "@/lib/shared";
import type { DepartmentMemberLink, User } from "@/types/api";
+import { toast } from "sonner";
type DepartmentMemberModalMode = "add" | "assignRole";
@@ -409,7 +412,7 @@ function DepartmentMembersEmptyState() {
Участники не найдены
- Измени поисковый запрос.
+ В этом отделе пока нет состава.
);
@@ -734,10 +737,12 @@ export default function DepartmentDetailPage() {
const departmentId = Number(params?.id);
const h = useDepartmentPage(departmentId);
const [managementMode, setManagementMode] = useState(false);
+ const [membersExpanded, setMembersExpanded] = useState(false);
const [roleMenuOpenForId, setRoleMenuOpenForId] = useState(null);
const roleMenuRef = useRef(null);
const [departmentMenuOpen, setDepartmentMenuOpen] = useState(false);
const departmentMenuRef = useRef(null);
+ const [openingDepartmentChat, setOpeningDepartmentChat] = useState(false);
const activeMembersCount = h.members.filter((member) => member.is_active).length;
const roleOptions = h.roles.map((role) => ({ id: role.id, name: role.name }));
@@ -748,6 +753,12 @@ export default function DepartmentDetailPage() {
const isManagementMode = canUseManagementMode && managementMode;
+ useEffect(() => {
+ if (isManagementMode) {
+ setMembersExpanded(true);
+ }
+ }, [isManagementMode]);
+
useEffect(() => {
if (roleMenuOpenForId === null) return;
@@ -794,6 +805,22 @@ export default function DepartmentDetailPage() {
};
}, [departmentMenuOpen]);
+ const openDepartmentChat = async () => {
+ if (!h.department) return;
+
+ try {
+ setOpeningDepartmentChat(true);
+ const response = await apiClient.getDepartmentDiscussionChat(h.department.id) as {
+ chat_id: number;
+ };
+ router.push(`/messages/${response.chat_id}`);
+ } catch {
+ toast.error("Не удалось открыть чат отдела");
+ } finally {
+ setOpeningDepartmentChat(false);
+ }
+ };
+
return (
@@ -817,178 +844,232 @@ export default function DepartmentDetailPage() {
) : h.department ? (
<>
-
-
-
-
-
- Отдел
-
-
-
- {activeMembersCount} участников
-
-
{h.roles.length} ролей
- {isManagementMode ? (
-
Режим управления
- ) : null}
+
+
+
+
Отдел
+
+
+
+ {activeMembersCount} участников
+
+ {h.roles.length} ролей
+ {isManagementMode ? (
+ Режим управления
+ ) : null}
+
+ {(canUseManagementMode || h.userPerms.can_manage) ? (
+
+
+
+ {departmentMenuOpen ? (
+
+ {h.userPerms.can_manage ? (
+
+ ) : null}
+
+ {h.userPerms.can_assign_roles ? (
+
+ ) : null}
+
+ {h.userPerms.can_assign_roles ? (
+
+ ) : null}
+
+ {h.userPerms.can_manage ? (
+
+ ) : null}
+
+ {canUseManagementMode ? (
+
+ ) : null}
+
+ {h.userPerms.can_manage ? (
+ <>
+
+
+ >
+ ) : null}
+
+ ) : null}
+
+ ) : null}
+
+
{h.department.name}
{h.department.description}
-
- {(canUseManagementMode || h.userPerms.can_manage) ? (
-
+
+
+
+
+
+
+
+
+
+ Сотрудники отдела
+
+
+ Состав отдела и назначенные роли.
+
+
+ {h.filteredMembers.length ? (
+
+ ) : null}
+
- {departmentMenuOpen ? (
-
- {h.userPerms.can_manage ? (
-
- ) : null}
-
- {h.userPerms.can_assign_roles ? (
-
- ) : null}
-
- {h.userPerms.can_assign_roles ? (
-
- ) : null}
-
- {h.userPerms.can_manage ? (
-
- ) : null}
-
- {canUseManagementMode ? (
-
- ) : null}
-
- {h.userPerms.can_manage ? (
- <>
-
-
- >
- ) : null}
-
- ) : null}
+ {membersExpanded || !h.filteredMembers.length ? (
+
+ {h.filteredMembers.length ? (
+ h.filteredMembers.map((member) => (
+
+ ))
+ ) : (
+
+ )}
) : null}
-
-
-
- {h.filteredMembers.length ? (
- h.filteredMembers.map((member) => (
-
- ))
- ) : (
-
- )}
-
-
+
+
+
+
>
) : null}
diff --git a/frontend/src/app/messages/page.tsx b/frontend/src/app/messages/page.tsx
index 69bf43e6..e91ff3e5 100644
--- a/frontend/src/app/messages/page.tsx
+++ b/frontend/src/app/messages/page.tsx
@@ -35,6 +35,7 @@ const chatTypeOptions = [
{ key: "all", label: "Все" },
{ key: "global", label: "Глобальный" },
{ key: "channel", label: "Каналы" },
+ { key: "comments", label: "Отделы" },
{ key: "private", label: "Личные" },
{ key: "group", label: "Группы" },
{ key: "announcement", label: "Объявления" },
@@ -278,6 +279,7 @@ export default function MessagesPage() {
all: { total: chats.length, unread: 0 },
global: { total: 0, unread: 0 },
channel: { total: 0, unread: 0 },
+ comments: { total: 0, unread: 0 },
private: { total: 0, unread: 0 },
group: { total: 0, unread: 0 },
announcement: { total: 0, unread: 0 },
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index 47ab5118..fb90463b 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -1,11 +1,14 @@
"use client";
-import { ChevronRight, Heart, ImageIcon, MessageSquare, Paperclip, Pencil, Pin, Plus, Send, Trash2, X } from "lucide-react";
+import { ChevronRight, 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 { apiClient } from "@/lib/api";
import { resolveMediaUrl } from "@/lib/url";
-import { Suspense, useEffect, useRef, useState } from "react";
+import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import type { Comment, Post } from "@/types/api";
import { useUser } from "@/contexts/UserContext";
@@ -49,19 +52,6 @@ function HomePageContent() {
const [commentsOpen, setCommentsOpen] = useState(false);
const [activePost, setActivePost] = useState
(null);
- const [comments, setComments] = useState([]);
- const [commentsLoading, setCommentsLoading] = useState(false);
- const [commentsError, setCommentsError] = useState(null);
- const [newComment, setNewComment] = useState("");
- const [commentSending, setCommentSending] = useState(false);
- const [editingCommentId, setEditingCommentId] = useState(null);
- const [editingCommentText, setEditingCommentText] = useState("");
- const [commentImage, setCommentImage] = useState(null);
- const [commentAttachment, setCommentAttachment] = useState(null);
- const commentImageRef = useRef(null);
- const commentAttachmentRef = useRef(null);
- const [commentActionId, setCommentActionId] = useState(null);
- const commentsBottomRef = useRef(null);
const [createPostOpen, setCreatePostOpen] = useState(false);
const [createType, setCreateType] = useState<"company" | "department">("company");
const [createDepartmentId, setCreateDepartmentId] = useState("");
@@ -113,6 +103,7 @@ function HomePageContent() {
const closeCommentsModal = () => {
setCommentsOpen(false);
+ setActivePost(null);
clearPostParam();
};
@@ -147,7 +138,7 @@ function HomePageContent() {
hasPermission("employees.manage_feed")
);
const canCreatePost = canCreateCompanyPost || canCreateDepartmentPost || ((user?.departments?.length || 0) > 0);
- const userDepartments = user?.departments || [];
+ const userDepartments = useMemo(() => user?.departments || [], [user?.departments]);
const canManageAnyPost = Boolean(
auth?.is_staff ||
auth?.is_superuser ||
@@ -164,12 +155,12 @@ function HomePageContent() {
hasPermission("pin_post")
);
- const sortPostsPinnedFirst = (items: Post[]) => [...items].sort((left, right) => {
+ const sortPostsPinnedFirst = useCallback((items: Post[]) => [...items].sort((left, right) => {
const leftPinned = left.pinned ? 1 : 0;
const rightPinned = right.pinned ? 1 : 0;
if (leftPinned !== rightPinned) return rightPinned - leftPinned;
return new Date(right.created_at).getTime() - new Date(left.created_at).getTime();
- });
+ }), []);
const canEditPost = (post: Post) => {
if (canManageAnyPost) return true;
@@ -190,19 +181,10 @@ function HomePageContent() {
return canDeleteAnyComments;
};
- const scrollCommentsToBottom = (smooth = true) => {
- requestAnimationFrame(() => {
- commentsBottomRef.current?.scrollIntoView({
- behavior: smooth ? "smooth" : "auto",
- block: "end",
- });
- });
- };
-
- const refreshPosts = async () => {
+ const refreshPosts = useCallback(async () => {
const response = await apiClient.getPosts();
setPosts(sortPostsPinnedFirst(response.results));
- };
+ }, [sortPostsPinnedFirst]);
useEffect(() => {
async function loadPosts() {
@@ -216,25 +198,7 @@ function HomePageContent() {
}
}
loadPosts();
- }, []);
-
- useEffect(() => {
- if (!commentsOpen && !createPostOpen) return;
- const onKeyDown = (e: KeyboardEvent) => {
- if (e.key === "Escape") {
- closeCommentsModal();
- }
- };
-
- const previous = document.body.style.overflow;
- document.body.style.overflow = "hidden";
- window.addEventListener("keydown", onKeyDown);
-
- return () => {
- document.body.style.overflow = previous;
- window.removeEventListener("keydown", onKeyDown);
- };
- }, [commentsOpen, createPostOpen]);
+ }, [refreshPosts]);
useEffect(() => {
if (postMenuOpenId === null) return;
@@ -267,11 +231,6 @@ function HomePageContent() {
setCreateDepartmentId(String(userDepartments[0].id));
}, [createType, createDepartmentId, userDepartments]);
- useEffect(() => {
- if (!commentsOpen || commentsLoading) return;
- scrollCommentsToBottom(false);
- }, [commentsOpen, commentsLoading, comments.length]);
-
const handleLikeToggle = async (post: Post) => {
if (likeBusyId === post.id) return;
@@ -327,11 +286,20 @@ function HomePageContent() {
try {
const response = currentlyPinned
- ? await apiClient.unpinPost(post.id)
- : await apiClient.pinPost(post.id);
+ ? await apiClient.unpinPost(post.id, "global")
+ : await apiClient.pinPost(post.id, "global");
setPosts((prev) => sortPostsPinnedFirst(
- prev.map((item) => item.id === post.id ? { ...item, pinned: Boolean(response?.pinned) } : item)
+ prev.map((item) =>
+ item.id === post.id
+ ? {
+ ...item,
+ pinned: Boolean(response?.pinned),
+ pinned_global: Boolean(response?.pinned_global),
+ pinned_department: Boolean(response?.pinned_department),
+ }
+ : item
+ )
));
} catch (err) {
console.error("Ошибка закрепления публикации:", err);
@@ -343,26 +311,6 @@ function HomePageContent() {
}
};
- const loadComments = async (postId: number) => {
- setCommentsLoading(true);
- setCommentsError(null);
- try {
- const response = await apiClient.getComments({ post: postId });
- const items = Array.isArray(response)
- ? response
- : Array.isArray(response?.results)
- ? response.results
- : [];
- setComments(items);
- } catch (err) {
- console.error("Ошибка загрузки комментариев:", err);
- setCommentsError("Не удалось загрузить комментарии");
- setComments([]);
- } finally {
- setCommentsLoading(false);
- }
- };
-
const openLikesPopover = async (postId: number, likesCount: number) => {
setLikesPopoverPostId(postId);
if (!likesCount) {
@@ -391,13 +339,30 @@ function HomePageContent() {
}
};
- const openComments = async (post: Post) => {
+ const openComments = (post: Post) => {
setActivePost(post);
setCommentsOpen(true);
- setNewComment("");
- setEditingCommentId(null);
- setEditingCommentText("");
- await loadComments(post.id);
+ };
+
+ const applyCommentCountDelta = (postId: number, delta: number) => {
+ setPosts((prev) =>
+ prev.map((item) =>
+ item.id === postId
+ ? {
+ ...item,
+ comments_count: Math.max(0, (item.comments_count || 0) + delta),
+ }
+ : item
+ )
+ );
+ setActivePost((prev) =>
+ prev && prev.id === postId
+ ? {
+ ...prev,
+ comments_count: Math.max(0, (prev.comments_count || 0) + delta),
+ }
+ : prev
+ );
};
useEffect(() => {
@@ -407,101 +372,9 @@ function HomePageContent() {
const targetPost = posts.find((post) => post.id === linkedPostId);
if (!targetPost) return;
- void openComments(targetPost);
+ openComments(targetPost);
}, [activePost?.id, commentsOpen, linkedPostId, posts]);
- const handleCreateComment = async () => {
- if (!activePost) return;
- const text = newComment.trim();
- if (!text && !commentImage && !commentAttachment) return;
-
- setCommentSending(true);
- try {
- const created = await apiClient.createComment(
- activePost.id,
- text || " ",
- commentImage || undefined,
- commentAttachment || undefined
- );
- setComments((prev) => [...prev, created]);
- setNewComment("");
- setCommentImage(null);
- setCommentAttachment(null);
- setPosts((prev) =>
- prev.map((p) =>
- p.id === activePost.id
- ? { ...p, comments_count: (p.comments_count || 0) + 1 }
- : p
- )
- );
- setActivePost((prev) =>
- prev ? { ...prev, comments_count: (prev.comments_count || 0) + 1 } : prev
- );
- scrollCommentsToBottom();
- } catch (err) {
- console.error("Ошибка отправки комментария:", err);
- } finally {
- setCommentSending(false);
- }
- };
-
- const startEditComment = (comment: Comment) => {
- setEditingCommentId(comment.id);
- setEditingCommentText((comment.text || comment.content || "").trim());
- };
-
- const cancelEditComment = () => {
- setEditingCommentId(null);
- setEditingCommentText("");
- };
-
- const saveEditComment = async (commentId: number) => {
- const text = editingCommentText.trim();
- if (!text) return;
-
- setCommentActionId(commentId);
- try {
- const updated = await apiClient.updateComment(commentId, text);
- setComments((prev) => prev.map((c) => (c.id === commentId ? { ...c, ...updated } : c)));
- cancelEditComment();
- } catch (err) {
- console.error("Ошибка редактирования комментария:", err);
- } finally {
- setCommentActionId(null);
- }
- };
-
- const removeComment = async (comment: Comment) => {
- if (!activePost) return;
-
- setCommentActionId(comment.id);
- try {
- await apiClient.deleteComment(comment.id);
- setComments((prev) => prev.filter((c) => c.id !== comment.id));
-
- setPosts((prev) =>
- prev.map((p) =>
- p.id === activePost.id
- ? { ...p, comments_count: Math.max(0, (p.comments_count || 0) - 1) }
- : p
- )
- );
- setActivePost((prev) =>
- prev
- ? { ...prev, comments_count: Math.max(0, (prev.comments_count || 0) - 1) }
- : prev
- );
-
- if (editingCommentId === comment.id) {
- cancelEditComment();
- }
- } catch (err) {
- console.error("Ошибка удаления комментария:", err);
- } finally {
- setCommentActionId(null);
- }
- };
-
const openCreatePostModal = () => {
setCreateError(null);
setEditingPostId(null);
@@ -630,15 +503,6 @@ function HomePageContent() {
) : (
posts.map((post) => {
- const authorName = post.author
- ? `${post.author.last_name} ${post.author.first_name}`.trim()
- : 'Аноним';
- const authorInitials = post.author
- ? `${post.author.last_name?.[0] || ''}${post.author.first_name?.[0] || ''}`
- : 'А';
-
- const postText = (post.body || post.content || "").trim();
-
// Форматируем дату
const postDate = new Date(post.created_at);
const now = new Date();
@@ -651,36 +515,12 @@ function HomePageContent() {
: `${Math.floor(diffHours / 24)} дн. назад`;
return (
-
-
-
-
-
- {post.author?.avatar ? (
-
})
- ) : (
- authorInitials
- )}
-
-
-
-
{authorName}
-
-
{timeAgo}
- {post.pinned ? (
-
-
-
- ) : null}
-
-
-
-
- {(canEditPost(post) || canDeletePost(post)) ? (
+ {timeAgo}}
+ headerActions={(
+ (canEditPost(post) || canDeletePost(post)) ? (
- {post.pinned ? "Открепить" : "Закрепить"}
+ {post.pinned ? "Открепить в общей ленте" : "Закрепить в общей ленте"}
) : null}
{canEditPost(post) ? (
@@ -726,7 +566,6 @@ function HomePageContent() {
Редактировать
) : null}
-
{canDeletePost(post) ? (
- ) : null}
-
- {post.title ? (
- {post.title}
- ) : null}
- {postText ? (
- {postText}
- ) : null}
- {post.image && (
-
-
,)
-
+ ) : null
)}
- {(post.attachment || post.attachment_url) && (
-
- )}
-
-
openLikesPopover(post.id, post.likes_count || 0)}
- onMouseLeave={() => setLikesPopoverPostId((prev) => (prev === post.id ? null : prev))}
- >
-
-
- {likesPopoverPostId === post.id ? (
-
-
Лайкнули
- {likesLoadingPostId === post.id ? (
-
Загрузка...
- ) : likesUsersEndpointUnavailable ? (
-
Список лайкнувших временно недоступен
- ) : (likesUsersMap[post.id] || []).length === 0 ? (
-
Пока нет лайков
- ) : (
-
- {(likesUsersMap[post.id] || []).map((u) => {
- const name = formatUserName(u);
- const initials = formatInitials(u.first_name, u.last_name);
-
- return (
-
-
- {u.avatar ? (
-
})
- ) : (
- initials
- )}
-
-
{name}
-
- );
- })}
-
- )}
+ imageSrc={withImageCacheBuster(resolveMediaUrl(post.image), post.id)}
+ isLikeActive={Boolean(post.is_liked)}
+ likeDisabled={likeBusyId === post.id}
+ likesPopover={likesPopoverPostId === post.id ? (
+
+
Лайкнули
+ {likesLoadingPostId === post.id ? (
+
Загрузка...
+ ) : likesUsersEndpointUnavailable ? (
+
Список лайкнувших временно недоступен
+ ) : (likesUsersMap[post.id] || []).length === 0 ? (
+
Пока нет лайков
+ ) : (
+
+ {(likesUsersMap[post.id] || []).map((u) => {
+ const name = formatUserName(u);
+ const initials = formatInitials(u.first_name, u.last_name);
+ return (
+
+ );
+ })}
- ) : null}
+ )}
-
-
-
+ ) : null}
+ likesWrapperProps={{
+ onMouseEnter: () => openLikesPopover(post.id, post.likes_count || 0),
+ onMouseLeave: () => setLikesPopoverPostId((prev) => (prev === post.id ? null : prev)),
+ }}
+ onLikeToggle={handleLikeToggle}
+ onOpenComments={openComments}
+ pinMarkerTitle="Закреплено в общей ленте"
+ pinnedStyle="inline"
+ />
);
})
)}
@@ -954,196 +747,15 @@ function HomePageContent() {
-
- {(commentImage || commentAttachment) && (
-
- {commentImage && (
-
-
- {commentImage.name}
-
-
- )}
- {commentAttachment && (
-
-
-
{commentAttachment.name}
-
-
- )}
-
- )}
-
-
- }
- >
- {activePost && (
- <>
- Публикация #{activePost.id}
-
- {commentsLoading ? (
-
Загрузка комментариев...
- ) : commentsError ? (
-
{commentsError}
- ) : comments.length === 0 ? (
-
Комментариев пока нет
- ) : (
-
- {comments.map((comment) => {
- const commentText = (comment.text || comment.content || "").trim();
- const authorName = comment.author
- ? `${comment.author.last_name || ""} ${comment.author.first_name || ""}`.trim() || "Пользователь"
- : "Пользователь";
- const authorInitials = comment.author
- ? `${comment.author.last_name?.[0] || ""}${comment.author.first_name?.[0] || ""}` || "П"
- : "П";
-
- return (
-
-
-
- {comment.author?.avatar ? (
-
})
- ) : (
- authorInitials
- )}
-
-
-
-
{authorName}
-
- {canEditComment(comment) ? (
-
- ) : null}
-
- {canDeleteComment(comment) ? (
-
- ) : null}
-
-
-
- {editingCommentId === comment.id ? (
-
- ) : (
- <>
- {commentText &&
{commentText}
}
- {comment.image && (
-
-
})
-
- )}
- {comment.attachment && (
-
- )}
- >
- )}
-
-
-
- );
- })}
-
-
- )}
-
- >
- )}
-
+ onCommentCountChange={applyCommentCountDelta}
+ post={activePost}
+ />
);
}
diff --git a/frontend/src/components/departments/DepartmentFeedSection.tsx b/frontend/src/components/departments/DepartmentFeedSection.tsx
new file mode 100644
index 00000000..87f0bf74
--- /dev/null
+++ b/frontend/src/components/departments/DepartmentFeedSection.tsx
@@ -0,0 +1,770 @@
+"use client";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ ChevronRight,
+ ImageIcon,
+ Paperclip,
+ Pencil,
+ Pin,
+ Plus,
+ Trash2,
+ X,
+} from "lucide-react";
+import { toast } from "sonner";
+
+import { extractDepartmentApiErrorMessage } from "@/components/departments/api-error";
+import { FeedPostCard } from "@/components/feed/FeedPostCard";
+import { PostCommentsModal } from "@/components/feed/PostCommentsModal";
+import { RequestAvatar } from "@/components/requests/RequestAvatar";
+import { Modal } from "@/components/ui/Modal";
+import { useUser } from "@/contexts/UserContext";
+import { apiClient } from "@/lib/api";
+import type { PaginatedResponse, Post } from "@/types/api";
+
+type DepartmentFeedSectionProps = {
+ canCreatePosts: boolean;
+ canManageFeed?: boolean;
+ currentUserId?: number | null;
+ departmentId: number;
+ departmentName: string;
+};
+
+export function DepartmentFeedSection({
+ canCreatePosts,
+ canManageFeed = false,
+ currentUserId,
+ departmentId,
+ departmentName,
+}: DepartmentFeedSectionProps) {
+ const { user } = useUser();
+ const [posts, setPosts] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [createOpen, setCreateOpen] = useState(false);
+ const [createTitle, setCreateTitle] = useState("");
+ const [createBody, setCreateBody] = useState("");
+ const [createImage, setCreateImage] = useState(null);
+ const [createAttachment, setCreateAttachment] = useState(null);
+ const [createError, setCreateError] = useState(null);
+ const [createSubmitting, setCreateSubmitting] = useState(false);
+ const [commentsOpen, setCommentsOpen] = useState(false);
+ const [activePost, setActivePost] = useState(null);
+ const [editingPostId, setEditingPostId] = useState(null);
+ const [postActionId, setPostActionId] = useState(null);
+ const [postMenuOpenId, setPostMenuOpenId] = useState(null);
+ const [likeBusyId, setLikeBusyId] = useState(null);
+ const [likesPopoverPostId, setLikesPopoverPostId] = useState(null);
+ const [likesLoadingPostId, setLikesLoadingPostId] = useState(null);
+ const [likesUsersMap, setLikesUsersMap] = useState>>({});
+ const [likesUsersEndpointUnavailable, setLikesUsersEndpointUnavailable] = useState(false);
+ const postMenuRef = useRef(null);
+
+ const auth = user?.auth;
+ const authPerms = useMemo(() => auth?.permissions || [], [auth?.permissions]);
+ const authByApp = useMemo(() => auth?.permissions_by_app || {}, [auth?.permissions_by_app]);
+
+ const hasPermission = useCallback((perm: string) => {
+ if (!perm) return false;
+ if (authPerms.includes(perm)) return true;
+ if (perm.includes(".")) {
+ const [app, code] = perm.split(".", 2);
+ return Boolean(authByApp?.[app]?.includes(code));
+ }
+ return Object.values(authByApp).some((codes) => codes.includes(perm));
+ }, [authByApp, authPerms]);
+
+ const canPinPost = Boolean(
+ auth?.is_staff ||
+ auth?.is_superuser ||
+ hasPermission("feed.pin_post") ||
+ hasPermission("pin_post")
+ );
+
+ const canEditPost = useCallback((post: Post) => {
+ if (canManageFeed) return true;
+ return Boolean(post.author?.id && currentUserId && post.author.id === currentUserId);
+ }, [canManageFeed, currentUserId]);
+
+ const canDeletePost = useCallback((post: Post) => {
+ if (canManageFeed) return true;
+ return Boolean(post.author?.id && currentUserId && post.author.id === currentUserId);
+ }, [canManageFeed, currentUserId]);
+
+ const sortPostsPinnedFirst = useCallback((items: Post[]) => {
+ return [...items].sort((left, right) => {
+ const leftPinned = left.pinned ? 1 : 0;
+ const rightPinned = right.pinned ? 1 : 0;
+ if (leftPinned !== rightPinned) return rightPinned - leftPinned;
+ return new Date(right.created_at).getTime() - new Date(left.created_at).getTime();
+ });
+ }, []);
+
+ const loadPosts = useCallback(async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const response = await apiClient.getPosts({
+ type: "department",
+ department: departmentId,
+ limit: 20,
+ pinScope: "department",
+ }) as PaginatedResponse;
+ setPosts(sortPostsPinnedFirst(response.results || []));
+ } catch (loadError) {
+ console.error("Не удалось загрузить новости отдела:", loadError);
+ setError("Не удалось загрузить новости отдела");
+ } finally {
+ setLoading(false);
+ }
+ }, [departmentId, sortPostsPinnedFirst]);
+
+ useEffect(() => {
+ void loadPosts();
+ }, [loadPosts]);
+
+ const resetCreateForm = useCallback(() => {
+ setCreateTitle("");
+ setCreateBody("");
+ setCreateImage(null);
+ setCreateAttachment(null);
+ setCreateError(null);
+ }, []);
+
+ const openCreateModal = () => {
+ resetCreateForm();
+ setEditingPostId(null);
+ setCreateOpen(true);
+ };
+
+ const closeCreateModal = () => {
+ if (createSubmitting) return;
+ resetCreateForm();
+ setEditingPostId(null);
+ setCreateOpen(false);
+ };
+
+ const openEditPostModal = useCallback((post: Post) => {
+ setCreateError(null);
+ setEditingPostId(post.id);
+ setCreateTitle((post.title || "").trim());
+ setCreateBody((post.body || post.content || "").trim());
+ setCreateImage(null);
+ setCreateAttachment(null);
+ setCreateOpen(true);
+ }, []);
+
+ const openCommentsModal = useCallback((post: Post) => {
+ setActivePost(post);
+ setCommentsOpen(true);
+ }, []);
+
+ const closeCommentsModal = useCallback(() => {
+ setCommentsOpen(false);
+ setActivePost(null);
+ }, []);
+
+ const applyCommentCountDelta = useCallback(
+ (postId: number, delta: number) => {
+ setPosts((previous) =>
+ previous.map((item) =>
+ item.id === postId
+ ? {
+ ...item,
+ comments_count: Math.max(0, (item.comments_count || 0) + delta),
+ }
+ : item,
+ ),
+ );
+ setActivePost((previous) =>
+ previous && previous.id === postId
+ ? {
+ ...previous,
+ comments_count: Math.max(
+ 0,
+ (previous.comments_count || 0) + delta,
+ ),
+ }
+ : previous,
+ );
+ },
+ [],
+ );
+
+ const submitCreatePost = useCallback(async () => {
+ const title = createTitle.trim();
+ const body = createBody.trim();
+
+ if (!title) {
+ setCreateError("Заполни заголовок публикации");
+ return;
+ }
+
+ if (!body && !createImage && !createAttachment) {
+ setCreateError("Добавь текст, изображение или вложение");
+ return;
+ }
+
+ try {
+ setCreateSubmitting(true);
+ setCreateError(null);
+ if (editingPostId) {
+ await apiClient.updatePost(editingPostId, {
+ type: "department",
+ department: departmentId,
+ title,
+ body: body || undefined,
+ image: createImage || undefined,
+ attachment: createAttachment || undefined,
+ });
+ } else {
+ await apiClient.createPost({
+ type: "department",
+ department: departmentId,
+ title,
+ body: body || undefined,
+ image: createImage || undefined,
+ attachment: createAttachment || undefined,
+ });
+ }
+ await loadPosts();
+ setCreateOpen(false);
+ resetCreateForm();
+ setEditingPostId(null);
+ toast.success(editingPostId ? "Публикация обновлена" : "Публикация добавлена в ленту отдела");
+ } catch (submitError) {
+ console.error("Не удалось создать публикацию отдела:", submitError);
+ setCreateError(
+ extractDepartmentApiErrorMessage(
+ submitError,
+ "Не удалось создать публикацию отдела",
+ ),
+ );
+ } finally {
+ setCreateSubmitting(false);
+ }
+ }, [
+ createAttachment,
+ createBody,
+ createImage,
+ createTitle,
+ departmentId,
+ editingPostId,
+ loadPosts,
+ resetCreateForm,
+ ]);
+
+ const removePost = useCallback(async (post: Post) => {
+ setPostActionId(post.id);
+ try {
+ await apiClient.deletePost(post.id);
+ setPosts((previous) => previous.filter((item) => item.id !== post.id));
+ setPostMenuOpenId(null);
+ toast.success("Публикация удалена");
+ } catch (deleteError) {
+ console.error("Не удалось удалить публикацию:", deleteError);
+ toast.error(
+ extractDepartmentApiErrorMessage(
+ deleteError,
+ "Не удалось удалить публикацию",
+ ),
+ );
+ } finally {
+ setPostActionId(null);
+ }
+ }, []);
+
+ const handlePinToggle = useCallback(async (post: Post) => {
+ if (postActionId === post.id) return;
+ const currentlyPinned = Boolean(post.pinned);
+ setPostActionId(post.id);
+ setPostMenuOpenId(null);
+ setPosts((previous) =>
+ sortPostsPinnedFirst(
+ previous.map((item) =>
+ item.id === post.id ? { ...item, pinned: !currentlyPinned } : item,
+ ),
+ ),
+ );
+ try {
+ const response = currentlyPinned
+ ? await apiClient.unpinPost(post.id, "department")
+ : await apiClient.pinPost(post.id, "department");
+ setPosts((previous) =>
+ sortPostsPinnedFirst(
+ previous.map((item) =>
+ item.id === post.id
+ ? {
+ ...item,
+ pinned: Boolean(response?.pinned),
+ pinned_global: Boolean(response?.pinned_global),
+ pinned_department: Boolean(response?.pinned_department),
+ }
+ : item,
+ ),
+ ),
+ );
+ } catch (pinError) {
+ console.error("Не удалось изменить закрепление публикации:", pinError);
+ setPosts((previous) =>
+ sortPostsPinnedFirst(
+ previous.map((item) =>
+ item.id === post.id ? { ...item, pinned: currentlyPinned } : item,
+ ),
+ ),
+ );
+ } finally {
+ setPostActionId(null);
+ }
+ }, [postActionId, sortPostsPinnedFirst]);
+
+ const handleLikeToggle = useCallback(async (post: Post) => {
+ if (likeBusyId === post.id) return;
+
+ const currentlyLiked = Boolean(post.is_liked);
+ const optimisticLikes = Math.max(0, (post.likes_count || 0) + (currentlyLiked ? -1 : 1));
+ setLikeBusyId(post.id);
+ setPosts((previous) =>
+ previous.map((item) =>
+ item.id === post.id
+ ? { ...item, is_liked: !currentlyLiked, likes_count: optimisticLikes }
+ : item,
+ ),
+ );
+
+ try {
+ const response = currentlyLiked
+ ? await apiClient.unlikePost(post.id)
+ : await apiClient.likePost(post.id);
+ setPosts((previous) =>
+ previous.map((item) =>
+ item.id === post.id
+ ? { ...item, is_liked: response.liked, likes_count: response.likes_count }
+ : item,
+ ),
+ );
+ } catch (likeError) {
+ console.error("Не удалось переключить лайк:", likeError);
+ setPosts((previous) =>
+ previous.map((item) =>
+ item.id === post.id
+ ? { ...item, is_liked: currentlyLiked, likes_count: post.likes_count }
+ : item,
+ ),
+ );
+ } finally {
+ setLikeBusyId(null);
+ }
+ }, [likeBusyId]);
+
+ const formatUserName = useCallback((person: { first_name?: string; last_name?: string; full_name?: string }) => {
+ const composed = `${person.last_name || ""} ${person.first_name || ""}`.trim();
+ return composed || person.full_name || "Пользователь";
+ }, []);
+
+ const formatInitials = useCallback((firstName?: string, lastName?: string) => {
+ return `${(lastName || "").trim().charAt(0)}${(firstName || "").trim().charAt(0)}` || "П";
+ }, []);
+
+ const openLikesPopover = useCallback(async (postId: number, likesCount: number) => {
+ setLikesPopoverPostId(postId);
+ if (!likesCount) {
+ setLikesUsersMap((prev) => ({ ...prev, [postId]: [] }));
+ return;
+ }
+ if (likesUsersEndpointUnavailable) return;
+ if (likesUsersMap[postId]) return;
+ setLikesLoadingPostId(postId);
+ try {
+ const response = await apiClient.getPostLikers(postId);
+ setLikesUsersMap((prev) => ({ ...prev, [postId]: response.results || [] }));
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : "";
+ if (
+ message.includes("404") ||
+ message.includes("NetworkError") ||
+ message.includes("Failed to fetch")
+ ) {
+ setLikesUsersEndpointUnavailable(true);
+ }
+ setLikesUsersMap((prev) => ({ ...prev, [postId]: [] }));
+ } finally {
+ setLikesLoadingPostId((prev) => (prev === postId ? null : prev));
+ }
+ }, [likesUsersEndpointUnavailable, likesUsersMap]);
+
+ useEffect(() => {
+ if (postMenuOpenId === null) return;
+ const handlePointerDown = (event: MouseEvent) => {
+ if (postMenuRef.current && !postMenuRef.current.contains(event.target as Node)) {
+ setPostMenuOpenId(null);
+ }
+ };
+ const handleEscape = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ setPostMenuOpenId(null);
+ }
+ };
+ document.addEventListener("mousedown", handlePointerDown);
+ document.addEventListener("keydown", handleEscape);
+ return () => {
+ document.removeEventListener("mousedown", handlePointerDown);
+ document.removeEventListener("keydown", handleEscape);
+ };
+ }, [postMenuOpenId, postMenuRef]);
+
+ const postsContent = useMemo(() => {
+ if (loading) {
+ return (
+
+
+
Загрузка новостей отдела...
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
{error}
+
+
+ );
+ }
+
+ if (!posts.length) {
+ return (
+
+
+ В отделе пока нет публикаций
+
+
+ Здесь будут появляться новости, объявления и важные сообщения отдела.
+
+
+ );
+ }
+
+ return (
+
+ {posts.map((post) => {
+ return (
+
+ {post.created_at_display ||
+ new Date(post.created_at).toLocaleString("ru-RU")}
+
+ }
+ headerActions={(
+ (canEditPost(post) || canDeletePost(post)) ? (
+
+
+
+ {postMenuOpenId === post.id ? (
+
+ {canPinPost ? (
+
+ ) : null}
+ {canEditPost(post) ? (
+
+ ) : null}
+ {canDeletePost(post) ? (
+
+ ) : null}
+
+ ) : null}
+
+ ) : null
+ )}
+ isLikeActive={Boolean(post.is_liked)}
+ likeDisabled={likeBusyId === post.id}
+ likesPopover={likesPopoverPostId === post.id ? (
+
+
Лайкнули
+ {likesLoadingPostId === post.id ? (
+
Загрузка...
+ ) : likesUsersEndpointUnavailable ? (
+
Список лайкнувших временно недоступен
+ ) : (likesUsersMap[post.id] || []).length === 0 ? (
+
Пока нет лайков
+ ) : (
+
+ {(likesUsersMap[post.id] || []).map((person) => {
+ const name = formatUserName(person);
+ return (
+
+ );
+ })}
+
+ )}
+
+ ) : null}
+ likesWrapperProps={{
+ onMouseEnter: () => openLikesPopover(post.id, post.likes_count || 0),
+ onMouseLeave: () => setLikesPopoverPostId((prev) => (prev === post.id ? null : prev)),
+ }}
+ onLikeToggle={handleLikeToggle}
+ onOpenComments={openCommentsModal}
+ pinMarkerTitle="Закреплено в ленте отдела"
+ pinnedStyle="inline"
+ />
+ );
+ })}
+
+ );
+ }, [
+ canDeletePost,
+ canEditPost,
+ canPinPost,
+ error,
+ formatInitials,
+ formatUserName,
+ handleLikeToggle,
+ handlePinToggle,
+ likeBusyId,
+ likesLoadingPostId,
+ likesPopoverPostId,
+ likesUsersEndpointUnavailable,
+ likesUsersMap,
+ loadPosts,
+ loading,
+ openCommentsModal,
+ openEditPostModal,
+ openLikesPopover,
+ postActionId,
+ postMenuOpenId,
+ posts,
+ removePost,
+ ]);
+
+ return (
+ <>
+
+
+
+
Лента отдела
+
+ Новости {departmentName}
+
+
+ Публикации, объявления и важные сообщения отдела.
+
+
+ {canCreatePosts ? (
+
+ ) : null}
+
+
+
+ {postsContent}
+
+
+
+
+
+
+
+ }
+ >
+
+ {createError ? (
+
+ {createError}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+ {
+ setCreateAttachment(event.target.files?.[0] || null);
+ if (createError) setCreateError(null);
+ }}
+ className="block w-full text-sm text-[var(--foreground)] file:mr-3 file:rounded-md file:border-0 file:bg-[var(--surface-secondary)] file:px-3 file:py-2 file:text-sm file:text-[var(--foreground)] hover:file:bg-[var(--surface-tertiary)]"
+ />
+ {createAttachment ? (
+
+
+
+ {createAttachment.name}
+
+
+
+ ) : null}
+
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/components/feed/FeedPostCard.tsx b/frontend/src/components/feed/FeedPostCard.tsx
new file mode 100644
index 00000000..f612086d
--- /dev/null
+++ b/frontend/src/components/feed/FeedPostCard.tsx
@@ -0,0 +1,203 @@
+"use client";
+
+import Image from "next/image";
+import type { ReactNode } from "react";
+import { Heart, MessageSquare, Paperclip, Pin } from "lucide-react";
+
+import { RequestAvatar } from "@/components/requests/RequestAvatar";
+import { resolveMediaUrl } from "@/lib/url";
+import type { Post } from "@/types/api";
+
+type FeedPostCardProps = {
+ post: Post;
+ authorSubtitle: ReactNode;
+ attachmentButtonClassName?: string;
+ commentsButtonClassName?: string;
+ footerAction?: ReactNode;
+ headerActions?: ReactNode;
+ imageSrc?: string;
+ isLikeActive?: boolean;
+ likeButtonClassName?: string;
+ likeDisabled?: boolean;
+ likesPopover?: ReactNode;
+ likesWrapperProps?: {
+ onMouseEnter?: () => void;
+ onMouseLeave?: () => void;
+ };
+ onLikeToggle?: (post: Post) => void;
+ onOpenComments: (post: Post) => void;
+ pinMarkerTitle?: string;
+ pinnedStyle?: "inline" | "badge" | "none";
+};
+
+function formatAuthorName(post: Post) {
+ const firstName = post.author?.first_name || "";
+ const lastName = post.author?.last_name || "";
+ const fullName = `${lastName} ${firstName}`.trim();
+ return fullName || post.author?.email || "Сотрудник";
+}
+
+function formatAuthorFallback(post: Post) {
+ return (
+ post.author?.last_name?.[0] ||
+ post.author?.first_name?.[0] ||
+ post.author?.email?.[0] ||
+ "П"
+ ).toUpperCase();
+}
+
+export function FeedPostCard({
+ post,
+ authorSubtitle,
+ attachmentButtonClassName,
+ commentsButtonClassName = "app-action-ghost flex items-center gap-2 rounded-lg px-3 py-2",
+ footerAction,
+ headerActions,
+ imageSrc,
+ isLikeActive = false,
+ likeButtonClassName,
+ likeDisabled = false,
+ likesPopover,
+ likesWrapperProps,
+ onLikeToggle,
+ onOpenComments,
+ pinMarkerTitle = "Закрепленная публикация",
+ pinnedStyle = "inline",
+}: FeedPostCardProps) {
+ const authorName = formatAuthorName(post);
+ const authorFallback = formatAuthorFallback(post);
+ const postText = (post.body || post.content || "").trim();
+ const resolvedImageSrc = resolveMediaUrl(imageSrc || post.image);
+ const attachmentHref = resolveMediaUrl(post.attachment || post.attachment_url);
+ const resolvedLikeButtonClassName =
+ likeButtonClassName ||
+ `flex items-center gap-2 rounded-lg px-3 py-2 transition ${
+ isLikeActive
+ ? "app-action-ghost text-[var(--accent-primary)]"
+ : "app-action-ghost"
+ }`;
+
+ return (
+
+
+
+ {post.title ? (
+
+ {post.title}
+
+ ) : null}
+
+ {postText ? (
+
+ {postText}
+
+ ) : null}
+
+ {resolvedImageSrc ? (
+
+
+
+ ) : null}
+
+
+
+
+ {onLikeToggle ? (
+
+ ) : (
+
+
+ {post.likes_count || 0}
+
+ )}
+ {likesPopover}
+
+
+
+
+ {attachmentHref ? (
+
+
+ Вложение
+
+ ) : null}
+
+
+ {footerAction}
+
+
+ );
+}
diff --git a/frontend/src/components/feed/PostCommentsModal.tsx b/frontend/src/components/feed/PostCommentsModal.tsx
new file mode 100644
index 00000000..7782d8e5
--- /dev/null
+++ b/frontend/src/components/feed/PostCommentsModal.tsx
@@ -0,0 +1,625 @@
+"use client";
+
+import Image from "next/image";
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
+import { ImageIcon, MessageSquare, Paperclip, Pencil, Send, Smile, X } from "lucide-react";
+import { toast } from "sonner";
+
+import { extractDepartmentApiErrorMessage } from "@/components/departments/api-error";
+import { CommentDeleteButton } from "@/components/shared/CommentControls";
+import { RequestAvatar } from "@/components/requests/RequestAvatar";
+import { Modal } from "@/components/ui/Modal";
+import { apiClient } from "@/lib/api";
+import { resolveMediaUrl } from "@/lib/url";
+import type { Comment, PaginatedResponse, Post } from "@/types/api";
+
+type PostCommentsModalProps = {
+ canDeleteComment?: (comment: Comment) => boolean;
+ canEditComment?: (comment: Comment) => boolean;
+ currentUserId?: number | null;
+ isOpen: boolean;
+ onClose: () => void;
+ onCommentCountChange?: (postId: number, delta: number) => void;
+ post: Post | null;
+};
+
+const ALL_REACTIONS = [
+ "👍","❤️","😂","🔥","👏","🎉","😊","😉","😁","🤝",
+ "🙏","😮","😢","😡","💯","✅","👀","🤔","😍","😎",
+ "🤩","🥳","😴","🫡","👌","💪","🙌","🧠","💡","🚀",
+ "🎯","⭐","✨","💩","🫶","🤗","😅","🤯","🥲","🫠",
+];
+
+function formatAuthorName(comment: Comment) {
+ const firstName = comment.author?.first_name || "";
+ const lastName = comment.author?.last_name || "";
+ const fullName = `${lastName} ${firstName}`.trim();
+ return fullName || comment.author?.email || "Сотрудник";
+}
+
+function formatCommentFallback(comment: Comment) {
+ return (
+ comment.author?.last_name?.[0] ||
+ comment.author?.first_name?.[0] ||
+ comment.author?.email?.[0] ||
+ "К"
+ ).toUpperCase();
+}
+
+function formatDate(value?: string) {
+ if (!value) return "";
+ const parsed = new Date(value);
+ if (Number.isNaN(parsed.getTime())) return "";
+ return parsed.toLocaleString("ru-RU", {
+ day: "2-digit",
+ month: "2-digit",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+export function PostCommentsModal({
+ canDeleteComment,
+ canEditComment,
+ currentUserId,
+ isOpen,
+ onClose,
+ onCommentCountChange,
+ post,
+}: PostCommentsModalProps) {
+ const [comments, setComments] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [draft, setDraft] = useState("");
+ const [draftImage, setDraftImage] = useState(null);
+ const [draftAttachment, setDraftAttachment] = useState(null);
+ const [sending, setSending] = useState(false);
+ const [actionId, setActionId] = useState(null);
+ const [editingCommentId, setEditingCommentId] = useState(null);
+ const [editingCommentText, setEditingCommentText] = useState("");
+ const [showEmojiPicker, setShowEmojiPicker] = useState(false);
+
+ const attachmentRef = useRef(null);
+ const composerInputRef = useRef(null);
+ const commentsBottomRef = useRef(null);
+ const [composerExpanded, setComposerExpanded] = useState(false);
+
+ const postPreview = useMemo(() => {
+ if (!post) return "";
+ return (post.body || post.content || "").trim();
+ }, [post]);
+
+ const resetComposer = useCallback(() => {
+ setDraft("");
+ setDraftImage(null);
+ setDraftAttachment(null);
+ }, []);
+
+ const cancelEdit = useCallback(() => {
+ setEditingCommentId(null);
+ setEditingCommentText("");
+ }, []);
+
+ const scrollCommentsToBottom = useCallback((smooth = true) => {
+ requestAnimationFrame(() => {
+ commentsBottomRef.current?.scrollIntoView({
+ behavior: smooth ? "smooth" : "auto",
+ block: "end",
+ });
+ });
+ }, []);
+
+ const loadComments = useCallback(async () => {
+ if (!post) return;
+
+ try {
+ setLoading(true);
+ setError(null);
+ const response = (await apiClient.getComments({
+ post: post.id,
+ })) as PaginatedResponse | Comment[];
+ const items = Array.isArray(response)
+ ? response
+ : Array.isArray(response?.results)
+ ? response.results
+ : [];
+ setComments(items);
+ } catch (loadError) {
+ console.error("Не удалось загрузить комментарии публикации:", loadError);
+ setError(
+ extractDepartmentApiErrorMessage(
+ loadError,
+ "Не удалось загрузить комментарии",
+ ),
+ );
+ setComments([]);
+ } finally {
+ setLoading(false);
+ }
+ }, [post]);
+
+ useEffect(() => {
+ if (!isOpen || !post) return;
+ resetComposer();
+ cancelEdit();
+ setShowEmojiPicker(false);
+ void loadComments();
+ }, [cancelEdit, isOpen, loadComments, post, resetComposer]);
+
+ useEffect(() => {
+ if (!isOpen || loading) return;
+ scrollCommentsToBottom(false);
+ }, [comments.length, isOpen, loading, scrollCommentsToBottom]);
+
+ useLayoutEffect(() => {
+ const input = composerInputRef.current;
+ if (!input) return;
+
+ const minHeight = 38;
+ const maxHeight = 128;
+
+ input.style.height = "0px";
+ const nextHeight = Math.min(input.scrollHeight, maxHeight);
+ const resolvedHeight = Math.max(nextHeight, minHeight);
+
+ input.style.height = `${resolvedHeight}px`;
+ input.style.overflowY = input.scrollHeight > maxHeight ? "auto" : "hidden";
+ setComposerExpanded(resolvedHeight > minHeight + 2);
+ }, [draft]);
+
+ const submitComment = useCallback(async () => {
+ if (!post) return;
+
+ const text = draft.trim();
+ if (!text && !draftImage && !draftAttachment) {
+ setError("Добавь текст, изображение или вложение");
+ return;
+ }
+
+ try {
+ setSending(true);
+ setError(null);
+ const created = (await apiClient.createComment(
+ post.id,
+ text || " ",
+ draftImage || undefined,
+ draftAttachment || undefined,
+ )) as Comment;
+ setComments((previous) => [...previous, created]);
+ resetComposer();
+ onCommentCountChange?.(post.id, 1);
+ scrollCommentsToBottom();
+ } catch (submitError) {
+ console.error("Не удалось отправить комментарий:", submitError);
+ setError(
+ extractDepartmentApiErrorMessage(
+ submitError,
+ "Не удалось отправить комментарий",
+ ),
+ );
+ } finally {
+ setSending(false);
+ }
+ }, [
+ draft,
+ draftAttachment,
+ draftImage,
+ onCommentCountChange,
+ post,
+ resetComposer,
+ scrollCommentsToBottom,
+ ]);
+
+ const beginEdit = useCallback((comment: Comment) => {
+ setEditingCommentId(comment.id);
+ setEditingCommentText((comment.text || comment.content || "").trim());
+ if (error) setError(null);
+ }, [error]);
+
+ const saveEditedComment = useCallback(
+ async (commentId: number) => {
+ const text = editingCommentText.trim();
+ if (!text) return;
+
+ try {
+ setActionId(commentId);
+ setError(null);
+ const updated = (await apiClient.updateComment(commentId, text)) as Comment;
+ setComments((previous) =>
+ previous.map((item) =>
+ item.id === commentId ? { ...item, ...updated } : item,
+ ),
+ );
+ cancelEdit();
+ } catch (saveError) {
+ console.error("Не удалось обновить комментарий:", saveError);
+ setError(
+ extractDepartmentApiErrorMessage(
+ saveError,
+ "Не удалось сохранить комментарий",
+ ),
+ );
+ } finally {
+ setActionId(null);
+ }
+ },
+ [cancelEdit, editingCommentText],
+ );
+
+ const deleteComment = useCallback(
+ async (comment: Comment) => {
+ if (!post) return;
+
+ try {
+ setActionId(comment.id);
+ await apiClient.deleteComment(comment.id);
+ setComments((previous) =>
+ previous.filter((item) => item.id !== comment.id),
+ );
+ onCommentCountChange?.(post.id, -1);
+ if (editingCommentId === comment.id) {
+ cancelEdit();
+ }
+ } catch (deleteError) {
+ console.error("Не удалось удалить комментарий:", deleteError);
+ toast.error(
+ extractDepartmentApiErrorMessage(
+ deleteError,
+ "Не удалось удалить комментарий",
+ ),
+ );
+ } finally {
+ setActionId(null);
+ }
+ },
+ [cancelEdit, editingCommentId, onCommentCountChange, post],
+ );
+
+ return (
+
+ {post ? (
+
+
+
+ Публикация
+
+
+ {post.title || "Без заголовка"}
+
+ {postPreview ? (
+
+ {postPreview}
+
+ ) : null}
+
+ {formatDate(post.created_at)}
+
+ Комментариев: {Math.max(comments.length, post.comments_count || 0)}
+
+
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+
+
+
+ {loading ? (
+
+
+
+ Загрузка комментариев...
+
+
+ ) : comments.length === 0 ? (
+
+
+ Комментариев пока нет
+
+
+ Начни обсуждение первым.
+
+
+ ) : (
+
+ {comments.map((comment) => {
+ const authorName = formatAuthorName(comment);
+ const commentText = (comment.text || comment.content || "").trim();
+ const isOwnComment = Boolean(
+ comment.author?.id &&
+ currentUserId &&
+ comment.author.id === currentUserId,
+ );
+ const allowEdit = canEditComment
+ ? canEditComment(comment)
+ : isOwnComment;
+ const allowDelete = canDeleteComment
+ ? canDeleteComment(comment)
+ : isOwnComment;
+
+ return (
+
+
+
+
+
+
+
+ {authorName}
+
+
+ {formatDate(comment.created_at)}
+
+
+ {(allowEdit || allowDelete) ? (
+
+ {allowEdit ? (
+
+ ) : null}
+ {allowDelete ? (
+
void deleteComment(comment)}
+ />
+ ) : null}
+
+ ) : null}
+
+
+ {editingCommentId === comment.id ? (
+
+ ) : (
+ <>
+ {commentText ? (
+
+ {commentText}
+
+ ) : null}
+
+ {comment.image ? (
+
+
+
+ ) : null}
+
+ {comment.attachment ? (
+
+ ) : null}
+ >
+ )}
+
+
+
+ );
+ })}
+
+
+ )}
+
+
+
+
+
+ {(draftImage || draftAttachment) ? (
+
+ {draftImage ? (
+
+
+ {draftImage.name}
+
+
+ ) : null}
+ {draftAttachment ? (
+
+
+
+ {draftAttachment.name}
+
+
+
+ ) : null}
+
+ ) : null}
+
+
{
+ const file = event.target.files?.[0] || null;
+ if (file?.type?.startsWith("image/")) {
+ setDraftImage(file);
+ } else {
+ setDraftAttachment(file);
+ }
+ event.target.value = "";
+ if (error) setError(null);
+ }}
+ />
+
+
+
+
+
+
+
+ {showEmojiPicker ? (
+
+
+ {ALL_REACTIONS.map((emoji) => (
+
+ ))}
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/frontend/src/components/shared/CommentControls.tsx b/frontend/src/components/shared/CommentControls.tsx
index b2dbce39..0097ccd3 100644
--- a/frontend/src/components/shared/CommentControls.tsx
+++ b/frontend/src/components/shared/CommentControls.tsx
@@ -10,6 +10,7 @@ type CommentComposerProps = {
onSubmit: () => void | Promise;
placeholder?: string;
rows?: number;
+ submitDisabled?: boolean;
value: string;
};
@@ -21,9 +22,11 @@ export function CommentComposer({
onSubmit,
placeholder = "Добавить комментарий",
rows = 2,
+ submitDisabled,
value,
}: CommentComposerProps) {
- const submitDisabled = disabled || !value.trim();
+ const resolvedSubmitDisabled =
+ submitDisabled ?? (disabled || !value.trim());
if (multiline) {
return (
@@ -48,7 +51,7 @@ export function CommentComposer({