Merge branch 'develop'

This commit is contained in:
lizerk 2026-04-03 21:03:30 +03:00
commit e53e0e3712
67 changed files with 5043 additions and 2215 deletions

View File

@ -8,6 +8,4 @@ class ApiConfig(AppConfig):
def ready(self):
"""Инициализация при загрузке приложения."""
# Патч для django-scheduler теперь применяется в
# scheduling.apps.SchedulingConfig
pass

61
backend/api/docs_views.py Normal file
View 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

View File

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

View File

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

View File

@ -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": {...}
},
...

View File

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

View File

@ -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 уведомление

View File

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

View File

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

View 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

View File

@ -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] + '...'

View 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=&lt;jwt_access_token&gt;</code>, либо заголовок
<code>Authorization: Bearer &lt;jwt_access_token&gt;</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>

View File

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

View File

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

View File

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

View 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`.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 с правильной логикой

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>
);
}

View File

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

View File

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

View File

@ -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>
</>
);
}

View 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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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,
};
}

View 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,
};
}

View 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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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