diff --git a/backend/communications/api/viewsets.py b/backend/communications/api/viewsets.py index 24eab330..b7beb286 100644 --- a/backend/communications/api/viewsets.py +++ b/backend/communications/api/viewsets.py @@ -60,6 +60,18 @@ logger = logging.getLogger(__name__) User = get_user_model() +def _parse_bool_query_param(value, default=True): + if value is None: + return default + + normalized = str(value).strip().lower() + if normalized in {'1', 'true', 'yes', 'on'}: + return True + if normalized in {'0', 'false', 'no', 'off'}: + return False + return default + + class ChatViewSet(viewsets.ModelViewSet): """ ViewSet для управления чатами @@ -563,7 +575,12 @@ class ChatViewSet(viewsets.ModelViewSet): @action(detail=True, methods=['get']) def messages(self, request, pk=None): - """Загрузка сообщений чата (пагинация по времени)""" + """Загрузка сообщений чата (пагинация по времени). + + Query params: + - mark_read=false: не обновлять last_read_message при after_id/after_ts. + Нужен для тихой синхронизации клиента после разрыва WebSocket. + """ chat = self.get_object() # Проверка доступа @@ -582,6 +599,10 @@ class ChatViewSet(viewsets.ModelViewSet): limit = min(int(request.query_params.get('limit', 50)), 100) except (ValueError, TypeError): limit = 50 + mark_read = _parse_bool_query_param( + request.query_params.get('mark_read'), + default=True, + ) queryset = chat.messages.filter(is_deleted=False).select_related( 'author', 'reply_to', 'reply_to__author', 'poll' @@ -633,7 +654,7 @@ class ChatViewSet(viewsets.ModelViewSet): # Автоотметка при загрузке НОВЫХ сообщений (after_id/after_ts) # При scroll вверх (before_id) - не отмечаем - if (after_id or after_ts) and messages: + if mark_read and (after_id or after_ts) and messages: self._auto_mark_read(chat, request.user, messages) return Response({ diff --git a/backend/notifications/api/views.py b/backend/notifications/api/views.py index 5d0f5906..c35b787a 100644 --- a/backend/notifications/api/views.py +++ b/backend/notifications/api/views.py @@ -16,6 +16,10 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from ..models import Notification, UserChannelPreferences +from ..realtime import ( + send_notification_read_event, + send_notifications_read_all_event, +) from .serializers import ( ChannelPreferencesSerializer, CountResponseSerializer, @@ -188,8 +192,12 @@ def mark_as_read(request, notification_id): id=notification_id, recipient=request.user ) + was_unread = notification.unread notification.mark_as_read() + if was_unread: + send_notification_read_event(request.user.id, notification_id) + return Response({ 'status': 'success', 'notification_id': notification_id @@ -252,8 +260,16 @@ def mark_all_as_read(request): if verb: queryset = queryset.filter(verb=verb) + notification_ids = list(queryset.values_list('id', flat=True)) count = queryset.mark_all_as_read() + if notification_ids: + send_notifications_read_all_event( + request.user.id, + notification_ids, + category=verb, + ) + return Response({ 'status': 'success', 'count': count @@ -299,8 +315,16 @@ def mark_category_as_read(request): verb__in=verbs, ) + notification_ids = list(queryset.values_list('id', flat=True)) count = queryset.mark_all_as_read() + if notification_ids: + send_notifications_read_all_event( + request.user.id, + notification_ids, + category=request.data.get('category'), + ) + return Response({ 'status': 'success', 'verbs': verbs, diff --git a/backend/notifications/realtime.py b/backend/notifications/realtime.py new file mode 100644 index 00000000..fbfc7e7c --- /dev/null +++ b/backend/notifications/realtime.py @@ -0,0 +1,74 @@ +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer + +from .models import Notification + + +def get_unread_notifications_count(user_id): + return Notification.objects.filter( + recipient_id=user_id, + unread=True, + deleted=False, + ).count() + + +def _send_user_event(user_id, event): + channel_layer = get_channel_layer() + if channel_layer is None: + return + + async_to_sync(channel_layer.group_send)(f"user_{user_id}", event) + + +def send_notification_read_event(user_id, notification_id, unread_count=None): + count = ( + get_unread_notifications_count(user_id) + if unread_count is None + else unread_count + ) + + _send_user_event( + user_id, + { + "type": "notification_read", + "notification_id": notification_id, + "unread_count": count, + }, + ) + _send_user_event( + user_id, + { + "type": "notification_count_update", + "count": count, + }, + ) + + +def send_notifications_read_all_event( + user_id, + notification_ids, + unread_count=None, + category=None, +): + count = ( + get_unread_notifications_count(user_id) + if unread_count is None + else unread_count + ) + + _send_user_event( + user_id, + { + "type": "notifications_read_all", + "notification_ids": notification_ids, + "category": category, + "unread_count": count, + }, + ) + _send_user_event( + user_id, + { + "type": "notification_count_update", + "count": count, + }, + ) diff --git a/backend/realtime/consumers.py b/backend/realtime/consumers.py index d34e79d0..0f545cc4 100644 --- a/backend/realtime/consumers.py +++ b/backend/realtime/consumers.py @@ -186,6 +186,30 @@ class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer): elif action == "vote_poll": await self._handle_vote_poll(content) + @staticmethod + def _extract_notification_chat_id(notification): + """Достаёт chat_id из payload уведомления, если он есть.""" + if not isinstance(notification, dict): + return None + + data = notification.get("data") + if not isinstance(data, dict): + return None + + chat_id = data.get("chat_id") + try: + return int(chat_id) + except (TypeError, ValueError): + return None + + def _should_suppress_active_chat_notification(self, notification): + """Не показываем уведомление о сообщении в уже открытом чате.""" + if self.active_chat_id is None: + return False + + notification_chat_id = self._extract_notification_chat_id(notification) + return notification_chat_id == self.active_chat_id + # ==================== Обработчики уведомлений ==================== async def notification_message(self, event): @@ -193,16 +217,36 @@ class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer): Новое уведомление от WebSocketNotificationSender. Вызывается из notifications/senders/websocket.py """ + notification = event.get("message", {}) + + if self._should_suppress_active_chat_notification(notification): + logger.info( + "[UserWS] Suppressed notification for active chat %s in session %s", + self.active_chat_id, + self.channel_name, + ) + return + await self.send_json( - {"type": "notification", "notification": event.get("message", {})} + {"type": "notification", "notification": notification} ) async def notification_new(self, event): """Новое уведомление (вызывается из notifications/services.py)""" + notification = event.get("notification", {}) + + if self._should_suppress_active_chat_notification(notification): + logger.info( + "[UserWS] Suppressed notification_new for active chat %s in session %s", + self.active_chat_id, + self.channel_name, + ) + return + await self.send_json( { "type": "notification", - "notification": event.get("notification", {}), + "notification": notification, } ) @@ -215,6 +259,27 @@ class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer): {"type": "unread_count", "count": event.get("count", 0)} ) + async def notification_read(self, event): + """Синхронизация прочтения одного уведомления между сессиями.""" + await self.send_json( + { + "type": "notification_read", + "notification_id": event.get("notification_id"), + "unread_count": event.get("unread_count"), + } + ) + + async def notifications_read_all(self, event): + """Синхронизация массового прочтения уведомлений между сессиями.""" + await self.send_json( + { + "type": "notifications_read_all", + "notification_ids": event.get("notification_ids", []), + "category": event.get("category"), + "unread_count": event.get("unread_count"), + } + ) + # ==================== Обработка событий закупок ==================== async def poll_update(self, event): diff --git a/backend/tests/api/v1/notifications/test_notifications_api.py b/backend/tests/api/v1/notifications/test_notifications_api.py new file mode 100644 index 00000000..2da9bba0 --- /dev/null +++ b/backend/tests/api/v1/notifications/test_notifications_api.py @@ -0,0 +1,108 @@ +from unittest.mock import patch + +import pytest + +from notifications.models import Notification + + +pytestmark = pytest.mark.django_db + + +class TestNotificationRealtimeEvents: + def test_mark_as_read_sends_realtime_event(self, api_client, user_factory): + user = user_factory() + notification = Notification.objects.create( + recipient=user, + verb="request_updated", + description="Заявка обновлена", + ) + + api_client.force_authenticate(user=user) + + with patch( + "notifications.api.views.send_notification_read_event" + ) as send_event: + response = api_client.post( + f"/api/v1/notifications/{notification.id}/read/" + ) + + assert response.status_code == 200 + notification.refresh_from_db() + assert notification.unread is False + send_event.assert_called_once_with(user.id, notification.id) + + def test_mark_all_as_read_sends_realtime_event_with_ids( + self, api_client, user_factory + ): + user = user_factory() + first = Notification.objects.create( + recipient=user, + verb="request_new", + description="Новая заявка", + ) + second = Notification.objects.create( + recipient=user, + verb="request_updated", + description="Заявка обновлена", + ) + + api_client.force_authenticate(user=user) + + with patch( + "notifications.api.views.send_notifications_read_all_event" + ) as send_event: + response = api_client.post("/api/v1/notifications/read-all/") + + assert response.status_code == 200 + assert ( + Notification.objects.filter( + recipient=user, + unread=True, + deleted=False, + ).count() + == 0 + ) + send_event.assert_called_once() + args, kwargs = send_event.call_args + assert args[0] == user.id + assert set(args[1]) == {first.id, second.id} + assert kwargs == {"category": None} + + def test_mark_category_as_read_sends_category_realtime_event( + self, api_client, user_factory + ): + user = user_factory() + matching = Notification.objects.create( + recipient=user, + verb="request_updated", + description="Заявка обновлена", + ) + other = Notification.objects.create( + recipient=user, + verb="document_uploaded", + description="Документ загружен", + ) + + api_client.force_authenticate(user=user) + + with patch( + "notifications.api.views.send_notifications_read_all_event" + ) as send_event: + response = api_client.post( + "/api/v1/notifications/category/read/", + { + "verbs": ["request_updated"], + "category": "Заявки", + }, + format="json", + ) + + assert response.status_code == 200 + matching.refresh_from_db() + other.refresh_from_db() + assert matching.unread is False + assert other.unread is True + send_event.assert_called_once() + args, kwargs = send_event.call_args + assert args == (user.id, [matching.id]) + assert kwargs == {"category": "Заявки"} diff --git a/backend/tests/realtime/test_websocket_events.py b/backend/tests/realtime/test_websocket_events.py index f8edfd03..c4deb3f0 100644 --- a/backend/tests/realtime/test_websocket_events.py +++ b/backend/tests/realtime/test_websocket_events.py @@ -544,3 +544,144 @@ class TestChatMarkedReadEvent: import asyncio with pytest.raises(asyncio.TimeoutError): await comm2.receive_json_from(timeout=2) + + # Cleanup: after TimeoutError, communicator may be in cancelled state + try: + await comm2.disconnect() + except asyncio.CancelledError: + pass + try: + await comm1.disconnect() + except asyncio.CancelledError: + pass + +class TestNotificationStateEvents: + """Тесты синхронизации состояния уведомлений через user channel.""" + + @pytest.mark.asyncio + async def test_active_chat_session_does_not_receive_own_chat_notification( + self, + ws_communicator, + user, + test_chat, + ): + """Уведомление о сообщении не должно приходить в сессию с открытым этим чатом.""" + import asyncio + + comm_active = await ws_communicator(user=user) + comm_background = await ws_communicator(user=user) + + connected_active, _ = await comm_active.connect() + connected_background, _ = await comm_background.connect() + assert connected_active and connected_background + + await comm_active.send_json_to( + { + "action": "open_chat", + "chat_id": test_chat.id, + "load_history": False, + } + ) + await comm_active.receive_json_from(timeout=5) + + channel_layer = get_channel_layer() + payload = { + "type": "notification", + "id": 777, + "verb": "chat_new_message", + "description": "New message in active chat", + "action_url": f"/messages/{test_chat.id}", + "timestamp": "2026-04-02T10:00:00Z", + "unread": True, + "data": { + "chat_id": test_chat.id, + "message_id": 123, + }, + } + + await channel_layer.group_send( + f"user_{user.id}", + { + "type": "notification_message", + "message": payload, + } + ) + + with pytest.raises(asyncio.TimeoutError): + await comm_active.receive_json_from(timeout=2) + + response = await comm_background.receive_json_from(timeout=5) + assert response["type"] == "notification" + assert response["notification"]["id"] == 777 + assert response["notification"]["data"]["chat_id"] == test_chat.id + + try: + await comm_background.disconnect() + except asyncio.CancelledError: + pass + try: + await comm_active.disconnect() + except asyncio.CancelledError: + pass + + @pytest.mark.asyncio + async def test_notification_read_sync_between_sessions(self, ws_communicator, user): + comm1 = await ws_communicator(user=user) + comm2 = await ws_communicator(user=user) + + connected1, _ = await comm1.connect() + connected2, _ = await comm2.connect() + assert connected1 and connected2 + + channel_layer = get_channel_layer() + await channel_layer.group_send( + f'user_{user.id}', + { + 'type': 'notification_read', + 'notification_id': 321, + 'unread_count': 4, + } + ) + + response1 = await comm1.receive_json_from(timeout=5) + response2 = await comm2.receive_json_from(timeout=5) + + assert response1['type'] == 'notification_read' + assert response1['notification_id'] == 321 + assert response1['unread_count'] == 4 + assert response2['type'] == 'notification_read' + + await comm1.disconnect() + await comm2.disconnect() + + @pytest.mark.asyncio + async def test_notifications_read_all_sync_between_sessions(self, ws_communicator, user): + comm1 = await ws_communicator(user=user) + comm2 = await ws_communicator(user=user) + + connected1, _ = await comm1.connect() + connected2, _ = await comm2.connect() + assert connected1 and connected2 + + channel_layer = get_channel_layer() + await channel_layer.group_send( + f'user_{user.id}', + { + 'type': 'notifications_read_all', + 'notification_ids': [11, 12, 13], + 'category': 'Заявки', + 'unread_count': 0, + } + ) + + response1 = await comm1.receive_json_from(timeout=5) + response2 = await comm2.receive_json_from(timeout=5) + + assert response1['type'] == 'notifications_read_all' + assert response1['notification_ids'] == [11, 12, 13] + assert response1['category'] == 'Заявки' + assert response1['unread_count'] == 0 + assert response2['type'] == 'notifications_read_all' + + await comm1.disconnect() + await comm2.disconnect() diff --git a/frontend/src/app/equipment/page.tsx b/frontend/src/app/equipment/page.tsx index a0e923bf..3753c32a 100644 --- a/frontend/src/app/equipment/page.tsx +++ b/frontend/src/app/equipment/page.tsx @@ -1,72 +1,18 @@ "use client"; import { AppShell } from "../../components/AppShell"; -import { apiClient } from "@/lib/api"; import { useUser } from "@/contexts/UserContext"; -import { canManageEquipment } from "@/lib/permissions"; -import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; -import type { - Department, - Equipment, - EquipmentCategory, - EquipmentComment, - EquipmentCreateOptions, - EquipmentTransferHistoryEntry, - MaintenanceRecord, - User, -} from "@/types/api"; import { Archive, ArrowRightLeft, ArrowUpDown, ChevronDown, Filter, MessageSquare, Monitor, Pencil, Plus, QrCode, Search, Shield, Trash2, Wrench, X } from "lucide-react"; +import { SearchableSelectSingle } from "@/components/shared/SearchableSelect"; +import { formatDate, formatMoney } from "@/lib/shared"; +import { useEquipmentPage } from "@/hooks/useEquipmentPage"; -/* ──── form state ──── */ -type EquipmentFormState = { - name: string; - notes: string; - category: number | null; - department: number | null; - responsible_person: number | null; - quantity: number; - serial_number: string; - purchase_date: string; - purchase_cost: string; - location: string; -}; - -const emptyForm: EquipmentFormState = { - name: "", - notes: "", - category: null, - department: null, - responsible_person: null, - quantity: 1, - serial_number: "", - purchase_date: "", - purchase_cost: "", - location: "", -}; - -type EquipmentListMode = "all" | "mine" | "warranty"; - -type OperationModal = "transfer" | "writeoff" | "maintenance" | null; - -type TransferFormState = { - to_department: number | null; - to_person: number | null; - reason: string; -}; - -type MaintenanceFormState = { - type: string; - description: string; - cost: string; - date: string; -}; - -const listModeMeta: Array<{ value: EquipmentListMode; label: string }> = [ +const listModeMeta = [ { value: "all", label: "Весь реестр" }, { value: "mine", label: "Мое оборудование" }, { value: "warranty", label: "Истекает гарантия" }, -]; +] as const; const orderingOptions = [ { value: "-created_at", label: "Сначала новые" }, @@ -117,729 +63,91 @@ const defaultStatusMeta = { surfaceClass: "border-gray-200", }; -function formatDate(value?: string | null): string { - if (!value) return ""; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return ""; - return date.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric" }); -} - -function formatMoney(value?: string | number | null): string { - if (value === null || value === undefined || value === "") return "—"; - const amount = Number(value); - if (!Number.isFinite(amount)) return "—"; - return `${amount.toLocaleString("ru-RU")} ₽`; -} - /* ──── main page ──── */ export default function EquipmentPage() { const { user } = useUser(); - const [items, setItems] = useState([]); - const [detailsMap, setDetailsMap] = useState>({}); - const [transferHistoryMap, setTransferHistoryMap] = useState>({}); - const [maintenanceMap, setMaintenanceMap] = useState>({}); - const [employees, setEmployees] = useState([]); - const [departments, setDepartments] = useState([]); - const [categories, setCategories] = useState([]); - const [createOptions, setCreateOptions] = useState(null); - const [previewInventoryNumber, setPreviewInventoryNumber] = useState(""); - const [commentsMap, setCommentsMap] = useState>({}); - const [expandedRows, setExpandedRows] = useState>({}); - const [expandedComments, setExpandedComments] = useState>({}); - const [commentDrafts, setCommentDrafts] = useState>({}); - const [loadingRowDetails, setLoadingRowDetails] = useState>({}); - - const [createOpen, setCreateOpen] = useState(false); - const [editingId, setEditingId] = useState(null); - const [form, setForm] = useState(emptyForm); - const [listMode, setListMode] = useState("all"); - const [ordering, setOrdering] = useState("-created_at"); - const [operationModal, setOperationModal] = useState(null); - const [selectedEquipment, setSelectedEquipment] = useState(null); - const [writeOffReason, setWriteOffReason] = useState(""); - const [transferForm, setTransferForm] = useState({ to_department: null, to_person: null, reason: "" }); - const [maintenanceForm, setMaintenanceForm] = useState({ - type: "repair", - description: "", - cost: "", - date: new Date().toISOString().slice(0, 10), - }); - - const [searchQuery, setSearch] = useState(""); - const deferredSearchQuery = useDeferredValue(searchQuery); - const [statusFilter, setStatusFilter] = useState(""); - const [categoryFilter, setCategoryFilter] = useState(""); - const [departmentFilter, setDepartmentFilter] = useState(""); - const [responsibleFilter, setResponsibleFilter] = useState(""); - const [dateFromFilter, setDateFromFilter] = useState(""); - const [dateToFilter, setDateToFilter] = useState(""); - const [filtersOpen, setFiltersOpen] = useState(false); - - const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [error, setError] = useState(null); - const [actionError, setActionError] = useState(null); - const [actionSuccess, setActionSuccess] = useState(null); - const [busyKey, setBusyKey] = useState(null); - const [nextPage, setNextPage] = useState(null); - - const auth = user?.auth; - const canManage = canManageEquipment(user); - const isCreateMode = createOpen && editingId === null; - - /* ──── helpers ──── */ - const displayUserName = (person?: User | null) => { - if (!person) return "—"; - const full = `${person.last_name || ""} ${person.first_name || ""}`.trim(); - return full || (person as any)?.full_name || (person as any)?.display_name || person.email || "Пользователь"; - }; - - const extractNextPage = (nextUrl?: string | null): number | null => { - if (!nextUrl) return null; - try { - const parsed = new URL(nextUrl, window.location.origin); - const num = Number(parsed.searchParams.get("page")); - return Number.isFinite(num) && num > 0 ? num : null; - } catch { - return null; - } - }; - - const buildParams = (page: number): Record => { - const p: Record = { page }; - if (deferredSearchQuery.trim()) p.search = deferredSearchQuery.trim(); - if (ordering) p.ordering = ordering; - if (statusFilter) p.status = statusFilter; - if (categoryFilter) p.category = categoryFilter; - if (departmentFilter) p.department = departmentFilter; - if (responsibleFilter) p.responsible_person = responsibleFilter; - if (dateFromFilter) p.purchase_date__gte = dateFromFilter; - if (dateToFilter) p.purchase_date__lte = dateToFilter; - return p; - }; - - const getCategoryName = (eq: Equipment): string => { - if (eq.category_name) return eq.category_name; - const cat = categories.find((c) => c.id === Number(eq.category)); - return cat?.name || "—"; - }; - - const getDepartmentName = (eq: Equipment): string => { - if (eq.department_name) return eq.department_name; - const dep = departments.find((d) => d.id === Number(eq.department)); - return dep?.name || "—"; - }; - - const getResponsibleName = (eq: Equipment): string => { - if (eq.responsible_name) return eq.responsible_name; - if (eq.responsible_person && typeof eq.responsible_person === "number") { - const emp = employees.find((e) => e.id === eq.responsible_person); - return emp ? displayUserName(emp) : "—"; - } - return "—"; - }; - - const getResponsibleLink = (eq: Equipment): string => { - if (!eq.responsible_person || typeof eq.responsible_person !== "number") return ""; - if (eq.responsible_person === user?.id) return "/profile"; - return `/users/${eq.responsible_person}`; - }; - - const getEquipmentMeta = (eq: Equipment) => { - return [ - { label: "Категория", value: getCategoryName(eq) }, - { label: "Отдел", value: getDepartmentName(eq) }, - { label: "Ответственный", value: getResponsibleName(eq) }, - { label: "Серийный номер", value: eq.serial_number || "—" }, - { label: "Расположение", value: eq.location || "—" }, - { label: "Гарантия до", value: formatDate(eq.warranty_until) || "—" }, - { label: "Обслуживаний", value: String(eq.maintenance_count ?? "—") }, - { label: "Стоимость", value: formatMoney(eq.purchase_cost) }, - { label: "Добавлено", value: formatDate(eq.created_at) || "—" }, - ]; - }; - - const filteredDepartmentsForForm = useMemo(() => { - if (!createOptions || !isCreateMode || createOptions.allowed_departments.length === 0) { - return departments; - } - const allowedIds = new Set(createOptions.allowed_departments.map((dept) => dept.id)); - return departments.filter((dept) => allowedIds.has(dept.id)); - }, [createOptions, departments, isCreateMode]); - - const filteredEmployeesForForm = useMemo(() => { - if (!isCreateMode || !createOptions || createOptions.can_choose_responsible) { - return employees; - } - if (!createOptions.default_responsible) { - return []; - } - return employees.filter((employee) => employee.id === createOptions.default_responsible?.id); - }, [createOptions, employees, isCreateMode]); - - const applyCreateDefaults = (options: EquipmentCreateOptions | null) => { - if (!options) return; - - setForm((prev) => { - const nextDepartment = options.can_choose_department - ? prev.department - : options.allowed_departments[0]?.id ?? prev.department; - const nextResponsible = options.can_choose_responsible - ? prev.responsible_person - : options.default_responsible?.id ?? prev.responsible_person; - - return { - ...prev, - department: nextDepartment, - responsible_person: nextResponsible, - }; - }); - }; - - const loadRowDetails = async (equipmentId: number) => { - if (loadingRowDetails[equipmentId]) return; - - try { - setLoadingRowDetails((prev) => ({ ...prev, [equipmentId]: true })); - const results = await Promise.allSettled([ - detailsMap[equipmentId] ? Promise.resolve(detailsMap[equipmentId]) : apiClient.getEquipmentDetail(equipmentId), - transferHistoryMap[equipmentId] ? Promise.resolve(transferHistoryMap[equipmentId]) : apiClient.getEquipmentTransferHistory(equipmentId), - maintenanceMap[equipmentId] ? Promise.resolve(maintenanceMap[equipmentId]) : apiClient.getMaintenanceRecords({ equipment: equipmentId }), - ]); - - const [detailResult, transferResult, maintenanceResult] = results; - - if (detailResult.status === "fulfilled") { - setDetailsMap((prev) => ({ ...prev, [equipmentId]: detailResult.value })); - } - - if (transferResult.status === "fulfilled") { - setTransferHistoryMap((prev) => ({ ...prev, [equipmentId]: Array.isArray(transferResult.value) ? transferResult.value : transferResult.value.results || [] })); - } - - if (maintenanceResult.status === "fulfilled") { - setMaintenanceMap((prev) => ({ - ...prev, - [equipmentId]: Array.isArray(maintenanceResult.value) ? maintenanceResult.value : maintenanceResult.value.results || [], - })); - } - } catch (error) { - console.error("Ошибка загрузки деталей оборудования:", error); - } finally { - setLoadingRowDetails((prev) => ({ ...prev, [equipmentId]: false })); - } - }; - - const openCreateModal = async () => { - setEditingId(null); - resetForm(); - setActionError(null); - setActionSuccess(null); - setCreateOpen(true); - - try { - const [options, inventoryPreview] = await Promise.all([ - apiClient.getEquipmentCreateOptions(), - apiClient.generateEquipmentInventoryNumber(), - ]); - setCreateOptions(options); - setPreviewInventoryNumber(inventoryPreview.inventory_number || ""); - applyCreateDefaults(options); - } catch (error) { - console.error("Ошибка загрузки опций создания оборудования:", error); - } - }; - - const openOperationModal = (type: Exclude, equipment: Equipment) => { - setSelectedEquipment(equipment); - setOperationModal(type); - setActionError(null); - - if (type === "transfer") { - setTransferForm({ - to_department: equipment.department ?? null, - to_person: typeof equipment.responsible_person === "number" ? equipment.responsible_person : null, - reason: "", - }); - } - - if (type === "writeoff") { - setWriteOffReason(""); - } - - if (type === "maintenance") { - setMaintenanceForm({ - type: "repair", - description: "", - cost: "", - date: new Date().toISOString().slice(0, 10), - }); - } - }; - - const closeOperationModal = () => { - setOperationModal(null); - setSelectedEquipment(null); - setWriteOffReason(""); - setTransferForm({ to_department: null, to_person: null, reason: "" }); - setMaintenanceForm({ type: "repair", description: "", cost: "", date: new Date().toISOString().slice(0, 10) }); - }; - - /* ──── load data ──── */ - const fetchEquipmentPage = async (page: number) => { - if (listMode === "mine") { - return apiClient.getMyEquipment({ page }); - } - - if (listMode === "warranty") { - if (page > 1) { - return { results: [], next: null }; - } - return apiClient.getWarrantyExpiringEquipment(); - } - - return apiClient.getEquipment(buildParams(page)); - }; - - const reloadEquipmentList = async () => { - const res = await fetchEquipmentPage(1); - const results = Array.isArray(res) ? res : (res.results || []); - setItems(results); - setNextPage(extractNextPage((res as { next?: string | null }).next)); - }; - - useEffect(() => { - (async () => { - try { - setLoading(true); - setError(null); - const res = await fetchEquipmentPage(1); - const results = Array.isArray(res) ? res : (res.results || []); - setItems(results); - setNextPage(extractNextPage(res.next)); - } catch (e: any) { - console.error("Ошибка загрузки оборудования:", e); - setError("Не удалось загрузить оборудование"); - } finally { - setLoading(false); - } - })(); - }, [ - listMode, - deferredSearchQuery, - ordering, - statusFilter, - categoryFilter, - departmentFilter, - responsibleFilter, - dateFromFilter, - dateToFilter, - ]); - - useEffect(() => { - async function loadAllPages(fetcher: (params: any) => Promise): Promise { - const all: T[] = []; - let page = 1; - while (true) { - const res = await fetcher({ page, limit: 200 }); - const results = Array.isArray(res) ? res : (res.results || []); - all.push(...results); - if (Array.isArray(res) || !res.next) break; - page++; - } - return all; - } - - (async () => { - try { - const [allEmployees, allDepartments, allCategories] = await Promise.all([ - loadAllPages((p) => apiClient.getEmployees(p)), - loadAllPages((p) => apiClient.getDepartments(p)), - loadAllPages((p) => apiClient.getEquipmentCategories(p)), - ]); - setEmployees(allEmployees); - setDepartments(allDepartments); - setCategories(allCategories); - } catch (e) { - console.error("Ошибка загрузки справочников:", e); - } - })(); - }, []); - - useEffect(() => { - (async () => { - try { - const options = await apiClient.getEquipmentCreateOptions(); - setCreateOptions(options); - } catch (error) { - console.error("Ошибка загрузки create options:", error); - } - })(); - }, []); - - /* ──── filtered ──── */ - const sortItemsLocally = (source: Equipment[]) => { - const sorted = [...source]; - - sorted.sort((left, right) => { - switch (ordering) { - case "name": - return (left.name || "").localeCompare(right.name || "", "ru"); - case "purchase_date": - return new Date(left.purchase_date || 0).getTime() - new Date(right.purchase_date || 0).getTime(); - case "-purchase_date": - return new Date(right.purchase_date || 0).getTime() - new Date(left.purchase_date || 0).getTime(); - case "created_at": - return new Date(left.created_at || 0).getTime() - new Date(right.created_at || 0).getTime(); - case "-created_at": - default: - return new Date(right.created_at || 0).getTime() - new Date(left.created_at || 0).getTime(); - } - }); - - return sorted; - }; - - const filteredItems = useMemo(() => { - const q = searchQuery.trim().toLowerCase(); - let scopedItems = [...items]; - - if (listMode !== "all") { - scopedItems = scopedItems.filter((item) => { - const itemDate = item.purchase_date || ""; - if (statusFilter && item.status !== statusFilter) return false; - if (categoryFilter && Number(item.category) !== Number(categoryFilter)) return false; - if (departmentFilter && Number(item.department) !== Number(departmentFilter)) return false; - if (responsibleFilter && Number(item.responsible_person) !== Number(responsibleFilter)) return false; - if (dateFromFilter && itemDate && itemDate < dateFromFilter) return false; - if (dateToFilter && itemDate && itemDate > dateToFilter) return false; - return true; - }); - } - - const sorted = listMode === "all" ? items : sortItemsLocally(scopedItems); - if (!q) return sorted; - return sorted.filter((item) => { - const name = (item.name || "").toLowerCase(); - const notes = (item.notes || "").toLowerCase(); - const sn = (item.serial_number || "").toLowerCase(); - const inv = (item.inventory_number || "").toLowerCase(); - const responsible = getResponsibleName(item).toLowerCase(); - const cat = getCategoryName(item).toLowerCase(); - return name.includes(q) || notes.includes(q) || sn.includes(q) || inv.includes(q) || responsible.includes(q) || cat.includes(q); - }); - }, [ - items, - listMode, - ordering, - searchQuery, - statusFilter, - categoryFilter, - departmentFilter, - responsibleFilter, - dateFromFilter, - dateToFilter, - employees, + const { + auth, + actionError, + actionSuccess, + activeFilterCount, + busyKey, + canManage, categories, + categoryFilter, + closeModal, + closeOperationModal, + commentDrafts, + commentsMap, + createOptions, + dateFromFilter, + dateToFilter, + departmentFilter, departments, - ]); - - /* ──── form ──── */ - const resetForm = () => setForm(emptyForm); - - const openEdit = (eq: Equipment) => { - setEditingId(eq.id); - setCreateOpen(false); - setActionError(null); - setActionSuccess(null); - setPreviewInventoryNumber(eq.inventory_number || ""); - setForm({ - name: eq.name || "", - notes: eq.notes || "", - category: eq.category ?? null, - department: eq.department ?? null, - responsible_person: typeof eq.responsible_person === "number" ? eq.responsible_person : null, - quantity: 1, - serial_number: eq.serial_number || "", - purchase_date: eq.purchase_date || "", - purchase_cost: String(eq.purchase_cost || ""), - location: eq.location || "", - }); - }; - - const handleSave = async (mode: "create" | "edit") => { - try { - setBusyKey(`${mode}-save`); - setActionError(null); - setActionSuccess(null); - - if (!form.name.trim()) { setActionError("Укажите название оборудования."); return; } - if (!form.category) { setActionError("Выберите категорию."); return; } - if (!form.department) { setActionError("Выберите отдел."); return; } - if (!form.purchase_date) { setActionError("Укажите дату покупки."); return; } - if (!form.purchase_cost) { setActionError("Укажите стоимость."); return; } - - const payload: Record = { - name: form.name, - notes: form.notes, - category: form.category, - department: form.department, - purchase_date: form.purchase_date, - purchase_cost: form.purchase_cost, - }; - - if (form.responsible_person) payload.responsible_person = form.responsible_person; - if (mode === "create" && form.quantity > 1) payload.quantity = form.quantity; - if (form.serial_number) payload.serial_number = form.serial_number; - if (form.location) payload.location = form.location; - - if (mode === "create") { - await apiClient.createEquipment(payload); - setActionSuccess("Оборудование добавлено."); - setCreateOpen(false); - } else if (editingId) { - await apiClient.updateEquipment(editingId, payload); - setActionSuccess("Оборудование обновлено."); - setEditingId(null); - } - - resetForm(); - await reloadEquipmentList(); - } catch (e: any) { - const raw = String(e?.message || "Не удалось сохранить"); - let readable = raw; - try { - const parsed = JSON.parse(raw); - readable = Object.entries(parsed).map(([key, val]) => `${key}: ${Array.isArray(val) ? val.join(", ") : val}`).join(". "); - } catch {} - setActionError(readable); - } finally { - setBusyKey(null); - } - }; - - const handleLoadMore = async () => { - if (!nextPage || loadingMore) return; - try { - setLoadingMore(true); - const res = await fetchEquipmentPage(nextPage); - const chunk = Array.isArray(res) ? res : (res.results || []); - setItems((prev) => { - const known = new Set(prev.map((r) => r.id)); - return [...prev, ...chunk.filter((r: Equipment) => !known.has(r.id))]; - }); - setNextPage(extractNextPage(res.next)); - } catch { - setError("Не удалось загрузить ещё"); - } finally { - setLoadingMore(false); - } - }; - - /* ──── actions ──── */ - const handleDelete = async (id: number) => { - if (!confirm("Удалить это оборудование?")) return; - try { setBusyKey(`delete-${id}`); await apiClient.deleteEquipment(id); setItems((p) => p.filter((r) => r.id !== id)); } catch { setActionError("Не удалось удалить"); } finally { setBusyKey(null); } }; - - const toggleRow = (equipmentId: number) => { - setExpandedRows((prev) => { - const nextOpen = !prev[equipmentId]; - if (nextOpen) { - void loadRowDetails(equipmentId); - } - return { ...prev, [equipmentId]: nextOpen }; - }); - }; - - const handleTransfer = async () => { - if (!selectedEquipment) return; - try { - setBusyKey(`transfer-${selectedEquipment.id}`); - await apiClient.transferEquipment(selectedEquipment.id, { - to_department: transferForm.to_department, - to_person: transferForm.to_person, - reason: transferForm.reason, - }); - setActionSuccess("Оборудование переведено."); - closeOperationModal(); - await reloadEquipmentList(); - await loadRowDetails(selectedEquipment.id); - } catch (error: any) { - setActionError(String(error?.message || "Не удалось выполнить перевод")); - } finally { - setBusyKey(null); - } - }; - - const handleWriteOff = async () => { - if (!selectedEquipment) return; - try { - setBusyKey(`writeoff-${selectedEquipment.id}`); - await apiClient.writeOffEquipment(selectedEquipment.id, writeOffReason); - setActionSuccess("Оборудование списано."); - closeOperationModal(); - await reloadEquipmentList(); - await loadRowDetails(selectedEquipment.id); - } catch (error: any) { - setActionError(String(error?.message || "Не удалось списать оборудование")); - } finally { - setBusyKey(null); - } - }; - - const handleMaintenance = async () => { - if (!selectedEquipment) return; - try { - setBusyKey(`maintenance-${selectedEquipment.id}`); - await apiClient.addEquipmentMaintenance(selectedEquipment.id, { - type: maintenanceForm.type, - description: maintenanceForm.description, - cost: maintenanceForm.cost || undefined, - date: maintenanceForm.date, - }); - setActionSuccess("Запись обслуживания добавлена."); - closeOperationModal(); - await loadRowDetails(selectedEquipment.id); - } catch (error: any) { - setActionError(String(error?.message || "Не удалось добавить обслуживание")); - } finally { - setBusyKey(null); - } - }; - - const handleOpenQr = async (equipmentId: number) => { - try { - setBusyKey(`qr-${equipmentId}`); - const blobUrl = await apiClient.getEquipmentQrCodeBlobUrl(equipmentId); - window.open(blobUrl, "_blank", "noopener,noreferrer"); - } catch (error: any) { - setActionError(String(error?.message || "Не удалось получить QR-код")); - } finally { - setBusyKey(null); - } - }; - - const toggleComments = async (eqId: number) => { - const isOpen = Boolean(expandedComments[eqId]); - setExpandedComments((p) => ({ ...p, [eqId]: !isOpen })); - if (!isOpen && !commentsMap[eqId]) { - try { - const c = await apiClient.getEquipmentComments(eqId); - setCommentsMap((p) => ({ ...p, [eqId]: Array.isArray(c) ? c : c.results || [] })); - } catch { - setCommentsMap((p) => ({ ...p, [eqId]: [] })); - } - } - }; - - const handleAddComment = async (eqId: number) => { - const text = (commentDrafts[eqId] || "").trim(); - if (!text) return; - try { - setBusyKey(`comment-${eqId}`); - const saved = await apiClient.addEquipmentComment(eqId, text); - setCommentsMap((p) => ({ ...p, [eqId]: [...(p[eqId] || []), saved] })); - setCommentDrafts((p) => ({ ...p, [eqId]: "" })); - } catch { - setActionError("Не удалось добавить комментарий"); - } finally { - setBusyKey(null); - } - }; - - const handleDeleteComment = async (eqId: number, commentId: number) => { - try { - setBusyKey(`comment-delete-${commentId}`); - await apiClient.deleteEquipmentComment(eqId, commentId); - setCommentsMap((p) => ({ ...p, [eqId]: (p[eqId] || []).filter((c) => c.id !== commentId) })); - } catch { - setActionError("Не удалось удалить комментарий"); - } finally { - setBusyKey(null); - } - }; - - /* ──── SearchableSelect (single) ──── */ - const SearchableSelectSingle = ({ label, items: selectItems, selectedId, onSelect, placeholder, disabled = false }: { - label: string; - items: { id: number; name: string }[]; - selectedId: number | null; - onSelect: (id: number | null) => void; - placeholder?: string; - disabled?: boolean; - }) => { - const [open, setOpen] = useState(false); - const [q, setQ] = useState(""); - const ref = useRef(null); - - useEffect(() => { - const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); - }; - document.addEventListener("mousedown", handler); - return () => document.removeEventListener("mousedown", handler); - }, []); - - const filtered = selectItems.filter((i) => i.name.toLowerCase().includes(q.toLowerCase())); - const selectedName = selectItems.find((i) => i.id === selectedId)?.name; - - return ( -
- - - {open && ( -
-
- setQ(e.target.value)} placeholder="Поиск..." className="w-full rounded border border-gray-200 px-2 py-1.5 text-sm focus:border-sky-400 focus:outline-none" autoFocus /> -
-
- {selectedId && ( - - )} - {filtered.length === 0 ? ( -

Ничего не найдено

- ) : ( - filtered.map((item) => ( - - )) - )} -
-
- )} -
- ); - }; - - const modalMode: "create" | "edit" = editingId ? "edit" : "create"; - const isModalOpen = createOpen || editingId !== null; - - const closeModal = () => { - setCreateOpen(false); - setEditingId(null); - resetForm(); - setActionError(null); - }; - - const activeFilterCount = [statusFilter, categoryFilter, departmentFilter, responsibleFilter, dateFromFilter, dateToFilter].filter(Boolean).length; + detailsMap, + displayUserName, + employees, + error, + expandedComments, + expandedRows, + filteredDepartmentsForForm, + filteredEmployeesForForm, + filteredItems, + filtersOpen, + form, + getEquipmentMeta, + getResponsibleLink, + getResponsibleName, + handleAddComment, + handleDelete, + handleDeleteComment, + handleLoadMore, + handleMaintenance, + handleOpenQr, + handleSave, + handleTransfer, + handleWriteOff, + isCreateMode, + isModalOpen, + listMode, + loading, + loadingMore, + loadingRowDetails, + maintenanceForm, + maintenanceMap, + modalMode, + nextPage, + openCreateModal, + openEdit, + openOperationModal, + operationModal, + ordering, + previewInventoryNumber, + responsibleFilter, + searchQuery, + selectedEquipment, + setCategoryFilter, + setCommentDrafts, + setDateFromFilter, + setDateToFilter, + setDepartmentFilter, + setFiltersOpen, + setForm, + setListMode, + setMaintenanceForm, + setOrdering, + setResponsibleFilter, + setSearchQuery, + setStatusFilter, + setTransferForm, + setWriteOffReason, + statusFilter, + transferForm, + transferHistoryMap, + toggleComments, + toggleRow, + writeOffReason, + } = useEquipmentPage(user); /* ──── render ──── */ return ( @@ -875,7 +183,7 @@ export default function EquipmentPage() { setSearch(e.target.value)} + onChange={(e) => setSearchQuery(e.target.value)} placeholder="Поиск по оборудованию" className="w-full rounded-lg border border-gray-200 bg-gray-50 py-2.5 pl-9 pr-3 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100" /> diff --git a/frontend/src/app/messages-chatscope/page.tsx b/frontend/src/app/messages-chatscope/page.tsx index d5131332..247532ba 100644 --- a/frontend/src/app/messages-chatscope/page.tsx +++ b/frontend/src/app/messages-chatscope/page.tsx @@ -22,6 +22,7 @@ import { apiClient } from '@/lib/api'; import { useUser } from '@/contexts/UserContext'; import { resolveMediaUrl, getWebSocketUrl } from '@/lib/url'; import NewChatModal from '@/components/NewChatModal'; +import wsManager from '@/lib/websocketManager'; import type { Chat as ChatType, Message as MessageType } from '@/types/api'; function getChatTitle(chat: ChatType, currentUserId?: number): string { @@ -144,6 +145,16 @@ export default function ChatscapeMessenger() { }, [selectedChatId]); // WebSocket для real-time обновлений + useEffect(() => { + if (!selectedChatId) return; + + wsManager.setActiveChat(selectedChatId); + + return () => { + wsManager.clearActiveChat(selectedChatId); + }; + }, [selectedChatId]); + useEffect(() => { if (!selectedChatId) return; diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index 5901b82e..fa4a90c9 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -1,217 +1,25 @@ "use client"; import React, { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from "react"; -import Link from "next/link"; import { useParams } from "next/navigation"; -import { MessageCircle, Send, ArrowLeft, Paperclip, X, FileText, ChevronRight, ChevronDown, Reply, Pencil, Trash2, Smile, Pin, Bell, BellOff } from "lucide-react"; +import { MessageCircle, ChevronDown } from "lucide-react"; import { AppShell } from "../../../components/AppShell"; +import ChatDialogHeader from "@/components/messages/ChatDialogHeader"; +import ChatMediaPreviewModal from "@/components/messages/ChatMediaPreviewModal"; +import ChatMessageItem, { MediaPreview } from "@/components/messages/ChatMessageItem"; +import MessageActionsMenu from "@/components/messages/MessageActionsMenu"; +import MessageComposer from "@/components/messages/MessageComposer"; +import ReactionPickerModal from "@/components/messages/ReactionPickerModal"; import { apiClient } from "@/lib/api"; -import type { Chat, Message, MessageAttachment } from "@/types/api"; +import type { Chat, Message } from "@/types/api"; import { useUser } from "@/contexts/UserContext"; +import { useChatFallbackSync } from "@/hooks/useChatFallbackSync"; +import { useSilentChatReloadGuard } from "@/hooks/useSilentChatReloadGuard"; import { useWebSocket } from "@/hooks/useWebSocket"; +import { uniqueMessagesById } from "@/lib/messages/chatUtils"; +import { formatDayDivider, getMessagePreviewText, getReplyToId } from "@/lib/messages/messageUtils"; +import wsManager from "@/lib/websocketManager"; import ScrollableMessageList, { ScrollableMessageListInner } from "@/components/ScrollableMessageList"; -import { resolveMediaUrl } from "@/lib/url"; - -function getUserFullName(lastName?: string, firstName?: string): string { - return `${lastName || ""} ${firstName || ""}`.trim(); -} - -function getInterlocutorFromParticipants(chat: Chat, currentUserId?: number) { - const participants = (chat.participants || []).filter( - (p): p is Exclude => typeof p === "object" && p !== null - ); - return participants.find((p) => p.id !== currentUserId); -} - -function getInterlocutorFromParticipantDetails(chat: Chat, currentUserId?: number) { - return (chat.participant_details || []).find((p) => p.id !== currentUserId); -} - -function getChatTitle(chat: Chat, currentUserId?: number): string { - const chatKind = chat.chat_type || chat.type; - const rawName = (chat.name || "").trim(); - - if (chatKind === "direct" || chatKind === "private" || !rawName || rawName.toLowerCase() === "диалог") { - if (chat.interlocutor?.name?.trim()) { - return chat.interlocutor.name.trim(); - } - - const detailsOther = getInterlocutorFromParticipantDetails(chat, currentUserId); - if (detailsOther?.name?.trim()) { - return detailsOther.name.trim(); - } - - const other = getInterlocutorFromParticipants(chat, currentUserId); - if (other && typeof other === "object") { - const name = getUserFullName(other.last_name, other.first_name); - if (name) return name; - if (other.email) return other.email; - } - } - - return rawName || "Диалог"; -} - -function getChatAvatar(chat: Chat, currentUserId?: number): string { - const chatKind = chat.chat_type || chat.type; - if (chatKind === "direct" || chatKind === "private" || (chat.name || "").trim().toLowerCase() === "диалог") { - if (chat.interlocutor?.avatar) return chat.interlocutor.avatar; - - const detailsOther = getInterlocutorFromParticipantDetails(chat, currentUserId); - if (detailsOther?.avatar) return detailsOther.avatar; - - const other = getInterlocutorFromParticipants(chat, currentUserId); - if (other?.avatar) return other.avatar; - } - return chat.avatar || ""; -} - -function getChatInitials(chat: Chat, currentUserId?: number): string { - const title = getChatTitle(chat, currentUserId); - return ( - title - .split(" ") - .filter(Boolean) - .slice(0, 2) - .map((p) => p[0]?.toUpperCase() || "") - .join("") || "Ч" - ); -} - -function getMessageInitials(message: Message): string { - const name = (message.author_name || "").trim(); - if (!name) return "С"; - const parts = name.split(" ").filter(Boolean); - if (parts.length === 1) { - return parts[0].slice(0, 2).toUpperCase(); - } - return `${parts[0][0] || ""}${parts[1][0] || ""}`.toUpperCase(); -} - -function formatTime(date?: string): string { - if (!date) return ""; - const d = new Date(date); - if (Number.isNaN(d.getTime())) return ""; - return d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }); -} - -function getMessageDate(message: Message): Date | null { - if (message.created_at) { - const d = new Date(message.created_at); - return Number.isNaN(d.getTime()) ? null : d; - } - - if (message.created_ts) { - const d = new Date(message.created_ts); - return Number.isNaN(d.getTime()) ? null : d; - } - - return null; -} - -function formatDayDivider(date: Date): string { - return date.toLocaleDateString("ru-RU", { - day: "2-digit", - month: "long", - year: "numeric", - }); -} - -function messageTime(message: Message): string { - const d = getMessageDate(message); - if (!d) return ""; - - const now = new Date(); - const isToday = d.toDateString() === now.toDateString(); - if (isToday) { - return d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }); - } - - return d.toLocaleString("ru-RU", { - day: "2-digit", - month: "2-digit", - hour: "2-digit", - minute: "2-digit", - }); -} - -function isSameDay(a: Date | null, b: Date | null): boolean { - if (!a || !b) return false; - return ( - a.getFullYear() === b.getFullYear() && - a.getMonth() === b.getMonth() && - a.getDate() === b.getDate() - ); -} - -function formatFileSize(size?: number): string { - if (!size || size <= 0) return ""; - const kb = size / 1024; - if (kb < 1024) return `${Math.round(kb)} КБ`; - const mb = kb / 1024; - return `${mb.toFixed(1)} МБ`; -} - -function isImageAttachment(att: MessageAttachment): boolean { - const mime = (att.mime_type || "").toLowerCase(); - const type = (att.file_type || "").toLowerCase(); - const name = (att.file_name || "").toLowerCase(); - const byExt = /\.(png|jpe?g|gif|webp|bmp|svg|avif)$/i.test(name); - return mime.startsWith("image/") || type === "image" || byExt; -} - -function isVideoAttachment(att: MessageAttachment): boolean { - const mime = (att.mime_type || "").toLowerCase(); - const type = (att.file_type || "").toLowerCase(); - const name = (att.file_name || "").toLowerCase(); - const byExt = /\.(mp4|webm|mov|m4v|avi|mkv|3gp|mpeg|mpg)$/i.test(name); - return mime.startsWith("video/") || type === "video" || byExt; -} - -function isAudioAttachment(att: MessageAttachment): boolean { - const mime = (att.mime_type || "").toLowerCase(); - const type = (att.file_type || "").toLowerCase(); - return mime.startsWith("audio/") || type === "audio"; -} - -function normalizePossiblyEncodedUrl(value: string): string { - const input = (value || "").trim(); - if (!input) return ""; - - try { - // В API URL может приходить уже закодированным (%D0...). - // Декодируем один раз и кодируем обратно, чтобы избежать %25D0... (double-encoding). - return encodeURI(decodeURI(input)); - } catch { - return encodeURI(input); - } -} - -function uniqueMessagesById(items: Message[]): Message[] { - const map = new Map(); - items.forEach((msg) => { - map.set(msg.id, msg); - }); - return Array.from(map.values()); -} - -function getMessagePreviewText(message: Message): string { - if (message.content?.trim()) return message.content.trim(); - if (message.attachments?.length) return "[Вложение]"; - return "[Сообщение]"; -} - -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") { - return message.reply_to.id; - } - if (message.reply_to_message && typeof message.reply_to_message.id === "number") { - return message.reply_to_message.id; - } - return null; -} type ReplyTarget = { id: number; @@ -219,6 +27,11 @@ type ReplyTarget = { preview: string; }; +type MessageActionsAnchor = { + x: number; + y: number; +}; + const RECENT_REACTIONS_KEY = "eusrr_recent_reactions"; const MAX_RECENT_REACTIONS = 5; const ALL_REACTIONS = [ @@ -245,6 +58,7 @@ export default function MessageDialogPage() { const params = useParams<{ chatId: string }>(); const chatId = Number(params.chatId); const { user, loading: userLoading } = useUser(); + const { handleReconnectExhausted, resetReloadGuard } = useSilentChatReloadGuard(chatId || null); const [chat, setChat] = useState(null); const [messages, setMessages] = useState([]); @@ -263,7 +77,7 @@ export default function MessageDialogPage() { const [attachedFiles, setAttachedFiles] = useState([]); const [brokenMedia, setBrokenMedia] = useState>({}); const [useOriginalImage, setUseOriginalImage] = useState>({}); - const [mediaPreview, setMediaPreview] = useState<{ type: "image" | "video"; src: string; name: string } | null>(null); + const [mediaPreview, setMediaPreview] = useState(null); const [floatingDate, setFloatingDate] = useState(null); const [showFloatingDate, setShowFloatingDate] = useState(false); const [showScrollToBottom, setShowScrollToBottom] = useState(false); @@ -285,6 +99,7 @@ export default function MessageDialogPage() { const anchorOlderLoadAttemptsRef = useRef(0); const [anchorRetryTick, setAnchorRetryTick] = useState(0); const hasMoreNewerRef = useRef(false); + const fallbackSyncInFlightRef = useRef(false); // Локальное состояние для настроек чата const [isPinned, setIsPinned] = useState(false); @@ -335,11 +150,32 @@ export default function MessageDialogPage() { ); // WebSocket для real-time обновлений - const { isConnected, sendTyping, handlers, reconnectAttempts } = useWebSocket({ + const { isConnected, sendTyping, handlers } = useWebSocket({ chatId: chatId || null, - autoConnect: true + autoConnect: true, + onReconnectExhausted: handleReconnectExhausted, }); + useEffect(() => { + if (!chatId || Number.isNaN(chatId)) { + return; + } + + wsManager.setActiveChat(chatId); + + return () => { + wsManager.clearActiveChat(chatId); + }; + }, [chatId]); + + useEffect(() => { + if (!isConnected) { + return; + } + + resetReloadGuard(); + }, [isConnected, resetReloadGuard]); + // Проверка, находится ли пользователь внизу списка const isNearBottom = useCallback(() => { const component = messagesViewportRef.current; @@ -359,6 +195,62 @@ export default function MessageDialogPage() { component.scrollToBottom(smooth ? 'smooth' : 'auto'); }, []); + const syncLatestMessages = useCallback(async ( + options: { limit?: number; scrollOnSync?: boolean } = {} + ) => { + if ( + !chatId || + Number.isNaN(chatId) || + messagesLoading || + messages.length === 0 || + fallbackSyncInFlightRef.current + ) { + return; + } + + const newestMessage = messages[messages.length - 1]; + const shouldMarkRead = isNearBottom(); + + fallbackSyncInFlightRef.current = true; + + try { + const response = await apiClient.getChatMessages(chatId, { + limit: options.limit ?? 20, + after_id: newestMessage.id, + mark_read: shouldMarkRead, + }); + + const newerMessages = response.messages || []; + if (newerMessages.length > 0) { + setMessages((prev) => uniqueMessagesById([...prev, ...newerMessages])); + + if (shouldMarkRead) { + const lastLoadedMessage = newerMessages[newerMessages.length - 1]; + setChat((prev) => + prev + ? { + ...prev, + last_read_message_id: Math.max(prev.last_read_message_id ?? 0, lastLoadedMessage.id), + } + : prev + ); + } + + if (shouldMarkRead && options.scrollOnSync) { + requestAnimationFrame(() => { + scrollToBottom(false); + }); + } + } + + setHasMoreNewer(Boolean(response.has_more)); + } catch (e) { + console.error("Ошибка тихой синхронизации сообщений:", e); + } finally { + fallbackSyncInFlightRef.current = false; + } + }, [chatId, isNearBottom, messages, messagesLoading, scrollToBottom]); + // Обновление reactions_summary для конкретного сообщения const updateMessageReactionsSummary = useCallback(( messageId: number, @@ -373,6 +265,10 @@ export default function MessageDialogPage() { handlers.current.onMessage = (data) => { if (data.type === 'new_message') { // Новое сообщение + if (!data.message) { + return; + } + const newMsg = data.message; const isMyMessage = newMsg.author_id === user?.id; const wasNearBottom = isNearBottom(); @@ -434,6 +330,10 @@ export default function MessageDialogPage() { } else if (data.type === 'message_edited') { // Редактирование сообщения + if (!data.message) { + return; + } + const editedMsg = data.message; setMessages(prev => prev.map(m => m.id === editedMsg.id ? { ...m, ...editedMsg } : m @@ -468,20 +368,35 @@ export default function MessageDialogPage() { } else if (data.type === 'reaction_added') { // Реакция добавлена - if (data.reactions_summary && data.message_id) { + if (data.reactions_summary && typeof data.message_id === 'number') { updateMessageReactionsSummary(data.message_id, data.reactions_summary); } } else if (data.type === 'reaction_removed') { // Реакция удалена - if (data.reactions_summary && data.message_id) { + if (data.reactions_summary && typeof data.message_id === 'number') { updateMessageReactionsSummary(data.message_id, data.reactions_summary); } } + else if (data.type === 'marked_read' && data.chat_id === chatId && typeof data.last_read_message_id === 'number') { + const lastReadMessageId = data.last_read_message_id; + + setChat((prev) => + prev + ? { + ...prev, + last_read_message_id: Math.max(prev.last_read_message_id ?? 0, lastReadMessageId), + } + : prev + ); + } }; handlers.current.onConnect = () => { - // WebSocket подключен + void syncLatestMessages({ + limit: 30, + scrollOnSync: isNearBottom(), + }); }; handlers.current.onDisconnect = () => { @@ -491,7 +406,7 @@ export default function MessageDialogPage() { handlers.current.onError = (error) => { console.error('❌ WebSocket error:', error); }; - }, [user?.id, chatId, isNearBottom, scrollToBottom, updateMessageReactionsSummary]); + }, [handlers, user?.id, chatId, isNearBottom, scrollToBottom, syncLatestMessages, updateMessageReactionsSummary]); // Очистка таймаута при размонтировании useEffect(() => { @@ -594,7 +509,7 @@ export default function MessageDialogPage() { setError(null); const loadedChat = await apiClient.getChat(chatId); setChat(loadedChat); - } catch (e: any) { + } catch { setChat(null); setError("Не удалось загрузить чат. Проверьте подключение и попробуйте снова."); } finally { @@ -603,7 +518,7 @@ export default function MessageDialogPage() { } loadChats(); - }, [chatId, userLoading, user?.id]); + }, [chatId, userLoading, user, user?.id]); useEffect(() => { async function loadMessages() { @@ -666,12 +581,13 @@ export default function MessageDialogPage() { anchorScrollAttemptsRef.current = 0; anchorOlderLoadAttemptsRef.current = 0; setAnchorRetryTick(0); - } catch (e: any) { - if (String(e?.message || "").includes("403")) { + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("403")) { setError("Нет доступа к этому чату"); setMessages([]); } else { - console.error("Ошибка загрузки сообщений:", e); + console.error("Ошибка загрузки сообщений:", error); } setHasMoreOlder(false); setHasMoreNewer(false); @@ -688,7 +604,7 @@ export default function MessageDialogPage() { } loadMessages(); - }, [chatId, userLoading, user?.id]); + }, [chatId, userLoading, user, user?.id]); // Скролл к якорю или вниз после загрузки сообщений // Anchor scroll используется при переходе по прямой ссылке на сообщение (например, из уведомления) @@ -761,7 +677,7 @@ export default function MessageDialogPage() { // ScrollableMessageList уже корректирует позицию автоматически через getSnapshotBeforeUpdate // Дублирующая корректировка вызывала "подергивание" при загрузке старых сообщений - const loadOlderMessages = async () => { + const loadOlderMessages = useCallback(async () => { if (!chatId || Number.isNaN(chatId) || loadingOlder || messagesLoading || messages.length === 0) { return; } @@ -795,25 +711,39 @@ export default function MessageDialogPage() { } finally { setLoadingOlder(false); } - }; + }, [allowOneOlderProbe, chatId, hasMoreOlder, loadingOlder, messages, messagesLoading]); - const loadNewerMessages = async () => { + const loadNewerMessages = useCallback(async () => { if (!chatId || Number.isNaN(chatId) || loadingNewer || messagesLoading || !hasMoreNewer || messages.length === 0) { return; } const newestMessage = messages[messages.length - 1]; + const shouldMarkRead = isNearBottom(); try { setLoadingNewer(true); const response = await apiClient.getChatMessages(chatId, { limit: 10, // Ограничено для минимальной погрешности автоотметки (было 40) after_id: newestMessage.id, + mark_read: shouldMarkRead, }); const newerMessages = response.messages || []; if (newerMessages.length > 0) { setMessages((prev) => uniqueMessagesById([...prev, ...newerMessages])); + + if (shouldMarkRead) { + const lastLoadedMessage = newerMessages[newerMessages.length - 1]; + setChat((prev) => + prev + ? { + ...prev, + last_read_message_id: Math.max(prev.last_read_message_id ?? 0, lastLoadedMessage.id), + } + : prev + ); + } // НЕ делаем автоскролл - ScrollableMessageList сам решит: // - Если был внизу (sticky) → автоматически проскроллит вниз @@ -827,7 +757,18 @@ export default function MessageDialogPage() { } finally { setLoadingNewer(false); } - }; + }, [chatId, hasMoreNewer, isNearBottom, loadingNewer, messages, messagesLoading]); + + useChatFallbackSync({ + chatId: chatId || null, + isConnected, + userLoading, + hasUser: Boolean(user), + messagesLoading, + hasMessages: messages.length > 0, + isNearBottom, + syncLatestMessages, + }); useEffect(() => { const component = messagesViewportRef.current; @@ -884,7 +825,7 @@ export default function MessageDialogPage() { viewport.addEventListener("scroll", onScroll, { passive: true }); return () => viewport.removeEventListener("scroll", onScroll); - }, [chatId, hasMoreOlder, hasMoreNewer, loadingOlder, loadingNewer, messagesLoading, messages]); + }, [chatId, hasMoreOlder, hasMoreNewer, isNearBottom, loadNewerMessages, loadOlderMessages, loadingOlder, loadingNewer, messagesLoading, messages]); const handleSend = async () => { const text = messageText.trim(); @@ -1026,6 +967,33 @@ export default function MessageDialogPage() { } }; + const handleToggleMessageActions = useCallback((messageId: number, anchor: MessageActionsAnchor) => { + setExpandedReplyActionForId((prev) => { + if (prev === messageId) { + setActionsMenuAnchor(null); + return null; + } + + setReactionPickerForMessageId(null); + setShowComposerEmojiPicker(false); + setActionsMenuAnchor(anchor); + return messageId; + }); + }, []); + + const handleAttachmentLoad = useCallback((attachmentId: number) => { + setBrokenMedia((prev) => ({ ...prev, [attachmentId]: false })); + messagesViewportRef.current?.updateScrollPosition(); + }, []); + + const handleAttachmentError = useCallback((attachmentId: number) => { + setBrokenMedia((prev) => ({ ...prev, [attachmentId]: true })); + }, []); + + const handleUseOriginalImage = useCallback((attachmentId: number) => { + setUseOriginalImage((prev) => ({ ...prev, [attachmentId]: true })); + }, []); + const handleTogglePin = async () => { if (!chatId) return; @@ -1152,91 +1120,18 @@ export default function MessageDialogPage() { ) : (
- {/* Предупреждение о WebSocket */} - {!isConnected && reconnectAttempts > 2 && ( -
-
-
-

⚠️ Real-time обновления отключены

-

- Backend не поддерживает WebSocket. Запустите через Daphne: -

- - cd backend && ..\\.venv\\Scripts\\daphne -p 9000 eusrr_backend.asgi:application - -
- -
-
- )} -
{chat ? ( <> -
-
-
- {getChatAvatar(chat, user?.id) ? ( - {getChatTitle(chat, - ) : ( - getChatInitials(chat, user?.id) - )} -
- -
-

{getChatTitle(chat, user?.id)}

-
-

{(chat.type || chat.chat_type) === "group" ? "Групповой чат" : "Диалог"}

- -
- -
- {/* Кнопка закрепления чата */} - - - {/* Кнопка управления уведомлениями */} - - - {/* Кнопка возврата к списку */} - - - -
-
+
{/* Плавающая дата */} @@ -1266,269 +1161,29 @@ export default function MessageDialogPage() { {loadingOlder ? (

Подгружаем старые сообщения...

) : null} - {messages.map((message, index) => { - const currentDate = getMessageDate(message); - const prevDate = index > 0 ? getMessageDate(messages[index - 1]) : null; - const showDayDivider = index === 0 || !isSameDay(currentDate, prevDate); + {messages.map((message) => { const replyToId = getReplyToId(message); const repliedMessage = replyToId ? messagesById.get(replyToId) : null; - const isReplyMenuOpen = expandedReplyActionForId === message.id; - - const isMine = - user?.id && - (message.author_id === user.id || message.author?.id === user.id || message.sender?.id === user.id); - const canManage = canManageMessage(message); - const canReply = !message.is_deleted; - const hasActions = canReply || canManage; return ( - -
-
- {!isMine ? ( -
-
- {message.avatar || message.author?.avatar ? ( - {message.author_name - ) : ( - getMessageInitials(message) - )} -
-
- ) : null} - -
-
- {hasActions ? ( -
- -
- ) : null} - - {!isMine ? ( -

- {message.author_name || message.author?.last_name || message.sender?.last_name || "Сотрудник"} -

- ) : null} - - {replyToId ? ( -
-

{repliedMessage?.author_name || "Ответ на сообщение"}

-

{repliedMessage ? getMessagePreviewText(repliedMessage) : `Сообщение #${replyToId}`}

-
- ) : null} - - {message.is_deleted ? ( -

Сообщение удалено

- ) : message.content ? ( -

{message.content}

- ) : null} - - {message.attachments && message.attachments.length > 0 ? ( -
- {message.attachments.map((att) => ( -
- {isImageAttachment(att) ? ( - brokenMedia[att.id] ? ( - - - Предпросмотр недоступен — открыть файл - - ) : ( - - ) - ) : isVideoAttachment(att) ? ( - brokenMedia[att.id] ? ( - - - - Видео не поддерживается в браузере — открыть файл - - - ) : ( - - ) - ) : isAudioAttachment(att) ? ( - brokenMedia[att.id] ? ( - - - Аудио не поддерживается — открыть файл - - ) : ( - - ) - ) : ( - - - {att.file_name} - {formatFileSize(att.file_size)} - - )} -
- ))} -
- ) : null} - -

- {messageTime(message)} -

- - {Object.keys(message.reactions_summary || {}).length > 0 ? ( -
- {Object.entries(message.reactions_summary || {}).map(([emoji, meta]) => { - const mine = hasMyReaction(message, emoji); - return ( - - ); - })} -
- ) : null} -
- -
-
-
-
+ ); })}
@@ -1556,7 +1211,6 @@ export default function MessageDialogPage() { )}
- {/* Индикатор "печатает..." */} {isTyping && (
@@ -1568,155 +1222,38 @@ export default function MessageDialogPage() {
)} - {editingMessageId ? ( -
-
-

Режим редактирования

-

Измените текст сообщения и отправьте

-
- -
- ) : null} - - {replyTo ? ( -
-
-

Ответ: {replyTo.author}

-

{replyTo.preview}

-
- -
- ) : null} - - {attachedFiles.length > 0 ? ( -
- {attachedFiles.map((file, index) => ( - - {file.name} - - - ))} -
- ) : null} - - {canSendMessages ? ( -
- - - -
- - - {showComposerEmojiPicker ? ( -
-
- {ALL_REACTIONS.map((emoji) => ( - - ))} -
-
- ) : null} - -