refactor(departments): unify department feed and discussion flows
This commit is contained in:
parent
8fa588bd55
commit
beb4b8b86b
@ -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)
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
# ============================================================
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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"]
|
||||
|
||||
18
backend/feed/migrations/0008_alter_post_body.py
Normal file
18
backend/feed/migrations/0008_alter_post_body.py
Normal 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='Текст'),
|
||||
),
|
||||
]
|
||||
62
backend/feed/migrations/0009_split_post_pins_by_scope.py
Normal file
62
backend/feed/migrations/0009_split_post_pins_by_scope.py
Normal 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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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"]),
|
||||
|
||||
@ -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:
|
||||
# Личная публикация - подписчикам
|
||||
|
||||
263
backend/tests/api/v1/employees/test_department_discussion.py
Normal file
263
backend/tests/api/v1/employees/test_department_discussion.py
Normal 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
|
||||
80
backend/tests/api/v1/employees/test_department_user_perms.py
Normal file
80
backend/tests/api/v1/employees/test_department_user_perms.py
Normal 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
|
||||
340
backend/tests/api/v1/feed/test_posts.py
Normal file
340
backend/tests/api/v1/feed/test_posts.py
Normal 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
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 |
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
770
frontend/src/components/departments/DepartmentFeedSection.tsx
Normal file
770
frontend/src/components/departments/DepartmentFeedSection.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
203
frontend/src/components/feed/FeedPostCard.tsx
Normal file
203
frontend/src/components/feed/FeedPostCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
625
frontend/src/components/feed/PostCommentsModal.tsx
Normal file
625
frontend/src/components/feed/PostCommentsModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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="Отправить комментарий"
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -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 }) });
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user