Merge branch 'develop'
This commit is contained in:
commit
e53e0e3712
@ -8,6 +8,4 @@ class ApiConfig(AppConfig):
|
||||
|
||||
def ready(self):
|
||||
"""Инициализация при загрузке приложения."""
|
||||
# Патч для django-scheduler теперь применяется в
|
||||
# scheduling.apps.SchedulingConfig
|
||||
pass
|
||||
|
||||
61
backend/api/docs_views.py
Normal file
61
backend/api/docs_views.py
Normal file
@ -0,0 +1,61 @@
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.urls import reverse
|
||||
from django.views.generic import TemplateView, View
|
||||
|
||||
|
||||
ASYNCAPI_SCHEMA_PATH = (
|
||||
Path(settings.BASE_DIR)
|
||||
/ "docs"
|
||||
/ "architecture"
|
||||
/ "websocket-asyncapi.yaml"
|
||||
)
|
||||
|
||||
|
||||
def _load_asyncapi_schema_text() -> str:
|
||||
try:
|
||||
return ASYNCAPI_SCHEMA_PATH.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise Http404("AsyncAPI schema file not found") from exc
|
||||
|
||||
|
||||
class AsyncAPISchemaView(View):
|
||||
"""Отдаёт AsyncAPI схему WebSocket-контура отдельным endpoint."""
|
||||
|
||||
http_method_names = ["get"]
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
schema_text = _load_asyncapi_schema_text()
|
||||
response = HttpResponse(
|
||||
schema_text,
|
||||
content_type="application/yaml; charset=utf-8",
|
||||
)
|
||||
response["Content-Disposition"] = (
|
||||
'inline; filename="websocket-asyncapi.yaml"'
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
class AsyncAPIDocsView(TemplateView):
|
||||
"""Простая HTML-страница с WS-документацией и raw AsyncAPI схемой."""
|
||||
|
||||
template_name = "api/websocket_docs.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
ws_scheme = "wss" if self.request.is_secure() else "ws"
|
||||
context.update(
|
||||
{
|
||||
"asyncapi_schema_url": reverse("api:ws-schema"),
|
||||
"openapi_schema_url": reverse("api:schema"),
|
||||
"swagger_url": reverse("api:swagger-ui"),
|
||||
"redoc_url": reverse("api:redoc"),
|
||||
"ws_endpoint_url": (
|
||||
f"{ws_scheme}://{self.request.get_host()}/ws/"
|
||||
),
|
||||
"schema_text": _load_asyncapi_schema_text(),
|
||||
}
|
||||
)
|
||||
return context
|
||||
@ -9,6 +9,7 @@ from drf_spectacular.views import (
|
||||
from rest_framework.permissions import AllowAny
|
||||
|
||||
from .auth.views import JWTTokenRefreshView, PhoneOrEmailTokenObtainPairView
|
||||
from .docs_views import AsyncAPIDocsView, AsyncAPISchemaView
|
||||
app_name = "api"
|
||||
|
||||
urlpatterns = [
|
||||
@ -38,6 +39,8 @@ urlpatterns = [
|
||||
),
|
||||
name="redoc",
|
||||
),
|
||||
path("ws/schema/", AsyncAPISchemaView.as_view(), name="ws-schema"),
|
||||
path("ws/docs/", AsyncAPIDocsView.as_view(), name="ws-docs"),
|
||||
path("v1/", include(("api.v1.urls", "v1"), namespace="v1")),
|
||||
path(
|
||||
"auth/token/",
|
||||
|
||||
@ -24,7 +24,6 @@ class SearchResultSerializer(serializers.Serializer):
|
||||
description = serializers.CharField(
|
||||
help_text="Описание/превью контента", allow_blank=True, required=False
|
||||
)
|
||||
url = serializers.CharField(help_text="Ссылка на объект", allow_blank=True)
|
||||
meta = serializers.DictField(
|
||||
help_text="Дополнительные метаданные", required=False, allow_null=True
|
||||
)
|
||||
|
||||
@ -53,7 +53,6 @@ def _format_employee(obj: Any) -> Dict[str, Any]:
|
||||
"description": getattr(obj.position, "name", "")
|
||||
if hasattr(obj, "position")
|
||||
else "",
|
||||
"url": f"/employees/{obj.pk}/",
|
||||
"meta": {
|
||||
"email": obj.email,
|
||||
"phone": str(obj.phone_number) if obj.phone_number else "",
|
||||
@ -68,7 +67,6 @@ def _format_department(obj: Any) -> Dict[str, Any]:
|
||||
"object_id": obj.pk,
|
||||
"title": obj.name,
|
||||
"description": obj.description or "",
|
||||
"url": f"/departments/{obj.pk}/",
|
||||
"meta": {
|
||||
"head": str(obj.head) if hasattr(obj, "head") and obj.head else None
|
||||
},
|
||||
@ -84,7 +82,6 @@ def _format_post(obj: Any) -> Dict[str, Any]:
|
||||
"description": obj.body[:200] + "..."
|
||||
if len(obj.body) > 200
|
||||
else obj.body,
|
||||
"url": f"/feed/{obj.pk}/",
|
||||
"meta": {
|
||||
"author": str(obj.author) if hasattr(obj, "author") else None,
|
||||
"created_at": obj.created_at.isoformat()
|
||||
@ -103,7 +100,6 @@ def _format_request(obj: Any) -> Dict[str, Any]:
|
||||
"description": obj.comment[:200] + "..."
|
||||
if obj.comment and len(obj.comment) > 200
|
||||
else (obj.comment or ""),
|
||||
"url": f"/requests/{obj.pk}/",
|
||||
"meta": {
|
||||
"status": obj.status,
|
||||
"type": obj.type,
|
||||
@ -119,7 +115,6 @@ def _format_chat(obj: Any) -> Dict[str, Any]:
|
||||
"object_id": obj.pk,
|
||||
"title": obj.name or f"Чат #{obj.pk}",
|
||||
"description": obj.description or "",
|
||||
"url": f"/messages?chat={obj.pk}",
|
||||
"meta": {
|
||||
"type": obj.type if hasattr(obj, "type") else None,
|
||||
},
|
||||
@ -137,14 +132,10 @@ def _format_message(obj: Any) -> Dict[str, Any]:
|
||||
"description": obj.content[:200] + "..."
|
||||
if len(obj.content) > 200
|
||||
else obj.content,
|
||||
"url": (
|
||||
f"/messages?chat={
|
||||
obj.chat.pk if hasattr(obj, 'chat') else ''
|
||||
}#msg-{obj.pk}"
|
||||
),
|
||||
"meta": {
|
||||
"author": str(obj.author) if hasattr(obj, "author") else None,
|
||||
"chat": str(obj.chat) if hasattr(obj, "chat") else None,
|
||||
"chat_id": obj.chat.pk if hasattr(obj, "chat") else None,
|
||||
},
|
||||
}
|
||||
|
||||
@ -156,7 +147,6 @@ def _format_event(obj: Any) -> Dict[str, Any]:
|
||||
"object_id": obj.pk,
|
||||
"title": obj.title,
|
||||
"description": obj.description or "",
|
||||
"url": f"/calendar?event={obj.pk}",
|
||||
"meta": {
|
||||
"start_date": obj.start_date.isoformat()
|
||||
if hasattr(obj, "start_date")
|
||||
@ -172,7 +162,6 @@ def _format_schedule_event(obj: Any) -> Dict[str, Any]:
|
||||
"object_id": obj.pk,
|
||||
"title": obj.title,
|
||||
"description": obj.description or "",
|
||||
"url": "/calendar/",
|
||||
"meta": {
|
||||
"start": obj.start.isoformat() if hasattr(obj, "start") else None,
|
||||
"end": obj.end.isoformat()
|
||||
@ -191,7 +180,6 @@ def _format_procurement_request(obj: Any) -> Dict[str, Any]:
|
||||
"description": obj.description[:200] + "..."
|
||||
if len(obj.description) > 200
|
||||
else obj.description,
|
||||
"url": f"/procurement/requests/{obj.pk}/",
|
||||
"meta": {
|
||||
"status": obj.status if hasattr(obj, "status") else None,
|
||||
"requestor": str(obj.requestor)
|
||||
@ -215,7 +203,6 @@ def _format_equipment(obj: Any) -> Dict[str, Any]:
|
||||
if obj.serial_number
|
||||
else f"Инв. №: {obj.inventory_number}"
|
||||
),
|
||||
"url": f"/procurement/equipment/{obj.pk}/",
|
||||
"meta": {
|
||||
"inventory_number": obj.inventory_number,
|
||||
"status": obj.status if hasattr(obj, "status") else None,
|
||||
@ -235,7 +222,6 @@ def _format_document(obj: Any) -> Dict[str, Any]:
|
||||
if obj.description and len(obj.description) > 200
|
||||
else (obj.description or "")
|
||||
),
|
||||
"url": f"/documents/{obj.pk}/",
|
||||
"meta": {
|
||||
"uploaded_by": str(obj.uploaded_by)
|
||||
if hasattr(obj, "uploaded_by") and obj.uploaded_by
|
||||
@ -247,23 +233,34 @@ def _format_document(obj: Any) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _get_notification_search_title(obj: Any) -> str:
|
||||
"""Строит заголовок уведомления для поиска без legacy-зависимостей."""
|
||||
data = getattr(obj, "data", None) or {}
|
||||
if isinstance(data, dict) and data.get("title"):
|
||||
return str(data["title"])
|
||||
|
||||
verb = getattr(obj, "verb", "") or "notification"
|
||||
return str(verb).replace("_", " ").title()
|
||||
|
||||
|
||||
def _get_notification_search_message(obj: Any) -> str:
|
||||
"""Возвращает текст уведомления для результатов поиска."""
|
||||
return getattr(obj, "description", "") or ""
|
||||
|
||||
|
||||
def _format_notification(obj: Any) -> Dict[str, Any]:
|
||||
"""Форматирует уведомление для API."""
|
||||
message = _get_notification_search_message(obj)
|
||||
|
||||
return {
|
||||
"model_name": "notification",
|
||||
"object_id": obj.pk,
|
||||
"title": obj.title,
|
||||
"description": obj.short_message
|
||||
or (
|
||||
obj.message[:150] + "..." if len(obj.message) > 150 else obj.message
|
||||
),
|
||||
"url": obj.action_url
|
||||
if hasattr(obj, "action_url") and obj.action_url
|
||||
else "/notifications/",
|
||||
"title": _get_notification_search_title(obj),
|
||||
"description": message[:150] + "..." if len(message) > 150 else message,
|
||||
"meta": {
|
||||
"is_read": obj.is_read if hasattr(obj, "is_read") else False,
|
||||
"created_at": obj.created_at.isoformat()
|
||||
if hasattr(obj, "created_at")
|
||||
"is_read": not getattr(obj, "unread", True),
|
||||
"created_at": obj.timestamp.isoformat()
|
||||
if hasattr(obj, "timestamp") and obj.timestamp
|
||||
else None,
|
||||
},
|
||||
}
|
||||
@ -296,7 +293,6 @@ def _format_result(obj: Any, model_name: str) -> Dict[str, Any]:
|
||||
"object_id": obj.pk,
|
||||
"title": str(obj),
|
||||
"description": "",
|
||||
"url": "#",
|
||||
"meta": {},
|
||||
}
|
||||
|
||||
@ -339,7 +335,6 @@ def search_api_view(request: DRFRequest) -> Response:
|
||||
"object_id": 123,
|
||||
"title": "Заголовок",
|
||||
"description": "Описание",
|
||||
"url": "/path/to/object/",
|
||||
"meta": {...}
|
||||
},
|
||||
...
|
||||
|
||||
@ -134,12 +134,20 @@ class ChatListSerializer(serializers.ModelSerializer):
|
||||
|
||||
@extend_schema_field(ChatLastMessageSerializer)
|
||||
def get_last_message(self, obj):
|
||||
"""Последнее сообщение в чате"""
|
||||
last_msg = (
|
||||
obj.messages.filter(is_deleted=False)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
"""Последнее сообщение в чате (использует prefetch если доступен)"""
|
||||
last_msg = None
|
||||
if (
|
||||
hasattr(obj, '_prefetched_last_message')
|
||||
and obj._prefetched_last_message
|
||||
):
|
||||
last_msg = obj._prefetched_last_message[0]
|
||||
else:
|
||||
last_msg = (
|
||||
obj.messages.filter(is_deleted=False)
|
||||
.select_related('author')
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
if last_msg:
|
||||
return {
|
||||
"id": last_msg.id,
|
||||
@ -153,12 +161,11 @@ class ChatListSerializer(serializers.ModelSerializer):
|
||||
|
||||
@extend_schema_field(serializers.ListField(child=serializers.CharField()))
|
||||
def get_participant_names(self, obj):
|
||||
"""Имена участников (для приватных чатов)"""
|
||||
"""Имена участников (для приватных чатов, из prefetch)"""
|
||||
if obj.type == "private":
|
||||
active_members = obj.memberships.filter(
|
||||
is_active=True
|
||||
).select_related("user")[:5]
|
||||
return [m.user.get_full_name() for m in active_members]
|
||||
# memberships уже prefetch'нуты в ChatViewSet.get_queryset
|
||||
members = list(obj.memberships.all())[:5]
|
||||
return [m.user.get_full_name() for m in members]
|
||||
return []
|
||||
|
||||
@extend_schema_field(
|
||||
|
||||
@ -72,6 +72,40 @@ def _parse_bool_query_param(value, default=True):
|
||||
return default
|
||||
|
||||
|
||||
def _build_message_search_snippet(message, query, max_length=140):
|
||||
query_normalized = (query or '').strip().lower()
|
||||
content = (message.content or '').strip()
|
||||
|
||||
if content:
|
||||
if not query_normalized:
|
||||
return f"{content[:max_length].rstrip()}…" if len(content) > max_length else content
|
||||
|
||||
content_normalized = content.lower()
|
||||
match_index = content_normalized.find(query_normalized)
|
||||
|
||||
if match_index < 0:
|
||||
return f"{content[:max_length].rstrip()}…" if len(content) > max_length else content
|
||||
|
||||
context_before = max_length // 3
|
||||
context_after = max_length - context_before - len(query_normalized)
|
||||
start = max(0, match_index - context_before)
|
||||
end = min(len(content), match_index + len(query_normalized) + context_after)
|
||||
snippet = content[start:end].strip()
|
||||
|
||||
if start > 0:
|
||||
snippet = f"…{snippet}"
|
||||
if end < len(content):
|
||||
snippet = f"{snippet}…"
|
||||
return snippet
|
||||
|
||||
attachment_names = [a.file_name for a in getattr(message, 'attachments').all() if a.file_name]
|
||||
if attachment_names:
|
||||
joined_names = ', '.join(attachment_names)
|
||||
return f"Файлы: {joined_names}"
|
||||
|
||||
return 'Сообщение без текста'
|
||||
|
||||
|
||||
class ChatViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet для управления чатами
|
||||
@ -116,7 +150,6 @@ class ChatViewSet(viewsets.ModelViewSet):
|
||||
queryset = Chat.objects.filter(
|
||||
Q(memberships__user=user, memberships__is_active=True)
|
||||
| Q(participants=user)
|
||||
| Q(participants=user)
|
||||
| Q(include_all_users=True)
|
||||
| Q(created_by=user) # Создатель всегда видит свои чаты
|
||||
).select_related(
|
||||
@ -139,7 +172,15 @@ class ChatViewSet(viewsets.ModelViewSet):
|
||||
'read_states',
|
||||
queryset=ChatReadState.objects.filter(user=user),
|
||||
to_attr='my_read_state'
|
||||
)
|
||||
),
|
||||
# Prefetch последнее сообщение чата (1 запрос вместо N)
|
||||
Prefetch(
|
||||
'messages',
|
||||
queryset=Message.objects.filter(
|
||||
is_deleted=False,
|
||||
).select_related('author').order_by('-created_at')[:1],
|
||||
to_attr='_prefetched_last_message'
|
||||
),
|
||||
).distinct()
|
||||
|
||||
# Исключаем чаты-комментарии из общего списка
|
||||
@ -562,14 +603,27 @@ class ChatViewSet(viewsets.ModelViewSet):
|
||||
self._send_marked_read_event(user.id, chat.id, last_message.id)
|
||||
|
||||
def _send_marked_read_event(self, user_id, chat_id, message_id):
|
||||
"""WebSocket событие для синхронизации между вкладками"""
|
||||
"""WebSocket событие для синхронизации и read receipts."""
|
||||
channel_layer = get_channel_layer()
|
||||
event_payload = {
|
||||
'chat_id': chat_id,
|
||||
'last_read_message_id': message_id,
|
||||
'reader_user_id': user_id,
|
||||
}
|
||||
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
f'user_{user_id}',
|
||||
{
|
||||
'type': 'chat_marked_read_sync',
|
||||
**event_payload,
|
||||
}
|
||||
)
|
||||
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
f'chat_{chat_id}',
|
||||
{
|
||||
'type': 'chat_marked_read',
|
||||
'chat_id': chat_id,
|
||||
'last_read_message_id': message_id
|
||||
**event_payload,
|
||||
}
|
||||
)
|
||||
|
||||
@ -607,7 +661,14 @@ class ChatViewSet(viewsets.ModelViewSet):
|
||||
queryset = chat.messages.filter(is_deleted=False).select_related(
|
||||
'author', 'reply_to', 'reply_to__author', 'poll'
|
||||
).prefetch_related(
|
||||
'attachments', 'reactions', 'reactions__user', 'poll__options'
|
||||
'attachments',
|
||||
'reactions',
|
||||
'reactions__user',
|
||||
'poll__options',
|
||||
Prefetch(
|
||||
'chat__read_states',
|
||||
queryset=ChatReadState.objects.select_related('user'),
|
||||
),
|
||||
)
|
||||
|
||||
# Определяем порядок сортировки в зависимости от типа запроса
|
||||
@ -706,7 +767,14 @@ class ChatViewSet(viewsets.ModelViewSet):
|
||||
queryset = chat.messages.filter(is_deleted=False).select_related(
|
||||
'author', 'reply_to', 'reply_to__author', 'poll'
|
||||
).prefetch_related(
|
||||
'attachments', 'reactions', 'reactions__user', 'poll__options'
|
||||
'attachments',
|
||||
'reactions',
|
||||
'reactions__user',
|
||||
'poll__options',
|
||||
Prefetch(
|
||||
'chat__read_states',
|
||||
queryset=ChatReadState.objects.select_related('user'),
|
||||
),
|
||||
)
|
||||
|
||||
# Если нет around_id, пытаемся получить last_read_message_id
|
||||
@ -821,6 +889,79 @@ class ChatViewSet(viewsets.ModelViewSet):
|
||||
'has_more_after': len(after) > after_limit
|
||||
})
|
||||
|
||||
@action(detail=True, methods=['get'], url_path='search-messages')
|
||||
def search_messages(self, request, pk=None):
|
||||
"""Поиск сообщений внутри одного чата с пагинацией и сниппетами."""
|
||||
chat = self.get_object()
|
||||
|
||||
if not user_can_access_chat(chat, request.user):
|
||||
return Response(
|
||||
{'error': 'Access denied'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
query = (request.query_params.get('q') or '').strip()
|
||||
|
||||
try:
|
||||
limit = min(max(int(request.query_params.get('limit', 20)), 1), 100)
|
||||
except (ValueError, TypeError):
|
||||
limit = 20
|
||||
|
||||
try:
|
||||
offset = max(int(request.query_params.get('offset', 0)), 0)
|
||||
except (ValueError, TypeError):
|
||||
offset = 0
|
||||
|
||||
if len(query) < 2:
|
||||
return Response({
|
||||
'query': query,
|
||||
'count': 0,
|
||||
'offset': offset,
|
||||
'next_offset': None,
|
||||
'results': [],
|
||||
})
|
||||
|
||||
queryset = chat.messages.filter(
|
||||
is_deleted=False,
|
||||
).select_related(
|
||||
'author'
|
||||
).prefetch_related(
|
||||
'attachments'
|
||||
).filter(
|
||||
Q(content__icontains=query) | Q(attachments__file_name__icontains=query)
|
||||
).distinct().order_by('-created_at', '-id')
|
||||
|
||||
total_count = queryset.count()
|
||||
matches = list(queryset[offset:offset + limit])
|
||||
next_offset = offset + limit if (offset + limit) < total_count else None
|
||||
|
||||
results = []
|
||||
for message in matches:
|
||||
author = message.author
|
||||
author_name = (
|
||||
f"{getattr(author, 'last_name', '')} {getattr(author, 'first_name', '')}".strip()
|
||||
or getattr(author, 'username', '')
|
||||
or 'Сотрудник'
|
||||
)
|
||||
attachments = list(message.attachments.all())
|
||||
results.append({
|
||||
'message_id': message.id,
|
||||
'content': message.content or '',
|
||||
'snippet': _build_message_search_snippet(message, query),
|
||||
'author_name': author_name,
|
||||
'created_at': message.created_at,
|
||||
'attachments_count': len(attachments),
|
||||
'has_attachments': bool(attachments),
|
||||
})
|
||||
|
||||
return Response({
|
||||
'query': query,
|
||||
'count': total_count,
|
||||
'offset': offset,
|
||||
'next_offset': next_offset,
|
||||
'results': results,
|
||||
})
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='mark-read')
|
||||
def mark_read(self, request, pk=None):
|
||||
"""
|
||||
@ -1058,9 +1199,15 @@ class MessageViewSet(viewsets.ModelViewSet):
|
||||
|
||||
# Перезагружаем с prefetch
|
||||
message = Message.objects.select_related(
|
||||
'author', 'reply_to', 'reply_to__author', 'poll'
|
||||
'author', 'reply_to', 'reply_to__author', 'poll', 'chat'
|
||||
).prefetch_related(
|
||||
'attachments', 'reactions', 'reactions__user'
|
||||
'attachments',
|
||||
'reactions',
|
||||
'reactions__user',
|
||||
Prefetch(
|
||||
'chat__read_states',
|
||||
queryset=ChatReadState.objects.select_related('user'),
|
||||
),
|
||||
).get(pk=message.id)
|
||||
|
||||
# Отправляем через WebSocket
|
||||
@ -1215,9 +1362,16 @@ class MessageViewSet(viewsets.ModelViewSet):
|
||||
# Перезагружаем с нуля чтобы получить обновленные attachments
|
||||
# Используем новый запрос, чтобы избежать кэширования
|
||||
instance = Message.objects.select_related(
|
||||
'author', 'reply_to', 'reply_to__author', 'poll'
|
||||
'author', 'reply_to', 'reply_to__author', 'poll', 'chat'
|
||||
).prefetch_related(
|
||||
'attachments', 'reactions', 'reactions__user', 'poll__options'
|
||||
'attachments',
|
||||
'reactions',
|
||||
'reactions__user',
|
||||
'poll__options',
|
||||
Prefetch(
|
||||
'chat__read_states',
|
||||
queryset=ChatReadState.objects.select_related('user'),
|
||||
),
|
||||
).get(pk=instance.id)
|
||||
|
||||
# WebSocket уведомление
|
||||
|
||||
@ -13,10 +13,10 @@ WebSocket Consumer Mixin для чатов.
|
||||
|
||||
import logging
|
||||
from channels.db import database_sync_to_async
|
||||
from django.db.models import Q
|
||||
from django.db.models import Prefetch, Q
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import Chat, ChatMembership, Message, MessageReaction
|
||||
from .models import Chat, ChatMembership, ChatReadState, Message, MessageReaction
|
||||
from .serialization import serialize_message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -299,17 +299,21 @@ class ChatConsumerMixin:
|
||||
}
|
||||
)
|
||||
|
||||
async def chat_marked_read(self, event):
|
||||
"""Синхронизация отметки прочитанного между вкладками.
|
||||
|
||||
Telegram-style.
|
||||
"""
|
||||
async def _send_marked_read_to_client(self, event, *, allow_self: bool):
|
||||
chat_id = event.get("chat_id")
|
||||
last_read_message_id = event.get("last_read_message_id")
|
||||
reader_user_id = event.get("reader_user_id")
|
||||
|
||||
if chat_id != self.active_chat_id:
|
||||
return
|
||||
|
||||
if not allow_self and reader_user_id == getattr(self.user, "id", None):
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"[ChatMixin.chat_marked_read] Sending to client: "
|
||||
f"chat={chat_id}, last_read_message_id={last_read_message_id}"
|
||||
f"chat={chat_id}, last_read_message_id={last_read_message_id}, "
|
||||
f"reader_user_id={reader_user_id}"
|
||||
)
|
||||
|
||||
await self.send_json(
|
||||
@ -317,9 +321,18 @@ class ChatConsumerMixin:
|
||||
"type": "marked_read",
|
||||
"chat_id": chat_id,
|
||||
"last_read_message_id": last_read_message_id,
|
||||
"reader_user_id": reader_user_id,
|
||||
}
|
||||
)
|
||||
|
||||
async def chat_marked_read(self, event):
|
||||
"""Read receipt для других участников активного чата."""
|
||||
await self._send_marked_read_to_client(event, allow_self=False)
|
||||
|
||||
async def chat_marked_read_sync(self, event):
|
||||
"""Синхронизация отметки прочитанного между вкладками читателя."""
|
||||
await self._send_marked_read_to_client(event, allow_self=True)
|
||||
|
||||
# ==================== Обработка действий пользователя ====================
|
||||
|
||||
async def _handle_send_message(self, content):
|
||||
@ -710,8 +723,15 @@ class ChatConsumerMixin:
|
||||
"""Получить последние сообщения чата"""
|
||||
messages = (
|
||||
Message.objects.filter(chat_id=chat_id)
|
||||
.select_related("author")
|
||||
.prefetch_related("attachments", "reactions")
|
||||
.select_related("author", "chat")
|
||||
.prefetch_related(
|
||||
"attachments",
|
||||
"reactions",
|
||||
Prefetch(
|
||||
"chat__read_states",
|
||||
queryset=ChatReadState.objects.select_related("user"),
|
||||
),
|
||||
)
|
||||
.order_by("-created_at")[:limit]
|
||||
)
|
||||
|
||||
|
||||
@ -48,6 +48,100 @@ def _get_author_url(author) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _is_message_read(m) -> bool:
|
||||
"""True, если сообщение прочитал хотя бы один другой участник."""
|
||||
chat = getattr(m, "chat", None)
|
||||
if (
|
||||
not chat
|
||||
or not getattr(m, "id", None)
|
||||
or not getattr(m, "author_id", None)
|
||||
):
|
||||
return False
|
||||
|
||||
prefetched = getattr(chat, "_prefetched_objects_cache", {})
|
||||
read_states = prefetched.get("read_states")
|
||||
if read_states is not None:
|
||||
return any(
|
||||
state.user_id != m.author_id
|
||||
and state.last_read_message_id
|
||||
and state.last_read_message_id >= m.id
|
||||
for state in read_states
|
||||
)
|
||||
|
||||
return chat.read_states.exclude(user_id=m.author_id).filter(
|
||||
last_read_message_id__gte=m.id
|
||||
).exists()
|
||||
|
||||
|
||||
def _get_user_avatar_url(user) -> str:
|
||||
if not user:
|
||||
return ""
|
||||
|
||||
try:
|
||||
if getattr(user, "avatar", None) and user.avatar:
|
||||
return user.avatar.url
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _serialize_reply_preview(reply_msg) -> dict:
|
||||
is_deleted = bool(getattr(reply_msg, "is_deleted", False))
|
||||
|
||||
return {
|
||||
"id": reply_msg.id,
|
||||
"content": reply_msg.content[:100] if reply_msg.content else "",
|
||||
"author_name": (
|
||||
reply_msg.author.get_full_name()
|
||||
if reply_msg.author
|
||||
else "Неизвестный"
|
||||
),
|
||||
"is_deleted": is_deleted,
|
||||
"has_attachments": bool(getattr(reply_msg, "has_attachments", False)),
|
||||
}
|
||||
|
||||
|
||||
def _get_message_read_by(m) -> list[dict]:
|
||||
"""Список участников, которые дочитали сообщение."""
|
||||
chat = getattr(m, "chat", None)
|
||||
if (
|
||||
not chat
|
||||
or not getattr(m, "id", None)
|
||||
or not getattr(m, "author_id", None)
|
||||
):
|
||||
return []
|
||||
|
||||
prefetched = getattr(chat, "_prefetched_objects_cache", {})
|
||||
read_states = prefetched.get("read_states")
|
||||
if read_states is None:
|
||||
read_states = chat.read_states.select_related("user").filter(
|
||||
last_read_message_id__gte=m.id
|
||||
)
|
||||
|
||||
readers = []
|
||||
for state in read_states:
|
||||
if state.user_id == m.author_id:
|
||||
continue
|
||||
if not state.last_read_message_id or state.last_read_message_id < m.id:
|
||||
continue
|
||||
|
||||
user = getattr(state, "user", None)
|
||||
if not user:
|
||||
continue
|
||||
|
||||
readers.append(
|
||||
{
|
||||
"id": user.id,
|
||||
"name": user.get_full_name() or user.username,
|
||||
"avatar": _get_user_avatar_url(user),
|
||||
}
|
||||
)
|
||||
|
||||
readers.sort(key=lambda item: item["name"].lower())
|
||||
return readers
|
||||
|
||||
|
||||
def serialize_message(m) -> dict:
|
||||
"""
|
||||
Сериализация сообщения с поддержкой всех полей.
|
||||
@ -63,12 +157,7 @@ def serialize_message(m) -> dict:
|
||||
"""
|
||||
author = m.author
|
||||
author_name = author.get_full_name() or author.username
|
||||
avatar = ""
|
||||
try:
|
||||
if getattr(author, "avatar", None) and author.avatar:
|
||||
avatar = author.avatar.url
|
||||
except Exception:
|
||||
avatar = ""
|
||||
avatar = _get_user_avatar_url(author)
|
||||
|
||||
# Базовые поля
|
||||
data = {
|
||||
@ -83,12 +172,15 @@ def serialize_message(m) -> dict:
|
||||
# Статусные поля
|
||||
"is_edited": m.is_edited,
|
||||
"edited_at": m.edited_at.isoformat() if m.edited_at else None,
|
||||
"is_read": _is_message_read(m),
|
||||
"read_by": _get_message_read_by(m),
|
||||
"is_deleted": m.is_deleted,
|
||||
"is_pinned": m.is_pinned,
|
||||
"is_forwarded": m.is_forwarded,
|
||||
"is_system": m.is_system,
|
||||
"has_attachments": m.has_attachments,
|
||||
}
|
||||
data["read_count"] = len(data["read_by"])
|
||||
|
||||
# Информация о пересылке (используем forward_metadata)
|
||||
if m.is_forwarded:
|
||||
@ -205,17 +297,7 @@ def serialize_message(m) -> dict:
|
||||
pk=m.reply_to_id
|
||||
)
|
||||
|
||||
data["reply_to"] = {
|
||||
"id": reply_msg.id,
|
||||
"content": (
|
||||
reply_msg.content[:100] if reply_msg.content else ""
|
||||
),
|
||||
"author_name": (
|
||||
reply_msg.author.get_full_name()
|
||||
if reply_msg.author
|
||||
else "Неизвестный"
|
||||
),
|
||||
}
|
||||
data["reply_to"] = _serialize_reply_preview(reply_msg)
|
||||
except Exception:
|
||||
# Если не удалось загрузить reply_to, просто пропускаем
|
||||
pass
|
||||
|
||||
560
backend/docs/architecture/websocket-asyncapi.yaml
Normal file
560
backend/docs/architecture/websocket-asyncapi.yaml
Normal file
@ -0,0 +1,560 @@
|
||||
asyncapi: 2.6.0
|
||||
info:
|
||||
title: EUSRR Realtime WebSocket API
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Отдельная AsyncAPI-спецификация для WebSocket-контура EUSRR.
|
||||
|
||||
HTTP API продолжает документироваться через OpenAPI в /api/schema/.
|
||||
Этот документ описывает только двусторонний realtime-канал /ws/.
|
||||
|
||||
Аутентификация:
|
||||
- query-параметр ?token=<jwt_access_token>
|
||||
- или заголовок Authorization: Bearer <jwt_access_token>
|
||||
|
||||
defaultContentType: application/json
|
||||
servers:
|
||||
development:
|
||||
url: localhost:9000/ws/
|
||||
protocol: ws
|
||||
description: Локальная разработка
|
||||
production:
|
||||
url: api.example.com/ws/
|
||||
protocol: wss
|
||||
description: Продакшен через TLS
|
||||
channels:
|
||||
userRealtime:
|
||||
address: /ws/
|
||||
description: |
|
||||
Универсальное пользовательское соединение для всех real-time сценариев:
|
||||
- сообщения и список чатов
|
||||
- уведомления и unread count
|
||||
- typing indicators
|
||||
- реакции и голосования
|
||||
- procurement updates
|
||||
publish:
|
||||
operationId: clientAction
|
||||
summary: Команды от клиента к серверу
|
||||
message:
|
||||
oneOf:
|
||||
- $ref: '#/components/messages/OpenChatAction'
|
||||
- $ref: '#/components/messages/CloseChatAction'
|
||||
- $ref: '#/components/messages/SendMessageAction'
|
||||
- $ref: '#/components/messages/EditMessageAction'
|
||||
- $ref: '#/components/messages/DeleteMessageAction'
|
||||
- $ref: '#/components/messages/AddReactionAction'
|
||||
- $ref: '#/components/messages/RemoveReactionAction'
|
||||
- $ref: '#/components/messages/TypingAction'
|
||||
- $ref: '#/components/messages/StopTypingAction'
|
||||
- $ref: '#/components/messages/VotePollAction'
|
||||
- $ref: '#/components/messages/MarkReadAction'
|
||||
subscribe:
|
||||
operationId: serverEvent
|
||||
summary: События от сервера клиенту
|
||||
message:
|
||||
oneOf:
|
||||
- $ref: '#/components/messages/PingEvent'
|
||||
- $ref: '#/components/messages/ErrorEvent'
|
||||
- $ref: '#/components/messages/ChatOpenedEvent'
|
||||
- $ref: '#/components/messages/ChatClosedEvent'
|
||||
- $ref: '#/components/messages/InitialMessagesEvent'
|
||||
- $ref: '#/components/messages/NewMessageEvent'
|
||||
- $ref: '#/components/messages/ListUpdateEvent'
|
||||
- $ref: '#/components/messages/MessageUpdatedEvent'
|
||||
- $ref: '#/components/messages/MessageDeletedEvent'
|
||||
- $ref: '#/components/messages/ReactionAddedEvent'
|
||||
- $ref: '#/components/messages/ReactionRemovedEvent'
|
||||
- $ref: '#/components/messages/TypingStartEvent'
|
||||
- $ref: '#/components/messages/TypingStopEvent'
|
||||
- $ref: '#/components/messages/PollUpdateEvent'
|
||||
- $ref: '#/components/messages/MarkedReadEvent'
|
||||
- $ref: '#/components/messages/NotificationEvent'
|
||||
- $ref: '#/components/messages/UnreadCountEvent'
|
||||
- $ref: '#/components/messages/NotificationReadEvent'
|
||||
- $ref: '#/components/messages/NotificationsReadAllEvent'
|
||||
- $ref: '#/components/messages/ProcurementUpdateEvent'
|
||||
components:
|
||||
schemas:
|
||||
ChatId:
|
||||
type: integer
|
||||
minimum: 1
|
||||
MessageId:
|
||||
type: integer
|
||||
minimum: 1
|
||||
UserId:
|
||||
type: integer
|
||||
minimum: 1
|
||||
Emoji:
|
||||
type: string
|
||||
minLength: 1
|
||||
GenericMessagePayload:
|
||||
type: object
|
||||
description: |
|
||||
Сериализованное сообщение чата. Полный набор полей зависит от
|
||||
communications.serialization.serialize_message.
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
content:
|
||||
type: string
|
||||
nullable: true
|
||||
chat:
|
||||
type: integer
|
||||
author:
|
||||
type: integer
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required:
|
||||
- id
|
||||
additionalProperties: true
|
||||
NotificationPayload:
|
||||
type: object
|
||||
description: Сериализованное уведомление пользователя.
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
verb:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
unread:
|
||||
type: boolean
|
||||
action_url:
|
||||
type: string
|
||||
nullable: true
|
||||
data:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
required:
|
||||
- id
|
||||
- verb
|
||||
- unread
|
||||
additionalProperties: true
|
||||
messages:
|
||||
OpenChatAction:
|
||||
name: OpenChatAction
|
||||
title: Открыть чат
|
||||
payload:
|
||||
type: object
|
||||
required: [action, chat_id]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [open_chat]
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
load_history:
|
||||
type: boolean
|
||||
default: false
|
||||
CloseChatAction:
|
||||
name: CloseChatAction
|
||||
title: Закрыть чат
|
||||
payload:
|
||||
type: object
|
||||
required: [action, chat_id]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [close_chat]
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
SendMessageAction:
|
||||
name: SendMessageAction
|
||||
title: Отправить сообщение
|
||||
payload:
|
||||
type: object
|
||||
required: [action]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [send_message]
|
||||
content:
|
||||
type: string
|
||||
text:
|
||||
type: string
|
||||
EditMessageAction:
|
||||
name: EditMessageAction
|
||||
title: Редактировать сообщение
|
||||
payload:
|
||||
type: object
|
||||
required: [action, message_id, content]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [edit_message]
|
||||
message_id:
|
||||
$ref: '#/components/schemas/MessageId'
|
||||
content:
|
||||
type: string
|
||||
DeleteMessageAction:
|
||||
name: DeleteMessageAction
|
||||
title: Удалить сообщение
|
||||
payload:
|
||||
type: object
|
||||
required: [action, message_id]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [delete_message]
|
||||
message_id:
|
||||
$ref: '#/components/schemas/MessageId'
|
||||
AddReactionAction:
|
||||
name: AddReactionAction
|
||||
title: Добавить реакцию
|
||||
payload:
|
||||
type: object
|
||||
required: [action, message_id, emoji]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [add_reaction]
|
||||
message_id:
|
||||
$ref: '#/components/schemas/MessageId'
|
||||
emoji:
|
||||
$ref: '#/components/schemas/Emoji'
|
||||
RemoveReactionAction:
|
||||
name: RemoveReactionAction
|
||||
title: Удалить реакцию
|
||||
payload:
|
||||
type: object
|
||||
required: [action, message_id, emoji]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [remove_reaction]
|
||||
message_id:
|
||||
$ref: '#/components/schemas/MessageId'
|
||||
emoji:
|
||||
$ref: '#/components/schemas/Emoji'
|
||||
TypingAction:
|
||||
name: TypingAction
|
||||
title: Пользователь печатает
|
||||
payload:
|
||||
type: object
|
||||
required: [action]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [typing]
|
||||
StopTypingAction:
|
||||
name: StopTypingAction
|
||||
title: Пользователь перестал печатать
|
||||
payload:
|
||||
type: object
|
||||
required: [action]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [stop_typing]
|
||||
VotePollAction:
|
||||
name: VotePollAction
|
||||
title: Голосование в опросе
|
||||
payload:
|
||||
type: object
|
||||
required: [action]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [vote_poll]
|
||||
additionalProperties: true
|
||||
MarkReadAction:
|
||||
name: MarkReadAction
|
||||
title: Deprecated mark_read
|
||||
payload:
|
||||
type: object
|
||||
required: [action, chat_id]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [mark_read]
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
note:
|
||||
type: string
|
||||
example: Deprecated, используйте HTTP mark-read endpoint
|
||||
PingEvent:
|
||||
name: PingEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, timestamp]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [ping]
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
ErrorEvent:
|
||||
name: ErrorEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, error]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [error]
|
||||
error:
|
||||
type: string
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
ChatOpenedEvent:
|
||||
name: ChatOpenedEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, chat_id]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [chat_opened]
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
ChatClosedEvent:
|
||||
name: ChatClosedEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, chat_id]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [chat_closed]
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
InitialMessagesEvent:
|
||||
name: InitialMessagesEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, chat_id, messages]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [initial_messages]
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
messages:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/GenericMessagePayload'
|
||||
NewMessageEvent:
|
||||
name: NewMessageEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, chat_id, message]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [new_message]
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
message:
|
||||
$ref: '#/components/schemas/GenericMessagePayload'
|
||||
ListUpdateEvent:
|
||||
name: ListUpdateEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, chat_id, message]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [list_update]
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
message:
|
||||
$ref: '#/components/schemas/GenericMessagePayload'
|
||||
MessageUpdatedEvent:
|
||||
name: MessageUpdatedEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, chat_id, message]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [message_updated]
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
message:
|
||||
$ref: '#/components/schemas/GenericMessagePayload'
|
||||
MessageDeletedEvent:
|
||||
name: MessageDeletedEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, chat_id, message_id]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [message_deleted]
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
message_id:
|
||||
$ref: '#/components/schemas/MessageId'
|
||||
ReactionAddedEvent:
|
||||
name: ReactionAddedEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, chat_id, message_id, emoji, user_id]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [reaction_added]
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
message_id:
|
||||
$ref: '#/components/schemas/MessageId'
|
||||
emoji:
|
||||
$ref: '#/components/schemas/Emoji'
|
||||
user_id:
|
||||
$ref: '#/components/schemas/UserId'
|
||||
user_name:
|
||||
type: string
|
||||
reactions_summary:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
ReactionRemovedEvent:
|
||||
name: ReactionRemovedEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, chat_id, message_id, emoji, user_id]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [reaction_removed]
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
message_id:
|
||||
$ref: '#/components/schemas/MessageId'
|
||||
emoji:
|
||||
$ref: '#/components/schemas/Emoji'
|
||||
user_id:
|
||||
$ref: '#/components/schemas/UserId'
|
||||
reactions_summary:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
TypingStartEvent:
|
||||
name: TypingStartEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, chat_id, user_id]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [typing_start]
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
user_id:
|
||||
$ref: '#/components/schemas/UserId'
|
||||
user_name:
|
||||
type: string
|
||||
TypingStopEvent:
|
||||
name: TypingStopEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, chat_id, user_id]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [typing_stop]
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
user_id:
|
||||
$ref: '#/components/schemas/UserId'
|
||||
PollUpdateEvent:
|
||||
name: PollUpdateEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, chat_id]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [poll_update]
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
poll_id:
|
||||
type: integer
|
||||
message_id:
|
||||
$ref: '#/components/schemas/MessageId'
|
||||
results:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
MarkedReadEvent:
|
||||
name: MarkedReadEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, chat_id, last_read_message_id]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [marked_read]
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
last_read_message_id:
|
||||
$ref: '#/components/schemas/MessageId'
|
||||
NotificationEvent:
|
||||
name: NotificationEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, notification]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [notification]
|
||||
notification:
|
||||
$ref: '#/components/schemas/NotificationPayload'
|
||||
UnreadCountEvent:
|
||||
name: UnreadCountEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, count]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [unread_count]
|
||||
count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
NotificationReadEvent:
|
||||
name: NotificationReadEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, notification_id, unread_count]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [notification_read]
|
||||
notification_id:
|
||||
type: integer
|
||||
unread_count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
NotificationsReadAllEvent:
|
||||
name: NotificationsReadAllEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, notification_ids, unread_count]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [notifications_read_all]
|
||||
notification_ids:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
category:
|
||||
type: string
|
||||
nullable: true
|
||||
unread_count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
ProcurementUpdateEvent:
|
||||
name: ProcurementUpdateEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, event, data]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [procurement_update]
|
||||
event:
|
||||
type: string
|
||||
data:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
@ -1,6 +1,7 @@
|
||||
"""
|
||||
API endpoints для системы уведомлений v2.0
|
||||
|
||||
|
||||
Новая архитектура:
|
||||
- Notification с GenericForeignKey (actor/action_object/target)
|
||||
- verb вместо category/type
|
||||
@ -35,16 +36,6 @@ from .serializers import (
|
||||
)
|
||||
|
||||
|
||||
def _get_notification_title(notification):
|
||||
"""Получить заголовок уведомления из data или сгенерировать fallback"""
|
||||
# Приоритет: title из data (задается при создании уведомления)
|
||||
if notification.data and 'title' in notification.data:
|
||||
return notification.data['title']
|
||||
|
||||
# Fallback: генерируем простой заголовок из verb
|
||||
return notification.verb.replace('_', ' ').title()
|
||||
|
||||
|
||||
def _format_time_or_none(value):
|
||||
if value is None:
|
||||
return None
|
||||
@ -126,7 +117,11 @@ def get_notifications(request):
|
||||
{
|
||||
'id': n.id,
|
||||
# Frontend ожидает title, message, is_read, created_at
|
||||
'title': _get_notification_title(n),
|
||||
'title': (
|
||||
n.data['title']
|
||||
if n.data and 'title' in n.data
|
||||
else n.verb.replace('_', ' ').title()
|
||||
),
|
||||
'message': n.description,
|
||||
'short_message': (
|
||||
n.description[:100] + '...'
|
||||
|
||||
137
backend/templates/api/websocket_docs.html
Normal file
137
backend/templates/api/websocket_docs.html
Normal file
@ -0,0 +1,137 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>EUSRR WebSocket AsyncAPI</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #f6f8fb;
|
||||
color: #1f2937;
|
||||
}
|
||||
.page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px 48px;
|
||||
}
|
||||
.hero {
|
||||
background: linear-gradient(135deg, #ffffff, #eef6ff);
|
||||
border: 1px solid #dbe5f1;
|
||||
border-radius: 20px;
|
||||
padding: 28px;
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
h1, h2 {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
p {
|
||||
line-height: 1.6;
|
||||
}
|
||||
.links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin: 20px 0 0;
|
||||
}
|
||||
.links a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
text-decoration: none;
|
||||
border: 1px solid #c7d7ea;
|
||||
background: #fff;
|
||||
color: #0f4c81;
|
||||
font-weight: 600;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #dbe5f1;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
code, pre {
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
code {
|
||||
background: #eef4fb;
|
||||
border-radius: 6px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: #0f172a;
|
||||
color: #dbeafe;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
overflow-x: auto;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.section {
|
||||
margin-top: 28px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<section class="hero">
|
||||
<h1>WebSocket AsyncAPI</h1>
|
||||
<p>
|
||||
Отдельная документация realtime-контура EUSRR. HTTP API продолжает
|
||||
жить в OpenAPI, а WebSocket-контур документируется отдельно через
|
||||
AsyncAPI, без смешивания двух протоколов в одной схеме.
|
||||
</p>
|
||||
<div class="links">
|
||||
<a href="{{ asyncapi_schema_url }}">Скачать AsyncAPI YAML</a>
|
||||
<a href="{{ swagger_url }}">HTTP Swagger</a>
|
||||
<a href="{{ redoc_url }}">HTTP ReDoc</a>
|
||||
<a href="{{ openapi_schema_url }}">HTTP OpenAPI schema</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid section">
|
||||
<article class="card">
|
||||
<h2>Точка подключения</h2>
|
||||
<p><code>{{ ws_endpoint_url }}</code></p>
|
||||
<p>
|
||||
Аутентификация поддерживает либо query-параметр
|
||||
<code>?token=<jwt_access_token></code>, либо заголовок
|
||||
<code>Authorization: Bearer <jwt_access_token></code>.
|
||||
</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>Что описано</h2>
|
||||
<p>
|
||||
В схеме отражены клиентские action-сообщения и серверные
|
||||
realtime-события для чатов, уведомлений, typing, реакций,
|
||||
read-sync и procurement updates.
|
||||
</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>Почему отдельно</h2>
|
||||
<p>
|
||||
Swagger и ReDoc используют OpenAPI-схему HTTP API. Для
|
||||
WebSocket-протокола отдельный AsyncAPI-документ чище,
|
||||
понятнее и лучше масштабируется.
|
||||
</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Содержимое AsyncAPI</h2>
|
||||
<pre>{{ schema_text }}</pre>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -51,3 +51,27 @@ def test_swagger_and_redoc_pages_are_available(api_client: APIClient):
|
||||
|
||||
assert "swagger-ui" in swagger_response.content.decode().lower()
|
||||
assert "redoc" in redoc_response.content.decode().lower()
|
||||
|
||||
|
||||
def test_websocket_asyncapi_schema_is_available(api_client: APIClient):
|
||||
response = api_client.get(reverse("api:ws-schema"))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "application/yaml" in response["Content-Type"]
|
||||
|
||||
schema = response.content.decode()
|
||||
assert "asyncapi: 2.6.0" in schema
|
||||
assert "address: /ws/" in schema
|
||||
assert "OpenChatAction" in schema
|
||||
assert "NotificationEvent" in schema
|
||||
|
||||
|
||||
def test_websocket_asyncapi_docs_page_is_available(api_client: APIClient):
|
||||
response = api_client.get(reverse("api:ws-docs"))
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
html = response.content.decode().lower()
|
||||
assert "websocket asyncapi" in html
|
||||
assert reverse("api:ws-schema") in response.content.decode()
|
||||
assert "/ws/" in response.content.decode()
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from communications.models import Chat, ChatMembership, ChatUserSettings, Message
|
||||
from communications.models import Chat, ChatMembership, ChatReadState, ChatUserSettings, Message
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.urls import reverse
|
||||
from employees.models import Department
|
||||
@ -201,6 +201,39 @@ class TestChatViewSet:
|
||||
assert "messages" in response.data
|
||||
assert len(response.data["messages"]) == 5
|
||||
|
||||
def test_chat_messages_include_read_by_users(self, auth_client, private_chat, user1, user2):
|
||||
"""Payload сообщения содержит список пользователей, которые его прочитали."""
|
||||
user2.avatar = "avatars/read-by-user2.jpg"
|
||||
user2.save(update_fields=["avatar"])
|
||||
|
||||
message = Message.objects.create(
|
||||
chat=private_chat,
|
||||
author=user1,
|
||||
content="Read me",
|
||||
)
|
||||
read_state = ChatReadState.objects.get(
|
||||
chat=private_chat,
|
||||
user=user2,
|
||||
)
|
||||
read_state.last_read_message = message
|
||||
read_state.unread_count = 0
|
||||
read_state.save(update_fields=["last_read_message", "unread_count", "updated_at"])
|
||||
|
||||
url = f"/api/v1/communications/chats/{private_chat.pk}/messages/"
|
||||
response = auth_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
payload = response.data["messages"][0]
|
||||
assert payload["is_read"] is True
|
||||
assert payload["read_count"] == 1
|
||||
assert payload["read_by"] == [
|
||||
{
|
||||
"id": user2.id,
|
||||
"name": user2.get_full_name(),
|
||||
"avatar": user2.avatar.url,
|
||||
}
|
||||
]
|
||||
|
||||
def test_mark_read(self, auth_client, private_chat, user1):
|
||||
"""Пометка чата как прочитанного"""
|
||||
url = f"/api/v1/communications/chats/{private_chat.pk}/mark-read/"
|
||||
@ -2418,6 +2451,35 @@ class TestDeleteVariousMessageTypes:
|
||||
assert reply2.is_deleted is False
|
||||
assert reply1.reply_to_id == original.id
|
||||
|
||||
def test_messages_around_keeps_deleted_reply_preview_context_contract(
|
||||
self, auth_client, private_chat, user1
|
||||
):
|
||||
"""Ответ на удаленное сообщение сохраняет контекст preview и помечается как deleted."""
|
||||
original = Message.objects.create(
|
||||
chat=private_chat, author=user1, content="Original to delete"
|
||||
)
|
||||
reply = Message.objects.create(
|
||||
chat=private_chat, author=user1, content="Reply", reply_to=original
|
||||
)
|
||||
|
||||
delete_url = f"/api/v1/communications/messages/{original.pk}/"
|
||||
delete_response = auth_client.delete(delete_url)
|
||||
|
||||
assert delete_response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
url = f"/api/v1/communications/chats/{private_chat.pk}/messages-around/"
|
||||
response = auth_client.get(url, {"around_id": reply.id})
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
serialized_reply = next(
|
||||
item for item in response.data["messages"] if item["id"] == reply.id
|
||||
)
|
||||
|
||||
assert serialized_reply["reply_to"]["id"] == original.id
|
||||
assert serialized_reply["reply_to"]["is_deleted"] is True
|
||||
assert serialized_reply["reply_to"]["content"] == "Original to delete"
|
||||
assert "author_name" in serialized_reply["reply_to"]
|
||||
|
||||
def test_bulk_delete_various_types(self, auth_client, private_chat, user1):
|
||||
"""Массовое удаление сообщений разных типов"""
|
||||
from communications.models import MessageAttachment, Poll, PollOption
|
||||
|
||||
@ -10,6 +10,7 @@ from rest_framework.test import APIClient
|
||||
|
||||
from employees.models import Employee, Department, Position
|
||||
from feed.models import Post
|
||||
from notifications.models import Notification
|
||||
from requests_app.models import Request
|
||||
|
||||
|
||||
@ -251,7 +252,7 @@ class TestSearchAPI:
|
||||
assert 'model_name' in result
|
||||
assert 'object_id' in result
|
||||
assert 'title' in result
|
||||
assert 'url' in result
|
||||
assert 'url' not in result
|
||||
|
||||
def test_search_request_permissions(self, api_client, sample_data):
|
||||
"""Пользователи видят только свои заявления."""
|
||||
@ -296,6 +297,30 @@ class TestSearchAPI:
|
||||
posts = [r for r in response.data['results'] if r['model_name'] == 'post']
|
||||
assert len(posts) > 0
|
||||
|
||||
def test_search_formats_notification_v2_without_500(self, api_client, regular_user):
|
||||
"""Поиск по уведомлениям v2 не падает на legacy-ожиданиях формата."""
|
||||
from django.core.management import call_command
|
||||
|
||||
Notification.objects.create(
|
||||
recipient=regular_user,
|
||||
verb="system_notice",
|
||||
description="Это обычный текст уведомления для поиска",
|
||||
data={"title": "Важное уведомление"},
|
||||
action_url="/documents/5/",
|
||||
)
|
||||
call_command('buildwatson', verbosity=0)
|
||||
|
||||
response = api_client.get('/api/v1/search/?q=обычный')
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
notifications = [
|
||||
r for r in response.data['results'] if r['model_name'] == 'notification'
|
||||
]
|
||||
assert len(notifications) > 0
|
||||
assert notifications[0]['title'] == 'Важное уведомление'
|
||||
assert 'обычный текст уведомления' in notifications[0]['description'].lower()
|
||||
assert 'url' not in notifications[0]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestSearchIndexing:
|
||||
|
||||
150
docs/reports/CHAT_REFACTORING_ANALYSIS.md
Normal file
150
docs/reports/CHAT_REFACTORING_ANALYSIS.md
Normal file
@ -0,0 +1,150 @@
|
||||
# Отчёт: Рефакторинг чатов EUSRR + Сравнительный анализ
|
||||
|
||||
## 1. Что было сделано
|
||||
|
||||
### 1.1 Frontend — Декомпозиция монолита `page.tsx`
|
||||
|
||||
| Метрика | До | После |
|
||||
|---|---|---|
|
||||
| Строк в `page.tsx` | **1 989** | **893** (−55%) |
|
||||
| `useState` в `page.tsx` | 40+ | 18 (UI-only) |
|
||||
| `useEffect` в `page.tsx` | 15+ | 10 |
|
||||
| `useCallback` в `page.tsx` | 20+ | ~12 |
|
||||
| Выделенные хуки | 0 | 3 новых |
|
||||
|
||||
**Новые файлы:**
|
||||
|
||||
- `hooks/useMarkRead.ts` — дебаунсированная отметка прочтения с очередью, watermark-синхронизация.
|
||||
- `hooks/useChatMessages.ts` — состояние сообщений, пагинация, pending/optimistic, unread-трекинг, навигация по дате, возврат к непрочитанным.
|
||||
- `hooks/useChatScroll.ts` — управление скроллом, floating date, кнопка «вниз», определение позиции.
|
||||
|
||||
### 1.2 Исправление бага со счётчиком непрочитанных
|
||||
|
||||
**Причина:** `flushMarkRead` после `markChatAsRead` вызывал `getChat(chatId)` и перезаписывал `newMessagesBelowCount` серверным значением (0), из-за чего бейдж исчезал мгновенно.
|
||||
|
||||
**Решение:** `onReadAcknowledged` в `useMarkRead` теперь **не трогает** локальный счётчик. Сброс `newMessagesBelowCount` происходит **только** когда пользователь физически доскроллит до низа (`syncScrollToBottomState`). Серверный `chat.unread_count` обновляется отдельно через `setChat(fresh)`.
|
||||
|
||||
### 1.3 Backend — N+1 оптимизации
|
||||
|
||||
| Проблема | Файл | Решение |
|
||||
|---|---|---|
|
||||
| `get_last_message` делал N запросов (1 на чат) | `serializers.py` | Добавлен `Prefetch('messages', ...)` с `to_attr='_prefetched_last_message'` в `ChatViewSet.get_queryset` |
|
||||
| `get_participant_names` перезапрашивал memberships | `serializers.py` | Теперь использует уже prefetch'нутый `obj.memberships.all()` |
|
||||
| Дублирующийся `Q(participants=user)` | `viewsets.py` | Удалён дубль |
|
||||
|
||||
**Уже было оптимизировано (не трогали):**
|
||||
- `reactions__user` — prefetch в `MessageViewSet.get_queryset` ✓
|
||||
- `ChatReadState.unread_count` — денормализовано через сигнал ✓
|
||||
- `react`/`unreact` — уже используют `select_related('user')` ✓
|
||||
|
||||
---
|
||||
|
||||
## 2. Оценка нагрузки на БД
|
||||
|
||||
### 2.1 Список чатов (`GET /chats/`)
|
||||
|
||||
| Ресурс | Запросов (до) | Запросов (после) |
|
||||
|---|---|---|
|
||||
| Основной запрос чатов | 1 | 1 |
|
||||
| Prefetch memberships | 1 | 1 |
|
||||
| Prefetch user_settings | 1 | 1 |
|
||||
| Prefetch read_states | 1 | 1 |
|
||||
| **last_message** | **N** (1 на чат) | **1** (prefetch) |
|
||||
| **participant_names** | **N** (1 на чат) | **0** (из prefetch) |
|
||||
| **ИТОГО для 50 чатов** | **~104** | **~5** |
|
||||
|
||||
### 2.2 Страница чата (`GET /chats/{id}/`, `GET /messages/`)
|
||||
|
||||
| Операция | Запросов |
|
||||
|---|---|
|
||||
| Загрузка чата | 5 (chat + 4 prefetch) |
|
||||
| Загрузка 30 сообщений вокруг anchor | 1 + 4 prefetch = 5 |
|
||||
| WebSocket new_message | 0 (из памяти) |
|
||||
| mark-read (дебаунс 300ms) | 1 POST + 1 GET (refresh chat) |
|
||||
| Реакция | 1 POST + 1 SELECT (summary) |
|
||||
| **Типичное открытие чата** | **~12 запросов** |
|
||||
|
||||
### 2.3 Узкие места (остаются)
|
||||
|
||||
1. **`get_last_message` в retrieve** — для единичного `getChat()` prefetch не помогает (1 запрос). Допустимо.
|
||||
2. **`PollViewSet.vote`** — `option.votes.count()` в цикле (N+1). Нужен `annotate(Count('votes'))`.
|
||||
3. **`ChatViewSet.create`** — `User.objects.get(id=user_id)` в цикле при добавлении участников. Нужен `User.objects.filter(id__in=...)`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Сравнение с популярными мессенджерами
|
||||
|
||||
### 3.1 Архитектура доставки сообщений
|
||||
|
||||
| Фича | Telegram | WhatsApp | Slack | **EUSRR** |
|
||||
|---|---|---|---|---|
|
||||
| Транспорт | MTProto (custom) | Signal Protocol | WebSocket | **WebSocket** |
|
||||
| Оффлайн-очередь | Push + pull при connect | Push + pull | Pull при connect | **Pull при connect** (fallback sync) |
|
||||
| E2E шифрование | Secret Chats only | Да | Enterprise only | **Нет** (корп. сеть) |
|
||||
| Оптимистичные сообщения | Да | Да | Да | **Да** ✓ |
|
||||
| Pending → Delayed → Failed | Да (часы/галочки) | Да | Нет | **Да** ✓ |
|
||||
|
||||
### 3.2 Управление прочтением
|
||||
|
||||
| Фича | Telegram | WhatsApp | Slack | **EUSRR** |
|
||||
|---|---|---|---|---|
|
||||
| Подсчёт непрочитанных | Денормализован | Денормализован | Денормализован (badge API) | **Денормализован** (`ChatReadState.unread_count`) ✓ |
|
||||
| Mark-as-read | При открытии (2с задержка) | При открытии | При фокусе канала | **При скролле до сообщения** (300ms debounce) |
|
||||
| Двойные галочки (read receipt) | Да | Да | Нет | **Да** (`is_read`, `read_by`) ✓ |
|
||||
| Кто прочитал | Нет (кроме групп Premium) | Нет | Hover-подсказка | **Модальное окно** ✓ |
|
||||
|
||||
### 3.3 Пагинация и скролл
|
||||
|
||||
| Фича | Telegram | WhatsApp | Slack | **EUSRR** |
|
||||
|---|---|---|---|---|
|
||||
| Пагинация | Бикурсорная (around + offset) | Local-first DB (SQLite) | Cursor-based | **Бикурсорная** (`before_id`/`after_id`/`around_id`) ✓ |
|
||||
| Навигация по дате | Да (календарь) | Да (jump to date) | Нет | **Да** ✓ |
|
||||
| Scroll restoration | position-to-bottom | Anchor-based | Виртуализация | **Anchor-based** (`getSnapshotBeforeUpdate`) ✓ |
|
||||
| Виртуализация списка | Нет (native) | Нет (native) | Да | **Нет** ⚠️ |
|
||||
|
||||
### 3.4 Реакции
|
||||
|
||||
| Фича | Telegram | WhatsApp | Slack | **EUSRR** |
|
||||
|---|---|---|---|---|
|
||||
| Быстрые реакции | 5 недавних + picker | 6 недавних + picker | Несколько + picker | **5 недавних + 40 emoji** ✓ |
|
||||
| Custom emoji | Premium | Нет | Workspace emoji | **Нет** |
|
||||
| Реакция-нотификация | Push | Push | Push | **WebSocket** ✓ |
|
||||
|
||||
### 3.5 Что есть у конкурентов, но нет у EUSRR
|
||||
|
||||
| Фича | Приоритет | Сложность |
|
||||
|---|---|---|
|
||||
| **Виртуализация списка сообщений** (react-window) | Высокий (>1000 сообщений = тормоза) | Средняя |
|
||||
| **Реал-тайм обновление списка чатов** (unread badge без перезагрузки) | Высокий | Низкая (WS уже есть) |
|
||||
| **Threads** (ветки обсуждений) | Средний | Высокая |
|
||||
| **Поиск по сообщениям** | Средний | Средняя |
|
||||
| **Голосовые сообщения** | Низкий | Средняя |
|
||||
| **Пересланные сообщения (UI)** | Низкий | Низкая |
|
||||
|
||||
### 3.6 Что у EUSRR лучше аналогов
|
||||
|
||||
1. **Система ролей в чатах** (admin/moderator/member/guest) — нет у Telegram/WhatsApp в таком виде.
|
||||
2. **Интеграция с оргструктурой** — чаты отделов, привязка к LDAP.
|
||||
3. **Контекстные чаты** (комментарии к постам/документам) — нет у классических мессенджеров.
|
||||
4. **Управление доступом** — гости без права отправки, модерация контента.
|
||||
|
||||
---
|
||||
|
||||
## 4. Рекомендации по дальнейшим улучшениям
|
||||
|
||||
### Высокий приоритет
|
||||
|
||||
1. **Виртуализация списка сообщений** — при >500 сообщений DOM замедляется. Использовать `@tanstack/react-virtual` или `react-window`.
|
||||
2. **WebSocket обновление списка чатов** — `wsManager` уже существует; нужен подписчик на `new_message` для обновления `last_message` и `unread_count` в списке без перезагрузки.
|
||||
3. **Annotate poll vote counts** — `PollViewSet.vote` имеет N+1 на `option.votes.count()`. Заменить на `annotate(Count('votes'))`.
|
||||
|
||||
### Средний приоритет
|
||||
|
||||
4. **State management** — рассмотреть Zustand или TanStack Query для кеширования чатов/сообщений между страницами.
|
||||
5. **Bulk create memberships** — `ChatViewSet.create` делает N запросов при добавлении участников.
|
||||
6. **Поиск по сообщениям** — PostgreSQL full-text search (`SearchVector`, `SearchQuery`).
|
||||
|
||||
### Низкий приоритет
|
||||
|
||||
7. Удалить дублирование WebSocket логики (`useWebSocket` vs `wsManager`).
|
||||
8. Typed API client вместо `any` в `createMessagesApi`.
|
||||
22
frontend/package-lock.json
generated
22
frontend/package-lock.json
generated
@ -18,6 +18,7 @@
|
||||
"@types/react-avatar-editor": "^13.0.4",
|
||||
"@types/xlsx": "^0.0.35",
|
||||
"date-fns": "^4.1.0",
|
||||
"emoji-picker-react": "^4.18.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"lucide-react": "^0.574.0",
|
||||
"mammoth": "^1.11.0",
|
||||
@ -4028,6 +4029,21 @@
|
||||
"integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/emoji-picker-react": {
|
||||
"version": "4.18.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.18.0.tgz",
|
||||
"integrity": "sha512-vLTrLfApXAIciguGE57pXPWs9lPLBspbEpPMiUq03TIli2dHZBiB+aZ0R9/Wat0xmTfcd4AuEzQgSYxEZ8C88Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"flairup": "1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
@ -4769,6 +4785,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/flairup": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/flairup/-/flairup-1.0.0.tgz",
|
||||
"integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/flat-cache": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
"@types/react-avatar-editor": "^13.0.4",
|
||||
"@types/xlsx": "^0.0.35",
|
||||
"date-fns": "^4.1.0",
|
||||
"emoji-picker-react": "^4.18.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"lucide-react": "^0.574.0",
|
||||
"mammoth": "^1.11.0",
|
||||
|
||||
@ -4,6 +4,7 @@ import { AppShell } from "../../components/AppShell";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import type { Document } from "@/types/api";
|
||||
import {
|
||||
Search,
|
||||
@ -62,6 +63,9 @@ function formatDate(value?: string): string {
|
||||
}
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
// State
|
||||
const [documents, setDocuments] = useState<Document[]>([]);
|
||||
const [folders, setFolders] = useState<FolderNode[]>([]);
|
||||
@ -95,6 +99,19 @@ export default function DocumentsPage() {
|
||||
|
||||
// Bulk selection
|
||||
const selection = useDocumentSelection(documents);
|
||||
const linkedDocumentId = Number(searchParams.get("document") || "");
|
||||
|
||||
const clearDocumentParam = () => {
|
||||
if (!searchParams.get("document")) return;
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
nextParams.delete("document");
|
||||
router.replace(nextParams.toString() ? `${pathname}?${nextParams.toString()}` : pathname, { scroll: false });
|
||||
};
|
||||
|
||||
const closeSelectedDocument = () => {
|
||||
setSelectedDocument(null);
|
||||
clearDocumentParam();
|
||||
};
|
||||
|
||||
const loadDocuments = async () => {
|
||||
try {
|
||||
@ -143,6 +160,26 @@ export default function DocumentsPage() {
|
||||
loadTags();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!linkedDocumentId || selectedDocument?.id === linkedDocumentId) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
apiClient.getDocument(linkedDocumentId)
|
||||
.then((document) => {
|
||||
if (!cancelled) {
|
||||
setSelectedDocument(document);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Ошибка deep-link документа:", error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [linkedDocumentId, selectedDocument?.id]);
|
||||
|
||||
const filteredDocuments = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
|
||||
@ -813,7 +850,7 @@ export default function DocumentsPage() {
|
||||
<DocumentDetailModal
|
||||
document={selectedDocument}
|
||||
isOpen={!!selectedDocument}
|
||||
onClose={() => setSelectedDocument(null)}
|
||||
onClose={closeSelectedDocument}
|
||||
onUpdate={() => {
|
||||
loadDocuments();
|
||||
if (selectedDocument) {
|
||||
|
||||
@ -69,8 +69,7 @@ export default function EmployeesPage() {
|
||||
setCreatingChatFor(employee.id);
|
||||
try {
|
||||
// Ищем существующий приватный чат с этим пользователем
|
||||
const chatsResponse = await apiClient.getChats();
|
||||
const allChats = chatsResponse.results || chatsResponse;
|
||||
const allChats = await apiClient.getAllChats();
|
||||
|
||||
const existingChat = allChats.find((chat: any) => {
|
||||
if (chat.type !== 'private') return false;
|
||||
|
||||
@ -7,6 +7,7 @@ import { Archive, ArrowRightLeft, ArrowUpDown, ChevronDown, Filter, MessageSquar
|
||||
import { SearchableSelectSingle } from "@/components/shared/SearchableSelect";
|
||||
import { formatDate, formatMoney } from "@/lib/shared";
|
||||
import { useEquipmentPage } from "@/hooks/useEquipmentPage";
|
||||
import { Modal } from "@/components/ui";
|
||||
|
||||
const listModeMeta = [
|
||||
{ value: "all", label: "Весь реестр" },
|
||||
@ -611,14 +612,14 @@ export default function EquipmentPage() {
|
||||
)}
|
||||
|
||||
{/* ===== Modal create/edit ===== */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={(e) => { if (e.target === e.currentTarget) closeModal(); }}>
|
||||
<div className="relative max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-2xl bg-white p-5 shadow-xl">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-gray-900">{modalMode === "create" ? "Добавить оборудование" : "Редактировать оборудование"}</h2>
|
||||
<button type="button" onClick={closeModal} className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"><X size={18} /></button>
|
||||
<Modal isOpen={isModalOpen} onClose={closeModal} title={modalMode === "create" ? "Добавить оборудование" : "Редактировать оборудование"} size="md" footer={
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<button type="button" onClick={closeModal} className="rounded-lg bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">Отмена</button>
|
||||
<button type="button" onClick={() => handleSave(modalMode)} disabled={busyKey !== null} className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-60">
|
||||
{modalMode === "create" ? "Добавить" : "Сохранить"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
}>
|
||||
{actionError && <p className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{actionError}</p>}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
@ -740,30 +741,20 @@ export default function EquipmentPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="mt-5 flex flex-wrap items-center justify-end gap-2 border-t border-gray-100 pt-4">
|
||||
<button type="button" onClick={closeModal} className="rounded-lg bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">Отмена</button>
|
||||
<button type="button" onClick={() => handleSave(modalMode)} disabled={busyKey !== null} className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-60">
|
||||
{modalMode === "create" ? "Добавить" : "Сохранить"}
|
||||
</button>
|
||||
<Modal isOpen={!!operationModal && !!selectedEquipment} onClose={closeOperationModal} title={
|
||||
operationModal === "transfer" ? "Перевод оборудования" :
|
||||
operationModal === "writeoff" ? "Списание оборудования" :
|
||||
"Добавить обслуживание"
|
||||
} size="md" footer={
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button type="button" onClick={closeOperationModal} className="rounded-lg bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">Отмена</button>
|
||||
{operationModal === "transfer" && <button type="button" onClick={() => { void handleTransfer(); }} disabled={busyKey !== null} className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-60">Перевести</button>}
|
||||
{operationModal === "writeoff" && <button type="button" onClick={() => { void handleWriteOff(); }} disabled={busyKey !== null} className="rounded-lg bg-rose-600 px-3 py-2 text-sm font-medium text-white hover:bg-rose-700 disabled:opacity-60">Списать</button>}
|
||||
{operationModal === "maintenance" && <button type="button" onClick={() => { void handleMaintenance(); }} disabled={busyKey !== null} className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-60">Добавить</button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{operationModal && selectedEquipment && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 p-4" onClick={(e) => { if (e.target === e.currentTarget) closeOperationModal(); }}>
|
||||
<div className="relative w-full max-w-lg rounded-2xl bg-white p-5 shadow-xl">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
{operationModal === "transfer" && "Перевод оборудования"}
|
||||
{operationModal === "writeoff" && "Списание оборудования"}
|
||||
{operationModal === "maintenance" && "Добавить обслуживание"}
|
||||
</h3>
|
||||
<button type="button" onClick={closeOperationModal} className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"><X size={18} /></button>
|
||||
</div>
|
||||
|
||||
}>
|
||||
{operationModal === "transfer" && (
|
||||
<div className="space-y-3">
|
||||
<SearchableSelectSingle
|
||||
@ -821,16 +812,7 @@ export default function EquipmentPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex items-center justify-end gap-2 border-t border-gray-100 pt-4">
|
||||
<button type="button" onClick={closeOperationModal} className="rounded-lg bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">Отмена</button>
|
||||
{operationModal === "transfer" && <button type="button" onClick={() => { void handleTransfer(); }} disabled={busyKey !== null} className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-60">Перевести</button>}
|
||||
{operationModal === "writeoff" && <button type="button" onClick={() => { void handleWriteOff(); }} disabled={busyKey !== null} className="rounded-lg bg-rose-600 px-3 py-2 text-sm font-medium text-white hover:bg-rose-700 disabled:opacity-60">Списать</button>}
|
||||
{operationModal === "maintenance" && <button type="button" onClick={() => { void handleMaintenance(); }} disabled={busyKey !== null} className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-60">Добавить</button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* ===== Attachment preview removed (no attachment fields in model) ===== */}
|
||||
</AppShell>
|
||||
|
||||
@ -92,8 +92,7 @@ export default function ChatscapeMessenger() {
|
||||
const loadChats = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await apiClient.getChats();
|
||||
const chatsList = response.results || [];
|
||||
const chatsList = await apiClient.getAllChats();
|
||||
setChats(chatsList);
|
||||
|
||||
// Автоматически открываем первый чат
|
||||
@ -114,8 +113,7 @@ export default function ChatscapeMessenger() {
|
||||
const handleChatCreated = async (chatId: number) => {
|
||||
try {
|
||||
// Перезагружаем список чатов
|
||||
const response = await apiClient.getChats();
|
||||
const chatsList = response.results || [];
|
||||
const chatsList = await apiClient.getAllChats();
|
||||
setChats(chatsList);
|
||||
|
||||
// Открываем новый чат
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -11,6 +11,7 @@ import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Modal } from "@/components/ui";
|
||||
|
||||
type UnreadFilter = "all" | "unread" | "read";
|
||||
type PinnedFilter = "all" | "pinned" | "unpinned";
|
||||
@ -112,8 +113,7 @@ export default function MessagesPage() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await apiClient.getChats();
|
||||
const items = response.results || [];
|
||||
const items = await apiClient.getAllChats();
|
||||
|
||||
// FRONTEND-ONLY: добираем детали чатов, чтобы получить имя/аватар собеседника
|
||||
// для private/direct в случае, когда list не отдает avatar/participant_details.
|
||||
@ -662,19 +662,25 @@ export default function MessagesPage() {
|
||||
)}
|
||||
|
||||
{/* Модальное окно создания чата */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold text-gray-900">Создать чат</h2>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="rounded-lg p-1 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
<Modal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} title="Создать чат" size="sm" closeOnEsc={!creating} footer={
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="flex-1 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateChat}
|
||||
disabled={creating}
|
||||
className="flex-1 rounded-lg bg-sky-500 px-4 py-2 text-sm font-medium text-white transition hover:bg-sky-600 disabled:opacity-50"
|
||||
>
|
||||
{creating ? 'Создание...' : 'Создать'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
}>
|
||||
<div className="space-y-4">
|
||||
{/* Тип чата */}
|
||||
<div>
|
||||
@ -800,29 +806,8 @@ export default function MessagesPage() {
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Кнопки */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="flex-1 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateChat}
|
||||
disabled={creating}
|
||||
className="flex-1 rounded-lg bg-sky-500 px-4 py-2 text-sm font-medium text-white transition hover:bg-sky-600 disabled:opacity-50"
|
||||
>
|
||||
{creating ? 'Создание...' : 'Создать'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,9 +2,11 @@
|
||||
|
||||
import { Heart, ImageIcon, MessageSquare, Paperclip, Pencil, Plus, Send, Trash2, X } from "lucide-react";
|
||||
import { AppShell } from "../components/AppShell";
|
||||
import { Modal } from "@/components/ui";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { resolveMediaUrl } from "@/lib/url";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import type { Comment, Post } from "@/types/api";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
|
||||
@ -18,6 +20,9 @@ type LikeUser = {
|
||||
|
||||
export default function Home() {
|
||||
const { user } = useUser();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -76,6 +81,19 @@ export default function Home() {
|
||||
const auth = user?.auth;
|
||||
const authPerms = auth?.permissions || [];
|
||||
const authByApp = auth?.permissions_by_app || {};
|
||||
const linkedPostId = Number(searchParams.get("post") || "");
|
||||
|
||||
const clearPostParam = () => {
|
||||
if (!searchParams.get("post")) return;
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
nextParams.delete("post");
|
||||
router.replace(nextParams.toString() ? `${pathname}?${nextParams.toString()}` : pathname, { scroll: false });
|
||||
};
|
||||
|
||||
const closeCommentsModal = () => {
|
||||
setCommentsOpen(false);
|
||||
clearPostParam();
|
||||
};
|
||||
|
||||
const hasPermission = (perm: string) => {
|
||||
if (!perm) return false;
|
||||
@ -170,7 +188,7 @@ export default function Home() {
|
||||
if (!commentsOpen && !createPostOpen) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setCommentsOpen(false);
|
||||
closeCommentsModal();
|
||||
}
|
||||
};
|
||||
|
||||
@ -295,6 +313,16 @@ export default function Home() {
|
||||
await loadComments(post.id);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!linkedPostId || posts.length === 0) return;
|
||||
if (commentsOpen && activePost?.id === linkedPostId) return;
|
||||
|
||||
const targetPost = posts.find((post) => post.id === linkedPostId);
|
||||
if (!targetPost) return;
|
||||
|
||||
void openComments(targetPost);
|
||||
}, [activePost?.id, commentsOpen, linkedPostId, posts]);
|
||||
|
||||
const handleCreateComment = async () => {
|
||||
if (!activePost) return;
|
||||
const text = newComment.trim();
|
||||
@ -549,7 +577,7 @@ export default function Home() {
|
||||
: `${Math.floor(diffHours / 24)} дн. назад`;
|
||||
|
||||
return (
|
||||
<article key={post.id} className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<article id={`post-${post.id}`} key={post.id} className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<header className="mb-3 flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative h-10 w-10">
|
||||
@ -683,152 +711,180 @@ export default function Home() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{createPostOpen ? (
|
||||
<div className="fixed inset-0 z-[100]">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Закрыть создание публикации"
|
||||
onClick={() => setCreatePostOpen(false)}
|
||||
className="absolute inset-0 bg-black/50"
|
||||
/>
|
||||
<Modal
|
||||
isOpen={createPostOpen}
|
||||
onClose={() => setCreatePostOpen(false)}
|
||||
title={editingPostId ? "Редактировать публикацию" : "Создать публикацию"}
|
||||
size="lg"
|
||||
closeOnEsc={!createSubmitting}
|
||||
footer={
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreatePostOpen(false)}
|
||||
className="rounded-lg bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={createSubmitting}
|
||||
onClick={handleCreatePost}
|
||||
className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-50"
|
||||
>
|
||||
{createSubmitting ? (editingPostId ? "Сохраняем..." : "Публикуем...") : (editingPostId ? "Сохранить" : "Опубликовать")}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{createError ? (
|
||||
<div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{createError}</div>
|
||||
) : null}
|
||||
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-2xl max-h-[80vh] mx-auto px-3 flex flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-gray-200 sm:px-6">
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3">
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
{editingPostId ? "Редактировать публикацию" : "Создать публикацию"}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreatePostOpen(false)}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full hover:bg-gray-100"
|
||||
{(canCreateCompanyPost || canCreateDepartmentPost) ? (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Тип публикации</label>
|
||||
<select
|
||||
value={createType}
|
||||
onChange={(e) => setCreateType(e.target.value as "company" | "department")}
|
||||
className="h-10 w-full rounded-lg border border-gray-300 px-3 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
|
||||
>
|
||||
<X size={18} className="text-gray-600" />
|
||||
</button>
|
||||
{canCreateCompanyPost ? <option value="company">Новость компании</option> : null}
|
||||
{canCreateDepartmentPost ? <option value="department">Новость отдела</option> : null}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||
<div className="space-y-4">
|
||||
{createError ? (
|
||||
<div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{createError}</div>
|
||||
) : null}
|
||||
|
||||
{(canCreateCompanyPost || canCreateDepartmentPost) ? (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Тип публикации</label>
|
||||
<select
|
||||
value={createType}
|
||||
onChange={(e) => setCreateType(e.target.value as "company" | "department")}
|
||||
className="h-10 w-full rounded-lg border border-gray-300 px-3 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
|
||||
>
|
||||
{canCreateCompanyPost ? <option value="company">Новость компании</option> : null}
|
||||
{canCreateDepartmentPost ? <option value="department">Новость отдела</option> : null}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{createType === "department" ? (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Отдел</label>
|
||||
<select
|
||||
value={createDepartmentId}
|
||||
onChange={(e) => setCreateDepartmentId(e.target.value)}
|
||||
className="h-10 w-full rounded-lg border border-gray-300 px-3 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
|
||||
>
|
||||
<option value="">Выберите отдел</option>
|
||||
{userDepartments.map((dept) => (
|
||||
<option key={dept.id} value={String(dept.id)}>{dept.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Заголовок</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createTitle}
|
||||
onChange={(e) => setCreateTitle(e.target.value)}
|
||||
className="h-10 w-full rounded-lg border border-gray-300 px-3 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Содержание</label>
|
||||
<textarea
|
||||
value={createBody}
|
||||
onChange={(e) => setCreateBody(e.target.value)}
|
||||
className="min-h-32 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Изображение</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => setCreateImage(e.target.files?.[0] || null)}
|
||||
className="block w-full text-sm text-gray-700 file:mr-3 file:rounded-md file:border-0 file:bg-sky-50 file:px-3 file:py-2 file:text-sm file:text-sky-700 hover:file:bg-sky-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Вложение</label>
|
||||
<input
|
||||
type="file"
|
||||
onChange={(e) => setCreateAttachment(e.target.files?.[0] || null)}
|
||||
className="block w-full text-sm text-gray-700 file:mr-3 file:rounded-md file:border-0 file:bg-gray-100 file:px-3 file:py-2 file:text-sm file:text-gray-700 hover:file:bg-gray-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{createType === "department" ? (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Отдел</label>
|
||||
<select
|
||||
value={createDepartmentId}
|
||||
onChange={(e) => setCreateDepartmentId(e.target.value)}
|
||||
className="h-10 w-full rounded-lg border border-gray-300 px-3 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
|
||||
>
|
||||
<option value="">Выберите отдел</option>
|
||||
{userDepartments.map((dept) => (
|
||||
<option key={dept.id} value={String(dept.id)}>{dept.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="border-t border-gray-100 px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreatePostOpen(false)}
|
||||
className="rounded-lg bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={createSubmitting}
|
||||
onClick={handleCreatePost}
|
||||
className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-50"
|
||||
>
|
||||
{createSubmitting ? (editingPostId ? "Сохраняем..." : "Публикуем...") : (editingPostId ? "Сохранить" : "Опубликовать")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Заголовок</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createTitle}
|
||||
onChange={(e) => setCreateTitle(e.target.value)}
|
||||
className="h-10 w-full rounded-lg border border-gray-300 px-3 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Содержание</label>
|
||||
<textarea
|
||||
value={createBody}
|
||||
onChange={(e) => setCreateBody(e.target.value)}
|
||||
className="min-h-32 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Изображение</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => setCreateImage(e.target.files?.[0] || null)}
|
||||
className="block w-full text-sm text-gray-700 file:mr-3 file:rounded-md file:border-0 file:bg-sky-50 file:px-3 file:py-2 file:text-sm file:text-sky-700 hover:file:bg-sky-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Вложение</label>
|
||||
<input
|
||||
type="file"
|
||||
onChange={(e) => setCreateAttachment(e.target.files?.[0] || null)}
|
||||
className="block w-full text-sm text-gray-700 file:mr-3 file:rounded-md file:border-0 file:bg-gray-100 file:px-3 file:py-2 file:text-sm file:text-gray-700 hover:file:bg-gray-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
{commentsOpen && activePost && (
|
||||
<div className="fixed inset-0 z-[100]">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Закрыть комментарии"
|
||||
onClick={() => setCommentsOpen(false)}
|
||||
className="absolute inset-0 bg-black/50"
|
||||
/>
|
||||
|
||||
<div className="absolute inset-x-2 sm:inset-x-3 top-2 sm:top-4 bottom-2 sm:bottom-4 mx-auto flex max-w-[95vw] sm:max-w-2xl flex-col overflow-hidden rounded-xl sm:rounded-2xl bg-white shadow-2xl ring-1 ring-gray-200">
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-3 sm:px-4 py-2.5 sm:py-3">
|
||||
<div>
|
||||
<p className="text-xs sm:text-sm font-semibold text-gray-900">Комментарии</p>
|
||||
<p className="text-[10px] sm:text-xs text-gray-500">Публикация #{activePost.id}</p>
|
||||
<Modal
|
||||
isOpen={commentsOpen && !!activePost}
|
||||
onClose={closeCommentsModal}
|
||||
title="Комментарии"
|
||||
size="lg"
|
||||
noPadding
|
||||
footer={
|
||||
<div>
|
||||
{(commentImage || commentAttachment) && (
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
{commentImage && (
|
||||
<div className="flex items-center gap-1.5 rounded-md bg-sky-50 px-2 py-1 text-xs text-sky-700 ring-1 ring-sky-200">
|
||||
<ImageIcon size={12} />
|
||||
<span className="max-w-[120px] truncate">{commentImage.name}</span>
|
||||
<button type="button" onClick={() => setCommentImage(null)} className="ml-0.5 text-sky-400 hover:text-sky-700"><X size={12} /></button>
|
||||
</div>
|
||||
)}
|
||||
{commentAttachment && (
|
||||
<div className="flex items-center gap-1.5 rounded-md bg-gray-100 px-2 py-1 text-xs text-gray-700 ring-1 ring-gray-200">
|
||||
<Paperclip size={12} />
|
||||
<span className="max-w-[120px] truncate">{commentAttachment.name}</span>
|
||||
<button type="button" onClick={() => setCommentAttachment(null)} className="ml-0.5 text-gray-400 hover:text-gray-700"><X size={12} /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<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={() => setCommentsOpen(false)}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full hover:bg-gray-100"
|
||||
onClick={() => commentImageRef.current?.click()}
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
title="Прикрепить изображение"
|
||||
>
|
||||
<X size={18} className="text-gray-600" />
|
||||
<ImageIcon size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => commentAttachmentRef.current?.click()}
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
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="h-10 flex-1 rounded-lg border border-gray-300 px-3 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={commentSending || (!newComment.trim() && !commentImage && !commentAttachment)}
|
||||
onClick={handleCreateComment}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-lg bg-sky-500 text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{activePost && (
|
||||
<>
|
||||
<p className="px-4 pt-1 pb-2 text-[10px] sm:text-xs text-gray-500">Публикация #{activePost.id}</p>
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3">
|
||||
{commentsLoading ? (
|
||||
<p className="text-sm text-gray-500">Загрузка комментариев...</p>
|
||||
@ -943,71 +999,9 @@ export default function Home() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-100 px-4 py-3">
|
||||
{(commentImage || commentAttachment) && (
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
{commentImage && (
|
||||
<div className="flex items-center gap-1.5 rounded-md bg-sky-50 px-2 py-1 text-xs text-sky-700 ring-1 ring-sky-200">
|
||||
<ImageIcon size={12} />
|
||||
<span className="max-w-[120px] truncate">{commentImage.name}</span>
|
||||
<button type="button" onClick={() => setCommentImage(null)} className="ml-0.5 text-sky-400 hover:text-sky-700"><X size={12} /></button>
|
||||
</div>
|
||||
)}
|
||||
{commentAttachment && (
|
||||
<div className="flex items-center gap-1.5 rounded-md bg-gray-100 px-2 py-1 text-xs text-gray-700 ring-1 ring-gray-200">
|
||||
<Paperclip size={12} />
|
||||
<span className="max-w-[120px] truncate">{commentAttachment.name}</span>
|
||||
<button type="button" onClick={() => setCommentAttachment(null)} className="ml-0.5 text-gray-400 hover:text-gray-700"><X size={12} /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<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="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
title="Прикрепить изображение"
|
||||
>
|
||||
<ImageIcon size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => commentAttachmentRef.current?.click()}
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
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="h-10 flex-1 rounded-lg border border-gray-300 px-3 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={commentSending || (!newComment.trim() && !commentImage && !commentAttachment)}
|
||||
onClick={handleCreateComment}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-lg bg-sky-500 text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -30,6 +30,7 @@ import {
|
||||
import { SearchableSelectSingle } from "@/components/shared/SearchableSelect";
|
||||
import { formatDate, formatMoney } from "@/lib/shared";
|
||||
import { useProcurementPage } from "@/hooks/useProcurementPage";
|
||||
import { Modal } from "@/components/ui";
|
||||
|
||||
/* ══════════════════════════════════════════════════════
|
||||
Constants & helpers
|
||||
@ -596,13 +597,14 @@ export default function ProcurementPage() {
|
||||
)}
|
||||
|
||||
{/* ══════════ Create / Edit modal ══════════ */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={(e) => { if (e.target === e.currentTarget) closeModal(); }}>
|
||||
<div className="relative max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-2xl bg-white p-5 shadow-xl">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-gray-900">{modalMode === "create" ? "Новая заявка на закупку" : "Редактировать заявку"}</h2>
|
||||
<button type="button" onClick={closeModal} className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"><X size={18} /></button>
|
||||
<Modal isOpen={isModalOpen} onClose={closeModal} title={modalMode === "create" ? "Новая заявка на закупку" : "Редактировать заявку"} size="lg" footer={
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<button type="button" onClick={closeModal} className="rounded-lg bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">Отмена</button>
|
||||
<button type="button" onClick={handleSave} disabled={busyKey === "save"} className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-60">
|
||||
{modalMode === "create" ? "Создать черновик" : "Сохранить"}
|
||||
</button>
|
||||
</div>
|
||||
}>
|
||||
|
||||
{actionError && <p className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{actionError}</p>}
|
||||
|
||||
@ -723,17 +725,7 @@ export default function ProcurementPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── modal buttons ── */}
|
||||
<div className="mt-5 flex flex-wrap items-center justify-end gap-2 border-t border-gray-100 pt-4">
|
||||
<button type="button" onClick={closeModal} className="rounded-lg bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">Отмена</button>
|
||||
<button type="button" onClick={handleSave} disabled={busyKey === "save"} className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-60">
|
||||
{modalMode === "create" ? "Создать черновик" : "Сохранить"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,11 +2,13 @@
|
||||
/* eslint-disable react-hooks/refs -- All h.* values come from useState, not useRef. React compiler false positive. */
|
||||
|
||||
import { AppShell } from "../../components/AppShell";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { Modal } from "@/components/ui";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import { canManageRequests, canProcessRequests } from "@/lib/permissions";
|
||||
import { useRef } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import type { User } from "@/types/api";
|
||||
import { ArrowUpDown, Ban, ChevronDown, FileSignature, Filter, MessageSquare, Paperclip, Pencil, Plus, Search, ThumbsDown, ThumbsUp, Trash2, X, Zap } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
@ -24,11 +26,29 @@ const SwipeApprovalMode = dynamic(() => import("@/components/requests/SwipeAppro
|
||||
|
||||
export default function RequestsPage() {
|
||||
const { user } = useUser();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const canProcess = canProcessRequests(user);
|
||||
const canManage = canManageRequests(user);
|
||||
const auth = user?.auth;
|
||||
const h = useRequestsPage(user?.id);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const openedLinkedRequestIdRef = useRef<number | null>(null);
|
||||
const loadingLinkedRequestIdRef = useRef<number | null>(null);
|
||||
const linkedRequestId = Number(searchParams.get("request") || "");
|
||||
|
||||
const clearRequestParam = () => {
|
||||
if (!searchParams.get("request")) return;
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
nextParams.delete("request");
|
||||
router.replace(nextParams.toString() ? `${pathname}?${nextParams.toString()}` : pathname, { scroll: false });
|
||||
};
|
||||
|
||||
const closeDetailsRequest = () => {
|
||||
h.setDetailsRequest(null);
|
||||
clearRequestParam();
|
||||
};
|
||||
|
||||
const renderUserBadge = (person: User, large = false) => {
|
||||
const personLink = userProfileLink(person, user?.id);
|
||||
@ -51,6 +71,57 @@ export default function RequestsPage() {
|
||||
: <span className={cls}>{chip}</span>;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!linkedRequestId) {
|
||||
openedLinkedRequestIdRef.current = null;
|
||||
loadingLinkedRequestIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (h.detailsRequest?.id === linkedRequestId) {
|
||||
openedLinkedRequestIdRef.current = linkedRequestId;
|
||||
loadingLinkedRequestIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (openedLinkedRequestIdRef.current === linkedRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = h.requests.find((item) => item.id === linkedRequestId);
|
||||
if (existing) {
|
||||
openedLinkedRequestIdRef.current = linkedRequestId;
|
||||
loadingLinkedRequestIdRef.current = null;
|
||||
h.setDetailsRequest(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
if (loadingLinkedRequestIdRef.current === linkedRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadingLinkedRequestIdRef.current = linkedRequestId;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
apiClient.getRequest(linkedRequestId)
|
||||
.then((request) => {
|
||||
if (!cancelled && openedLinkedRequestIdRef.current !== linkedRequestId) {
|
||||
openedLinkedRequestIdRef.current = linkedRequestId;
|
||||
loadingLinkedRequestIdRef.current = null;
|
||||
h.setDetailsRequest(request);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
loadingLinkedRequestIdRef.current = null;
|
||||
console.error("Ошибка deep-link заявления:", error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [h.detailsRequest?.id, h.requests, h.setDetailsRequest, linkedRequestId]);
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
{h.loading ? (
|
||||
@ -218,7 +289,7 @@ export default function RequestsPage() {
|
||||
)}
|
||||
|
||||
{/* Detail modal */}
|
||||
<Modal isOpen={Boolean(h.detailsRequest)} onClose={() => h.setDetailsRequest(null)} title="Полная информация по заявлению" size="lg">
|
||||
<Modal isOpen={Boolean(h.detailsRequest)} onClose={closeDetailsRequest} title="Полная информация по заявлению" size="lg">
|
||||
{h.detailsRequest && (() => {
|
||||
const dr = h.detailsRequest; const da = dr.employee || dr.created_by; const dap = dr.approver || dr.assigned_to;
|
||||
const ds = statusMeta[String(dr.status || "").toLowerCase()] ?? defaultStatusMeta;
|
||||
@ -241,10 +312,13 @@ export default function RequestsPage() {
|
||||
</Modal>
|
||||
|
||||
{/* Create/Edit modal */}
|
||||
{h.isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={(e) => { if (e.target === e.currentTarget) h.closeModal(); }}>
|
||||
<div className="relative max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-2xl bg-white p-5 shadow-xl">
|
||||
<div className="mb-4 flex items-center justify-between"><h2 className="text-base font-semibold text-gray-900">{h.modalMode === "create" ? "Новое заявление" : "Редактировать заявление"}</h2><button type="button" onClick={h.closeModal} className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"><X size={18} /></button></div>
|
||||
<Modal isOpen={h.isModalOpen} onClose={h.closeModal} title={h.modalMode === "create" ? "Новое заявление" : "Редактировать заявление"} size="md" footer={
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<button type="button" onClick={h.closeModal} className="rounded-lg bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">Отмена</button>
|
||||
<button type="button" onClick={() => h.handleCreateOrUpdate(h.modalMode, "draft")} disabled={h.busyKey !== null} className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-60">Сохранить как черновик</button>
|
||||
<button type="button" onClick={() => h.handleCreateOrUpdate(h.modalMode, "submitted")} disabled={h.busyKey !== null} className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-60">{h.modalMode === "create" ? "Создать" : "Сохранить"}</button>
|
||||
</div>
|
||||
}>
|
||||
{h.actionError && <p className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{h.actionError}</p>}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div><label className="mb-1 block text-xs font-medium text-gray-500">Тема заявления</label><input value={h.form.title} onChange={(e) => h.setForm((p) => ({ ...p, title: e.target.value }))} placeholder="Тема заявления" className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100" /></div>
|
||||
@ -257,26 +331,16 @@ export default function RequestsPage() {
|
||||
<div><input ref={fileInputRef} type="file" accept=".pdf,.jpg,.jpeg,.png" className="hidden" onChange={(e) => { const f = e.target.files?.[0] || null; if (f) { const ext = f.name.split(".").pop()?.toLowerCase() || ""; if (!["pdf","jpg","jpeg","png"].includes(ext)) return; } h.setForm((p) => ({ ...p, attachment: f })); }} /><button type="button" onClick={() => fileInputRef.current?.click()} className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-600 hover:bg-gray-50"><Paperclip size={14} />{h.form.attachment ? h.form.attachment.name : "Прикрепить файл"}</button></div>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700"><input type="checkbox" checked={h.form.sent_to_all_department} onChange={(e) => h.setForm((p) => ({ ...p, sent_to_all_department: e.target.checked }))} className="rounded border-gray-300" />Отправить всем сотрудникам выбранных отделов</label>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-wrap items-center justify-end gap-2 border-t border-gray-100 pt-4">
|
||||
<button type="button" onClick={h.closeModal} className="rounded-lg bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">Отмена</button>
|
||||
<button type="button" onClick={() => h.handleCreateOrUpdate(h.modalMode, "draft")} disabled={h.busyKey !== null} className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-60">Сохранить как черновик</button>
|
||||
<button type="button" onClick={() => h.handleCreateOrUpdate(h.modalMode, "submitted")} disabled={h.busyKey !== null} className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-60">{h.modalMode === "create" ? "Создать" : "Сохранить"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Attachment preview */}
|
||||
{h.attachmentPreview && (() => {
|
||||
const apUrl = h.attachmentPreview.url;
|
||||
const apName = h.attachmentPreview.name;
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4" onClick={(e) => { if (e.target === e.currentTarget) h.setAttachmentPreview(null); }}>
|
||||
<div className="relative flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-2xl bg-white shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3"><p className="truncate text-sm font-medium text-gray-800">{apName}</p><div className="flex items-center gap-2"><a href={apUrl} download className="rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50">Скачать</a><button type="button" onClick={() => h.setAttachmentPreview(null)} className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"><X size={18} /></button></div></div>
|
||||
<div className="flex-1 overflow-auto p-4">{(() => { const ext = apUrl.split(".").pop()?.toLowerCase() || ""; if (["jpg","jpeg","png","gif","webp","svg","bmp"].includes(ext)) return <img src={apUrl} alt={apName} className="mx-auto max-h-[70vh] rounded-lg object-contain" />; if (["mp4","webm","ogg","mov"].includes(ext)) return <video src={apUrl} controls className="mx-auto max-h-[70vh] rounded-lg" />; if (["mp3","wav","aac"].includes(ext)) return <audio src={apUrl} controls className="mx-auto mt-8" />; return <div className="flex flex-col items-center gap-3 py-12 text-center"><FileSignature size={40} className="text-gray-300" /><p className="text-sm text-gray-500">{apName}</p><a href={apUrl} download className="rounded-lg bg-sky-500 px-4 py-2 text-sm font-medium text-white hover:bg-sky-600">Скачать файл</a></div>; })()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Modal isOpen onClose={() => h.setAttachmentPreview(null)} title={apName} size="lg" footer={<a href={apUrl} download className="rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50">Скачать</a>}>
|
||||
<div className="flex-1 overflow-auto">{(() => { const ext = apUrl.split(".").pop()?.toLowerCase() || ""; if (["jpg","jpeg","png","gif","webp","svg","bmp"].includes(ext)) return <img src={apUrl} alt={apName} className="mx-auto max-h-[70vh] rounded-lg object-contain" />; if (["mp4","webm","ogg","mov"].includes(ext)) return <video src={apUrl} controls className="mx-auto max-h-[70vh] rounded-lg" />; if (["mp3","wav","aac"].includes(ext)) return <audio src={apUrl} controls className="mx-auto mt-8" />; return <div className="flex flex-col items-center gap-3 py-12 text-center"><FileSignature size={40} className="text-gray-300" /><p className="text-sm text-gray-500">{apName}</p><a href={apUrl} download className="rounded-lg bg-sky-500 px-4 py-2 text-sm font-medium text-white hover:bg-sky-600">Скачать файл</a></div>; })()}</div>
|
||||
</Modal>
|
||||
);
|
||||
})()}
|
||||
</AppShell>
|
||||
|
||||
@ -8,6 +8,41 @@ import { apiClient } from "@/lib/api";
|
||||
import type { SearchResponse, SearchResult, SearchModelType } from "@/types/api";
|
||||
import { Search, User, Building, FileText, MessageSquare, MessageCircle, Calendar, Inbox, ShoppingCart, Package, Bell } from "lucide-react";
|
||||
|
||||
function buildSearchHref(result: SearchResult): string {
|
||||
switch (result.model_name) {
|
||||
case "employee":
|
||||
return `/users/${result.object_id}`;
|
||||
case "department":
|
||||
return `/departments/${result.object_id}`;
|
||||
case "post":
|
||||
return `/?post=${result.object_id}`;
|
||||
case "request":
|
||||
return `/requests?request=${result.object_id}`;
|
||||
case "chat":
|
||||
return `/messages/${result.object_id}`;
|
||||
case "message": {
|
||||
const chatId = typeof result.meta?.chat_id === "number"
|
||||
? result.meta.chat_id
|
||||
: Number(result.meta?.chat_id || 0);
|
||||
return chatId ? `/messages/${chatId}?message=${result.object_id}` : "/messages";
|
||||
}
|
||||
case "event":
|
||||
return "/calendar";
|
||||
case "schedule_event":
|
||||
return `/calendar?event=${result.object_id}`;
|
||||
case "procurement_request":
|
||||
return "/procurement";
|
||||
case "equipment":
|
||||
return "/equipment";
|
||||
case "document":
|
||||
return `/documents?document=${result.object_id}`;
|
||||
case "notification":
|
||||
return "/notifications";
|
||||
default:
|
||||
return "/search";
|
||||
}
|
||||
}
|
||||
|
||||
// Иконки для разных типов результатов
|
||||
const modelIcons: Record<SearchModelType, React.ComponentType<{ size?: number; className?: string }>> = {
|
||||
employee: User,
|
||||
@ -166,7 +201,7 @@ function SearchPageContent() {
|
||||
{results.map((result) => (
|
||||
<Link
|
||||
key={`${result.model_name}-${result.object_id}`}
|
||||
href={result.url}
|
||||
href={buildSearchHref(result)}
|
||||
className="block rounded-lg border border-gray-200 p-3 transition-all hover:border-blue-300 hover:bg-blue-50/50 hover:shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
|
||||
@ -425,7 +425,7 @@ export function AppShell({ children }: AppShellProps) {
|
||||
return (
|
||||
<div className={`${isMessageDialogPage ? 'h-[100dvh] overflow-hidden' : 'min-h-screen'} bg-gradient-to-b from-sky-50 via-white to-white text-gray-900 flex flex-col`}>
|
||||
<Header onOpenLeftNav={() => setIsMobileLeftNavOpen(true)} onOpenCalendar={() => setIsMobileCalendarOpen(true)} />
|
||||
<div className="mx-auto flex w-full flex-1 min-h-0 max-w-6xl gap-6 px-4 py-4 sm:px-8 lg:py-8">
|
||||
<div className={`mx-auto flex w-full flex-1 min-h-0 max-w-6xl ${isMessageDialogPage ? 'gap-0 px-0 py-0 lg:gap-6 lg:px-8 lg:py-8' : 'gap-6 px-4 py-4 sm:px-8 lg:py-8'}`}>
|
||||
<LeftNav />
|
||||
<main className={`flex-1 min-w-0 min-h-0 space-y-6 ${isMessageDialogPage ? 'overflow-visible' : ''}`}>{children}</main>
|
||||
<CalendarSidebar
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import Cropper from 'react-easy-crop';
|
||||
import type { Area, Point } from 'react-easy-crop';
|
||||
import { Modal } from "@/components/ui";
|
||||
|
||||
interface AvatarCropperProps {
|
||||
onCropComplete: (croppedImage: string) => void;
|
||||
@ -59,8 +60,7 @@ export default function AvatarCropper({ onCropComplete, onCancel, initialImage }
|
||||
}, [croppedAreaPixels, initialImage, onCropComplete]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75">
|
||||
<div className="relative w-full max-w-2xl bg-white rounded-2xl shadow-2xl">
|
||||
<Modal isOpen onClose={onCancel} noHeader noPadding size="lg">
|
||||
{/* Заголовок */}
|
||||
<div className="border-b border-gray-200 px-6 py-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
@ -174,7 +174,6 @@ export default function AvatarCropper({ onCropComplete, onCancel, initialImage }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import { Calendar, dateFnsLocalizer, View, ToolbarProps, NavigateAction } from "
|
||||
import { format, parse, startOfWeek, getDay } from "date-fns";
|
||||
import { ru } from "date-fns/locale";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { useCalendar } from "@/contexts/CalendarContext";
|
||||
import { X, Plus, Trash2, Settings, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
@ -122,6 +123,9 @@ const CustomToolbar = ({ label, onNavigate, onView, view, date }: ToolbarProps<C
|
||||
};
|
||||
|
||||
export function BigCalendar() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { calendars, selectedCalendarId, setSelectedCalendarId, reloadCalendars } = useCalendar();
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -139,6 +143,14 @@ export function BigCalendar() {
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||
const [showEventDetailsModal, setShowEventDetailsModal] = useState(false);
|
||||
const [viewingEvent, setViewingEvent] = useState<CalendarEvent | null>(null);
|
||||
const linkedEventId = Number(searchParams.get("event") || "");
|
||||
|
||||
const clearEventParam = useCallback(() => {
|
||||
if (!searchParams.get("event")) return;
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
nextParams.delete("event");
|
||||
router.replace(nextParams.toString() ? `${pathname}?${nextParams.toString()}` : pathname, { scroll: false });
|
||||
}, [pathname, router, searchParams]);
|
||||
|
||||
// Загрузка событий
|
||||
const loadEvents = useCallback(async () => {
|
||||
@ -220,6 +232,32 @@ export function BigCalendar() {
|
||||
loadEvents();
|
||||
}, [loadEvents]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!linkedEventId || viewingEvent?.id === linkedEventId) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
apiClient.getEvent(linkedEventId)
|
||||
.then((event) => {
|
||||
if (cancelled) return;
|
||||
|
||||
setViewingEvent({
|
||||
...event,
|
||||
start: new Date(event.start),
|
||||
end: new Date(event.end),
|
||||
});
|
||||
setCurrentDate(new Date(event.start));
|
||||
setShowEventDetailsModal(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Ошибка deep-link события календаря:", error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [linkedEventId, viewingEvent?.id]);
|
||||
|
||||
// Обработчики календаря
|
||||
const handleSelectSlot = useCallback(
|
||||
({ start }: { start: Date; end: Date }) => {
|
||||
@ -464,6 +502,7 @@ export function BigCalendar() {
|
||||
onClose={() => {
|
||||
setShowEventDetailsModal(false);
|
||||
setViewingEvent(null);
|
||||
clearEventParam();
|
||||
}}
|
||||
event={viewingEvent}
|
||||
onEdit={handleEditFromDetails}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { X, Trash2 } from "lucide-react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Modal } from "@/components/ui";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { useCalendar } from "@/contexts/CalendarContext";
|
||||
|
||||
@ -79,28 +80,39 @@ export function CalendarModal({ isOpen, onClose, calendar }: CalendarModalProps)
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm p-2 sm:p-4">
|
||||
<div className="w-full max-w-[95vw] sm:max-w-md rounded-xl sm:rounded-2xl bg-white p-4 sm:p-6 shadow-xl max-h-[95vh] sm:max-h-[90vh] overflow-y-auto">
|
||||
<div className="mb-3 sm:mb-4 flex items-center justify-between">
|
||||
<h3 className="text-base sm:text-lg font-semibold text-gray-900">
|
||||
{calendar?.id ? "Редактировать календарь" : "Создать календарь"}
|
||||
</h3>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={calendar?.id ? "Редактировать календарь" : "Создать календарь"}
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
className="rounded-full p-1 hover:bg-gray-100 disabled:opacity-50"
|
||||
onClick={handleSave}
|
||||
disabled={!name.trim() || saving}
|
||||
className="flex-1 rounded-lg bg-sky-500 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<X size={20} className="text-gray-600" />
|
||||
{saving ? "Сохранение..." : calendar?.id ? "Сохранить" : "Создать"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{calendar?.id && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={saving}
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-600 transition hover:bg-red-100 disabled:opacity-50"
|
||||
title="Удалить календарь"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{!calendar?.id && (
|
||||
<p className="text-sm text-gray-600">
|
||||
Создайте календарь для организации событий. Вы сможете добавлять встречи, задачи и напоминания.
|
||||
@ -123,28 +135,7 @@ export function CalendarModal({ isOpen, onClose, calendar }: CalendarModalProps)
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!name.trim() || saving}
|
||||
className="flex-1 rounded-lg bg-sky-500 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Сохранение..." : calendar?.id ? "Сохранить" : "Создать"}
|
||||
</button>
|
||||
|
||||
{calendar?.id && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={saving}
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-600 transition hover:bg-red-100 disabled:opacity-50"
|
||||
title="Удалить календарь"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, UserPlus, Trash2 } from 'lucide-react';
|
||||
import api from '@/lib/api';
|
||||
import { Modal } from '@/components/ui';
|
||||
|
||||
interface Participant {
|
||||
id: number;
|
||||
@ -182,14 +183,8 @@ export default function CalendarParticipantsModal({
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm p-2 sm:p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-[95vw] sm:max-w-2xl rounded-xl sm:rounded-2xl bg-white shadow-xl max-h-[95vh] sm:max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="md" noPadding>
|
||||
<div className="overflow-y-auto">
|
||||
<div className="flex items-center justify-between border-b px-4 sm:px-6 py-3 sm:py-4">
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-semibold text-gray-900">Участники календаря</h2>
|
||||
@ -397,6 +392,6 @@ export default function CalendarParticipantsModal({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { X, Check, Bell, BellOff, Eye, EyeOff, Loader2 } from "lucide-react";
|
||||
import { Check, Bell, BellOff, Eye, EyeOff, Loader2 } from "lucide-react";
|
||||
import { Modal } from "@/components/ui";
|
||||
import { apiClient } from "@/lib/api";
|
||||
|
||||
interface Calendar {
|
||||
@ -138,17 +139,11 @@ export function CalendarSubscriptionsModal({ isOpen, onClose, onUpdate }: Props)
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm p-2 sm:p-4">
|
||||
<div className="w-full max-w-[95vw] sm:max-w-2xl rounded-xl sm:rounded-2xl bg-white shadow-xl max-h-[95vh] sm:max-h-[90vh] overflow-y-auto">
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="md" noPadding>
|
||||
<div className="overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-4 sm:px-6 py-3 sm:py-4">
|
||||
<h3 className="text-base sm:text-lg font-semibold text-gray-900">Подписки на календари</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-full p-1 hover:bg-gray-100"
|
||||
>
|
||||
<X size={20} className="text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
@ -296,6 +291,6 @@ export function CalendarSubscriptionsModal({ isOpen, onClose, onUpdate }: Props)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { X, Trash2 } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { Modal } from "@/components/ui";
|
||||
|
||||
// Нормализация byweekday в массив чисел
|
||||
function normalizeByweekday(byweekday: any): number[] {
|
||||
@ -377,25 +378,41 @@ export function EventModal({
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen || !editingEvent) return null;
|
||||
if (!editingEvent) return null;
|
||||
|
||||
const footerContent = (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!editingEvent.title?.trim() || saving}
|
||||
className="flex-1 rounded-lg bg-sky-500 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Сохранение...' : (editingEvent.id ? 'Сохранить' : 'Создать')}
|
||||
</button>
|
||||
|
||||
{editingEvent.id && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={saving}
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-600 transition hover:bg-red-100 disabled:opacity-50"
|
||||
title="Удалить событие"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm p-2 sm:p-4">
|
||||
<div className="w-full max-w-[95vw] sm:max-w-md rounded-xl sm:rounded-2xl bg-white p-4 sm:p-6 shadow-xl max-h-[95vh] sm:max-h-[90vh] overflow-y-auto">
|
||||
<div className="mb-3 sm:mb-4 flex items-center justify-between">
|
||||
<h3 className="text-base sm:text-lg font-semibold text-gray-900">
|
||||
{editingEvent.id ? "Редактировать событие" : "Создать событие"}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
className="rounded-full p-1 hover:bg-gray-100 disabled:opacity-50"
|
||||
>
|
||||
<X size={20} className="text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={editingEvent.id ? "Редактировать событие" : "Создать событие"}
|
||||
size="sm"
|
||||
closeOnEsc={!saving}
|
||||
footer={footerContent}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
Название
|
||||
@ -799,29 +816,7 @@ export function EventModal({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!editingEvent.title?.trim() || saving}
|
||||
className="flex-1 rounded-lg bg-sky-500 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Сохранение...' : (editingEvent.id ? 'Сохранить' : 'Создать')}
|
||||
</button>
|
||||
|
||||
{editingEvent.id && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={saving}
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-600 transition hover:bg-red-100 disabled:opacity-50"
|
||||
title="Удалить событие"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { X, Search, Check, Users } from "lucide-react";
|
||||
import { X, Search, Check } from "lucide-react";
|
||||
import apiClient from "@/lib/api";
|
||||
import { Modal } from "@/components/ui";
|
||||
|
||||
interface Employee {
|
||||
id: number;
|
||||
@ -102,164 +103,147 @@ export default function NewChatModal({ isOpen, onClose, onChatCreated }: NewChat
|
||||
return `${emp.last_name} ${emp.first_name} ${emp.middle_name || ""}`.trim();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const isGroup = selectedEmployees.length > 1;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-2 sm:p-4">
|
||||
<div className="bg-white rounded-xl sm:rounded-2xl shadow-xl max-w-[95vw] sm:max-w-2xl w-full max-h-[95vh] sm:max-h-[80vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-3 sm:p-4 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4 sm:w-5 sm:h-5 text-gray-600" />
|
||||
<h2 className="text-lg sm:text-xl font-semibold">Новый чат</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Chat Name (for groups) */}
|
||||
{isGroup && (
|
||||
<div className="p-3 sm:p-4 border-b">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Название группы (необязательно)"
|
||||
value={chatName}
|
||||
onChange={(e) => setChatName(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm sm:text-base"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected Employees */}
|
||||
{selectedEmployees.length > 0 && (
|
||||
<div className="p-3 sm:p-4 border-b bg-gray-50">
|
||||
<div className="text-sm text-gray-600 mb-2">
|
||||
Выбрано: {selectedEmployees.length}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{employees
|
||||
.filter(emp => selectedEmployees.includes(emp.id))
|
||||
.map(emp => (
|
||||
<div
|
||||
key={emp.id}
|
||||
className="flex items-center gap-2 bg-blue-100 text-blue-700 px-3 py-1 rounded-full text-sm"
|
||||
>
|
||||
<span>{getEmployeeFullName(emp)}</span>
|
||||
<button
|
||||
onClick={() => toggleEmployee(emp.id)}
|
||||
className="hover:bg-blue-200 rounded-full p-0.5"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
<div className="p-3 sm:p-4 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск сотрудников..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm sm:text-base"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Employee List */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
Загрузка сотрудников...
|
||||
</div>
|
||||
) : employees.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
Сотрудники не найдены
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{employees.map((emp) => {
|
||||
const isSelected = selectedEmployees.includes(emp.id);
|
||||
return (
|
||||
<button
|
||||
key={emp.id}
|
||||
onClick={() => toggleEmployee(emp.id)}
|
||||
className={`
|
||||
w-full flex items-center gap-3 p-3 rounded-lg transition-colors
|
||||
${isSelected
|
||||
? "bg-blue-50 border-2 border-blue-500"
|
||||
: "bg-gray-50 hover:bg-gray-100 border-2 border-transparent"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div className="relative flex-shrink-0">
|
||||
{emp.avatar ? (
|
||||
<img
|
||||
src={emp.avatar}
|
||||
alt={getEmployeeFullName(emp)}
|
||||
className="w-10 h-10 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-500 flex items-center justify-center text-white font-semibold">
|
||||
{emp.first_name[0]}{emp.last_name[0]}
|
||||
</div>
|
||||
)}
|
||||
{isSelected && (
|
||||
<div className="absolute -top-1 -right-1 bg-blue-500 rounded-full p-0.5">
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Employee Info */}
|
||||
<div className="flex-1 text-left">
|
||||
<div className="font-medium text-gray-900">
|
||||
{getEmployeeFullName(emp)}
|
||||
</div>
|
||||
{emp.position && (
|
||||
<div className="text-sm text-gray-600">{emp.position}</div>
|
||||
)}
|
||||
{emp.department && (
|
||||
<div className="text-xs text-gray-500">{emp.department.name}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={creating}
|
||||
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateChat}
|
||||
disabled={selectedEmployees.length === 0 || creating}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{creating ? "Создание..." : "Создать чат"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
const footerContent = (
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={creating}
|
||||
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateChat}
|
||||
disabled={selectedEmployees.length === 0 || creating}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{creating ? "Создание..." : "Создать чат"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Новый чат" size="md" closeOnEsc={!creating} noPadding footer={footerContent}>
|
||||
{/* Chat Name (for groups) */}
|
||||
{isGroup && (
|
||||
<div className="p-3 sm:p-4 border-b">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Название группы (необязательно)"
|
||||
value={chatName}
|
||||
onChange={(e) => setChatName(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm sm:text-base"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected Employees */}
|
||||
{selectedEmployees.length > 0 && (
|
||||
<div className="p-3 sm:p-4 border-b bg-gray-50">
|
||||
<div className="text-sm text-gray-600 mb-2">
|
||||
Выбрано: {selectedEmployees.length}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{employees
|
||||
.filter(emp => selectedEmployees.includes(emp.id))
|
||||
.map(emp => (
|
||||
<div
|
||||
key={emp.id}
|
||||
className="flex items-center gap-2 bg-blue-100 text-blue-700 px-3 py-1 rounded-full text-sm"
|
||||
>
|
||||
<span>{getEmployeeFullName(emp)}</span>
|
||||
<button
|
||||
onClick={() => toggleEmployee(emp.id)}
|
||||
className="hover:bg-blue-200 rounded-full p-0.5"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
<div className="p-3 sm:p-4 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск сотрудников..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm sm:text-base"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Employee List */}
|
||||
<div className="max-h-[60vh] overflow-y-auto p-4">
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
Загрузка сотрудников...
|
||||
</div>
|
||||
) : employees.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
Сотрудники не найдены
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{employees.map((emp) => {
|
||||
const isSelected = selectedEmployees.includes(emp.id);
|
||||
return (
|
||||
<button
|
||||
key={emp.id}
|
||||
onClick={() => toggleEmployee(emp.id)}
|
||||
className={`
|
||||
w-full flex items-center gap-3 p-3 rounded-lg transition-colors
|
||||
${isSelected
|
||||
? "bg-blue-50 border-2 border-blue-500"
|
||||
: "bg-gray-50 hover:bg-gray-100 border-2 border-transparent"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div className="relative flex-shrink-0">
|
||||
{emp.avatar ? (
|
||||
<img
|
||||
src={emp.avatar}
|
||||
alt={getEmployeeFullName(emp)}
|
||||
className="w-10 h-10 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-500 flex items-center justify-center text-white font-semibold">
|
||||
{emp.first_name[0]}{emp.last_name[0]}
|
||||
</div>
|
||||
)}
|
||||
{isSelected && (
|
||||
<div className="absolute -top-1 -right-1 bg-blue-500 rounded-full p-0.5">
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Employee Info */}
|
||||
<div className="flex-1 text-left">
|
||||
<div className="font-medium text-gray-900">
|
||||
{getEmployeeFullName(emp)}
|
||||
</div>
|
||||
{emp.position && (
|
||||
<div className="text-sm text-gray-600">{emp.position}</div>
|
||||
)}
|
||||
{emp.department && (
|
||||
<div className="text-xs text-gray-500">{emp.department.name}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -27,6 +27,8 @@ interface Snapshot {
|
||||
clientHeight: number;
|
||||
scrollHeight: number;
|
||||
diff: number;
|
||||
anchorMessageId: string | null;
|
||||
anchorOffsetTop: number | null;
|
||||
}
|
||||
|
||||
export class ScrollableMessageListInner extends Component<ScrollableMessageListProps> {
|
||||
@ -47,6 +49,29 @@ export class ScrollableMessageListInner extends Component<ScrollableMessageListP
|
||||
scrollBehavior: 'smooth' as ScrollBehavior,
|
||||
};
|
||||
|
||||
private getScrollAnchor(container: HTMLDivElement): { messageId: string; offsetTop: number } | null {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const viewportTop = containerRect.top;
|
||||
const messageElements = container.querySelectorAll<HTMLElement>('[data-message-id]');
|
||||
|
||||
for (const element of messageElements) {
|
||||
const messageId = element.dataset.messageId;
|
||||
if (!messageId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.bottom > viewportTop) {
|
||||
return {
|
||||
messageId,
|
||||
offsetTop: rect.top - containerRect.top,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Начальный скролл вниз при монтировании
|
||||
if (this.props.autoScrollToBottomOnMount) {
|
||||
@ -115,11 +140,15 @@ export class ScrollableMessageListInner extends Component<ScrollableMessageListP
|
||||
container.scrollHeight + 1 === topHeight ||
|
||||
container.scrollHeight - 1 === topHeight;
|
||||
|
||||
const anchor = this.getScrollAnchor(container);
|
||||
|
||||
return {
|
||||
sticky,
|
||||
clientHeight: container.clientHeight,
|
||||
scrollHeight: container.scrollHeight,
|
||||
diff: container.scrollHeight - container.scrollTop,
|
||||
anchorMessageId: anchor?.messageId ?? null,
|
||||
anchorOffsetTop: anchor?.offsetTop ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@ -162,22 +191,19 @@ export class ScrollableMessageListInner extends Component<ScrollableMessageListP
|
||||
}
|
||||
} else {
|
||||
this.preventScrollTop = false;
|
||||
|
||||
// Особый случай: если пользователь был в самом верху списка при добавлении новых элементов
|
||||
// Это происходит при загрузке старых сообщений (prepend operation)
|
||||
// diff === scrollHeight означает что scrollTop был 0
|
||||
if (snapshot.diff >= snapshot.scrollHeight - 5 && container.scrollHeight > snapshot.scrollHeight) {
|
||||
// Прокручиваем ровно на высоту добавленного контента
|
||||
// Чтобы первое "старое" сообщение осталось видимым в той же позиции
|
||||
const addedHeight = container.scrollHeight - snapshot.scrollHeight;
|
||||
container.scrollTop = addedHeight;
|
||||
} else {
|
||||
// Стандартное восстановление позиции для всех остальных случаев
|
||||
// Сохраняет расстояние от текущей позиции до конца списка
|
||||
container.scrollTop =
|
||||
container.scrollHeight -
|
||||
snapshot.diff +
|
||||
(this.lastClientHeight - container.clientHeight);
|
||||
|
||||
if (snapshot.anchorMessageId && snapshot.anchorOffsetTop !== null) {
|
||||
const anchorElement = container.querySelector<HTMLElement>(`[data-message-id="${snapshot.anchorMessageId}"]`);
|
||||
|
||||
if (anchorElement) {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const currentOffsetTop = anchorElement.getBoundingClientRect().top - containerRect.top;
|
||||
const offsetDiff = currentOffsetTop - snapshot.anchorOffsetTop;
|
||||
|
||||
if (Math.abs(offsetDiff) > 0.5) {
|
||||
container.scrollTop += offsetDiff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -191,7 +217,7 @@ export class ScrollableMessageListInner extends Component<ScrollableMessageListP
|
||||
* Обработка изменений высоты контента (через MutationObserver)
|
||||
* Срабатывает когда загружаются изображения/видео и меняется scrollHeight
|
||||
*/
|
||||
handleContentMutation = () => {
|
||||
handleContentMutation = (mutations: MutationRecord[] = []) => {
|
||||
const container = this.containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
@ -200,6 +226,12 @@ export class ScrollableMessageListInner extends Component<ScrollableMessageListP
|
||||
// Если высота контента изменилась
|
||||
if (currentScrollHeight !== this.lastScrollHeight) {
|
||||
const heightDiff = currentScrollHeight - this.lastScrollHeight;
|
||||
const hasChildListMutation = mutations.some((mutation) => mutation.type === 'childList');
|
||||
|
||||
if (hasChildListMutation) {
|
||||
this.lastScrollHeight = currentScrollHeight;
|
||||
return;
|
||||
}
|
||||
|
||||
// ВАЖНО: Не корректируем если scrollTop близко к 0 (это prepend operation)
|
||||
// В этом случае коррекцию делает componentDidUpdate с правильной логикой
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { X, Plus, Clock, Calendar } from "lucide-react";
|
||||
import { Plus, Clock, Calendar } from "lucide-react";
|
||||
import { Modal } from "@/components/ui";
|
||||
import { format } from "date-fns";
|
||||
import { ru } from "date-fns/locale";
|
||||
|
||||
@ -51,26 +52,27 @@ export function ViewDayEventsModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm p-2 sm:p-4">
|
||||
<div className="w-full max-w-[95vw] sm:max-w-md rounded-xl sm:rounded-2xl bg-white p-4 sm:p-6 shadow-xl max-h-[95vh] sm:max-h-[90vh] overflow-y-auto">
|
||||
<div className="mb-3 sm:mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-base sm:text-lg font-semibold text-gray-900">События на {capitalizedDate}</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{sortedEvents.length === 0
|
||||
? "Нет событий"
|
||||
: `${sortedEvents.length} ${sortedEvents.length === 1 ? "событие" : "событий"}`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-full p-1 hover:bg-gray-100"
|
||||
>
|
||||
<X size={20} className="text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={`События на ${capitalizedDate}`}
|
||||
size="sm"
|
||||
footer={
|
||||
<button
|
||||
onClick={onCreateEvent}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-lg bg-sky-500 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-sky-600"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Создать событие
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-gray-500">
|
||||
{sortedEvents.length === 0
|
||||
? "Нет событий"
|
||||
: `${sortedEvents.length} ${sortedEvents.length === 1 ? "событие" : "событий"}`}
|
||||
</p>
|
||||
{sortedEvents.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Calendar size={40} className="text-gray-300 mb-2" />
|
||||
@ -126,17 +128,7 @@ export function ViewDayEventsModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={onCreateEvent}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-lg bg-sky-500 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-sky-600"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Создать событие
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { X, Edit2, Trash2, Clock, Calendar, FileText, Users } from "lucide-react";
|
||||
import { Edit2, Trash2, Clock, Calendar, FileText, Users } from "lucide-react";
|
||||
import { Modal } from "@/components/ui";
|
||||
import { format } from "date-fns";
|
||||
import { ru } from "date-fns/locale";
|
||||
import { useState, useEffect } from "react";
|
||||
@ -98,37 +99,46 @@ export function ViewEventDetailsModal({
|
||||
const capitalizedEnd = formatDateTime(event.end).charAt(0).toUpperCase() + formatDateTime(event.end).slice(1);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm p-2 sm:p-4">
|
||||
<div className="w-full max-w-[95vw] sm:max-w-md rounded-xl sm:rounded-2xl bg-white p-4 sm:p-6 shadow-xl max-h-[95vh] sm:max-h-[90vh] overflow-y-auto">
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0 pr-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{event.title}
|
||||
</h3>
|
||||
{event.rule && (
|
||||
<span className="text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded flex-shrink-0">
|
||||
⟲
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||
<div
|
||||
className="h-2.5 w-2.5 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: event.color_event || "#3498db" }}
|
||||
/>
|
||||
<span className="truncate">Календарь #{event.calendar}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={event.title}
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-full p-1 hover:bg-gray-100 flex-shrink-0"
|
||||
onClick={onEdit}
|
||||
className="flex-1 flex items-center justify-center gap-2 rounded-lg bg-sky-500 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-sky-600"
|
||||
>
|
||||
<X size={20} className="text-gray-600" />
|
||||
<Edit2 size={16} />
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-600 transition hover:bg-red-100"
|
||||
title="Удалить событие"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Event meta */}
|
||||
<div className="flex items-center gap-2">
|
||||
{event.rule && (
|
||||
<span className="text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded flex-shrink-0">
|
||||
⟲
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||
<div
|
||||
className="h-2.5 w-2.5 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: event.color_event || "#3498db" }}
|
||||
/>
|
||||
<span className="truncate">Календарь #{event.calendar}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Time */}
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Clock size={18} className="text-gray-400 mt-0.5 flex-shrink-0" />
|
||||
@ -229,24 +239,7 @@ export function ViewEventDetailsModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="flex-1 flex items-center justify-center gap-2 rounded-lg bg-sky-500 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-sky-600"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-600 transition hover:bg-red-100"
|
||||
title="Удалить событие"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import { X, Search, CheckCircle, AlertCircle, Users, Download } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { useModal, useClickOutside } from "@/hooks/useModal";
|
||||
import { Modal } from "@/components/ui";
|
||||
|
||||
interface Employee {
|
||||
id: number;
|
||||
@ -46,17 +46,6 @@ export function DocumentAcknowledgementsReport({
|
||||
const [search, setSearch] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<"all" | "acknowledged" | "unacknowledged">("all");
|
||||
|
||||
// Используем модальные хуки для улучшений UX
|
||||
const contentRef = useModal({
|
||||
isOpen: true,
|
||||
onClose,
|
||||
closeOnEsc: true,
|
||||
trapFocus: true,
|
||||
lockBodyScroll: true,
|
||||
});
|
||||
|
||||
const backdropRef = useClickOutside(onClose, true);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [documentId, search]);
|
||||
@ -129,14 +118,7 @@ export function DocumentAcknowledgementsReport({
|
||||
const displayList = getDisplayList();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={backdropRef}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-2 sm:p-4 transition-opacity duration-200"
|
||||
>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="flex h-[95vh] sm:h-[90vh] w-full max-w-[95vw] sm:max-w-4xl flex-col rounded-xl sm:rounded-2xl bg-white shadow-xl transition-all duration-200"
|
||||
>
|
||||
<Modal isOpen onClose={onClose} noHeader noPadding size="xl" className="h-[95vh] sm:h-[90vh]">
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-start gap-2 sm:gap-3 justify-between border-b border-gray-200 p-3 sm:p-6">
|
||||
<div className="min-w-0 flex-1">
|
||||
@ -294,7 +276,6 @@ export function DocumentAcknowledgementsReport({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import { Document as PDFDocument, Page, pdfjs } from "react-pdf";
|
||||
import { ChevronLeft, ChevronRight, Download, X, FileText, Table } from "lucide-react";
|
||||
import "react-pdf/dist/Page/AnnotationLayer.css";
|
||||
import "react-pdf/dist/Page/TextLayer.css";
|
||||
import { Modal } from "@/components/ui";
|
||||
|
||||
// Configure PDF.js worker
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
|
||||
@ -166,8 +167,7 @@ export function DocumentPreview({ fileUrl, fileName, onClose }: DocumentPreviewP
|
||||
const goToNextPage = () => setPageNumber((prev) => Math.min(prev + 1, numPages));
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-2 sm:p-4">
|
||||
<div className="relative flex h-full max-h-[95vh] sm:max-h-[90vh] w-full max-w-[95vw] sm:max-w-4xl flex-col rounded-xl sm:rounded-2xl bg-white shadow-2xl">
|
||||
<Modal isOpen onClose={onClose ?? (() => {})} noHeader noPadding size="xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-gray-200 px-3 sm:px-6 py-3 sm:py-4">
|
||||
<h3 className="truncate text-sm sm:text-lg font-semibold text-gray-900">{fileName}</h3>
|
||||
@ -419,7 +419,6 @@ export function DocumentPreview({ fileUrl, fileName, onClose }: DocumentPreviewP
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { RelatedDocument, Document } from '@/types/api';
|
||||
import { apiClient } from '@/lib/api';
|
||||
import { Modal } from "@/components/ui";
|
||||
|
||||
interface RelatedDocumentsListProps {
|
||||
documentId: number;
|
||||
@ -170,35 +170,16 @@ export default function RelatedDocumentsList({ documentId }: RelatedDocumentsLis
|
||||
)}
|
||||
|
||||
{/* Модальное окно добавления */}
|
||||
{showAddModal && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-2 sm:p-4"
|
||||
onClick={() => {
|
||||
setShowAddModal(false);
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-[95vw] sm:max-w-md rounded-xl sm:rounded-2xl bg-white shadow-xl max-h-[95vh] sm:max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-gray-200 px-4 sm:px-6 py-3 sm:py-4">
|
||||
<h5 className="text-base sm:text-lg font-semibold text-gray-900">Добавить связанный документ</h5>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-1 hover:bg-gray-100"
|
||||
onClick={() => {
|
||||
setShowAddModal(false);
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
}}
|
||||
>
|
||||
<X size={20} className="text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 sm:px-6 py-4">
|
||||
<Modal
|
||||
isOpen={showAddModal}
|
||||
onClose={() => {
|
||||
setShowAddModal(false);
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
}}
|
||||
title="Добавить связанный документ"
|
||||
size="sm"
|
||||
>
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
@ -241,10 +222,7 @@ export default function RelatedDocumentsList({ documentId }: RelatedDocumentsLis
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import { Users } from "lucide-react";
|
||||
import { getParticipantInitials, type MemberSearchResult } from "@/lib/messages/chatSettingsUtils";
|
||||
import { getUserFullName } from "@/lib/messages/chatUtils";
|
||||
import { resolveMediaUrl } from "@/lib/url";
|
||||
import { Modal } from "@/components/ui";
|
||||
|
||||
type AddChatMemberModalProps = {
|
||||
open: boolean;
|
||||
@ -28,83 +29,70 @@ export default function AddChatMemberModal({
|
||||
onSearchChange,
|
||||
onAddMember,
|
||||
}: AddChatMemberModalProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-gray-900">Добавить участника</h2>
|
||||
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={memberSearchQuery}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
placeholder="Введите имя или email..."
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">Минимум 2 символа для поиска</p>
|
||||
</div>
|
||||
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{memberSearchLoading ? (
|
||||
<div className="py-8 text-center">
|
||||
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-sky-400 border-t-transparent" />
|
||||
<p className="text-sm text-gray-500">Поиск...</p>
|
||||
</div>
|
||||
) : memberSearchQuery.trim().length < 2 ? (
|
||||
<div className="py-8 text-center">
|
||||
<Users className="mx-auto mb-2 h-12 w-12 text-gray-300" />
|
||||
<p className="text-sm text-gray-500">Начните вводить имя или email для поиска</p>
|
||||
</div>
|
||||
) : memberSearchResults.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-gray-500">Пользователи не найдены</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{memberSearchResults.map((result) => (
|
||||
<div key={result.id} className="flex items-center gap-3 rounded-lg border border-gray-200 p-3 transition hover:bg-gray-50">
|
||||
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white">
|
||||
{result.avatar ? (
|
||||
<Image
|
||||
src={resolveMediaUrl(result.avatar)}
|
||||
alt={result.name || getUserFullName(result.last_name, result.first_name)}
|
||||
width={40}
|
||||
height={40}
|
||||
unoptimized
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span>{getParticipantInitials(result.name || getUserFullName(result.last_name, result.first_name))}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-900">{result.name || getUserFullName(result.last_name, result.first_name)}</p>
|
||||
{result.email ? <p className="text-xs text-gray-500">{result.email}</p> : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAddMember(result.id)}
|
||||
disabled={actionLoading === `add-member-${result.id}`}
|
||||
className="rounded-lg bg-sky-600 px-3 py-1.5 text-xs font-medium text-white transition hover:bg-sky-700 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === `add-member-${result.id}` ? "..." : "Добавить"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Modal isOpen={open} onClose={onClose} title="Добавить участника" size="sm">
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={memberSearchQuery}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
placeholder="Введите имя или email..."
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">Минимум 2 символа для поиска</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{memberSearchLoading ? (
|
||||
<div className="py-8 text-center">
|
||||
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-sky-400 border-t-transparent" />
|
||||
<p className="text-sm text-gray-500">Поиск...</p>
|
||||
</div>
|
||||
) : memberSearchQuery.trim().length < 2 ? (
|
||||
<div className="py-8 text-center">
|
||||
<Users className="mx-auto mb-2 h-12 w-12 text-gray-300" />
|
||||
<p className="text-sm text-gray-500">Начните вводить имя или email для поиска</p>
|
||||
</div>
|
||||
) : memberSearchResults.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-gray-500">Пользователи не найдены</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{memberSearchResults.map((result) => (
|
||||
<div key={result.id} className="flex items-center gap-3 rounded-lg border border-gray-200 p-3 transition hover:bg-gray-50">
|
||||
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white">
|
||||
{result.avatar ? (
|
||||
<Image
|
||||
src={resolveMediaUrl(result.avatar)}
|
||||
alt={result.name || getUserFullName(result.last_name, result.first_name)}
|
||||
width={40}
|
||||
height={40}
|
||||
unoptimized
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span>{getParticipantInitials(result.name || getUserFullName(result.last_name, result.first_name))}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-900">{result.name || getUserFullName(result.last_name, result.first_name)}</p>
|
||||
{result.email ? <p className="text-xs text-gray-500">{result.email}</p> : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAddMember(result.id)}
|
||||
disabled={actionLoading === `add-member-${result.id}`}
|
||||
className="rounded-lg bg-sky-600 px-3 py-1.5 text-xs font-medium text-white transition hover:bg-sky-700 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === `add-member-${result.id}` ? "..." : "Добавить"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { ArrowLeft, Bell, BellOff, Pin } from "lucide-react";
|
||||
import { ArrowLeft, Bell, BellOff, MoreHorizontal, Pin, Search } from "lucide-react";
|
||||
|
||||
import type { Chat } from "@/types/api";
|
||||
import { getChatAvatar, getChatInitials, getChatTitle } from "@/lib/messages/chatUtils";
|
||||
@ -16,6 +17,7 @@ type ChatDialogHeaderProps = {
|
||||
notificationsEnabled: boolean;
|
||||
onTogglePin: () => void;
|
||||
onToggleNotifications: () => void;
|
||||
onOpenSearch: () => void;
|
||||
};
|
||||
|
||||
function getChatSubtitle(chat: Chat): string {
|
||||
@ -45,13 +47,53 @@ export default function ChatDialogHeader({
|
||||
notificationsEnabled,
|
||||
onTogglePin,
|
||||
onToggleNotifications,
|
||||
onOpenSearch,
|
||||
}: ChatDialogHeaderProps) {
|
||||
const avatar = getChatAvatar(chat, currentUserId);
|
||||
const title = getChatTitle(chat, currentUserId);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [menuOpen]);
|
||||
|
||||
const subtitle = getChatSubtitle(chat);
|
||||
|
||||
return (
|
||||
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-gray-100 pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<header className="relative z-30 flex shrink-0 items-center gap-3 border-b border-gray-100 bg-white px-4 pt-3 pb-3 lg:px-0 lg:pt-0">
|
||||
<Link
|
||||
href="/messages"
|
||||
aria-label="К списку чатов"
|
||||
title="К списку чатов"
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-gray-200 bg-white text-gray-600 transition hover:bg-gray-50 hover:text-sky-700"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</Link>
|
||||
|
||||
<Link href={`/messages/${chatId}/settings`} className="flex min-w-0 flex-1 items-center gap-3 transition hover:opacity-80">
|
||||
<div className="flex h-11 w-11 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white">
|
||||
{avatar ? (
|
||||
<div className="relative h-full w-full">
|
||||
@ -61,51 +103,68 @@ export default function ChatDialogHeader({
|
||||
getChatInitials(chat, currentUserId)
|
||||
)}
|
||||
</div>
|
||||
<Link href={`/messages/${chatId}/settings`} className="hover:opacity-80 transition">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900">{title}</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">{getChatSubtitle(chat)}</p>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-gray-900">{title}</p>
|
||||
<p className="truncate text-xs text-gray-500">{subtitle}</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div ref={menuRef} className="relative z-40 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTogglePin}
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-full border transition ${
|
||||
isPinned
|
||||
? "border-sky-500 bg-sky-50 text-sky-700 hover:bg-sky-100"
|
||||
: "border-gray-200 bg-white text-gray-600 hover:bg-gray-50 hover:text-sky-700"
|
||||
}`}
|
||||
title={isPinned ? "Открепить чат" : "Закрепить чат"}
|
||||
aria-label={isPinned ? "Открепить чат" : "Закрепить чат"}
|
||||
>
|
||||
<Pin size={16} className={isPinned ? "fill-current" : ""} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleNotifications}
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-full border transition ${
|
||||
notificationsEnabled
|
||||
? "border-gray-200 bg-white text-gray-600 hover:bg-gray-50 hover:text-sky-700"
|
||||
: "border-amber-500 bg-amber-50 text-amber-700 hover:bg-amber-100"
|
||||
}`}
|
||||
title={notificationsEnabled ? "Отключить уведомления" : "Включить уведомления"}
|
||||
aria-label={notificationsEnabled ? "Отключить уведомления" : "Включить уведомления"}
|
||||
>
|
||||
{notificationsEnabled ? <Bell size={16} /> : <BellOff size={16} />}
|
||||
</button>
|
||||
|
||||
<Link
|
||||
href="/messages"
|
||||
aria-label="К списку чатов"
|
||||
title="К списку чатов"
|
||||
onClick={() => setMenuOpen((prev) => !prev)}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full border border-gray-200 bg-white text-gray-600 transition hover:bg-gray-50 hover:text-sky-700"
|
||||
aria-label="Дополнительные действия"
|
||||
aria-expanded={menuOpen}
|
||||
aria-haspopup="menu"
|
||||
title="Дополнительные действия"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</Link>
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
|
||||
{menuOpen ? (
|
||||
<div className="absolute right-0 top-full z-[80] mt-2 w-56 overflow-hidden rounded-2xl border border-gray-200 bg-white py-1 shadow-lg shadow-gray-200/70 ring-1 ring-gray-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onOpenSearch();
|
||||
}}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm font-medium text-gray-700 transition hover:bg-gray-50"
|
||||
>
|
||||
<Search size={16} className="shrink-0 text-gray-500" />
|
||||
<span>Поиск по сообщениям</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onTogglePin();
|
||||
}}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm font-medium text-gray-700 transition hover:bg-gray-50"
|
||||
>
|
||||
<Pin size={16} className={`shrink-0 ${isPinned ? "fill-current text-sky-700" : "text-gray-500"}`} />
|
||||
<span>{isPinned ? "Открепить чат" : "Закрепить чат"}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onToggleNotifications();
|
||||
}}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm font-medium text-gray-700 transition hover:bg-gray-50"
|
||||
>
|
||||
{notificationsEnabled ? (
|
||||
<Bell size={16} className="shrink-0 text-gray-500" />
|
||||
) : (
|
||||
<BellOff size={16} className="shrink-0 text-amber-700" />
|
||||
)}
|
||||
<span>{notificationsEnabled ? "Отключить уведомления" : "Включить уведомления"}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@ -12,7 +12,7 @@ type ChatMediaPreviewModalProps = {
|
||||
|
||||
export default function ChatMediaPreviewModal({ preview, onClose }: ChatMediaPreviewModalProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 p-4" onClick={onClose}>
|
||||
<div data-overlay-root="true" className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 p-4" onClick={onClose}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
|
||||
@ -1,14 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { ChevronRight, FileText } from "lucide-react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
CheckCheck,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
PlayCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import { resolveMediaUrl } from "@/lib/url";
|
||||
import {
|
||||
formatFileSize,
|
||||
getReplyPreview,
|
||||
getReplyPreviewStatusLabel,
|
||||
getReplyPreviewText,
|
||||
getReplyToId,
|
||||
getMessageDate,
|
||||
getMessageInitials,
|
||||
getMessagePreviewText,
|
||||
isReplyPreviewDeleted,
|
||||
isAudioAttachment,
|
||||
isImageAttachment,
|
||||
isVideoAttachment,
|
||||
@ -22,16 +37,30 @@ export type MediaPreview = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
function MediaTypeBadge({ type }: { type: "image" | "video" }) {
|
||||
const isVideo = type === "video";
|
||||
const Icon = isVideo ? PlayCircle : ImageIcon;
|
||||
|
||||
return (
|
||||
<span className="absolute left-2 top-2 z-10 inline-flex items-center gap-1 rounded-full bg-black/60 px-2 py-1 text-[11px] font-medium text-white backdrop-blur-sm">
|
||||
<Icon size={12} />
|
||||
{isVideo ? "Видео" : "Фото"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type ChatMessageItemProps = {
|
||||
message: Message;
|
||||
currentUserId?: number;
|
||||
repliedMessage?: Message | null;
|
||||
isHighlighted?: boolean;
|
||||
isActionsOpen: boolean;
|
||||
canManage: boolean;
|
||||
canReply: boolean;
|
||||
brokenMedia: Record<number, boolean>;
|
||||
useOriginalImage: Record<number, boolean>;
|
||||
onToggleActions: (messageId: number, anchor: { x: number; y: number }) => void;
|
||||
onJumpToReply: (messageId: number) => void;
|
||||
onOpenMediaPreview: (preview: MediaPreview) => void;
|
||||
onAttachmentLoad: (attachmentId: number) => void;
|
||||
onAttachmentError: (attachmentId: number) => void;
|
||||
@ -44,16 +73,56 @@ function getMessageAuthorLabel(message: Message): string {
|
||||
return message.author_name || message.author?.last_name || message.sender?.last_name || "Сотрудник";
|
||||
}
|
||||
|
||||
function MessageDeliveryStatus({ sendState, isRead }: { sendState?: Message["send_state"]; isRead: boolean }) {
|
||||
if (sendState === "pending") {
|
||||
return (
|
||||
<span className="inline-flex items-center text-sky-100" title="Отправляется" aria-label="Отправляется">
|
||||
<Loader2 size={13} strokeWidth={2.2} className="animate-spin" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (sendState === "delayed") {
|
||||
return (
|
||||
<span className="inline-flex items-center text-sky-100" title="Сервер отвечает медленно" aria-label="Сервер отвечает медленно">
|
||||
<Clock3 size={13} strokeWidth={2.2} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (sendState === "failed") {
|
||||
return (
|
||||
<span className="inline-flex items-center text-rose-200" title="Не отправлено" aria-label="Не отправлено">
|
||||
<AlertCircle size={13} strokeWidth={2.2} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = isRead ? CheckCheck : Check;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center ${isRead ? "text-white" : "text-sky-100"}`}
|
||||
title={isRead ? "Прочитано" : "Отправлено"}
|
||||
aria-label={isRead ? "Прочитано" : "Отправлено"}
|
||||
>
|
||||
<Icon size={13} strokeWidth={2.2} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChatMessageItem({
|
||||
message,
|
||||
currentUserId,
|
||||
repliedMessage,
|
||||
isHighlighted = false,
|
||||
isActionsOpen,
|
||||
canManage,
|
||||
canReply,
|
||||
brokenMedia,
|
||||
useOriginalImage,
|
||||
onToggleActions,
|
||||
onJumpToReply,
|
||||
onOpenMediaPreview,
|
||||
onAttachmentLoad,
|
||||
onAttachmentError,
|
||||
@ -62,12 +131,17 @@ export default function ChatMessageItem({
|
||||
hasMyReaction,
|
||||
}: ChatMessageItemProps) {
|
||||
const currentDate = getMessageDate(message);
|
||||
const replyToId = repliedMessage?.id ?? message.reply_to_id ?? (typeof message.reply_to === "number" ? message.reply_to : null);
|
||||
const replyPreviewMessage = getReplyPreview(message, repliedMessage);
|
||||
const replyToId = getReplyToId(message);
|
||||
const canJumpToReply = Boolean(replyToId && !isReplyPreviewDeleted(replyPreviewMessage));
|
||||
const replyStatusLabel = getReplyPreviewStatusLabel(replyPreviewMessage);
|
||||
const isMine = Boolean(
|
||||
currentUserId &&
|
||||
(message.author_id === currentUserId || message.author?.id === currentUserId || message.sender?.id === currentUserId)
|
||||
);
|
||||
const hasActions = canReply || canManage;
|
||||
const isRead = Boolean(message.is_read);
|
||||
const sendState = message.send_state;
|
||||
|
||||
return (
|
||||
<div data-message-id={message.id} data-message-date={currentDate?.toISOString() || ""} className="mb-3 last:mb-0">
|
||||
@ -92,7 +166,7 @@ export default function ChatMessageItem({
|
||||
) : null}
|
||||
|
||||
<div className={`flex min-w-0 items-start gap-1 ${isMine ? "max-w-[88%] flex-row-reverse" : "max-w-[calc(100%-2.5rem)]"}`}>
|
||||
<div className={`relative min-w-0 rounded-2xl px-3 py-2 pr-9 ${isMine ? "bg-sky-500 text-white" : "bg-white text-gray-900 ring-1 ring-gray-100"}`}>
|
||||
<div className={`relative min-w-0 rounded-2xl px-3 py-2 pr-9 transition-shadow duration-300 ${isMine ? "bg-sky-500 text-white" : "bg-white text-gray-900 ring-1 ring-gray-100"} ${isHighlighted ? (isMine ? "shadow-[0_0_0_3px_rgba(125,211,252,0.45)]" : "ring-2 ring-sky-300 shadow-[0_0_0_4px_rgba(186,230,253,0.65)]") : ""}`}>
|
||||
{hasActions ? (
|
||||
<div className="absolute right-1 top-1 z-20">
|
||||
<button
|
||||
@ -116,10 +190,52 @@ export default function ChatMessageItem({
|
||||
{!isMine ? <p className="mb-1 text-[11px] font-medium text-gray-500">{getMessageAuthorLabel(message)}</p> : null}
|
||||
|
||||
{replyToId ? (
|
||||
<div className={`mb-2 rounded-lg border-l-2 px-2 py-1 text-xs ${isMine ? "border-sky-200 bg-sky-400/30 text-sky-50" : "border-gray-300 bg-gray-100 text-gray-600"}`}>
|
||||
<p className="font-medium">{repliedMessage?.author_name || "Ответ на сообщение"}</p>
|
||||
<p className="truncate">{repliedMessage ? getMessagePreviewText(repliedMessage) : `Сообщение #${replyToId}`}</p>
|
||||
</div>
|
||||
canJumpToReply ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onJumpToReply(replyToId)}
|
||||
className={[
|
||||
"group mb-2 block w-full rounded-xl border-l-2 px-2.5 py-1.5 text-left text-xs transition focus-visible:outline-none",
|
||||
isMine
|
||||
? "border-sky-200 bg-sky-400/30 text-sky-50 hover:bg-sky-400/40 focus-visible:bg-sky-400/40 focus-visible:ring-2 focus-visible:ring-white/35"
|
||||
: "border-gray-300 bg-gray-100 text-gray-600 hover:bg-gray-200 focus-visible:bg-gray-200 focus-visible:ring-2 focus-visible:ring-sky-200",
|
||||
].join(" ")}
|
||||
title="Перейти к исходному сообщению"
|
||||
aria-label={`Перейти к сообщению ${replyPreviewMessage?.author_name || `#${replyToId}`}`}
|
||||
>
|
||||
<p className="font-medium opacity-90 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
|
||||
{replyPreviewMessage?.author_name || "Ответ на сообщение"}
|
||||
</p>
|
||||
<p className="truncate opacity-90 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
|
||||
{replyPreviewMessage ? getReplyPreviewText(replyPreviewMessage) : `Сообщение #${replyToId}`}
|
||||
</p>
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
className={[
|
||||
"mb-2 block w-full rounded-xl border-l-2 px-2.5 py-1.5 text-left text-xs opacity-95",
|
||||
isMine
|
||||
? "border-sky-200/80 bg-sky-400/20 text-sky-50/95"
|
||||
: "border-gray-300 bg-gray-100 text-gray-600",
|
||||
].join(" ")}
|
||||
title="Исходное сообщение удалено"
|
||||
aria-label={`Исходное сообщение удалено${replyPreviewMessage?.author_name ? `, автор ${replyPreviewMessage.author_name}` : ""}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="min-w-0 truncate font-medium">
|
||||
{replyPreviewMessage?.author_name || "Ответ на сообщение"}
|
||||
</p>
|
||||
{replyStatusLabel ? (
|
||||
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${isMine ? "bg-white/18 text-white/90" : "bg-gray-200 text-gray-600"}`}>
|
||||
{replyStatusLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="truncate opacity-90">
|
||||
{replyPreviewMessage ? getReplyPreviewText(replyPreviewMessage) : "Сообщение удалено"}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{message.is_deleted ? (
|
||||
@ -131,6 +247,19 @@ export default function ChatMessageItem({
|
||||
{message.attachments && message.attachments.length > 0 ? (
|
||||
<div className="mt-2 space-y-2">
|
||||
{message.attachments.map((attachment) => {
|
||||
if (attachment.is_local) {
|
||||
return (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm ${isMine ? "bg-sky-400/25 text-sky-50" : "bg-white/80 text-gray-700 ring-1 ring-gray-200"}`}
|
||||
>
|
||||
<FileText size={16} className="shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{attachment.file_name}</span>
|
||||
<span className={`shrink-0 text-xs ${isMine ? "text-sky-100" : "text-gray-500"}`}>{formatFileSize(attachment.file_size)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fileUrl = resolveMediaUrl(attachment.file_url);
|
||||
|
||||
if (isImageAttachment(attachment)) {
|
||||
@ -159,8 +288,9 @@ export default function ChatMessageItem({
|
||||
key={attachment.id}
|
||||
type="button"
|
||||
onClick={() => onOpenMediaPreview({ type: "image", src: fileUrl, name: attachment.file_name })}
|
||||
className="block w-full overflow-hidden rounded-lg"
|
||||
className="relative block w-full overflow-hidden rounded-lg"
|
||||
>
|
||||
<MediaTypeBadge type="image" />
|
||||
<Image
|
||||
src={imageSrc}
|
||||
alt={attachment.file_name}
|
||||
@ -203,8 +333,9 @@ export default function ChatMessageItem({
|
||||
key={attachment.id}
|
||||
type="button"
|
||||
onClick={() => onOpenMediaPreview({ type: "video", src: fileUrl, name: attachment.file_name })}
|
||||
className="block w-full overflow-hidden rounded-lg"
|
||||
className="relative block w-full overflow-hidden rounded-lg"
|
||||
>
|
||||
<MediaTypeBadge type="video" />
|
||||
<video
|
||||
preload="metadata"
|
||||
playsInline
|
||||
@ -216,6 +347,11 @@ export default function ChatMessageItem({
|
||||
onError={() => onAttachmentError(attachment.id)}
|
||||
onLoadedData={() => onAttachmentLoad(attachment.id)}
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<span className="inline-flex h-12 w-12 items-center justify-center rounded-full bg-black/45 text-white backdrop-blur-sm">
|
||||
<PlayCircle size={26} fill="currentColor" strokeWidth={1.8} />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -267,7 +403,10 @@ export default function ChatMessageItem({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className={`mt-1 text-right text-[11px] ${isMine ? "text-sky-100" : "text-gray-400"}`}>{messageTime(message)}</p>
|
||||
<div className={`mt-1 flex items-center ${isMine ? "justify-end gap-1.5" : "justify-end"}`}>
|
||||
<p className={`text-right text-[11px] ${isMine ? "text-sky-100" : "text-gray-400"}`}>{messageTime(message)}</p>
|
||||
{isMine ? <MessageDeliveryStatus sendState={sendState} isRead={isRead} /> : null}
|
||||
</div>
|
||||
|
||||
{Object.keys(message.reactions_summary || {}).length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
|
||||
189
frontend/src/components/messages/ChatSearchPanel.tsx
Normal file
189
frontend/src/components/messages/ChatSearchPanel.tsx
Normal file
@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronUp, Loader2, Paperclip, Search, X } from "lucide-react";
|
||||
|
||||
import type { ChatMessageSearchResult } from "@/types/api";
|
||||
|
||||
type ChatSearchPanelProps = {
|
||||
isOpen: boolean;
|
||||
query: string;
|
||||
loading: boolean;
|
||||
loadingMore: boolean;
|
||||
error: string | null;
|
||||
results: ChatMessageSearchResult[];
|
||||
totalCount: number;
|
||||
nextOffset: number | null;
|
||||
selectedIndex: number;
|
||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||
onQueryChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
onSelect: (index: number) => void;
|
||||
onLoadMore: () => void;
|
||||
onSubmitSelection: () => void;
|
||||
};
|
||||
|
||||
function formatSearchDate(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
|
||||
return date.toLocaleString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export default function ChatSearchPanel({
|
||||
isOpen,
|
||||
query,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
results,
|
||||
totalCount,
|
||||
nextOffset,
|
||||
selectedIndex,
|
||||
inputRef,
|
||||
onQueryChange,
|
||||
onClose,
|
||||
onPrevious,
|
||||
onNext,
|
||||
onSelect,
|
||||
onLoadMore,
|
||||
onSubmitSelection,
|
||||
}: ChatSearchPanelProps) {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canNavigate = results.length > 0;
|
||||
const selectedLabel = canNavigate ? `${selectedIndex + 1}/${totalCount}` : totalCount > 0 ? `0/${totalCount}` : "0";
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-b border-gray-100 bg-white px-4 py-3 lg:px-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search size={15} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
onSubmitSelection();
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
onPrevious();
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
onNext();
|
||||
}
|
||||
}}
|
||||
placeholder="Поиск по сообщениям"
|
||||
className="h-10 w-full rounded-full border border-gray-200 bg-gray-50 pl-10 pr-4 text-sm text-gray-800 outline-none transition focus:border-sky-500 focus:bg-white focus:ring-2 focus:ring-sky-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 rounded-full border border-gray-200 bg-white px-2 py-1 text-xs text-gray-500">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPrevious}
|
||||
disabled={!canNavigate}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-full transition hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label="Предыдущее совпадение"
|
||||
title="Предыдущее совпадение"
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
</button>
|
||||
<span className="min-w-10 text-center font-medium text-gray-600">{selectedLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
disabled={!canNavigate}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-full transition hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label="Следующее совпадение"
|
||||
title="Следующее совпадение"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-gray-200 bg-white text-gray-500 transition hover:bg-gray-50 hover:text-sky-700"
|
||||
aria-label="Закрыть поиск"
|
||||
title="Закрыть поиск"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
{query.trim().length < 2 ? (
|
||||
<p className="text-xs text-gray-500">Введите минимум 2 символа для поиска по истории чата.</p>
|
||||
) : loading && results.length === 0 ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
<span>Ищем совпадения...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
) : results.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">Совпадений не найдено.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||
<span>Найдено: {totalCount}</span>
|
||||
<span>Можно перейти Enter или кликом</span>
|
||||
</div>
|
||||
|
||||
<div className="max-h-56 overflow-y-auto rounded-2xl border border-gray-100 bg-gray-50 p-2">
|
||||
<div className="space-y-1.5">
|
||||
{results.map((result, index) => (
|
||||
<button
|
||||
key={result.message_id}
|
||||
type="button"
|
||||
onClick={() => onSelect(index)}
|
||||
className={`block w-full rounded-xl px-3 py-2 text-left transition ${index === selectedIndex ? "bg-sky-50 ring-1 ring-sky-200" : "bg-white hover:bg-gray-100"}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="truncate text-xs font-semibold text-gray-700">{result.author_name}</p>
|
||||
<span className="shrink-0 text-[11px] text-gray-400">{formatSearchDate(result.created_at)}</span>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-sm text-gray-700">{result.snippet}</p>
|
||||
{result.has_attachments ? (
|
||||
<div className="mt-1 flex items-center gap-1 text-[11px] text-gray-400">
|
||||
<Paperclip size={11} />
|
||||
<span>Вложений: {result.attachments_count}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{nextOffset !== null ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{loadingMore ? <Loader2 size={14} className="animate-spin" /> : null}
|
||||
<span>{loadingMore ? "Загрузка..." : "Показать еще"}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -6,6 +6,7 @@ import Image from "next/image";
|
||||
import { getChatAvatar, getChatInitials } from "@/lib/messages/chatUtils";
|
||||
import { resolveMediaUrl } from "@/lib/url";
|
||||
import type { Chat } from "@/types/api";
|
||||
import { Modal } from "@/components/ui";
|
||||
|
||||
type EditChatModalProps = {
|
||||
chat: Chat | null;
|
||||
@ -36,15 +37,30 @@ export default function EditChatModal({
|
||||
onAvatarChange,
|
||||
onSave,
|
||||
}: EditChatModalProps) {
|
||||
if (!open || !chat) return null;
|
||||
|
||||
const avatar = getChatAvatar(chat, currentUserId);
|
||||
const footerContent = (
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={actionLoading === "edit"}
|
||||
className="flex-1 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={actionLoading === "edit" || !editName.trim()}
|
||||
className="flex-1 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-700 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === "edit" ? "Сохранение..." : "Сохранить"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
|
||||
<h2 className="mb-4 text-xl font-bold text-gray-900">Редактировать чат</h2>
|
||||
|
||||
<Modal isOpen={open && !!chat} onClose={onClose} title="Редактировать чат" size="sm" closeOnEsc={actionLoading !== "edit"} footer={footerContent}>
|
||||
{chat && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Аватар</label>
|
||||
@ -52,8 +68,8 @@ export default function EditChatModal({
|
||||
<div className="flex h-16 w-16 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-lg font-semibold text-white">
|
||||
{editAvatarPreview ? (
|
||||
<Image src={editAvatarPreview} alt="Preview" width={64} height={64} unoptimized className="h-full w-full object-cover" />
|
||||
) : avatar ? (
|
||||
<Image src={resolveMediaUrl(avatar)} alt="Avatar" width={64} height={64} unoptimized className="h-full w-full object-cover" />
|
||||
) : getChatAvatar(chat, currentUserId) ? (
|
||||
<Image src={resolveMediaUrl(getChatAvatar(chat, currentUserId)!)} alt="Avatar" width={64} height={64} unoptimized className="h-full w-full object-cover" />
|
||||
) : (
|
||||
getChatInitials(chat, currentUserId)
|
||||
)}
|
||||
@ -96,26 +112,7 @@ export default function EditChatModal({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={actionLoading === "edit"}
|
||||
className="flex-1 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={actionLoading === "edit" || !editName.trim()}
|
||||
className="flex-1 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-700 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === "edit" ? "Сохранение..." : "Сохранить"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,17 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { Pencil, Reply, Smile, Trash2 } from "lucide-react";
|
||||
import { CheckCheck, Pencil, Reply, Smile, Trash2 } from "lucide-react";
|
||||
|
||||
import type { Message } from "@/types/api";
|
||||
|
||||
type MessageActionsMenuProps = {
|
||||
anchor: { x: number; y: number };
|
||||
currentUserId?: number;
|
||||
message: Message;
|
||||
canReply: boolean;
|
||||
canManage: boolean;
|
||||
recentReactions: string[];
|
||||
onQuickReact: (emoji: string) => void;
|
||||
onOpenReactionPicker: () => void;
|
||||
onShowAllReaders?: () => void;
|
||||
onReply: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
@ -19,34 +21,44 @@ type MessageActionsMenuProps = {
|
||||
|
||||
export default function MessageActionsMenu({
|
||||
anchor,
|
||||
currentUserId,
|
||||
message,
|
||||
canReply,
|
||||
canManage,
|
||||
recentReactions,
|
||||
onQuickReact,
|
||||
onOpenReactionPicker,
|
||||
onShowAllReaders,
|
||||
onReply,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: MessageActionsMenuProps) {
|
||||
const isMine = Boolean(
|
||||
currentUserId &&
|
||||
(message.author_id === currentUserId || message.author?.id === currentUserId || message.sender?.id === currentUserId)
|
||||
);
|
||||
const readers = message.read_by || [];
|
||||
|
||||
return (
|
||||
<div
|
||||
data-actions-menu="true"
|
||||
className="fixed z-[60] flex min-w-[176px] flex-col gap-1 rounded-lg border border-gray-200 bg-white p-1 shadow-xl"
|
||||
className="fixed z-[60] min-w-[208px] max-w-[232px] rounded-xl border border-gray-200 bg-white py-1.5 shadow-lg ring-1 ring-slate-100"
|
||||
style={{
|
||||
left: anchor.x,
|
||||
top: anchor.y - 6,
|
||||
top: anchor.y - 8,
|
||||
transform: "translate(-100%, -100%)",
|
||||
}}
|
||||
>
|
||||
{canReply ? (
|
||||
<div className="mb-1 flex items-center gap-1 rounded-md bg-gray-50 p-1">
|
||||
<div className="border-b border-slate-100 px-2.5 pb-2 pt-1">
|
||||
<div className="mb-1.5 px-0.5 text-[10px] font-semibold uppercase tracking-[0.08em] text-gray-400">Реакции</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{recentReactions.map((emoji) => (
|
||||
<button
|
||||
key={`recent-${message.id}-${emoji}`}
|
||||
type="button"
|
||||
onClick={() => onQuickReact(emoji)}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-white text-base hover:bg-sky-50"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-base transition hover:bg-sky-50"
|
||||
title="Быстрая реакция"
|
||||
>
|
||||
{emoji}
|
||||
@ -55,46 +67,66 @@ export default function MessageActionsMenu({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenReactionPicker}
|
||||
className="ml-auto inline-flex h-7 w-7 items-center justify-center rounded-md bg-white text-gray-600 hover:bg-sky-50 hover:text-sky-700"
|
||||
className="ml-auto inline-flex h-8 w-8 items-center justify-center rounded-md text-gray-500 transition hover:bg-sky-50 hover:text-sky-700"
|
||||
title="Все смайлы"
|
||||
>
|
||||
<Smile size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canReply ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReply}
|
||||
className="inline-flex w-full items-center gap-1 rounded-md border border-sky-200 bg-sky-50 px-2 py-1 text-xs font-medium text-sky-700 hover:bg-sky-100"
|
||||
>
|
||||
<Reply size={12} />
|
||||
Ответить
|
||||
</button>
|
||||
) : null}
|
||||
<div className="py-1">
|
||||
{isMine && onShowAllReaders ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowAllReaders}
|
||||
className="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm text-gray-700 transition hover:bg-gray-50"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<CheckCheck size={13} className="text-gray-400" />
|
||||
Кто прочитал
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{readers.length}</span>
|
||||
</button>
|
||||
<div className="my-1 border-t border-slate-100" />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{canManage ? (
|
||||
<>
|
||||
{canReply ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReply}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-gray-700 transition hover:bg-sky-50"
|
||||
>
|
||||
<Reply size={13} className="text-gray-400" />
|
||||
Ответить
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{canManage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className="inline-flex w-full items-center gap-1 rounded-md border border-gray-200 bg-white px-2 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50"
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-gray-700 transition hover:bg-gray-50"
|
||||
>
|
||||
<Pencil size={12} />
|
||||
<Pencil size={13} className="text-gray-400" />
|
||||
Редактировать
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{canManage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="inline-flex w-full items-center gap-1 rounded-md border border-red-200 bg-red-50 px-2 py-1 text-xs font-medium text-red-700 hover:bg-red-100"
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-red-600 transition hover:bg-red-50"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
<Trash2 size={13} className="text-red-400" />
|
||||
Удалить
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,8 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
import type React from "react";
|
||||
import { Paperclip, Send, Smile, X } from "lucide-react";
|
||||
|
||||
const MIN_COMPOSER_HEIGHT = 38;
|
||||
const MAX_COMPOSER_HEIGHT = 128;
|
||||
|
||||
type ReplyTarget = {
|
||||
id: number;
|
||||
author: string;
|
||||
@ -22,6 +26,7 @@ type MessageComposerProps = {
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
messageInputRef: React.RefObject<HTMLTextAreaElement | null>;
|
||||
onPickFiles: () => void;
|
||||
onAddFiles: (files: File[]) => void;
|
||||
onFilesChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onRemoveFile: (index: number) => void;
|
||||
onToggleEmojiPicker: () => void;
|
||||
@ -47,6 +52,7 @@ export default function MessageComposer({
|
||||
fileInputRef,
|
||||
messageInputRef,
|
||||
onPickFiles,
|
||||
onAddFiles,
|
||||
onFilesChange,
|
||||
onRemoveFile,
|
||||
onToggleEmojiPicker,
|
||||
@ -58,8 +64,43 @@ export default function MessageComposer({
|
||||
onCancelEdit,
|
||||
onCancelReply,
|
||||
}: MessageComposerProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const canSend = editingMessageId ? messageText.trim().length > 0 : messageText.trim().length > 0 || attachedFiles.length > 0;
|
||||
|
||||
const handlePaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
if (editingMessageId) return;
|
||||
|
||||
const clipboardFiles = Array.from(event.clipboardData.files || []);
|
||||
const clipboardItemFiles = Array.from(event.clipboardData.items || [])
|
||||
.filter((item) => item.kind === "file")
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => Boolean(file));
|
||||
|
||||
const uniqueFiles = [...clipboardFiles, ...clipboardItemFiles].filter((file, index, files) => {
|
||||
const signature = `${file.name}|${file.size}|${file.type}|${file.lastModified}`;
|
||||
return files.findIndex((candidate) => `${candidate.name}|${candidate.size}|${candidate.type}|${candidate.lastModified}` === signature) === index;
|
||||
});
|
||||
|
||||
if (uniqueFiles.length > 0) {
|
||||
onAddFiles(uniqueFiles);
|
||||
}
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const input = messageInputRef.current;
|
||||
if (!input) return;
|
||||
|
||||
input.style.height = "0px";
|
||||
const nextHeight = Math.min(input.scrollHeight, MAX_COMPOSER_HEIGHT);
|
||||
const resolvedHeight = Math.max(nextHeight, MIN_COMPOSER_HEIGHT);
|
||||
|
||||
input.style.height = `${resolvedHeight}px`;
|
||||
input.style.overflowY = input.scrollHeight > MAX_COMPOSER_HEIGHT ? "auto" : "hidden";
|
||||
setIsExpanded(resolvedHeight > MIN_COMPOSER_HEIGHT + 2);
|
||||
}, [messageText, messageInputRef]);
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-t border-gray-100 bg-white pt-3">
|
||||
<>
|
||||
{editingMessageId ? (
|
||||
<div className="mb-2 flex items-start justify-between gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
||||
<div className="min-w-0">
|
||||
@ -99,7 +140,7 @@ export default function MessageComposer({
|
||||
{attachedFiles.map((file, index) => (
|
||||
<span
|
||||
key={`${file.name}-${file.size}-${index}`}
|
||||
className="inline-flex max-w-full items-center gap-1 rounded-full bg-sky-50 px-3 py-1 text-xs text-sky-700 ring-1 ring-sky-100"
|
||||
className="inline-flex max-w-full items-center gap-1 rounded-full border border-sky-100 bg-white px-3 py-1 text-xs text-sky-700"
|
||||
>
|
||||
<span className="truncate max-w-[180px]">{file.name}</span>
|
||||
<button
|
||||
@ -116,7 +157,7 @@ export default function MessageComposer({
|
||||
) : null}
|
||||
|
||||
{canSendMessages ? (
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex items-end gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
multiple
|
||||
@ -124,35 +165,47 @@ export default function MessageComposer({
|
||||
className="hidden"
|
||||
onChange={onFilesChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPickFiles}
|
||||
disabled={Boolean(editingMessageId)}
|
||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-600 leading-none transition hover:bg-gray-50 hover:text-sky-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title={editingMessageId ? "При редактировании вложения недоступны" : "Добавить файлы"}
|
||||
<div
|
||||
className={`relative min-w-0 flex-1 ${showEmojiPicker ? "overflow-visible" : "overflow-hidden"} border border-gray-200 bg-gray-50 shadow-[inset_0_1px_0_rgba(255,255,255,0.7)] transition focus-within:border-sky-500 focus-within:bg-white focus-within:ring-2 focus-within:ring-sky-100 ${
|
||||
isExpanded ? "rounded-[1.5rem]" : "h-10 rounded-full"
|
||||
}`}
|
||||
data-composer-emoji="true"
|
||||
>
|
||||
<Paperclip size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPickFiles}
|
||||
disabled={Boolean(editingMessageId)}
|
||||
className={`absolute left-3 z-10 inline-flex h-7 w-7 items-center justify-center rounded-full text-gray-400 transition hover:bg-white hover:text-sky-600 focus:outline-none focus:ring-2 focus:ring-sky-100 disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
isExpanded ? "bottom-2.5" : "top-1/2 -translate-y-1/2"
|
||||
}`}
|
||||
title={editingMessageId ? "При редактировании вложения недоступны" : "Добавить файлы"}
|
||||
aria-label={editingMessageId ? "При редактировании вложения недоступны" : "Добавить файлы"}
|
||||
>
|
||||
<Paperclip size={14} />
|
||||
</button>
|
||||
|
||||
<div className="relative w-full" data-composer-emoji="true">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleEmojiPicker}
|
||||
className="absolute right-2 top-1/2 z-10 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-gray-400 hover:bg-gray-100 hover:text-sky-600"
|
||||
className={`absolute right-3 z-10 inline-flex h-7 w-7 items-center justify-center rounded-full text-gray-400 transition hover:bg-white hover:text-sky-600 focus:outline-none focus:ring-2 focus:ring-sky-100 ${
|
||||
isExpanded ? "bottom-2.5" : "top-1/2 -translate-y-1/2"
|
||||
}`}
|
||||
title="Смайлы"
|
||||
aria-label="Открыть панель смайлов"
|
||||
aria-expanded={showEmojiPicker}
|
||||
>
|
||||
<Smile size={14} />
|
||||
</button>
|
||||
|
||||
{showEmojiPicker ? (
|
||||
<div className="absolute bottom-full right-0 z-20 mb-2 w-[260px] rounded-lg border border-gray-200 bg-white p-2 shadow-xl">
|
||||
<div className="absolute bottom-full right-0 z-20 mb-2 w-[260px] rounded-xl border border-gray-200 bg-white p-2 shadow-lg ring-1 ring-slate-100">
|
||||
<div className="grid max-h-48 grid-cols-8 gap-1 overflow-y-auto">
|
||||
{allReactions.map((emoji) => (
|
||||
<button
|
||||
key={`composer-${emoji}`}
|
||||
type="button"
|
||||
onClick={() => onSelectEmoji(emoji)}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-base hover:bg-sky-50"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-base transition hover:bg-sky-50"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
@ -169,6 +222,7 @@ export default function MessageComposer({
|
||||
onTyping();
|
||||
}}
|
||||
onClick={onInputClick}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
@ -177,16 +231,20 @@ export default function MessageComposer({
|
||||
}}
|
||||
rows={1}
|
||||
placeholder={editingMessageId ? "Редактируйте сообщение..." : "Введите сообщение..."}
|
||||
className="h-9 w-full resize-none rounded-lg border border-gray-200 bg-white px-3 py-2 pr-10 text-sm text-gray-900 outline-none ring-0 transition focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
|
||||
aria-label={editingMessageId ? "Редактирование сообщения" : "Поле ввода сообщения"}
|
||||
className={`w-full resize-none bg-transparent py-2.5 pl-11 pr-12 text-sm leading-5 text-gray-800 outline-none placeholder:text-gray-400 ${
|
||||
isExpanded ? "rounded-[1.5rem]" : "rounded-full"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSend}
|
||||
disabled={sending || (editingMessageId ? !messageText.trim() : (!messageText.trim() && attachedFiles.length === 0))}
|
||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-sky-500 text-white leading-none transition hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={sending || !canSend}
|
||||
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={editingMessageId ? "Сохранить" : "Отправить"}
|
||||
aria-label={editingMessageId ? "Сохранить сообщение" : "Отправить сообщение"}
|
||||
>
|
||||
<Send size={15} />
|
||||
</button>
|
||||
@ -201,6 +259,6 @@ export default function MessageComposer({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
77
frontend/src/components/messages/MessageReadersModal.tsx
Normal file
77
frontend/src/components/messages/MessageReadersModal.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { CheckCheck } from "lucide-react";
|
||||
|
||||
import { Modal } from "@/components/ui";
|
||||
import { resolveMediaUrl } from "@/lib/url";
|
||||
import type { MessageReader } from "@/types/api";
|
||||
|
||||
type MessageReadersModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
readers: MessageReader[];
|
||||
};
|
||||
|
||||
function getReaderInitials(name: string): string {
|
||||
const parts = name
|
||||
.split(/\s+/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (parts.length === 0) {
|
||||
return "П";
|
||||
}
|
||||
|
||||
return parts
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase() || "")
|
||||
.join("") || "П";
|
||||
}
|
||||
|
||||
export default function MessageReadersModal({ isOpen, onClose, readers }: MessageReadersModalProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Прочитали сообщение" size="sm">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<CheckCheck size={16} className="text-gray-400" />
|
||||
<p>{readers.length === 1 ? "Сообщение прочитал 1 сотрудник" : `Сообщение прочитали ${readers.length} сотрудников`}</p>
|
||||
</div>
|
||||
|
||||
{readers.length > 0 ? (
|
||||
<div className="max-h-[52vh] space-y-2 overflow-y-auto pr-1">
|
||||
{readers.map((reader) => (
|
||||
<div
|
||||
key={`message-reader-${reader.id}`}
|
||||
className="flex items-center gap-3 rounded-xl border border-gray-200 bg-white px-3 py-3 text-sm font-medium leading-5 text-gray-800"
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-sky-100 text-xs font-semibold text-sky-700 ring-1 ring-sky-200">
|
||||
{reader.avatar ? (
|
||||
<Image
|
||||
src={resolveMediaUrl(reader.avatar)}
|
||||
alt={reader.name}
|
||||
width={40}
|
||||
height={40}
|
||||
unoptimized
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span>{getReaderInitials(reader.name)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-gray-900">{reader.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-gray-200 bg-gray-50 px-4 py-6 text-center text-sm text-gray-500">
|
||||
Пока никто не дочитал это сообщение.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@ -1,36 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { EmojiStyle, SuggestionMode, Theme } from "emoji-picker-react";
|
||||
|
||||
import { Modal } from "@/components/ui";
|
||||
import emojiDataRu from "emoji-picker-react/dist/data/emojis-ru.js";
|
||||
|
||||
const EmojiPicker = dynamic(() => import("emoji-picker-react"), { ssr: false });
|
||||
|
||||
type ReactionPickerModalProps = {
|
||||
allReactions: string[];
|
||||
onClose: () => void;
|
||||
onSelect: (emoji: string) => void;
|
||||
};
|
||||
|
||||
export default function ReactionPickerModal({ allReactions, onClose, onSelect }: ReactionPickerModalProps) {
|
||||
export default function ReactionPickerModal({ onClose, onSelect }: ReactionPickerModalProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-2 sm:p-4" data-reaction-picker="true">
|
||||
<div className="w-full max-w-[95vw] rounded-xl bg-white p-4 shadow-xl sm:max-w-md sm:rounded-2xl sm:p-6">
|
||||
<div className="mb-3 flex items-center justify-between sm:mb-4">
|
||||
<h3 className="text-base font-semibold text-gray-900 sm:text-lg">Выберите реакцию</h3>
|
||||
<button type="button" onClick={onClose} className="rounded-full p-1 hover:bg-gray-100" aria-label="Закрыть">
|
||||
<X size={18} className="text-gray-600 sm:h-5 sm:w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid max-h-[60vh] grid-cols-6 gap-1.5 overflow-y-auto sm:max-h-[55vh] sm:grid-cols-8 sm:gap-2">
|
||||
{allReactions.map((emoji) => (
|
||||
<button
|
||||
key={`picker-${emoji}`}
|
||||
type="button"
|
||||
onClick={() => onSelect(emoji)}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-md text-lg hover:bg-sky-50"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
<Modal isOpen onClose={onClose} title="Выберите реакцию" size="sm" className="reaction-picker-modal">
|
||||
<div className="space-y-4" data-reaction-picker="true">
|
||||
<p className="text-sm text-gray-500">Быстрый способ отреагировать на сообщение без текста.</p>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-gray-100 bg-white">
|
||||
<EmojiPicker
|
||||
emojiData={emojiDataRu}
|
||||
onEmojiClick={(emoji) => onSelect(emoji.emoji)}
|
||||
searchPlaceholder="Поиск смайликов"
|
||||
theme={Theme.LIGHT}
|
||||
emojiStyle={EmojiStyle.NATIVE}
|
||||
suggestedEmojisMode={SuggestionMode.FREQUENT}
|
||||
lazyLoadEmojis
|
||||
previewConfig={{ showPreview: false }}
|
||||
width="100%"
|
||||
height={420}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import type { ProcurementSupplier } from "@/types/api";
|
||||
import { Pencil, Plus, Star, Trash2 } from "lucide-react";
|
||||
import { Modal } from "@/components/ui";
|
||||
|
||||
type Props = {
|
||||
canManage: boolean;
|
||||
@ -215,11 +216,13 @@ export default function ProcurementSuppliersPanel({ canManage }: Props) {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{formOpen && canManage && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={(e) => { if (e.target === e.currentTarget) closeForm(); }}>
|
||||
<div className="w-full max-w-xl rounded-2xl bg-white p-5 shadow-xl">
|
||||
<h3 className="text-base font-semibold text-gray-900">{editing ? "Редактировать поставщика" : "Новый поставщик"}</h3>
|
||||
<div className="mt-4 grid gap-3">
|
||||
<Modal isOpen={formOpen && canManage} onClose={closeForm} title={editing ? "Редактировать поставщика" : "Новый поставщик"} size="md" footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={closeForm} className="rounded-lg bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">Отмена</button>
|
||||
<button type="button" onClick={handleSave} disabled={busy} className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-60">Сохранить</button>
|
||||
</div>
|
||||
}>
|
||||
<div className="grid gap-3">
|
||||
<input value={form.name} onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))} placeholder="Название *" className="rounded-lg border border-gray-300 px-3 py-2 text-sm" />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input value={form.contact_person} onChange={(e) => setForm((prev) => ({ ...prev, contact_person: e.target.value }))} placeholder="Контактное лицо" className="rounded-lg border border-gray-300 px-3 py-2 text-sm" />
|
||||
@ -239,13 +242,7 @@ export default function ProcurementSuppliersPanel({ canManage }: Props) {
|
||||
<input type="checkbox" checked={form.is_active} onChange={(e) => setForm((prev) => ({ ...prev, is_active: e.target.checked }))} /> Активный поставщик
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button type="button" onClick={closeForm} className="rounded-lg bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">Отмена</button>
|
||||
<button type="button" onClick={handleSave} disabled={busy} className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-60">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -15,6 +15,10 @@ export interface ModalProps {
|
||||
closeOnEsc?: boolean;
|
||||
footer?: ReactNode;
|
||||
className?: string;
|
||||
/** Render children directly without padding wrappers (for custom layouts like flex-col) */
|
||||
noPadding?: boolean;
|
||||
/** Hide the built-in header (caller manages its own header inside children) */
|
||||
noHeader?: boolean;
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
@ -28,19 +32,18 @@ export function Modal({
|
||||
closeOnEsc = true,
|
||||
footer,
|
||||
className = "",
|
||||
noPadding = false,
|
||||
noHeader = false,
|
||||
}: ModalProps) {
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Анимация появления
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setIsAnimating(true);
|
||||
// Блокируем прокрутку body
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
// Разблокируем прокрутку при закрытии
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
|
||||
@ -49,7 +52,6 @@ export function Modal({
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// ESC для закрытия
|
||||
useEffect(() => {
|
||||
if (!isOpen || !closeOnEsc) return;
|
||||
|
||||
@ -63,7 +65,6 @@ export function Modal({
|
||||
return () => document.removeEventListener("keydown", handleEsc);
|
||||
}, [isOpen, closeOnEsc, onClose]);
|
||||
|
||||
// Focus trap
|
||||
useEffect(() => {
|
||||
if (!isOpen || !contentRef.current) return;
|
||||
|
||||
@ -76,20 +77,17 @@ export function Modal({
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
|
||||
// Фокус на первый элемент при открытии
|
||||
firstElement?.focus();
|
||||
|
||||
const handleTab = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Tab") return;
|
||||
|
||||
if (e.shiftKey) {
|
||||
// Shift + Tab
|
||||
if (document.activeElement === firstElement) {
|
||||
lastElement?.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
} else {
|
||||
// Tab
|
||||
if (document.activeElement === lastElement) {
|
||||
firstElement?.focus();
|
||||
e.preventDefault();
|
||||
@ -119,10 +117,13 @@ export function Modal({
|
||||
|
||||
const modalSizeClass = `w-full ${sizeClasses[size]} max-h-[95vh] sm:max-h-[90vh]`;
|
||||
|
||||
const showHeader = !noHeader && (title || showCloseButton);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={modalRef}
|
||||
onClick={handleBackdropClick}
|
||||
data-overlay-root="true"
|
||||
className={`fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm p-2 sm:p-4 transition-opacity duration-200 ${
|
||||
isAnimating ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
@ -132,48 +133,54 @@ export function Modal({
|
||||
>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={`flex flex-col rounded-xl sm:rounded-2xl bg-white shadow-xl overflow-y-auto transition-all duration-200 ${modalSizeClass} ${
|
||||
className={`flex flex-col rounded-xl sm:rounded-2xl bg-white shadow-xl overflow-hidden transition-all duration-200 ${modalSizeClass} ${
|
||||
isAnimating ? "scale-100 opacity-100" : "scale-95 opacity-0"
|
||||
} ${className}`}
|
||||
>
|
||||
{/* Header */}
|
||||
{(title || showCloseButton) && (
|
||||
<div className="flex shrink-0 items-center gap-2 sm:gap-3 mb-3 sm:mb-4 px-4 sm:px-6 pt-4 sm:pt-6">
|
||||
{title && (
|
||||
<h3
|
||||
id="modal-title"
|
||||
className="min-w-0 flex-1 text-sm sm:text-base lg:text-lg font-semibold text-gray-900 truncate"
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
{noPadding ? (
|
||||
children
|
||||
) : (
|
||||
<>
|
||||
{/* Header */}
|
||||
{showHeader && (
|
||||
<div className="flex shrink-0 items-center gap-2 sm:gap-3 mb-3 sm:mb-4 px-4 sm:px-6 pt-4 sm:pt-6">
|
||||
{title && (
|
||||
<h3
|
||||
id="modal-title"
|
||||
className="min-w-0 flex-1 text-sm sm:text-base lg:text-lg font-semibold text-gray-900 truncate"
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{showCloseButton && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-full p-1 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
|
||||
title="Закрыть"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{showCloseButton && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-full p-1 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
|
||||
title="Закрыть"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 sm:px-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{footer && (
|
||||
<div className="shrink-0 border-t border-gray-200 px-4 sm:px-6 py-3 sm:py-4">{footer}</div>
|
||||
)}
|
||||
|
||||
{/* Bottom padding */}
|
||||
<div className="pb-4 sm:pb-6" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 sm:px-6">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{footer && (
|
||||
<div className="shrink-0 border-t border-gray-200 px-4 sm:px-6 py-3 sm:py-4 mt-3 sm:mt-4">{footer}</div>
|
||||
)}
|
||||
|
||||
{/* Bottom padding */}
|
||||
<div className="pb-4 sm:pb-6" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { ChangeEvent } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { Modal } from "@/components/ui";
|
||||
import type { User } from "@/types/api";
|
||||
import type { UserProfileEditForm, UserProfileTextField } from "@/lib/users/userDetailUtils";
|
||||
|
||||
@ -32,25 +32,37 @@ export default function EditUserProfileModal({
|
||||
onTextFieldChange,
|
||||
person,
|
||||
}: EditUserProfileModalProps) {
|
||||
if (!isOpen || !person) {
|
||||
return null;
|
||||
}
|
||||
const footerContent = (
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={actionLoading === "edit"}
|
||||
className="flex-1 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={actionLoading === "edit" || !form.firstName.trim() || !form.lastName.trim() || !form.email.trim()}
|
||||
className="flex-1 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-700 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === "edit" ? "Сохранение..." : "Сохранить"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-2xl bg-white p-6 shadow-xl">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-gray-900">Редактировать профиль</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
disabled={actionLoading === "edit"}
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={isOpen && !!person}
|
||||
onClose={onClose}
|
||||
title="Редактировать профиль"
|
||||
size="lg"
|
||||
closeOnEsc={actionLoading !== "edit"}
|
||||
footer={footerContent}
|
||||
>
|
||||
{person && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||||
@ -212,26 +224,7 @@ export default function EditUserProfileModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={actionLoading === "edit"}
|
||||
className="flex-1 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={actionLoading === "edit" || !form.firstName.trim() || !form.lastName.trim() || !form.email.trim()}
|
||||
className="flex-1 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-700 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === "edit" ? "Сохранение..." : "Сохранить"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import { Modal } from "@/components/ui";
|
||||
import type { EmployeeActionField, EmployeeActionForm } from "@/lib/users/userDetailUtils";
|
||||
|
||||
type EmployeeActionOption = {
|
||||
@ -27,94 +27,83 @@ export default function EmployeeActionModal({
|
||||
onFieldChange,
|
||||
onSave,
|
||||
}: EmployeeActionModalProps) {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-gray-900">
|
||||
{form.editingId ? "Редактировать событие" : "Кадровое событие"}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
disabled={actionLoading === "action"}
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="action-type" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Тип события *
|
||||
</label>
|
||||
<select
|
||||
id="action-type"
|
||||
value={form.type}
|
||||
onChange={(event) => onFieldChange("type", event.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
|
||||
>
|
||||
<option value="">Выберите тип события</option>
|
||||
{actionTypes.map((type) => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="action-date" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Дата *
|
||||
</label>
|
||||
<input
|
||||
id="action-date"
|
||||
type="date"
|
||||
value={form.date}
|
||||
onChange={(event) => onFieldChange("date", event.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="action-comment" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Комментарий
|
||||
</label>
|
||||
<textarea
|
||||
id="action-comment"
|
||||
value={form.comment}
|
||||
onChange={(event) => onFieldChange("comment", event.target.value)}
|
||||
placeholder="Дополнительная информация..."
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={actionLoading === "action"}
|
||||
className="flex-1 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={actionLoading === "action" || !form.type || !form.date}
|
||||
className="flex-1 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-700 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === "action" ? "Сохранение..." : "Сохранить"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
const footerContent = (
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={actionLoading === "action"}
|
||||
className="flex-1 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={actionLoading === "action" || !form.type || !form.date}
|
||||
className="flex-1 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-700 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === "action" ? "Сохранение..." : "Сохранить"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={form.editingId ? "Редактировать событие" : "Кадровое событие"}
|
||||
size="sm"
|
||||
closeOnEsc={actionLoading !== "action"}
|
||||
footer={footerContent}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="action-type" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Тип события *
|
||||
</label>
|
||||
<select
|
||||
id="action-type"
|
||||
value={form.type}
|
||||
onChange={(event) => onFieldChange("type", event.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
|
||||
>
|
||||
<option value="">Выберите тип события</option>
|
||||
{actionTypes.map((type) => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="action-date" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Дата *
|
||||
</label>
|
||||
<input
|
||||
id="action-date"
|
||||
type="date"
|
||||
value={form.date}
|
||||
onChange={(event) => onFieldChange("date", event.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="action-comment" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Комментарий
|
||||
</label>
|
||||
<textarea
|
||||
id="action-comment"
|
||||
value={form.comment}
|
||||
onChange={(event) => onFieldChange("comment", event.target.value)}
|
||||
placeholder="Дополнительная информация..."
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@ -154,8 +154,8 @@ export function useChats() {
|
||||
useEffect(() => {
|
||||
async function fetchChats() {
|
||||
try {
|
||||
const data = await apiClient.getChats();
|
||||
setChats(data.results || data);
|
||||
const data = await apiClient.getAllChats();
|
||||
setChats(data);
|
||||
} catch (err) {
|
||||
setError(err as Error);
|
||||
} finally {
|
||||
|
||||
617
frontend/src/hooks/useChatMessages.ts
Normal file
617
frontend/src/hooks/useChatMessages.ts
Normal file
@ -0,0 +1,617 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import type { Chat, Message } from "@/types/api";
|
||||
import { getMessageTimestamp, uniqueMessagesById } from "@/lib/messages/chatUtils";
|
||||
import { getReplyToId } from "@/lib/messages/messageUtils";
|
||||
|
||||
const PENDING_MESSAGE_DELAY_MS = 8000;
|
||||
|
||||
/* ─── public types ─── */
|
||||
|
||||
export interface UseChatMessagesOptions {
|
||||
chatId: number;
|
||||
userId: number | undefined;
|
||||
userLoading: boolean;
|
||||
}
|
||||
|
||||
export interface UseChatMessagesReturn {
|
||||
chat: Chat | null;
|
||||
setChat: React.Dispatch<React.SetStateAction<Chat | null>>;
|
||||
messages: Message[];
|
||||
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
|
||||
pendingMessages: Message[];
|
||||
loading: boolean;
|
||||
messagesLoading: boolean;
|
||||
error: string | null;
|
||||
hasMoreOlder: boolean;
|
||||
hasMoreNewer: boolean;
|
||||
loadingOlder: boolean;
|
||||
loadingNewer: boolean;
|
||||
initialAnchorId: number | null;
|
||||
initialAnchorIndex: number | null;
|
||||
allowOneOlderProbe: boolean;
|
||||
anchorRetryTick: number;
|
||||
/** Whether we are in "date navigation" (history) mode */
|
||||
historyNavigationMode: boolean;
|
||||
setHistoryNavigationMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
/** Number of new (unread) messages that appeared below the viewport */
|
||||
newMessagesBelowCount: number;
|
||||
/** Ref containing unread-below message ids */
|
||||
unreadBelowIdsRef: React.MutableRefObject<Set<number>>;
|
||||
/** hasMoreNewer kept in a ref for synchronous access */
|
||||
hasMoreNewerRef: React.MutableRefObject<boolean>;
|
||||
/** Load older (prepend) */
|
||||
loadOlderMessages: () => Promise<void>;
|
||||
/** Load newer (append) */
|
||||
loadNewerMessages: (opts?: { markRead?: boolean }) => Promise<void>;
|
||||
/** Background "fallback sync" of latest messages */
|
||||
syncLatestMessages: (opts?: { limit?: number; scrollOnSync?: boolean }) => Promise<void>;
|
||||
/** Register incoming messages as "below viewport" */
|
||||
registerNewMessagesBelow: (msgs: Message[]) => void;
|
||||
/** Reset the "below viewport" counter & set */
|
||||
resetNewMessagesBelow: () => void;
|
||||
/** Remove a pending message by its local_id */
|
||||
removePendingMessage: (localId?: string | null) => void;
|
||||
/** Reconcile a server-confirmed message with its pending twin */
|
||||
removePendingByServer: (confirmed: Message) => void;
|
||||
/** Create an optimistic message for instant UI */
|
||||
buildOptimistic: (text: string, files: File[], localId: string, replyToId?: number) => Message;
|
||||
/** Append a pending message */
|
||||
addPending: (msg: Message) => void;
|
||||
/** Start the "delayed" timer for a pending message */
|
||||
schedulePendingTimer: (localId: string) => void;
|
||||
/** Mark a pending message as failed */
|
||||
markPendingFailed: (localId: string) => void;
|
||||
/** Jump to a specific date (history navigation) */
|
||||
jumpToDate: (dateISO: string) => Promise<void>;
|
||||
/** Return to unread boundary (around last_read_message_id) */
|
||||
returnToUnread: () => Promise<{
|
||||
hasMoreAfter: boolean;
|
||||
anchorId: number | null;
|
||||
anchorIndex: number | null;
|
||||
} | null>;
|
||||
/** Jump to a specific message by id and load surrounding context */
|
||||
jumpToMessage: (messageId: number) => Promise<{
|
||||
hasMoreAfter: boolean;
|
||||
anchorId: number | null;
|
||||
anchorIndex: number | null;
|
||||
} | null>;
|
||||
/** Set anchorRetryTick (used to retry anchor scroll) */
|
||||
setAnchorRetryTick: React.Dispatch<React.SetStateAction<number>>;
|
||||
/** Mark initial scroll as done */
|
||||
markInitialScrollDone: () => void;
|
||||
/** Whether initial scroll still pending */
|
||||
isInitialScrollPending: () => boolean;
|
||||
/** Ref to isNearBottom callback (set by scroll hook) */
|
||||
isNearBottomRef: React.MutableRefObject<() => boolean>;
|
||||
/** Ref to scrollToBottom callback (set by scroll hook) */
|
||||
scrollToBottomRef: React.MutableRefObject<(smooth?: boolean) => void>;
|
||||
}
|
||||
|
||||
/* ─── hook ─── */
|
||||
|
||||
export function useChatMessages({
|
||||
chatId,
|
||||
userId,
|
||||
userLoading,
|
||||
}: UseChatMessagesOptions): UseChatMessagesReturn {
|
||||
/* ── core state ── */
|
||||
const [chat, setChat] = useState<Chat | null>(null);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [pendingMessages, setPendingMessages] = useState<Message[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [messagesLoading, setMessagesLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/* ── pagination ── */
|
||||
const [hasMoreOlder, setHasMoreOlder] = useState(false);
|
||||
const [hasMoreNewer, setHasMoreNewer] = useState(false);
|
||||
const [loadingOlder, setLoadingOlder] = useState(false);
|
||||
const [loadingNewer, setLoadingNewer] = useState(false);
|
||||
|
||||
/* ── anchor / history ── */
|
||||
const [initialAnchorId, setInitialAnchorId] = useState<number | null>(null);
|
||||
const [initialAnchorIndex, setInitialAnchorIndex] = useState<number | null>(null);
|
||||
const [allowOneOlderProbe, setAllowOneOlderProbe] = useState(false);
|
||||
const [anchorRetryTick, setAnchorRetryTick] = useState(0);
|
||||
const [historyNavigationMode, setHistoryNavigationMode] = useState(false);
|
||||
|
||||
/* ── unread tracking ── */
|
||||
const [newMessagesBelowCount, setNewMessagesBelowCount] = useState(0);
|
||||
const unreadBelowIdsRef = useRef<Set<number>>(new Set());
|
||||
|
||||
/* ── refs ── */
|
||||
const hasMoreNewerRef = useRef(false);
|
||||
const initialScrolledRef = useRef(false);
|
||||
const pendingMessagesRef = useRef<Message[]>([]);
|
||||
const localSeqRef = useRef(0);
|
||||
const pendingTimersRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
const fallbackInFlightRef = useRef(false);
|
||||
|
||||
/** Injected by scroll hook */
|
||||
const isNearBottomRef = useRef<() => boolean>(() => false);
|
||||
const scrollToBottomRef = useRef<(smooth?: boolean) => void>(() => {});
|
||||
|
||||
/* keep refs in sync */
|
||||
useEffect(() => { hasMoreNewerRef.current = hasMoreNewer; }, [hasMoreNewer]);
|
||||
useEffect(() => { pendingMessagesRef.current = pendingMessages; }, [pendingMessages]);
|
||||
|
||||
/* ── pending message helpers ── */
|
||||
|
||||
const clearPendingTimer = useCallback((localId?: string | null) => {
|
||||
if (!localId) return;
|
||||
const t = pendingTimersRef.current.get(localId);
|
||||
if (t) { clearTimeout(t); pendingTimersRef.current.delete(localId); }
|
||||
}, []);
|
||||
|
||||
const markPendingAsDelayed = useCallback((localId: string) => {
|
||||
setPendingMessages(prev =>
|
||||
prev.map(m => m.local_id === localId && m.send_state === "pending" ? { ...m, send_state: "delayed" } : m),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const schedulePendingTimer = useCallback((localId: string) => {
|
||||
clearPendingTimer(localId);
|
||||
const t = setTimeout(() => { pendingTimersRef.current.delete(localId); markPendingAsDelayed(localId); }, PENDING_MESSAGE_DELAY_MS);
|
||||
pendingTimersRef.current.set(localId, t);
|
||||
}, [clearPendingTimer, markPendingAsDelayed]);
|
||||
|
||||
const removePendingMessage = useCallback((localId?: string | null) => {
|
||||
if (!localId) return;
|
||||
clearPendingTimer(localId);
|
||||
setPendingMessages(prev => prev.filter(m => m.local_id !== localId));
|
||||
}, [clearPendingTimer]);
|
||||
|
||||
const clearAllPendingTimers = useCallback(() => {
|
||||
pendingTimersRef.current.forEach(t => clearTimeout(t));
|
||||
pendingTimersRef.current.clear();
|
||||
}, []);
|
||||
|
||||
const markPendingFailed = useCallback((localId: string) => {
|
||||
clearPendingTimer(localId);
|
||||
setPendingMessages(prev => prev.map(m => m.local_id === localId ? { ...m, send_state: "failed" } : m));
|
||||
}, [clearPendingTimer]);
|
||||
|
||||
/* ── attachment signature matching ── */
|
||||
|
||||
const getAttachmentSig = useCallback((msg: Message) =>
|
||||
(msg.attachments || []).map(a => `${a.file_name}|${a.file_size ?? 0}|${a.mime_type ?? ""}`).sort().join("||"),
|
||||
[]);
|
||||
|
||||
const findMatchingPendingLocalId = useCallback((confirmed: Message) => {
|
||||
if (!userId || confirmed.author_id !== userId) return null;
|
||||
const confirmedReplyToId = getReplyToId(confirmed) ?? null;
|
||||
const confirmedSig = getAttachmentSig(confirmed);
|
||||
const confirmedTs = getMessageTimestamp(confirmed);
|
||||
|
||||
const candidates = pendingMessagesRef.current
|
||||
.filter(p => p.author_id === userId && p.content === confirmed.content)
|
||||
.filter(p => (getReplyToId(p) ?? null) === confirmedReplyToId)
|
||||
.filter(p => getAttachmentSig(p) === confirmedSig)
|
||||
.map(p => ({ localId: p.local_id ?? null, diff: Math.abs(getMessageTimestamp(p) - confirmedTs) }))
|
||||
.filter(c => Boolean(c.localId))
|
||||
.sort((a, b) => a.diff - b.diff);
|
||||
|
||||
const best = candidates[0];
|
||||
if (!best) return null;
|
||||
if (confirmedTs > 0 && best.diff > 10 * 60 * 1000) return null;
|
||||
return best.localId;
|
||||
}, [getAttachmentSig, userId]);
|
||||
|
||||
const removePendingByServer = useCallback((confirmed: Message) => {
|
||||
const localId = findMatchingPendingLocalId(confirmed);
|
||||
if (localId) removePendingMessage(localId);
|
||||
}, [findMatchingPendingLocalId, removePendingMessage]);
|
||||
|
||||
/* ── unread below ── */
|
||||
|
||||
const resetNewMessagesBelow = useCallback(() => {
|
||||
unreadBelowIdsRef.current.clear();
|
||||
setNewMessagesBelowCount(0);
|
||||
}, []);
|
||||
|
||||
const registerNewMessagesBelow = useCallback((incoming: Message[]) => {
|
||||
if (!userId || incoming.length === 0) return;
|
||||
let added = 0;
|
||||
incoming.forEach(m => {
|
||||
const authorId = m.author_id ?? m.author?.id ?? m.sender?.id;
|
||||
if (authorId === userId || m.id <= 0 || unreadBelowIdsRef.current.has(m.id)) return;
|
||||
unreadBelowIdsRef.current.add(m.id);
|
||||
added += 1;
|
||||
});
|
||||
if (added > 0) {
|
||||
setNewMessagesBelowCount(prev => prev + added);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
/* ── optimistic message builder ── */
|
||||
|
||||
const guessType = useCallback((f: File) => {
|
||||
const mime = (f.type || "").toLowerCase();
|
||||
if (mime.startsWith("image/")) return "image";
|
||||
if (mime.startsWith("video/")) return "video";
|
||||
if (mime.startsWith("audio/")) return "audio";
|
||||
return "file";
|
||||
}, []);
|
||||
|
||||
const buildOptimistic = useCallback((text: string, files: File[], localId: string, replyToId?: number): Message => {
|
||||
localSeqRef.current += 1;
|
||||
const now = Date.now() + localSeqRef.current;
|
||||
return {
|
||||
id: -now,
|
||||
chat: chatId,
|
||||
local_id: localId,
|
||||
author_id: userId,
|
||||
content: text,
|
||||
is_read: false,
|
||||
send_state: "pending",
|
||||
is_optimistic: true,
|
||||
created_at: new Date(now).toISOString(),
|
||||
created_ts: now,
|
||||
has_attachments: files.length > 0,
|
||||
attachments: files.map((f, i) => ({
|
||||
id: -(now * 100 + i),
|
||||
file_name: f.name,
|
||||
file_type: guessType(f),
|
||||
file_url: "",
|
||||
file_size: f.size,
|
||||
mime_type: f.type,
|
||||
is_local: true,
|
||||
})),
|
||||
reply_to_id: replyToId,
|
||||
};
|
||||
}, [chatId, guessType, userId]);
|
||||
|
||||
const addPending = useCallback((msg: Message) => {
|
||||
setPendingMessages(prev => [...prev, msg]);
|
||||
}, []);
|
||||
|
||||
/* ── load chat ── */
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId || Number.isNaN(chatId)) { setChat(null); setLoading(false); return; }
|
||||
if (userLoading) { setLoading(true); return; }
|
||||
if (!userId) { setChat(null); setLoading(false); return; }
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const loaded = await apiClient.getChat(chatId);
|
||||
if (!cancelled) setChat(loaded);
|
||||
} catch {
|
||||
if (!cancelled) { setChat(null); setError("Не удалось загрузить чат. Проверьте подключение и попробуйте снова."); }
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [chatId, userLoading, userId]);
|
||||
|
||||
/* ── load messages ── */
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId || Number.isNaN(chatId)) {
|
||||
clearAllPendingTimers(); setMessages([]); setPendingMessages([]);
|
||||
setHasMoreOlder(false); setHasMoreNewer(false);
|
||||
setInitialAnchorId(null); setInitialAnchorIndex(null);
|
||||
setAllowOneOlderProbe(false);
|
||||
initialScrolledRef.current = false;
|
||||
resetNewMessagesBelow();
|
||||
return;
|
||||
}
|
||||
if (userLoading || !userId) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
setMessagesLoading(true);
|
||||
const chatDetails = await apiClient.getChat(chatId);
|
||||
if (cancelled) return;
|
||||
setChat(chatDetails);
|
||||
const lastReadId = chatDetails.last_read_message_id;
|
||||
|
||||
const around = await apiClient.getChatMessagesAround(chatId, {
|
||||
limit: 30,
|
||||
around_id: lastReadId || undefined,
|
||||
});
|
||||
if (cancelled) return;
|
||||
|
||||
const aroundMsgs = around.messages || [];
|
||||
if (aroundMsgs.length > 0) {
|
||||
clearAllPendingTimers();
|
||||
const norm = uniqueMessagesById(aroundMsgs);
|
||||
setMessages(norm);
|
||||
setPendingMessages([]);
|
||||
setHasMoreOlder(typeof around.has_more_before === "boolean" ? around.has_more_before : norm.length >= 50);
|
||||
setHasMoreNewer(Boolean(around.has_more_after));
|
||||
setInitialAnchorId(around.anchor_id ?? null);
|
||||
setInitialAnchorIndex(typeof around.anchor_index === "number" ? around.anchor_index : null);
|
||||
setAllowOneOlderProbe(Boolean(around.anchor_id));
|
||||
|
||||
// Seed unread-below from loaded messages
|
||||
unreadBelowIdsRef.current = new Set(
|
||||
norm.filter(m => {
|
||||
const aId = m.author_id ?? m.author?.id ?? m.sender?.id;
|
||||
return aId !== userId && m.id > (lastReadId ?? 0);
|
||||
}).map(m => m.id),
|
||||
);
|
||||
setNewMessagesBelowCount(Math.max(chatDetails.unread_count ?? 0, unreadBelowIdsRef.current.size));
|
||||
} else {
|
||||
clearAllPendingTimers();
|
||||
const resp = await apiClient.getChatMessages(chatId, { limit: 50 });
|
||||
if (cancelled) return;
|
||||
const norm = uniqueMessagesById(resp.messages || []);
|
||||
setMessages(norm); setPendingMessages([]);
|
||||
setHasMoreOlder(Boolean(resp.has_more)); setHasMoreNewer(false);
|
||||
setInitialAnchorId(null); setInitialAnchorIndex(null); setAllowOneOlderProbe(false);
|
||||
unreadBelowIdsRef.current = new Set(
|
||||
norm.filter(m => {
|
||||
const aId = m.author_id ?? m.author?.id ?? m.sender?.id;
|
||||
return aId !== userId && m.id > (lastReadId ?? 0);
|
||||
}).map(m => m.id),
|
||||
);
|
||||
setNewMessagesBelowCount(Math.max(chatDetails.unread_count ?? 0, unreadBelowIdsRef.current.size));
|
||||
}
|
||||
|
||||
initialScrolledRef.current = false;
|
||||
setAnchorRetryTick(0);
|
||||
} catch (err: unknown) {
|
||||
if (cancelled) return;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes("403")) {
|
||||
setError("Нет доступа к этому чату");
|
||||
clearAllPendingTimers(); setMessages([]); setPendingMessages([]);
|
||||
} else {
|
||||
console.error("Ошибка загрузки сообщений:", err);
|
||||
}
|
||||
setHasMoreOlder(false); setHasMoreNewer(false);
|
||||
setInitialAnchorId(null); setInitialAnchorIndex(null); setAllowOneOlderProbe(false);
|
||||
resetNewMessagesBelow();
|
||||
initialScrolledRef.current = false;
|
||||
setAnchorRetryTick(0);
|
||||
} finally {
|
||||
if (!cancelled) setMessagesLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [chatId, clearAllPendingTimers, resetNewMessagesBelow, userLoading, userId]);
|
||||
|
||||
// Cleanup timers on unmount
|
||||
useEffect(() => () => { clearAllPendingTimers(); }, [clearAllPendingTimers]);
|
||||
|
||||
// Reset per-chat state
|
||||
useEffect(() => {
|
||||
setHistoryNavigationMode(false);
|
||||
resetNewMessagesBelow();
|
||||
}, [chatId, resetNewMessagesBelow]);
|
||||
|
||||
/* ── pagination ── */
|
||||
|
||||
const loadOlderMessages = useCallback(async () => {
|
||||
if (!chatId || Number.isNaN(chatId) || loadingOlder || messagesLoading || messages.length === 0) return;
|
||||
if (!hasMoreOlder && !allowOneOlderProbe) return;
|
||||
|
||||
const oldest = messages[0];
|
||||
try {
|
||||
setLoadingOlder(true);
|
||||
const resp = await apiClient.getChatMessages(chatId, { limit: 40, before_id: oldest.id });
|
||||
const older = resp.messages || [];
|
||||
if (older.length > 0) setMessages(prev => uniqueMessagesById([...older, ...prev]));
|
||||
setHasMoreOlder(Boolean(resp.has_more));
|
||||
setAllowOneOlderProbe(false);
|
||||
} catch (e) {
|
||||
console.error("Ошибка подгрузки старых сообщений:", e);
|
||||
} finally {
|
||||
setLoadingOlder(false);
|
||||
}
|
||||
}, [allowOneOlderProbe, chatId, hasMoreOlder, loadingOlder, messages, messagesLoading]);
|
||||
|
||||
const loadNewerMessages = useCallback(async (opts?: { markRead?: boolean }) => {
|
||||
if (!chatId || Number.isNaN(chatId) || loadingNewer || messagesLoading || !hasMoreNewer || messages.length === 0) return;
|
||||
|
||||
const newest = messages[messages.length - 1];
|
||||
try {
|
||||
setLoadingNewer(true);
|
||||
const resp = await apiClient.getChatMessages(chatId, { limit: 10, after_id: newest.id, mark_read: false });
|
||||
const newer = resp.messages || [];
|
||||
if (newer.length > 0) {
|
||||
newer.forEach(removePendingByServer);
|
||||
setMessages(prev => uniqueMessagesById([...prev, ...newer]));
|
||||
}
|
||||
setHasMoreNewer(Boolean(resp.has_more));
|
||||
} catch (e) {
|
||||
console.error("Ошибка подгрузки новых сообщений:", e);
|
||||
} finally {
|
||||
setLoadingNewer(false);
|
||||
}
|
||||
}, [chatId, hasMoreNewer, loadingNewer, messages, messagesLoading, removePendingByServer]);
|
||||
|
||||
/* ── fallback sync ── */
|
||||
|
||||
const syncLatestMessages = useCallback(async (options: { limit?: number; scrollOnSync?: boolean } = {}) => {
|
||||
if (!chatId || Number.isNaN(chatId) || messagesLoading || messages.length === 0 || fallbackInFlightRef.current) return;
|
||||
|
||||
const newest = messages[messages.length - 1];
|
||||
fallbackInFlightRef.current = true;
|
||||
|
||||
try {
|
||||
const resp = await apiClient.getChatMessages(chatId, { limit: options.limit ?? 20, after_id: newest.id, mark_read: false });
|
||||
const newer = resp.messages || [];
|
||||
if (newer.length > 0) {
|
||||
newer.forEach(removePendingByServer);
|
||||
setMessages(prev => uniqueMessagesById([...prev, ...newer]));
|
||||
|
||||
if (options.scrollOnSync) {
|
||||
requestAnimationFrame(() => scrollToBottomRef.current(false));
|
||||
}
|
||||
}
|
||||
setHasMoreNewer(Boolean(resp.has_more));
|
||||
} catch (e) {
|
||||
console.error("Ошибка тихой синхронизации сообщений:", e);
|
||||
} finally {
|
||||
fallbackInFlightRef.current = false;
|
||||
}
|
||||
}, [chatId, messages, messagesLoading, removePendingByServer]);
|
||||
|
||||
/* ── jump to date ── */
|
||||
|
||||
const jumpToDate = useCallback(async (dateValue: string) => {
|
||||
if (!chatId || Number.isNaN(chatId) || !dateValue) return;
|
||||
const dayStart = new Date(`${dateValue}T00:00:00`);
|
||||
if (Number.isNaN(dayStart.getTime())) return;
|
||||
|
||||
try {
|
||||
setMessagesLoading(true);
|
||||
setHistoryNavigationMode(true);
|
||||
|
||||
const [olderResp, newerResp] = await Promise.all([
|
||||
apiClient.getChatMessages(chatId, { limit: 20, before: dayStart.toISOString() }),
|
||||
apiClient.getChatMessages(chatId, { limit: 20, after: dayStart.toISOString(), mark_read: false }),
|
||||
]);
|
||||
|
||||
const combined = uniqueMessagesById([...(olderResp.messages || []), ...(newerResp.messages || [])]);
|
||||
const anchor = (newerResp.messages || [])[0] || (olderResp.messages || []).at(-1) || combined[0] || null;
|
||||
if (!anchor) return;
|
||||
|
||||
setMessages(combined);
|
||||
setHasMoreOlder(Boolean(olderResp.has_more));
|
||||
setHasMoreNewer(Boolean(newerResp.has_more));
|
||||
setInitialAnchorId(anchor.id);
|
||||
setInitialAnchorIndex(combined.findIndex(m => m.id === anchor.id));
|
||||
setAllowOneOlderProbe(Boolean((newerResp.messages || [])[0]));
|
||||
|
||||
initialScrolledRef.current = false;
|
||||
setAnchorRetryTick(0);
|
||||
} catch (e) {
|
||||
console.error("Ошибка перехода к дате:", e);
|
||||
} finally {
|
||||
setMessagesLoading(false);
|
||||
}
|
||||
}, [chatId]);
|
||||
|
||||
/* ── return to unread boundary ── */
|
||||
|
||||
const returnToUnread = useCallback(async () => {
|
||||
if (!chatId || Number.isNaN(chatId)) return null;
|
||||
|
||||
const lastReadMessageId = Math.max(chat?.last_read_message_id ?? 0, 0);
|
||||
const shouldRestore = (hasMoreNewer || historyNavigationMode) && lastReadMessageId > 0;
|
||||
|
||||
if (!shouldRestore) return null;
|
||||
|
||||
try {
|
||||
setMessagesLoading(true);
|
||||
const around = await apiClient.getChatMessagesAround(chatId, { limit: 30, around_id: lastReadMessageId });
|
||||
const norm = uniqueMessagesById(around.messages || []);
|
||||
if (norm.length === 0) return null;
|
||||
|
||||
setMessages(norm);
|
||||
setHasMoreOlder(typeof around.has_more_before === "boolean" ? around.has_more_before : norm.length >= 50);
|
||||
setHasMoreNewer(Boolean(around.has_more_after));
|
||||
setInitialAnchorId(around.anchor_id ?? lastReadMessageId);
|
||||
setInitialAnchorIndex(typeof around.anchor_index === "number" ? around.anchor_index : null);
|
||||
setAllowOneOlderProbe(Boolean(around.anchor_id ?? lastReadMessageId));
|
||||
setHistoryNavigationMode(Boolean(around.has_more_after));
|
||||
|
||||
initialScrolledRef.current = false;
|
||||
setAnchorRetryTick(0);
|
||||
|
||||
return {
|
||||
hasMoreAfter: Boolean(around.has_more_after),
|
||||
anchorId: around.anchor_id ?? lastReadMessageId,
|
||||
anchorIndex: typeof around.anchor_index === "number" ? around.anchor_index : null,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Ошибка возврата к непрочитанным сообщениям:", e);
|
||||
return null;
|
||||
} finally {
|
||||
setMessagesLoading(false);
|
||||
}
|
||||
}, [chat?.last_read_message_id, chatId, hasMoreNewer, historyNavigationMode]);
|
||||
|
||||
const jumpToMessage = useCallback(async (messageId: number) => {
|
||||
if (!chatId || Number.isNaN(chatId) || !messageId) return null;
|
||||
|
||||
try {
|
||||
setMessagesLoading(true);
|
||||
setHistoryNavigationMode(true);
|
||||
|
||||
const around = await apiClient.getChatMessagesAround(chatId, { limit: 30, around_id: messageId });
|
||||
const norm = uniqueMessagesById(around.messages || []);
|
||||
if (norm.length === 0) return null;
|
||||
|
||||
setMessages(norm);
|
||||
setHasMoreOlder(typeof around.has_more_before === "boolean" ? around.has_more_before : norm.length >= 50);
|
||||
setHasMoreNewer(Boolean(around.has_more_after));
|
||||
setInitialAnchorId(around.anchor_id ?? messageId);
|
||||
setInitialAnchorIndex(typeof around.anchor_index === "number" ? around.anchor_index : norm.findIndex(m => m.id === (around.anchor_id ?? messageId)));
|
||||
setAllowOneOlderProbe(Boolean(around.anchor_id ?? messageId));
|
||||
|
||||
initialScrolledRef.current = false;
|
||||
setAnchorRetryTick(0);
|
||||
|
||||
return {
|
||||
hasMoreAfter: Boolean(around.has_more_after),
|
||||
anchorId: around.anchor_id ?? messageId,
|
||||
anchorIndex: typeof around.anchor_index === "number" ? around.anchor_index : norm.findIndex(m => m.id === (around.anchor_id ?? messageId)),
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Ошибка перехода к найденному сообщению:", e);
|
||||
return null;
|
||||
} finally {
|
||||
setMessagesLoading(false);
|
||||
}
|
||||
}, [chatId]);
|
||||
|
||||
/* ── scroll tracking helpers ── */
|
||||
|
||||
const markInitialScrollDone = useCallback(() => { initialScrolledRef.current = true; }, []);
|
||||
const isInitialScrollPending = useCallback(() => !initialScrolledRef.current, []);
|
||||
|
||||
return {
|
||||
chat,
|
||||
setChat,
|
||||
messages,
|
||||
setMessages,
|
||||
pendingMessages,
|
||||
loading,
|
||||
messagesLoading,
|
||||
error,
|
||||
hasMoreOlder,
|
||||
hasMoreNewer,
|
||||
loadingOlder,
|
||||
loadingNewer,
|
||||
initialAnchorId,
|
||||
initialAnchorIndex,
|
||||
allowOneOlderProbe,
|
||||
anchorRetryTick,
|
||||
historyNavigationMode,
|
||||
setHistoryNavigationMode,
|
||||
newMessagesBelowCount,
|
||||
unreadBelowIdsRef,
|
||||
hasMoreNewerRef,
|
||||
loadOlderMessages,
|
||||
loadNewerMessages,
|
||||
syncLatestMessages,
|
||||
registerNewMessagesBelow,
|
||||
resetNewMessagesBelow,
|
||||
removePendingMessage,
|
||||
removePendingByServer,
|
||||
buildOptimistic,
|
||||
addPending,
|
||||
schedulePendingTimer,
|
||||
markPendingFailed,
|
||||
jumpToDate,
|
||||
returnToUnread,
|
||||
jumpToMessage,
|
||||
setAnchorRetryTick,
|
||||
markInitialScrollDone,
|
||||
isInitialScrollPending,
|
||||
isNearBottomRef,
|
||||
scrollToBottomRef,
|
||||
};
|
||||
}
|
||||
194
frontend/src/hooks/useChatScroll.ts
Normal file
194
frontend/src/hooks/useChatScroll.ts
Normal file
@ -0,0 +1,194 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { formatDayDivider } from "@/lib/messages/messageUtils";
|
||||
import type { ScrollableMessageListInner } from "@/components/ScrollableMessageList";
|
||||
|
||||
/* ─── helpers ─── */
|
||||
|
||||
function formatDateInputValue(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, "0");
|
||||
const day = `${date.getDate()}`.padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function shiftDateInputValue(value: string, deltaDays: number): string {
|
||||
const baseDate = value ? new Date(`${value}T00:00:00`) : new Date();
|
||||
if (Number.isNaN(baseDate.getTime())) return formatDateInputValue(new Date());
|
||||
baseDate.setDate(baseDate.getDate() + deltaDays);
|
||||
return formatDateInputValue(baseDate);
|
||||
}
|
||||
|
||||
/* ─── types ─── */
|
||||
|
||||
export interface UseChatScrollOptions {
|
||||
chatId: number;
|
||||
/** Number of rendered messages, used to recalculate floating date after reload/jump */
|
||||
messageCount?: number;
|
||||
/** Ref to the scroll container component */
|
||||
viewportRef: React.RefObject<ScrollableMessageListInner | null>;
|
||||
/** Whether there are more newer messages to load */
|
||||
hasMoreNewerRef: React.MutableRefObject<boolean>;
|
||||
/** Ref to unread-below message ids */
|
||||
unreadBelowIdsRef: React.MutableRefObject<Set<number>>;
|
||||
/** Whether we are in history navigation mode */
|
||||
historyNavigationMode: boolean;
|
||||
}
|
||||
|
||||
export interface UseChatScrollReturn {
|
||||
/** Whether the scroll-to-bottom button should be visible */
|
||||
showScrollToBottom: boolean;
|
||||
setShowScrollToBottom: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
/** Floating date display */
|
||||
floatingDate: string | null;
|
||||
floatingDateValue: string | null;
|
||||
showFloatingDate: boolean;
|
||||
/** Date navigator state */
|
||||
isDateNavigatorOpen: boolean;
|
||||
setIsDateNavigatorOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
selectedHistoryDate: string;
|
||||
setSelectedHistoryDate: React.Dispatch<React.SetStateAction<string>>;
|
||||
/** Check if user is near bottom */
|
||||
isNearBottom: () => boolean;
|
||||
/** Scroll to bottom */
|
||||
scrollToBottom: (smooth?: boolean) => void;
|
||||
/** Synchronize the scroll-to-bottom button visibility. Returns true if at bottom. */
|
||||
syncScrollToBottomState: () => boolean;
|
||||
/** Open date navigator, optionally with a preset date */
|
||||
openDateNavigator: (dateValue?: string | null) => void;
|
||||
}
|
||||
|
||||
/* ─── hook ─── */
|
||||
|
||||
export function useChatScroll({
|
||||
chatId,
|
||||
messageCount = 0,
|
||||
viewportRef,
|
||||
hasMoreNewerRef,
|
||||
unreadBelowIdsRef,
|
||||
historyNavigationMode,
|
||||
}: UseChatScrollOptions): UseChatScrollReturn {
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||
const [floatingDate, setFloatingDate] = useState<string | null>(null);
|
||||
const [floatingDateValue, setFloatingDateValue] = useState<string | null>(null);
|
||||
const [showFloatingDate, setShowFloatingDate] = useState(false);
|
||||
const [isDateNavigatorOpen, setIsDateNavigatorOpen] = useState(false);
|
||||
const [selectedHistoryDate, setSelectedHistoryDate] = useState("");
|
||||
|
||||
const floatingDateTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Sync selected date from floating date when navigator is closed
|
||||
useEffect(() => {
|
||||
if (floatingDateValue && !isDateNavigatorOpen) {
|
||||
setSelectedHistoryDate(floatingDateValue);
|
||||
}
|
||||
}, [floatingDateValue, isDateNavigatorOpen]);
|
||||
|
||||
// Cleanup
|
||||
useEffect(() => () => {
|
||||
if (floatingDateTimeoutRef.current) clearTimeout(floatingDateTimeoutRef.current);
|
||||
}, []);
|
||||
|
||||
const isNearBottom = useCallback(() => {
|
||||
const c = viewportRef.current;
|
||||
if (!c?.containerRef?.current) return false;
|
||||
const v = c.containerRef.current;
|
||||
return v.scrollHeight - v.scrollTop - v.clientHeight <= 150;
|
||||
}, [viewportRef]);
|
||||
|
||||
const scrollToBottom = useCallback((smooth = false) => {
|
||||
viewportRef.current?.scrollToBottom(smooth ? "smooth" : "auto");
|
||||
}, [viewportRef]);
|
||||
|
||||
const syncScrollToBottomState = useCallback(() => {
|
||||
const atBottom = isNearBottom();
|
||||
const hasBufferedNewer = hasMoreNewerRef.current || unreadBelowIdsRef.current.size > 0;
|
||||
|
||||
if (atBottom && !hasMoreNewerRef.current) {
|
||||
setShowScrollToBottom(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
setShowScrollToBottom(!atBottom || hasBufferedNewer || historyNavigationMode);
|
||||
return atBottom;
|
||||
}, [hasMoreNewerRef, historyNavigationMode, isNearBottom, unreadBelowIdsRef]);
|
||||
|
||||
const openDateNavigator = useCallback((dateValue?: string | null) => {
|
||||
setSelectedHistoryDate(dateValue || floatingDateValue || formatDateInputValue(new Date()));
|
||||
setShowFloatingDate(true);
|
||||
setIsDateNavigatorOpen(true);
|
||||
}, [floatingDateValue]);
|
||||
|
||||
/** Update floating date from scroll position – call inside onScroll handler */
|
||||
const updateFloatingDate = useCallback((viewport: HTMLElement) => {
|
||||
const messageEls = viewport.querySelectorAll<HTMLElement>("[data-message-date]");
|
||||
const viewportTop = viewport.getBoundingClientRect().top;
|
||||
let topDate: string | null = null;
|
||||
let topDateVal: string | null = null;
|
||||
|
||||
for (let i = 0; i < messageEls.length; i += 1) {
|
||||
const el = messageEls[i];
|
||||
if (el.getBoundingClientRect().bottom > viewportTop + 8) {
|
||||
const iso = el.getAttribute("data-message-date");
|
||||
if (iso) {
|
||||
const d = new Date(iso);
|
||||
if (!Number.isNaN(d.getTime())) {
|
||||
topDate = formatDayDivider(d);
|
||||
topDateVal = formatDateInputValue(d);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (topDate) {
|
||||
setFloatingDate(topDate);
|
||||
setFloatingDateValue(topDateVal);
|
||||
setShowFloatingDate(true);
|
||||
|
||||
if (floatingDateTimeoutRef.current) clearTimeout(floatingDateTimeoutRef.current);
|
||||
floatingDateTimeoutRef.current = setTimeout(() => setShowFloatingDate(false), 1100);
|
||||
return;
|
||||
}
|
||||
|
||||
setFloatingDate(null);
|
||||
setFloatingDateValue(null);
|
||||
setShowFloatingDate(false);
|
||||
}, []);
|
||||
|
||||
// Expose updateFloatingDate as a stable ref so the scroll handler can call it
|
||||
const updateFloatingDateRef = useRef(updateFloatingDate);
|
||||
useEffect(() => { updateFloatingDateRef.current = updateFloatingDate; }, [updateFloatingDate]);
|
||||
|
||||
// Attach scroll handler after the message list actually exists in the DOM.
|
||||
// `ref.current` changes do not trigger effects, so we re-run when messageCount changes.
|
||||
useEffect(() => {
|
||||
const c = viewportRef.current;
|
||||
if (!c?.containerRef?.current) return;
|
||||
const viewport = c.containerRef.current;
|
||||
|
||||
const onScroll = () => updateFloatingDateRef.current(viewport);
|
||||
const frameId = window.requestAnimationFrame(onScroll);
|
||||
|
||||
viewport.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
viewport.removeEventListener("scroll", onScroll);
|
||||
};
|
||||
}, [chatId, messageCount, viewportRef]);
|
||||
|
||||
return {
|
||||
showScrollToBottom,
|
||||
setShowScrollToBottom,
|
||||
floatingDate,
|
||||
floatingDateValue,
|
||||
showFloatingDate,
|
||||
isDateNavigatorOpen,
|
||||
setIsDateNavigatorOpen,
|
||||
selectedHistoryDate,
|
||||
setSelectedHistoryDate,
|
||||
isNearBottom,
|
||||
scrollToBottom,
|
||||
syncScrollToBottomState,
|
||||
openDateNavigator,
|
||||
};
|
||||
}
|
||||
124
frontend/src/hooks/useMarkRead.ts
Normal file
124
frontend/src/hooks/useMarkRead.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
|
||||
const MARK_READ_DEBOUNCE_MS = 300;
|
||||
|
||||
interface UseMarkReadOptions {
|
||||
chatId: number;
|
||||
/** Callback to update the chat state after a successful read acknowledgment */
|
||||
onReadAcknowledged?: (confirmedId: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced mark-as-read logic.
|
||||
*
|
||||
* Returns helpers to schedule mark-read events, query the last confirmed
|
||||
* read position, and clean-up resources.
|
||||
*/
|
||||
export function useMarkRead({ chatId, onReadAcknowledged }: UseMarkReadOptions) {
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const inFlightRef = useRef(false);
|
||||
const queuedIdRef = useRef<number | null>(null);
|
||||
const lastConfirmedRef = useRef(0);
|
||||
const onReadAcknowledgedRef = useRef(onReadAcknowledged);
|
||||
|
||||
useEffect(() => {
|
||||
onReadAcknowledgedRef.current = onReadAcknowledged;
|
||||
}, [onReadAcknowledged]);
|
||||
|
||||
// Reset all state when chatId changes
|
||||
useEffect(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
inFlightRef.current = false;
|
||||
queuedIdRef.current = null;
|
||||
lastConfirmedRef.current = 0;
|
||||
}, [chatId]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const flush = useCallback(async () => {
|
||||
if (!chatId || Number.isNaN(chatId) || inFlightRef.current) return;
|
||||
|
||||
const targetId = queuedIdRef.current;
|
||||
if (typeof targetId !== "number") return;
|
||||
if (targetId <= lastConfirmedRef.current) {
|
||||
queuedIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
queuedIdRef.current = null;
|
||||
inFlightRef.current = true;
|
||||
|
||||
try {
|
||||
await apiClient.markChatAsRead(chatId, targetId);
|
||||
lastConfirmedRef.current = Math.max(lastConfirmedRef.current, targetId);
|
||||
onReadAcknowledgedRef.current?.(targetId);
|
||||
} catch (error) {
|
||||
console.error("Ошибка отложенной отметки прочтения:", error);
|
||||
// Re-queue the failed id so the next flush picks it up
|
||||
queuedIdRef.current = Math.max(queuedIdRef.current ?? 0, targetId);
|
||||
} finally {
|
||||
inFlightRef.current = false;
|
||||
|
||||
// If another id was queued while we were in-flight, schedule another flush
|
||||
if (
|
||||
queuedIdRef.current !== null &&
|
||||
queuedIdRef.current > lastConfirmedRef.current &&
|
||||
!timerRef.current
|
||||
) {
|
||||
timerRef.current = setTimeout(() => {
|
||||
timerRef.current = null;
|
||||
void flush();
|
||||
}, MARK_READ_DEBOUNCE_MS);
|
||||
}
|
||||
}
|
||||
}, [chatId]);
|
||||
|
||||
/**
|
||||
* Schedule a mark-read for the given message id.
|
||||
* Debounces calls so multiple rapid updates collapse into one.
|
||||
*/
|
||||
const scheduleMarkRead = useCallback(
|
||||
(messageId?: number | null) => {
|
||||
if (!chatId || typeof messageId !== "number") return;
|
||||
|
||||
const nextTarget = Math.max(queuedIdRef.current ?? 0, messageId);
|
||||
if (nextTarget <= lastConfirmedRef.current) return;
|
||||
|
||||
queuedIdRef.current = nextTarget;
|
||||
|
||||
if (inFlightRef.current) return;
|
||||
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
timerRef.current = null;
|
||||
void flush();
|
||||
}, MARK_READ_DEBOUNCE_MS);
|
||||
},
|
||||
[chatId, flush],
|
||||
);
|
||||
|
||||
/** Sync the confirmed watermark from external sources (e.g. chat.last_read_message_id). */
|
||||
const syncConfirmed = useCallback((id: number) => {
|
||||
lastConfirmedRef.current = Math.max(lastConfirmedRef.current, id);
|
||||
}, []);
|
||||
|
||||
/** Read-only accessor – useful for comparisons without triggering re-renders. */
|
||||
const getLastConfirmed = useCallback(() => lastConfirmedRef.current, []);
|
||||
|
||||
return { scheduleMarkRead, syncConfirmed, getLastConfirmed } as const;
|
||||
}
|
||||
@ -19,23 +19,12 @@ import {
|
||||
} from "@/lib/users/userDetailUtils";
|
||||
import type { Chat, EmployeeAction, User } from "@/types/api";
|
||||
|
||||
type ChatListResponse = Chat[] | { results?: Chat[] };
|
||||
type ChatWithMemberIds = Chat & { member_ids?: number[] };
|
||||
|
||||
const getErrorMessage = (error: unknown, fallback: string): string => (
|
||||
String((error as Error)?.message || fallback)
|
||||
);
|
||||
|
||||
const getChatResults = (response: ChatListResponse): ChatWithMemberIds[] => {
|
||||
if (Array.isArray(response)) {
|
||||
return response as ChatWithMemberIds[];
|
||||
}
|
||||
|
||||
return Array.isArray(response.results)
|
||||
? (response.results as ChatWithMemberIds[])
|
||||
: [];
|
||||
};
|
||||
|
||||
export function useUserDetailPage(userId: number, currentUser: User | null) {
|
||||
const router = useRouter();
|
||||
|
||||
@ -107,8 +96,7 @@ export function useUserDetailPage(userId: number, currentUser: User | null) {
|
||||
try {
|
||||
setCreatingChat(true);
|
||||
|
||||
const chatsResponse = await apiClient.getChats() as ChatListResponse;
|
||||
const allChats = getChatResults(chatsResponse);
|
||||
const allChats = await apiClient.getAllChats() as ChatWithMemberIds[];
|
||||
const existingChat = allChats.find((chat) => {
|
||||
if (chat.type !== "private") return false;
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ interface WebSocketMessage {
|
||||
user_id?: number;
|
||||
chat_id?: number;
|
||||
last_read_message_id?: number;
|
||||
reader_user_id?: number;
|
||||
reactions_summary?: ReactionsSummary;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@ -35,6 +36,8 @@ interface UseWebSocketOptions {
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const TYPING_THROTTLE_MS = 2000;
|
||||
|
||||
export function useWebSocket({
|
||||
chatId,
|
||||
autoConnect = true,
|
||||
@ -50,6 +53,7 @@ export function useWebSocket({
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const shouldReconnectRef = useRef(autoConnect);
|
||||
const connectRef = useRef<() => void>(() => {});
|
||||
const lastTypingSentAtRef = useRef(0);
|
||||
|
||||
const handlers = useRef<WebSocketHandlers>({
|
||||
onMessage: () => {},
|
||||
@ -191,15 +195,24 @@ export function useWebSocket({
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setReconnectAttempts(0);
|
||||
isConnectingRef.current = false;
|
||||
lastTypingSentAtRef.current = 0;
|
||||
}, [chatId]);
|
||||
|
||||
const sendTyping = useCallback(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({
|
||||
action: 'typing'
|
||||
}));
|
||||
if (!chatId || wsRef.current?.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastTypingSentAtRef.current < TYPING_THROTTLE_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastTypingSentAtRef.current = now;
|
||||
wsRef.current.send(JSON.stringify({
|
||||
action: 'typing'
|
||||
}));
|
||||
}, [chatId]);
|
||||
|
||||
const sendMessage = useCallback((message: Record<string, unknown>) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { ChatMessageSearchResponse } from '@/types/api';
|
||||
import type { RequestFn, GetTokenFn } from './utils';
|
||||
|
||||
export function createMessagesApi(request: RequestFn, getToken: GetTokenFn) {
|
||||
@ -20,13 +21,39 @@ export function createMessagesApi(request: RequestFn, getToken: GetTokenFn) {
|
||||
return result.message || result;
|
||||
}
|
||||
|
||||
function buildChatsQuery(params?: { search?: string; page?: number; page_size?: number }) {
|
||||
const qp = new URLSearchParams();
|
||||
if (params?.search) qp.append('search', params.search);
|
||||
if (params?.page) qp.append('page', params.page.toString());
|
||||
if (params?.page_size) qp.append('page_size', params.page_size.toString());
|
||||
const qs = qp.toString();
|
||||
return `/api/v1/communications/chats/${qs ? '?' + qs : ''}`;
|
||||
}
|
||||
|
||||
return {
|
||||
getChats: (params?: { search?: string; page?: number }) => {
|
||||
const qp = new URLSearchParams();
|
||||
if (params?.search) qp.append('search', params.search);
|
||||
if (params?.page) qp.append('page', params.page.toString());
|
||||
const qs = qp.toString();
|
||||
return request(`/api/v1/communications/chats/${qs ? '?' + qs : ''}`);
|
||||
getChats: (params?: { search?: string; page?: number; page_size?: number }) => request(buildChatsQuery(params)),
|
||||
getAllChats: async (params?: { search?: string; page_size?: number }) => {
|
||||
const pageSize = params?.page_size ?? 200;
|
||||
const chats: any[] = [];
|
||||
let page = 1;
|
||||
|
||||
for (;;) {
|
||||
const response = await request(buildChatsQuery({
|
||||
search: params?.search,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
})) as { results?: any[]; next?: string | null } | any[];
|
||||
const results = Array.isArray(response) ? response : (response.results || []);
|
||||
chats.push(...results);
|
||||
|
||||
if (Array.isArray(response) || !response.next) {
|
||||
break;
|
||||
}
|
||||
|
||||
page += 1;
|
||||
}
|
||||
|
||||
return chats;
|
||||
},
|
||||
getChat: (chatId: number) => request(`/api/v1/communications/chats/${chatId}/`),
|
||||
createChat: (data: { type: string; participants?: number[]; name?: string; description?: string; department?: number; include_all_employees?: boolean; avatar?: File }) => {
|
||||
@ -61,6 +88,13 @@ export function createMessagesApi(request: RequestFn, getToken: GetTokenFn) {
|
||||
const qs = qp.toString();
|
||||
return request(`/api/v1/communications/chats/${chatId}/messages-around/${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
searchChatMessages: (chatId: number | string, params: { q: string; limit?: number; offset?: number }) => {
|
||||
const qp = new URLSearchParams();
|
||||
qp.append('q', params.q);
|
||||
if (params.limit) qp.append('limit', params.limit.toString());
|
||||
if (typeof params.offset === 'number') qp.append('offset', params.offset.toString());
|
||||
return request(`/api/v1/communications/chats/${chatId}/search-messages/?${qp.toString()}`) as Promise<ChatMessageSearchResponse>;
|
||||
},
|
||||
sendMessage: (chatId: number | string, text: string, replyTo?: number) => uploadMessage(chatId, text, [], replyTo),
|
||||
sendMessageWithFiles: (chatId: number | string, text: string, files: File[], replyTo?: number) => uploadMessage(chatId, text, files, replyTo),
|
||||
updateMessage: (messageId: number, text: string) => request(`/api/v1/communications/messages/${messageId}/`, { method: 'PATCH', body: JSON.stringify({ content: text }) }),
|
||||
|
||||
@ -29,6 +29,7 @@ export function createRequestsApi(request: RequestFn, getToken: GetTokenFn) {
|
||||
const qs = qp.toString();
|
||||
return request(`/api/v1/requests/${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
getRequest: (requestId: number) => request(`/api/v1/requests/${requestId}/`),
|
||||
createRequest: (data: Record<string, any>, saveAs?: 'draft' | 'submitted') => formDataRequest('/api/v1/requests/', 'POST', data, saveAs),
|
||||
updateRequest: (requestId: number, data: Record<string, any>, saveAs?: 'draft' | 'submitted') => formDataRequest(`/api/v1/requests/${requestId}/`, 'PATCH', data, saveAs),
|
||||
deleteRequest: (requestId: number): Promise<void> => request(`/api/v1/requests/${requestId}/`, { method: 'DELETE' }),
|
||||
|
||||
@ -121,4 +121,38 @@ export function uniqueMessagesById(items: Message[]): Message[] {
|
||||
map.set(message.id, message);
|
||||
});
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
export function getMessageTimestamp(message: Message): number {
|
||||
if (typeof message.created_ts === "number") {
|
||||
return message.created_ts;
|
||||
}
|
||||
|
||||
if (message.created_at) {
|
||||
const value = new Date(message.created_at).getTime();
|
||||
if (!Number.isNaN(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function mergeDisplayMessages(serverMessages: Message[], pendingMessages: Message[]): Message[] {
|
||||
const combined = [...serverMessages, ...pendingMessages].map((message, index) => ({ message, index }));
|
||||
|
||||
combined.sort((left, right) => {
|
||||
const timestampDiff = getMessageTimestamp(left.message) - getMessageTimestamp(right.message);
|
||||
if (timestampDiff !== 0) {
|
||||
return timestampDiff;
|
||||
}
|
||||
|
||||
if (left.message.is_optimistic !== right.message.is_optimistic) {
|
||||
return left.message.is_optimistic ? 1 : -1;
|
||||
}
|
||||
|
||||
return left.index - right.index;
|
||||
});
|
||||
|
||||
return combined.map(({ message }) => message);
|
||||
}
|
||||
@ -1,4 +1,12 @@
|
||||
import type { Message, MessageAttachment } from "@/types/api";
|
||||
import type { Message, MessageAttachment, MessageReplyPreview } from "@/types/api";
|
||||
|
||||
type ReplyPreviewSource = Message | MessageReplyPreview;
|
||||
|
||||
function isReplyPreviewSource(value: unknown): value is ReplyPreviewSource {
|
||||
return Boolean(
|
||||
value && typeof value === "object" && typeof (value as { id?: unknown }).id === "number"
|
||||
);
|
||||
}
|
||||
|
||||
export function getMessageInitials(message: Message): string {
|
||||
const name = (message.author_name || "").trim();
|
||||
@ -89,13 +97,51 @@ export function getMessagePreviewText(message: Message): string {
|
||||
return "[Сообщение]";
|
||||
}
|
||||
|
||||
export function getReplyPreview(message: Message, loadedReply?: Message | null): ReplyPreviewSource | null {
|
||||
if (loadedReply) {
|
||||
return loadedReply;
|
||||
}
|
||||
|
||||
if (isReplyPreviewSource(message.reply_to)) {
|
||||
return message.reply_to;
|
||||
}
|
||||
|
||||
if (isReplyPreviewSource(message.reply_to_message)) {
|
||||
return message.reply_to_message;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isReplyPreviewDeleted(preview: ReplyPreviewSource | null | undefined): boolean {
|
||||
return Boolean(preview?.is_deleted);
|
||||
}
|
||||
|
||||
export function getReplyPreviewText(preview: ReplyPreviewSource): string {
|
||||
if (preview.content?.trim()) return preview.content.trim();
|
||||
if ("attachments" in preview && Array.isArray(preview.attachments) && preview.attachments.length > 0) {
|
||||
return "[Вложение]";
|
||||
}
|
||||
if (preview.has_attachments) return "[Вложение]";
|
||||
if (preview.is_deleted) return "Сообщение удалено";
|
||||
return "[Сообщение]";
|
||||
}
|
||||
|
||||
export function getReplyPreviewStatusLabel(preview: ReplyPreviewSource | null | undefined): string | null {
|
||||
if (!preview?.is_deleted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return "Удалено";
|
||||
}
|
||||
|
||||
export function getReplyToId(message: Message): number | null {
|
||||
if (typeof message.reply_to_id === "number") return message.reply_to_id;
|
||||
if (typeof message.reply_to === "number") return message.reply_to;
|
||||
if (message.reply_to && typeof message.reply_to === "object" && typeof message.reply_to.id === "number") {
|
||||
if (isReplyPreviewSource(message.reply_to)) {
|
||||
return message.reply_to.id;
|
||||
}
|
||||
if (message.reply_to_message && typeof message.reply_to_message.id === "number") {
|
||||
if (isReplyPreviewSource(message.reply_to_message)) {
|
||||
return message.reply_to_message.id;
|
||||
}
|
||||
return null;
|
||||
|
||||
@ -507,6 +507,7 @@ export interface ChatUserSettings {
|
||||
export interface Message {
|
||||
id: number;
|
||||
chat?: number;
|
||||
local_id?: string | null;
|
||||
sender?: User;
|
||||
author?: User;
|
||||
author_id?: number;
|
||||
@ -514,6 +515,10 @@ export interface Message {
|
||||
avatar?: string;
|
||||
content: string;
|
||||
is_read?: boolean;
|
||||
read_count?: number;
|
||||
read_by?: MessageReader[];
|
||||
send_state?: 'pending' | 'delayed' | 'failed';
|
||||
is_optimistic?: boolean;
|
||||
created_at?: string;
|
||||
created?: string;
|
||||
created_ts?: number;
|
||||
@ -522,8 +527,8 @@ export interface Message {
|
||||
has_attachments?: boolean;
|
||||
attachments?: MessageAttachment[];
|
||||
reply_to_id?: number;
|
||||
reply_to?: number | Message | null;
|
||||
reply_to_message?: Message | null;
|
||||
reply_to?: number | MessageReplyPreview | Message | null;
|
||||
reply_to_message?: MessageReplyPreview | Message | null;
|
||||
reactions_summary?: Record<string, {
|
||||
count: number;
|
||||
users?: number[];
|
||||
@ -531,6 +536,20 @@ export interface Message {
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface MessageReplyPreview {
|
||||
id: number;
|
||||
content: string;
|
||||
author_name?: string;
|
||||
is_deleted?: boolean;
|
||||
has_attachments?: boolean;
|
||||
}
|
||||
|
||||
export interface MessageReader {
|
||||
id: number;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export interface MessageAttachment {
|
||||
id: number;
|
||||
file_name: string;
|
||||
@ -541,6 +560,25 @@ export interface MessageAttachment {
|
||||
width?: number;
|
||||
height?: number;
|
||||
thumbnail?: string | null;
|
||||
is_local?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatMessageSearchResult {
|
||||
message_id: number;
|
||||
content: string;
|
||||
snippet: string;
|
||||
author_name: string;
|
||||
created_at: string;
|
||||
attachments_count: number;
|
||||
has_attachments: boolean;
|
||||
}
|
||||
|
||||
export interface ChatMessageSearchResponse {
|
||||
query: string;
|
||||
count: number;
|
||||
offset: number;
|
||||
next_offset: number | null;
|
||||
results: ChatMessageSearchResult[];
|
||||
}
|
||||
|
||||
// Calendar types (django-scheduler)
|
||||
@ -646,7 +684,6 @@ export interface SearchResult {
|
||||
object_id: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
url: string;
|
||||
meta?: Record<string, any>;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user