refactor(departments): unify department feed and discussion flows

This commit is contained in:
lizerk 2026-04-14 16:14:52 +03:00
parent 8fa588bd55
commit beb4b8b86b
31 changed files with 3095 additions and 731 deletions

View File

@ -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)

View File

@ -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))

View File

@ -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):

View File

@ -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')

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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)
# ============================================================

View File

@ -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(

View File

@ -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"]

View File

@ -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='Текст'),
),
]

View File

@ -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",
),
),
]

View File

@ -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"]),

View File

@ -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:
# Личная публикация - подписчикам

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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 |

View File

@ -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() {
Участники не найдены
</p>
<p className="app-text-muted mt-2 text-sm">
Измени поисковый запрос.
В этом отделе пока нет состава.
</p>
</div>
);
@ -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<number | null>(null);
const roleMenuRef = useRef<HTMLDivElement | null>(null);
const [departmentMenuOpen, setDepartmentMenuOpen] = useState(false);
const departmentMenuRef = useRef<HTMLDivElement | null>(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 (
<AppShell>
<div className="space-y-6">
@ -817,178 +844,232 @@ export default function DepartmentDetailPage() {
) : h.department ? (
<>
<section className="app-surface rounded-2xl p-5">
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="mb-3 flex flex-wrap items-center gap-2">
<MetaChip tone="accent">
<Building2 size={12} />
Отдел
</MetaChip>
<MetaChip>
<Users size={12} />
{activeMembersCount} участников
</MetaChip>
<MetaChip>{h.roles.length} ролей</MetaChip>
{isManagementMode ? (
<MetaChip tone="warning">Режим управления</MetaChip>
) : null}
<div className="mb-4">
<div className="mb-3 flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="app-card-caption mb-2">Отдел</p>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<MetaChip>
<Users size={12} />
{activeMembersCount} участников
</MetaChip>
<MetaChip>{h.roles.length} ролей</MetaChip>
{isManagementMode ? (
<MetaChip tone="warning">Режим управления</MetaChip>
) : null}
</div>
</div>
{(canUseManagementMode || h.userPerms.can_manage) ? (
<div
ref={departmentMenuOpen ? departmentMenuRef : null}
className="relative shrink-0"
>
<button
type="button"
onClick={() => setDepartmentMenuOpen((current) => !current)}
className="app-action-ghost flex h-8 w-8 items-center justify-center rounded-md"
title="Действия с отделом"
aria-label="Действия с отделом"
aria-expanded={departmentMenuOpen}
aria-haspopup="menu"
>
<ChevronRight
size={15}
className={`transition-transform duration-200 ${departmentMenuOpen ? "rotate-90" : ""}`}
/>
</button>
{departmentMenuOpen ? (
<div className="app-menu absolute right-0 top-full z-20 mt-2 w-56 rounded-xl py-1.5">
{h.userPerms.can_manage ? (
<button
type="button"
onClick={() => {
setDepartmentMenuOpen(false);
h.openAddMember();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<UserPlus size={14} className="app-text-muted" />
Добавить участников
</button>
) : null}
{h.userPerms.can_assign_roles ? (
<button
type="button"
onClick={() => {
setDepartmentMenuOpen(false);
h.openAssignRoleModal();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<UserRoundCog size={14} className="app-text-muted" />
Выдать роль
</button>
) : null}
{h.userPerms.can_assign_roles ? (
<button
type="button"
onClick={() => {
setDepartmentMenuOpen(false);
h.openCreateRole();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<BadgePlus size={14} className="app-text-muted" />
Создать роль
</button>
) : null}
{h.userPerms.can_manage ? (
<button
type="button"
onClick={() => {
setDepartmentMenuOpen(false);
h.openDepartmentEditor();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<PencilLine size={14} className="app-text-muted" />
Редактировать отдел
</button>
) : null}
{canUseManagementMode ? (
<button
type="button"
onClick={() => {
setDepartmentMenuOpen(false);
setRoleMenuOpenForId(null);
setManagementMode((current) => !current);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<ChevronDown
size={14}
className={`transition-transform ${isManagementMode ? "rotate-180" : "-rotate-90"}`}
/>
{isManagementMode ? "Вернуться к просмотру" : "Открыть управление"}
</button>
) : null}
{h.userPerms.can_manage ? (
<>
<div className="app-divider my-1 border-t" />
<button
type="button"
disabled={h.pendingKey === "department-delete"}
onClick={() => {
setDepartmentMenuOpen(false);
void (async () => {
const deleted = await h.submitDeleteDepartment();
if (deleted) {
router.push("/departments");
}
})();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--danger-foreground)] transition hover:bg-[var(--danger-soft)] disabled:opacity-50"
>
<Trash2 size={14} className="text-[var(--danger-foreground)]" />
Удалить отдел
</button>
</>
) : null}
</div>
) : null}
</div>
) : null}
</div>
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-[var(--foreground)] sm:text-3xl">
{h.department.name}
</h1>
<p className="app-text-muted mt-2 text-sm">
{h.department.description}
</p>
</div>
{(canUseManagementMode || h.userPerms.can_manage) ? (
<div ref={departmentMenuOpen ? departmentMenuRef : null} className="relative shrink-0">
<div className="mt-4">
<button
type="button"
onClick={() => setDepartmentMenuOpen((current) => !current)}
className="app-action-ghost flex h-8 w-8 items-center justify-center rounded-md"
title="Действия с отделом"
aria-label="Действия с отделом"
aria-expanded={departmentMenuOpen}
aria-haspopup="menu"
onClick={() => void openDepartmentChat()}
disabled={openingDepartmentChat}
className="app-action-primary inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
<ChevronRight
<MessageCircle size={16} />
Перейти в чат отдела
</button>
</div>
</div>
</div>
<section className="app-surface-muted rounded-2xl p-4 sm:p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[var(--muted-foreground)]">
Сотрудники отдела
</p>
<p className="app-text-muted mt-1 text-sm">
Состав отдела и назначенные роли.
</p>
</div>
{h.filteredMembers.length ? (
<button
type="button"
onClick={() => setMembersExpanded((current) => !current)}
className="app-action-secondary inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
aria-label={membersExpanded ? "Свернуть список сотрудников" : "Развернуть список сотрудников"}
aria-expanded={membersExpanded}
>
<ChevronDown
size={15}
className={`transition-transform duration-200 ${departmentMenuOpen ? "rotate-90" : ""}`}
className={`transition-transform duration-200 ${membersExpanded ? "rotate-180" : ""}`}
/>
</button>
) : null}
</div>
{departmentMenuOpen ? (
<div className="app-menu absolute right-0 top-full z-20 mt-2 w-56 rounded-xl py-1.5">
{h.userPerms.can_manage ? (
<button
type="button"
onClick={() => {
setDepartmentMenuOpen(false);
h.openAddMember();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<UserPlus size={14} className="app-text-muted" />
Добавить участников
</button>
) : null}
{h.userPerms.can_assign_roles ? (
<button
type="button"
onClick={() => {
setDepartmentMenuOpen(false);
h.openAssignRoleModal();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<UserRoundCog size={14} className="app-text-muted" />
Выдать роль
</button>
) : null}
{h.userPerms.can_assign_roles ? (
<button
type="button"
onClick={() => {
setDepartmentMenuOpen(false);
h.openCreateRole();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<BadgePlus size={14} className="app-text-muted" />
Создать роль
</button>
) : null}
{h.userPerms.can_manage ? (
<button
type="button"
onClick={() => {
setDepartmentMenuOpen(false);
h.openDepartmentEditor();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<PencilLine size={14} className="app-text-muted" />
Редактировать отдел
</button>
) : null}
{canUseManagementMode ? (
<button
type="button"
onClick={() => {
setDepartmentMenuOpen(false);
setRoleMenuOpenForId(null);
setManagementMode((current) => !current);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<ChevronDown
size={14}
className={`transition-transform ${isManagementMode ? "rotate-180" : "-rotate-90"}`}
/>
{isManagementMode ? "Вернуться к просмотру" : "Открыть управление"}
</button>
) : null}
{h.userPerms.can_manage ? (
<>
<div className="app-divider my-1 border-t" />
<button
type="button"
disabled={h.pendingKey === "department-delete"}
onClick={() => {
setDepartmentMenuOpen(false);
void (async () => {
const deleted = await h.submitDeleteDepartment();
if (deleted) {
router.push("/departments");
}
})();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--danger-foreground)] transition hover:bg-[var(--danger-soft)] disabled:opacity-50"
>
<Trash2 size={14} className="text-[var(--danger-foreground)]" />
Удалить отдел
</button>
</>
) : null}
</div>
) : null}
{membersExpanded || !h.filteredMembers.length ? (
<div className={getDepartmentMembersListClassName(isManagementMode)}>
{h.filteredMembers.length ? (
h.filteredMembers.map((member) => (
<DepartmentMemberRow
key={`${member.employee.id}-${member.is_active ? "active" : "inactive"}`}
canAssignRoles={isManagementMode && h.userPerms.can_assign_roles}
canChangeHead={isManagementMode && h.userPerms.can_change_head}
canManage={isManagementMode && h.userPerms.can_manage}
currentHeadId={h.department?.head?.id}
currentUserId={h.currentUserId}
member={member}
pendingKey={h.pendingKey}
roleOptions={roleOptions}
onAssignRole={h.submitMemberRole}
onCreateRole={h.openCreateRole}
onRemoveMember={h.submitRemoveMember}
onSetHead={h.submitQuickHeadChange}
onToggleRoleMenu={setRoleMenuOpenForId}
roleMenuOpenForId={roleMenuOpenForId}
roleMenuRef={roleMenuRef}
/>
))
) : (
<DepartmentMembersEmptyState />
)}
</div>
) : null}
</div>
<div className={getDepartmentMembersListClassName(isManagementMode)}>
{h.filteredMembers.length ? (
h.filteredMembers.map((member) => (
<DepartmentMemberRow
key={`${member.employee.id}-${member.is_active ? "active" : "inactive"}`}
canAssignRoles={isManagementMode && h.userPerms.can_assign_roles}
canChangeHead={isManagementMode && h.userPerms.can_change_head}
canManage={isManagementMode && h.userPerms.can_manage}
currentHeadId={h.department?.head?.id}
currentUserId={h.currentUserId}
member={member}
pendingKey={h.pendingKey}
roleOptions={roleOptions}
onAssignRole={h.submitMemberRole}
onCreateRole={h.openCreateRole}
onRemoveMember={h.submitRemoveMember}
onSetHead={h.submitQuickHeadChange}
onToggleRoleMenu={setRoleMenuOpenForId}
roleMenuOpenForId={roleMenuOpenForId}
roleMenuRef={roleMenuRef}
/>
))
) : (
<DepartmentMembersEmptyState />
)}
</div>
</section>
</section>
<DepartmentFeedSection
canCreatePosts={
h.userPerms.can_publish_posts || h.userPerms.can_manage_feed
}
canManageFeed={h.userPerms.can_manage_feed}
currentUserId={h.currentUserId}
departmentId={h.department.id}
departmentName={h.department.name}
/>
</>
) : null}
</div>

View File

@ -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 },

View File

@ -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<Post | null>(null);
const [comments, setComments] = useState<Comment[]>([]);
const [commentsLoading, setCommentsLoading] = useState(false);
const [commentsError, setCommentsError] = useState<string | null>(null);
const [newComment, setNewComment] = useState("");
const [commentSending, setCommentSending] = useState(false);
const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
const [editingCommentText, setEditingCommentText] = useState("");
const [commentImage, setCommentImage] = useState<File | null>(null);
const [commentAttachment, setCommentAttachment] = useState<File | null>(null);
const commentImageRef = useRef<HTMLInputElement | null>(null);
const commentAttachmentRef = useRef<HTMLInputElement | null>(null);
const [commentActionId, setCommentActionId] = useState<number | null>(null);
const commentsBottomRef = useRef<HTMLDivElement | null>(null);
const [createPostOpen, setCreatePostOpen] = useState(false);
const [createType, setCreateType] = useState<"company" | "department">("company");
const [createDepartmentId, setCreateDepartmentId] = useState<string>("");
@ -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() {
</div>
) : (
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 (
<article id={`post-${post.id}`} key={post.id} className="app-surface rounded-2xl p-5">
<header className="mb-3 flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="relative h-10 w-10">
<div className="app-avatar-fallback flex h-10 w-10 items-center justify-center overflow-hidden rounded-full text-sm font-semibold">
{post.author?.avatar ? (
<img src={resolveMediaUrl(post.author.avatar)} alt={authorName} className="h-full w-full object-cover" />
) : (
authorInitials
)}
</div>
</div>
<div>
<p className="text-sm font-semibold text-[var(--foreground)]">{authorName}</p>
<div className="app-text-muted flex items-center gap-1.5 text-xs">
<span>{timeAgo}</span>
{post.pinned ? (
<span
className="app-accent-text inline-flex items-center justify-center"
title="Закрепленная публикация"
aria-label="Закрепленная публикация"
>
<Pin size={12} className="shrink-0 fill-current" />
</span>
) : null}
</div>
</div>
</div>
{(canEditPost(post) || canDeletePost(post)) ? (
<FeedPostCard
key={post.id}
post={post}
authorSubtitle={<span>{timeAgo}</span>}
headerActions={(
(canEditPost(post) || canDeletePost(post)) ? (
<div
ref={postMenuOpenId === post.id ? postMenuRef : null}
className="relative shrink-0"
@ -710,7 +550,7 @@ function HomePageContent() {
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)] disabled:opacity-50"
>
<Pin size={14} className={post.pinned ? "fill-current app-accent-text" : ""} />
{post.pinned ? "Открепить" : "Закрепить"}
{post.pinned ? "Открепить в общей ленте" : "Закрепить в общей ленте"}
</button>
) : null}
{canEditPost(post) ? (
@ -726,7 +566,6 @@ function HomePageContent() {
Редактировать
</button>
) : null}
{canDeletePost(post) ? (
<button
type="button"
@ -744,96 +583,50 @@ function HomePageContent() {
</div>
) : null}
</div>
) : null}
</header>
{post.title ? (
<h3 className="app-text-wrap mb-1 text-base font-semibold text-[var(--foreground)]">{post.title}</h3>
) : null}
{postText ? (
<p className="app-text-wrap whitespace-pre-line text-sm leading-6 text-[var(--foreground)]">{postText}</p>
) : null}
{post.image && (
<div className="mt-3 overflow-hidden rounded-lg">
<img src={withImageCacheBuster(resolveMediaUrl(post.image), post.id)} alt="" className="w-full" />
</div>
) : null
)}
{(post.attachment || post.attachment_url) && (
<div className="mt-3">
<a
href={resolveMediaUrl(post.attachment || post.attachment_url)}
target="_blank"
rel="noopener noreferrer"
className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm"
>
📎 Вложение
</a>
</div>
)}
<div className="app-text-muted mt-4 flex items-center gap-4 text-sm">
<div
className="relative"
onMouseEnter={() => openLikesPopover(post.id, post.likes_count || 0)}
onMouseLeave={() => setLikesPopoverPostId((prev) => (prev === post.id ? null : prev))}
>
<button
type="button"
disabled={likeBusyId === post.id}
onClick={() => handleLikeToggle(post)}
className={`flex items-center gap-2 rounded-lg px-3 py-2 transition ${
post.is_liked
? "app-action-ghost text-[var(--accent-primary)]"
: "app-action-ghost"
}`}
>
<Heart
size={16}
className={post.is_liked ? "fill-[var(--accent-primary)] text-[var(--accent-primary)]" : "app-text-muted"}
/>
{post.likes_count || 0}
</button>
{likesPopoverPostId === post.id ? (
<div className="app-menu absolute left-0 top-full z-20 mt-1 w-64 rounded-xl p-2">
<p className="app-text-muted px-1 pb-1 text-xs font-semibold">Лайкнули</p>
{likesLoadingPostId === post.id ? (
<p className="app-text-muted px-1 py-1 text-xs">Загрузка...</p>
) : likesUsersEndpointUnavailable ? (
<p className="app-text-muted px-1 py-1 text-xs">Список лайкнувших временно недоступен</p>
) : (likesUsersMap[post.id] || []).length === 0 ? (
<p className="app-text-muted px-1 py-1 text-xs">Пока нет лайков</p>
) : (
<div className="max-h-56 space-y-1 overflow-y-auto">
{(likesUsersMap[post.id] || []).map((u) => {
const name = formatUserName(u);
const initials = formatInitials(u.first_name, u.last_name);
return (
<div key={u.id} className="flex items-center gap-2 rounded-lg px-1 py-1 hover:bg-[var(--surface-secondary)]">
<div className="app-avatar-fallback flex h-6 w-6 items-center justify-center overflow-hidden rounded-full text-[10px] font-semibold">
{u.avatar ? (
<img src={resolveMediaUrl(u.avatar)} alt={name} className="h-full w-full object-cover" />
) : (
initials
)}
</div>
<p className="truncate text-xs text-[var(--foreground)]">{name}</p>
</div>
);
})}
</div>
)}
imageSrc={withImageCacheBuster(resolveMediaUrl(post.image), post.id)}
isLikeActive={Boolean(post.is_liked)}
likeDisabled={likeBusyId === post.id}
likesPopover={likesPopoverPostId === post.id ? (
<div className="app-menu absolute left-0 top-full z-20 mt-1 w-64 rounded-xl p-2">
<p className="app-text-muted px-1 pb-1 text-xs font-semibold">Лайкнули</p>
{likesLoadingPostId === post.id ? (
<p className="app-text-muted px-1 py-1 text-xs">Загрузка...</p>
) : likesUsersEndpointUnavailable ? (
<p className="app-text-muted px-1 py-1 text-xs">Список лайкнувших временно недоступен</p>
) : (likesUsersMap[post.id] || []).length === 0 ? (
<p className="app-text-muted px-1 py-1 text-xs">Пока нет лайков</p>
) : (
<div className="max-h-56 space-y-1 overflow-y-auto">
{(likesUsersMap[post.id] || []).map((u) => {
const name = formatUserName(u);
const initials = formatInitials(u.first_name, u.last_name);
return (
<div key={u.id} className="flex items-center gap-2 rounded-lg px-1 py-1 hover:bg-[var(--surface-secondary)]">
<RequestAvatar
alt={name}
fallback={initials}
size="sm"
src={u.avatar}
/>
<p className="truncate text-xs text-[var(--foreground)]">{name}</p>
</div>
);
})}
</div>
) : null}
)}
</div>
<button
type="button"
onClick={() => openComments(post)}
className="app-action-ghost flex items-center gap-2 rounded-lg px-3 py-2"
>
<MessageSquare size={16} className="app-text-muted" /> {post.comments_count || 0}
</button>
</div>
</article>
) : 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() {
</div>
</Modal>
<Modal
isOpen={commentsOpen && !!activePost}
<PostCommentsModal
canDeleteComment={canDeleteComment}
canEditComment={canEditComment}
currentUserId={user?.id}
isOpen={commentsOpen}
onClose={closeCommentsModal}
title="Комментарии"
size="lg"
noPadding
footer={
<div>
{(commentImage || commentAttachment) && (
<div className="mb-2 flex flex-wrap gap-2">
{commentImage && (
<div className="app-badge app-badge-accent flex items-center gap-1.5 rounded-md px-2 py-1 text-xs">
<ImageIcon size={12} />
<span className="max-w-[120px] truncate">{commentImage.name}</span>
<button type="button" onClick={() => setCommentImage(null)} className="app-accent-text ml-0.5 hover:text-[var(--accent-primary)]"><X size={12} /></button>
</div>
)}
{commentAttachment && (
<div className="app-badge flex items-center gap-1.5 rounded-md px-2 py-1 text-xs">
<Paperclip size={12} />
<span className="max-w-[120px] truncate">{commentAttachment.name}</span>
<button type="button" onClick={() => setCommentAttachment(null)} className="app-text-muted ml-0.5 hover:text-[var(--foreground)]"><X size={12} /></button>
</div>
)}
</div>
)}
<div className="grid grid-cols-[auto_auto_auto] gap-2 sm:grid-cols-[auto_auto_minmax(0,1fr)_auto] sm:items-center">
<input ref={commentImageRef} type="file" accept="image/*" className="hidden" onChange={(e) => { setCommentImage(e.target.files?.[0] || null); e.target.value = ""; }} />
<input ref={commentAttachmentRef} type="file" className="hidden" onChange={(e) => { setCommentAttachment(e.target.files?.[0] || null); e.target.value = ""; }} />
<button
type="button"
onClick={() => commentImageRef.current?.click()}
className="app-action-ghost flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
title="Прикрепить изображение"
>
<ImageIcon size={18} />
</button>
<button
type="button"
onClick={() => commentAttachmentRef.current?.click()}
className="app-action-ghost flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
title="Прикрепить файл"
>
<Paperclip size={18} />
</button>
<input
type="text"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleCreateComment();
}
}}
placeholder="Напишите комментарий..."
className="app-input order-4 col-span-3 min-w-0 h-10 w-full rounded-lg px-3 text-sm sm:order-3 sm:col-span-1"
/>
<button
type="button"
disabled={commentSending || (!newComment.trim() && !commentImage && !commentAttachment)}
onClick={handleCreateComment}
className="app-action-primary order-3 flex h-10 w-10 items-center justify-center rounded-lg disabled:cursor-not-allowed disabled:opacity-50 sm:order-4"
>
<Send size={16} />
</button>
</div>
</div>
}
>
{activePost && (
<>
<p className="app-text-muted px-4 pb-2 pt-1 text-[10px] sm:text-xs">Публикация #{activePost.id}</p>
<div className="flex-1 overflow-y-auto px-4 py-3">
{commentsLoading ? (
<p className="app-text-muted text-sm">Загрузка комментариев...</p>
) : commentsError ? (
<p className="app-feedback-danger rounded-lg px-3 py-2 text-sm">{commentsError}</p>
) : comments.length === 0 ? (
<p className="app-text-muted text-sm">Комментариев пока нет</p>
) : (
<div className="space-y-3">
{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 (
<div key={comment.id} className="app-surface-muted rounded-xl px-3 py-2.5">
<div className="flex items-start gap-2.5">
<div className="app-avatar-fallback flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full text-[11px] font-semibold">
{comment.author?.avatar ? (
<img src={resolveMediaUrl(comment.author.avatar)} alt={authorName} className="h-full w-full object-cover" />
) : (
authorInitials
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<p className="text-xs font-semibold text-[var(--foreground)]">{authorName}</p>
<div className="flex items-center gap-1">
{canEditComment(comment) ? (
<button
type="button"
onClick={() => startEditComment(comment)}
className="app-action-ghost flex h-6 w-6 items-center justify-center rounded-md"
title="Редактировать"
>
<Pencil size={13} />
</button>
) : null}
{canDeleteComment(comment) ? (
<button
type="button"
disabled={commentActionId === comment.id}
onClick={() => removeComment(comment)}
className="app-action-danger flex h-6 w-6 items-center justify-center rounded-md disabled:opacity-50"
title="Удалить"
>
<Trash2 size={13} />
</button>
) : null}
</div>
</div>
{editingCommentId === comment.id ? (
<div className="mt-1 space-y-2">
<textarea
value={editingCommentText}
onChange={(e) => setEditingCommentText(e.target.value)}
className="app-input min-h-20 w-full rounded-lg px-2.5 py-2 text-sm"
/>
<div className="flex items-center gap-2">
<button
type="button"
disabled={commentActionId === comment.id || !editingCommentText.trim()}
onClick={() => saveEditComment(comment.id)}
className="app-action-primary rounded-md px-2.5 py-1.5 text-xs font-medium disabled:opacity-50"
>
Сохранить
</button>
<button
type="button"
disabled={commentActionId === comment.id}
onClick={cancelEditComment}
className="app-action-secondary rounded-md px-2.5 py-1.5 text-xs font-medium"
>
Отмена
</button>
</div>
</div>
) : (
<>
{commentText && <p className="app-text-wrap mt-1 text-sm leading-6 text-[var(--foreground)]">{commentText}</p>}
{comment.image && (
<div className="mt-2 overflow-hidden rounded-lg">
<img src={resolveMediaUrl(comment.image)} alt="" className="max-h-60 rounded-lg" />
</div>
)}
{comment.attachment && (
<div className="mt-2">
<a
href={resolveMediaUrl(comment.attachment)}
target="_blank"
rel="noopener noreferrer"
className="app-action-secondary inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs"
>
📎 Вложение
</a>
</div>
)}
</>
)}
</div>
</div>
</div>
);
})}
<div ref={commentsBottomRef} />
</div>
)}
</div>
</>
)}
</Modal>
onCommentCountChange={applyCommentCountDelta}
post={activePost}
/>
</AppShell>
);
}

View File

@ -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<Post[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [createTitle, setCreateTitle] = useState("");
const [createBody, setCreateBody] = useState("");
const [createImage, setCreateImage] = useState<File | null>(null);
const [createAttachment, setCreateAttachment] = useState<File | null>(null);
const [createError, setCreateError] = useState<string | null>(null);
const [createSubmitting, setCreateSubmitting] = useState(false);
const [commentsOpen, setCommentsOpen] = useState(false);
const [activePost, setActivePost] = useState<Post | null>(null);
const [editingPostId, setEditingPostId] = useState<number | null>(null);
const [postActionId, setPostActionId] = useState<number | null>(null);
const [postMenuOpenId, setPostMenuOpenId] = useState<number | null>(null);
const [likeBusyId, setLikeBusyId] = useState<number | null>(null);
const [likesPopoverPostId, setLikesPopoverPostId] = useState<number | null>(null);
const [likesLoadingPostId, setLikesLoadingPostId] = useState<number | null>(null);
const [likesUsersMap, setLikesUsersMap] = useState<Record<number, Array<{ id: number; first_name?: string; last_name?: string; full_name?: string; avatar?: string | null }>>>({});
const [likesUsersEndpointUnavailable, setLikesUsersEndpointUnavailable] = useState(false);
const postMenuRef = useRef<HTMLDivElement | null>(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<Post>;
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 (
<div className="py-8 text-center">
<div className="inline-block h-6 w-6 animate-spin rounded-full border-2 border-[var(--border-strong)] border-t-[var(--accent-primary)]" />
<p className="app-text-muted mt-3 text-sm">Загрузка новостей отдела...</p>
</div>
);
}
if (error) {
return (
<div className="app-feedback-danger rounded-xl p-4">
<p className="text-sm">{error}</p>
<button
type="button"
onClick={() => void loadPosts()}
className="app-action-secondary mt-3 rounded-lg px-3 py-2 text-sm"
>
Повторить
</button>
</div>
);
}
if (!posts.length) {
return (
<div className="app-surface rounded-xl px-4 py-8 text-center">
<p className="text-sm font-medium text-[var(--foreground)]">
В отделе пока нет публикаций
</p>
<p className="app-text-muted mt-2 text-sm">
Здесь будут появляться новости, объявления и важные сообщения отдела.
</p>
</div>
);
}
return (
<div className="space-y-3">
{posts.map((post) => {
return (
<FeedPostCard
key={post.id}
post={post}
authorSubtitle={
<span>
{post.created_at_display ||
new Date(post.created_at).toLocaleString("ru-RU")}
</span>
}
headerActions={(
(canEditPost(post) || canDeletePost(post)) ? (
<div
ref={postMenuOpenId === post.id ? postMenuRef : undefined}
className="relative shrink-0"
>
<button
type="button"
onClick={() => setPostMenuOpenId((prev) => (prev === post.id ? null : post.id))}
className="app-action-ghost flex h-8 w-8 items-center justify-center rounded-md"
title="Действия с публикацией"
aria-label="Действия с публикацией"
aria-expanded={postMenuOpenId === post.id}
aria-haspopup="menu"
>
<ChevronRight
size={15}
className={`transition-transform duration-200 ${postMenuOpenId === post.id ? "rotate-90" : ""}`}
/>
</button>
{postMenuOpenId === post.id ? (
<div className="app-menu absolute right-0 top-full z-20 mt-2 w-48 rounded-xl py-1.5">
{canPinPost ? (
<button
type="button"
disabled={postActionId === post.id}
onClick={() => void handlePinToggle(post)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)] disabled:opacity-50"
>
<Pin size={14} className={post.pinned ? "fill-current app-accent-text" : ""} />
{post.pinned ? "Открепить в ленте отдела" : "Закрепить в ленте отдела"}
</button>
) : null}
{canEditPost(post) ? (
<button
type="button"
onClick={() => {
setPostMenuOpenId(null);
openEditPostModal(post);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Pencil size={14} />
Редактировать
</button>
) : null}
{canDeletePost(post) ? (
<button
type="button"
disabled={postActionId === post.id}
onClick={() => {
setPostMenuOpenId(null);
void removePost(post);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--danger-foreground)] transition hover:bg-[var(--danger-soft)] disabled:opacity-50"
>
<Trash2 size={14} />
Удалить
</button>
) : null}
</div>
) : null}
</div>
) : null
)}
isLikeActive={Boolean(post.is_liked)}
likeDisabled={likeBusyId === post.id}
likesPopover={likesPopoverPostId === post.id ? (
<div className="app-menu absolute left-0 top-full z-20 mt-1 w-64 rounded-xl p-2">
<p className="app-text-muted px-1 pb-1 text-xs font-semibold">Лайкнули</p>
{likesLoadingPostId === post.id ? (
<p className="app-text-muted px-1 py-1 text-xs">Загрузка...</p>
) : likesUsersEndpointUnavailable ? (
<p className="app-text-muted px-1 py-1 text-xs">Список лайкнувших временно недоступен</p>
) : (likesUsersMap[post.id] || []).length === 0 ? (
<p className="app-text-muted px-1 py-1 text-xs">Пока нет лайков</p>
) : (
<div className="max-h-56 space-y-1 overflow-y-auto">
{(likesUsersMap[post.id] || []).map((person) => {
const name = formatUserName(person);
return (
<div key={person.id} className="flex items-center gap-2 rounded-lg px-1 py-1 hover:bg-[var(--surface-secondary)]">
<RequestAvatar
alt={name}
fallback={formatInitials(person.first_name, person.last_name)}
size="sm"
src={person.avatar}
/>
<p className="truncate text-xs text-[var(--foreground)]">{name}</p>
</div>
);
})}
</div>
)}
</div>
) : 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"
/>
);
})}
</div>
);
}, [
canDeletePost,
canEditPost,
canPinPost,
error,
formatInitials,
formatUserName,
handleLikeToggle,
handlePinToggle,
likeBusyId,
likesLoadingPostId,
likesPopoverPostId,
likesUsersEndpointUnavailable,
likesUsersMap,
loadPosts,
loading,
openCommentsModal,
openEditPostModal,
openLikesPopover,
postActionId,
postMenuOpenId,
posts,
removePost,
]);
return (
<>
<section className="app-surface rounded-2xl p-5">
<div className="mb-4 flex items-start justify-between gap-4">
<div className="min-w-0">
<p className="app-card-caption">Лента отдела</p>
<h2 className="mt-2 text-xl font-semibold text-[var(--foreground)]">
Новости {departmentName}
</h2>
<p className="app-text-muted mt-2 text-sm">
Публикации, объявления и важные сообщения отдела.
</p>
</div>
{canCreatePosts ? (
<button
type="button"
onClick={openCreateModal}
className="app-action-primary inline-flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-sm"
>
<Plus size={14} />
Создать публикацию
</button>
) : null}
</div>
<div className="app-surface-muted rounded-2xl p-4">
{postsContent}
</div>
</section>
<Modal
isOpen={createOpen}
onClose={closeCreateModal}
title={editingPostId ? "Редактировать публикацию отдела" : "Новая публикация отдела"}
size="lg"
footer={
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={closeCreateModal}
className="app-action-secondary rounded-lg px-4 py-2 text-sm"
>
Отмена
</button>
<button
type="button"
onClick={() => void submitCreatePost()}
disabled={createSubmitting}
className="app-action-primary rounded-lg px-4 py-2 text-sm disabled:opacity-50"
>
{createSubmitting
? editingPostId
? "Сохраняем..."
: "Публикуем..."
: editingPostId
? "Сохранить"
: "Опубликовать"}
</button>
</div>
}
>
<div className="space-y-4">
{createError ? (
<div className="app-feedback-danger rounded-lg px-3 py-2 text-sm">
{createError}
</div>
) : null}
<section className="app-surface-muted rounded-xl p-4">
<label className="mb-2 block text-sm font-medium text-[var(--foreground)]">
Заголовок *
</label>
<input
value={createTitle}
onChange={(event) => {
setCreateTitle(event.target.value);
if (createError) setCreateError(null);
}}
className="app-input w-full rounded-lg px-3 py-2 text-sm"
placeholder="Короткий заголовок публикации"
/>
</section>
<section className="app-surface-muted rounded-xl p-4">
<label className="mb-2 block text-sm font-medium text-[var(--foreground)]">
Текст
</label>
<textarea
value={createBody}
onChange={(event) => {
setCreateBody(event.target.value);
if (createError) setCreateError(null);
}}
rows={6}
className="app-input w-full rounded-xl px-3 py-2 text-sm"
placeholder="Текст новости, объявления или сообщения для отдела. Можно оставить пустым, если добавишь изображение или файл."
/>
</section>
<section className="app-surface-muted rounded-xl p-4">
<label className="mb-2 block text-sm font-medium text-[var(--foreground)]">
Изображение
</label>
<input
type="file"
accept="image/*"
onChange={(event) => {
setCreateImage(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(--accent-soft)] file:px-3 file:py-2 file:text-sm file:text-[var(--accent-primary-strong)] hover:file:bg-[color:color-mix(in_srgb,var(--accent-primary)_18%,var(--surface-primary))]"
/>
{createImage ? (
<div className="mt-3 inline-flex items-center gap-2 rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-secondary)] px-3 py-2 text-sm text-[var(--foreground)]">
<ImageIcon size={14} />
<span className="max-w-[18rem] truncate">{createImage.name}</span>
<button
type="button"
onClick={() => setCreateImage(null)}
className="app-text-muted hover:text-[var(--foreground)]"
aria-label="Убрать изображение"
>
<X size={14} />
</button>
</div>
) : null}
</section>
<section className="app-surface-muted rounded-xl p-4">
<label className="mb-2 block text-sm font-medium text-[var(--foreground)]">
Вложение
</label>
<input
type="file"
onChange={(event) => {
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 ? (
<div className="mt-3 inline-flex items-center gap-2 rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-secondary)] px-3 py-2 text-sm text-[var(--foreground)]">
<Paperclip size={14} />
<span className="max-w-[18rem] truncate">
{createAttachment.name}
</span>
<button
type="button"
onClick={() => setCreateAttachment(null)}
className="app-text-muted hover:text-[var(--foreground)]"
aria-label="Убрать вложение"
>
<X size={14} />
</button>
</div>
) : null}
</section>
</div>
</Modal>
<PostCommentsModal
currentUserId={currentUserId}
isOpen={commentsOpen}
onClose={closeCommentsModal}
onCommentCountChange={applyCommentCountDelta}
post={activePost}
/>
</>
);
}

View File

@ -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 (
<article id={`post-${post.id}`} className="app-surface rounded-2xl p-5">
<header className="mb-3 flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<RequestAvatar
alt={authorName}
fallback={authorFallback}
size="lg"
src={post.author?.avatar}
/>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-[var(--foreground)]">
{authorName}
</p>
<div className="app-text-muted flex items-center gap-1.5 text-xs">
{authorSubtitle}
{pinnedStyle === "inline" && post.pinned ? (
<span
className="app-accent-text inline-flex items-center justify-center"
title={pinMarkerTitle}
aria-label={pinMarkerTitle}
>
<Pin size={12} className="shrink-0 fill-current" />
</span>
) : null}
</div>
</div>
</div>
<div className="flex shrink-0 items-start gap-2">
{pinnedStyle === "badge" && post.pinned ? (
<span className="app-badge inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium">
<Pin size={12} className="app-accent-text fill-current" />
Закреплено
</span>
) : null}
{headerActions}
</div>
</header>
{post.title ? (
<h3 className="app-text-wrap mb-1 text-base font-semibold text-[var(--foreground)]">
{post.title}
</h3>
) : null}
{postText ? (
<p className="app-text-wrap whitespace-pre-line text-sm leading-6 text-[var(--foreground)]">
{postText}
</p>
) : null}
{resolvedImageSrc ? (
<div className="mt-3 overflow-hidden rounded-xl">
<Image
src={resolvedImageSrc}
alt={post.title || "Публикация"}
width={1200}
height={720}
className="max-h-72 w-full object-cover"
unoptimized
/>
</div>
) : null}
<div className="app-text-muted mt-4 flex flex-wrap items-center justify-between gap-3 text-sm">
<div className="flex items-center gap-4 text-sm">
<div className="relative" {...likesWrapperProps}>
{onLikeToggle ? (
<button
type="button"
disabled={likeDisabled}
onClick={() => onLikeToggle(post)}
className={resolvedLikeButtonClassName}
>
<Heart
size={16}
className={
isLikeActive
? "fill-[var(--accent-primary)] text-[var(--accent-primary)]"
: "app-text-muted"
}
/>
{post.likes_count || 0}
</button>
) : (
<span className="inline-flex items-center gap-2">
<Heart size={16} />
{post.likes_count || 0}
</span>
)}
{likesPopover}
</div>
<button
type="button"
onClick={() => onOpenComments(post)}
className={commentsButtonClassName}
>
<MessageSquare size={16} className="app-text-muted" />
{post.comments_count || 0}
</button>
{attachmentHref ? (
<a
href={attachmentHref}
target="_blank"
rel="noopener noreferrer"
className={
attachmentButtonClassName ||
"app-action-ghost inline-flex items-center gap-2 rounded-lg px-3 py-2"
}
>
<Paperclip size={16} className="app-text-muted" />
Вложение
</a>
) : null}
</div>
{footerAction}
</div>
</article>
);
}

View File

@ -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<Comment[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [draftImage, setDraftImage] = useState<File | null>(null);
const [draftAttachment, setDraftAttachment] = useState<File | null>(null);
const [sending, setSending] = useState(false);
const [actionId, setActionId] = useState<number | null>(null);
const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
const [editingCommentText, setEditingCommentText] = useState("");
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const attachmentRef = useRef<HTMLInputElement | null>(null);
const composerInputRef = useRef<HTMLTextAreaElement | null>(null);
const commentsBottomRef = useRef<HTMLDivElement | null>(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> | 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 (
<Modal
isOpen={isOpen && !!post}
onClose={onClose}
title="Комментарии"
size="lg"
noPadding
>
{post ? (
<div className="flex min-h-0 flex-1 flex-col">
<div className="shrink-0 space-y-4 px-4 pb-4 sm:px-6">
<section className="app-surface-muted rounded-xl p-4">
<p className="app-card-caption">Публикация</p>
<div className="mt-2 space-y-2">
<h3 className="app-text-wrap text-base font-semibold text-[var(--foreground)]">
{post.title || "Без заголовка"}
</h3>
{postPreview ? (
<p className="app-text-wrap app-text-muted line-clamp-3 text-sm leading-6">
{postPreview}
</p>
) : null}
<div className="app-text-muted flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
<span>{formatDate(post.created_at)}</span>
<span>
Комментариев: {Math.max(comments.length, post.comments_count || 0)}
</span>
</div>
</div>
</section>
{error ? (
<div className="app-feedback-danger rounded-xl p-3 text-sm">
{error}
</div>
) : null}
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4 sm:px-6">
<section className="app-surface rounded-xl p-4">
<div className="mb-3 flex items-center gap-2">
<MessageSquare size={16} className="app-text-muted" />
<p className="app-card-caption">Обсуждение</p>
</div>
{loading ? (
<div className="py-8 text-center">
<div className="inline-block h-6 w-6 animate-spin rounded-full border-2 border-[var(--border-strong)] border-t-[var(--accent-primary)]" />
<p className="app-text-muted mt-3 text-sm">
Загрузка комментариев...
</p>
</div>
) : comments.length === 0 ? (
<div className="app-surface-muted rounded-xl px-4 py-8 text-center">
<p className="text-sm font-medium text-[var(--foreground)]">
Комментариев пока нет
</p>
<p className="app-text-muted mt-2 text-sm">
Начни обсуждение первым.
</p>
</div>
) : (
<div className="space-y-3">
{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 (
<article
key={comment.id}
className="app-surface-muted rounded-xl p-3"
>
<div className="flex items-start gap-3">
<RequestAvatar
alt={authorName}
fallback={formatCommentFallback(comment)}
size="lg"
src={comment.author?.avatar}
/>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-[var(--foreground)]">
{authorName}
</p>
<p className="app-text-muted mt-0.5 text-xs">
{formatDate(comment.created_at)}
</p>
</div>
{(allowEdit || allowDelete) ? (
<div className="flex items-center gap-1">
{allowEdit ? (
<button
type="button"
onClick={() => beginEdit(comment)}
className="app-action-secondary inline-flex h-7 w-7 items-center justify-center rounded-lg"
title="Редактировать комментарий"
aria-label="Редактировать комментарий"
>
<Pencil size={14} />
</button>
) : null}
{allowDelete ? (
<CommentDeleteButton
disabled={actionId === comment.id}
onClick={() => void deleteComment(comment)}
/>
) : null}
</div>
) : null}
</div>
{editingCommentId === comment.id ? (
<div className="mt-3 space-y-2">
<textarea
value={editingCommentText}
onChange={(event) =>
setEditingCommentText(event.target.value)
}
className="app-input min-h-24 w-full rounded-lg p-3 text-sm resize-none"
/>
<div className="flex flex-wrap items-center justify-end gap-2">
<button
type="button"
onClick={cancelEdit}
className="app-action-secondary rounded-lg px-3 py-1.5 text-xs font-medium"
disabled={actionId === comment.id}
>
Отмена
</button>
<button
type="button"
onClick={() => void saveEditedComment(comment.id)}
className="app-action-primary rounded-lg px-3 py-1.5 text-xs font-medium disabled:opacity-50"
disabled={
actionId === comment.id ||
!editingCommentText.trim()
}
>
Сохранить
</button>
</div>
</div>
) : (
<>
{commentText ? (
<p className="app-text-wrap mt-2 text-sm leading-6 text-[var(--foreground)]">
{commentText}
</p>
) : null}
{comment.image ? (
<div className="mt-3 overflow-hidden rounded-xl">
<Image
src={resolveMediaUrl(comment.image)}
alt="Изображение комментария"
width={1200}
height={720}
className="max-h-64 w-full object-cover"
unoptimized
/>
</div>
) : null}
{comment.attachment ? (
<div className="mt-3">
<a
href={resolveMediaUrl(comment.attachment)}
target="_blank"
rel="noopener noreferrer"
className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm"
>
<Paperclip size={14} />
Открыть вложение
</a>
</div>
) : null}
</>
)}
</div>
</div>
</article>
);
})}
<div ref={commentsBottomRef} />
</div>
)}
</section>
</div>
<div className="app-surface-elevated shrink-0 border-t border-[var(--border-primary)] px-4 py-4 sm:px-6">
<div className="space-y-3">
{(draftImage || draftAttachment) ? (
<div className="flex flex-wrap gap-2">
{draftImage ? (
<div className="app-badge app-badge-accent inline-flex items-center gap-2 rounded-lg px-3 py-2 text-xs">
<ImageIcon size={12} />
<span className="max-w-[14rem] truncate">{draftImage.name}</span>
<button
type="button"
onClick={() => setDraftImage(null)}
className="app-accent-text hover:text-[var(--accent-primary)]"
aria-label="Убрать изображение"
>
<X size={12} />
</button>
</div>
) : null}
{draftAttachment ? (
<div className="app-badge inline-flex items-center gap-2 rounded-lg px-3 py-2 text-xs">
<Paperclip size={12} />
<span className="max-w-[14rem] truncate">
{draftAttachment.name}
</span>
<button
type="button"
onClick={() => setDraftAttachment(null)}
className="app-text-muted hover:text-[var(--foreground)]"
aria-label="Убрать вложение"
>
<X size={12} />
</button>
</div>
) : null}
</div>
) : null}
<input
ref={attachmentRef}
type="file"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0] || null;
if (file?.type?.startsWith("image/")) {
setDraftImage(file);
} else {
setDraftAttachment(file);
}
event.target.value = "";
if (error) setError(null);
}}
/>
<div className="flex items-end gap-2">
<div
className={`app-input relative min-w-0 flex-1 ${showEmojiPicker ? "overflow-visible" : "overflow-hidden"} shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] ${
composerExpanded ? "rounded-[1.5rem]" : "h-10 rounded-full"
}`}
data-composer-emoji="true"
>
<button
type="button"
onClick={() => attachmentRef.current?.click()}
className={`app-icon-button absolute left-3 z-10 inline-flex h-7 w-7 items-center justify-center rounded-full ${
composerExpanded ? "bottom-2.5" : "top-1/2 -translate-y-1/2"
}`}
title="Прикрепить файл"
aria-label="Прикрепить файл"
>
<Paperclip size={14} />
</button>
<button
type="button"
onClick={() => setShowEmojiPicker((value) => !value)}
className={`app-icon-button absolute right-3 z-10 inline-flex h-7 w-7 items-center justify-center rounded-full ${
composerExpanded ? "bottom-2.5" : "top-1/2 -translate-y-1/2"
}`}
title="Смайлы"
aria-label="Открыть панель смайлов"
aria-expanded={showEmojiPicker}
>
<Smile size={14} />
</button>
{showEmojiPicker ? (
<div className="app-menu absolute bottom-full right-0 z-20 mb-2 w-[260px] rounded-xl p-2">
<div className="grid max-h-48 grid-cols-8 gap-1 overflow-y-auto">
{ALL_REACTIONS.map((emoji) => (
<button
key={`comment-composer-${emoji}`}
type="button"
onClick={() => {
setDraft((value) => `${value}${emoji}`);
setShowEmojiPicker(false);
if (error) setError(null);
}}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-base transition hover:bg-sky-500/10"
>
{emoji}
</button>
))}
</div>
</div>
) : null}
<textarea
ref={composerInputRef}
value={draft}
onChange={(event) => {
setDraft(event.target.value);
if (error) setError(null);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void submitComment();
}
}}
rows={1}
placeholder="Введите комментарий..."
aria-label="Поле ввода комментария"
className={`w-full resize-none bg-transparent py-2.5 pl-11 pr-11 text-sm leading-5 text-[var(--foreground)] outline-none placeholder:text-[var(--muted-foreground)] ${
composerExpanded ? "rounded-[1.5rem]" : "rounded-full"
}`}
/>
</div>
<button
type="button"
onClick={() => void submitComment()}
disabled={sending || (!draft.trim() && !draftImage && !draftAttachment)}
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sky-500 text-white leading-none shadow-sm shadow-sky-200/70 transition hover:bg-sky-600 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-sky-100 disabled:cursor-not-allowed disabled:border disabled:border-gray-200 disabled:bg-gray-100 disabled:text-gray-400 disabled:shadow-none disabled:opacity-100"
title="Отправить комментарий"
aria-label="Отправить комментарий"
>
<Send size={15} />
</button>
</div>
</div>
</div>
</div>
) : null}
</Modal>
);
}

View File

@ -10,6 +10,7 @@ type CommentComposerProps = {
onSubmit: () => void | Promise<void>;
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({
<button
type="button"
onClick={() => void onSubmit()}
disabled={submitDisabled}
disabled={resolvedSubmitDisabled}
className="app-action-primary inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg disabled:opacity-50"
title="Отправить комментарий"
aria-label="Отправить комментарий"
@ -71,7 +74,7 @@ export function CommentComposer({
<button
type="button"
onClick={() => void onSubmit()}
disabled={submitDisabled}
disabled={resolvedSubmitDisabled}
className="app-action-primary inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg disabled:opacity-50"
title="Отправить комментарий"
aria-label="Отправить комментарий"

View File

@ -95,6 +95,8 @@ const EMPTY_USER_PERMS: DepartmentUserPermissions = {
can_manage: false,
can_change_head: false,
can_assign_roles: false,
can_publish_posts: false,
can_manage_feed: false,
};
const getErrorMessage = extractDepartmentApiErrorMessage;

View File

@ -62,6 +62,8 @@ export function createEmployeesApi(request: RequestFn) {
}),
getDepartmentMembers: (id: number | string) =>
request(`/api/v1/departments/${id}/members/`),
getDepartmentDiscussionChat: (id: number | string) =>
request(`/api/v1/departments/${id}/discussion-chat/`),
getDepartmentUserPerms: (id: number | string) =>
request(`/api/v1/departments/${id}/user-perms/`),
setDepartmentHead: (id: number | string, head_id: number | null) =>

View File

@ -2,6 +2,29 @@
import type { RequestFn, GetTokenFn } from './utils';
export function createFeedApi(request: RequestFn, getToken: GetTokenFn) {
type PinScope = 'global' | 'department';
async function extractErrorDetails(response: Response): Promise<string> {
const contentType = response.headers.get('content-type') || '';
try {
if (contentType.includes('application/json')) {
const data = await response.json();
if (typeof data?.detail === 'string' && data.detail.trim()) return data.detail;
const firstEntry = Object.entries(data || {})[0];
if (firstEntry) {
const value = firstEntry[1];
if (Array.isArray(value) && value[0]) return JSON.stringify(data);
if (typeof value === 'string' && value.trim()) return JSON.stringify(data);
}
return JSON.stringify(data);
}
const text = await response.text();
return text || response.statusText;
} catch {
return response.statusText;
}
}
async function formDataPost(url: string, method: string, data: Record<string, any>) {
const fd = new FormData();
if (data.title) fd.append('title', data.title);
@ -16,24 +39,33 @@ export function createFeedApi(request: RequestFn, getToken: GetTokenFn) {
const headers: HeadersInit = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const response = await fetch(url, { method, headers, body: fd });
if (!response.ok) { const t = await response.text(); console.error('API Error Response:', t); throw new Error(`API Error: ${response.status} ${response.statusText}`); }
if (!response.ok) {
const details = await extractErrorDetails(response);
console.error('API Error Response:', details);
throw new Error(`API Error: ${response.status} ${details}`);
}
return response.json();
}
return {
getPosts: (params?: { page?: number; search?: string; limit?: number }) => {
getPosts: (params?: { page?: number; search?: string; limit?: number; type?: string; department?: number | string; author?: number | string; pinned?: boolean; pinScope?: PinScope }) => {
const qp = new URLSearchParams();
if (params?.page) qp.append('page', params.page.toString());
if (params?.search) qp.append('search', params.search);
if (params?.limit) qp.append('limit', params.limit.toString());
if (params?.type) qp.append('type', params.type);
if (params?.department !== undefined) qp.append('department', String(params.department));
if (params?.author !== undefined) qp.append('author', String(params.author));
if (params?.pinned !== undefined) qp.append('pinned', String(params.pinned));
if (params?.pinScope) qp.append('pin_scope', params.pinScope);
const qs = qp.toString();
return request(`/api/v1/posts/${qs ? '?' + qs : ''}`);
},
createPost: (data: Record<string, any>) => formDataPost('/api/v1/posts/', 'POST', data),
updatePost: (postId: number, data: Record<string, any>) => formDataPost(`/api/v1/posts/${postId}/`, 'PATCH', data),
deletePost: (postId: number): Promise<void> => request(`/api/v1/posts/${postId}/`, { method: 'DELETE' }),
pinPost: (postId: number) => request(`/api/v1/posts/${postId}/pin/`, { method: 'POST' }),
unpinPost: (postId: number) => request(`/api/v1/posts/${postId}/unpin/`, { method: 'POST' }),
pinPost: (postId: number, scope: PinScope = 'global') => request(`/api/v1/posts/${postId}/pin/?scope=${scope}`, { method: 'POST' }),
unpinPost: (postId: number, scope: PinScope = 'global') => request(`/api/v1/posts/${postId}/unpin/?scope=${scope}`, { method: 'POST' }),
likePost: (postId: number) => request(`/api/v1/posts/${postId}/like/`, { method: 'POST' }),
unlikePost: (postId: number) => request(`/api/v1/posts/${postId}/unlike/`, { method: 'POST' }),
getPostLikers: (postId: number) => request(`/api/v1/posts/${postId}/likers/`),
@ -52,7 +84,11 @@ export function createFeedApi(request: RequestFn, getToken: GetTokenFn) {
const headers: HeadersInit = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const response = await fetch(`/api/v1/posts/${postId}/comments/`, { method: 'POST', headers, body: fd });
if (!response.ok) { const t = await response.text(); console.error('API Error Response:', t); throw new Error(`API Error: ${response.status} ${response.statusText}`); }
if (!response.ok) {
const details = await extractErrorDetails(response);
console.error('API Error Response:', details);
throw new Error(`API Error: ${response.status} ${details}`);
}
return response.json();
}
return request(`/api/v1/posts/${postId}/comments/`, { method: 'POST', body: JSON.stringify({ text }) });

View File

@ -139,6 +139,8 @@ export interface DepartmentUserPermissions {
can_manage: boolean;
can_change_head: boolean;
can_assign_roles: boolean;
can_publish_posts: boolean;
can_manage_feed: boolean;
}
export interface DepartmentPermissionChoice {
@ -187,8 +189,11 @@ export interface Post {
attachment_url?: string | null;
tags?: string[];
created_at: string;
created_at_display?: string;
updated_at: string;
pinned?: boolean;
pinned_global?: boolean;
pinned_department?: boolean;
likes_count: number;
comments_count: number;
is_liked?: boolean;