From 35a72501839bdf14d7cbc6540ae8ec2a27f0202b Mon Sep 17 00:00:00 2001 From: lizerk Date: Tue, 14 Apr 2026 23:11:32 +0300 Subject: [PATCH] refactor(ui): polish chat, requests, and profile actions --- backend/api/v1/employees/views/employees.py | 4 +- backend/api/v1/requests_app/serializers.py | 17 +- backend/communications/api/serializers.py | 10 +- backend/communications/api/viewsets.py | 28 +-- backend/communications/models.py | 30 ++- .../communications/notifications/handlers.py | 21 ++ backend/communications/serialization.py | 16 +- backend/requests_app/notifications/signals.py | 16 ++ .../communications/test_communications_api.py | 9 + .../tests/api/v1/employees/test_employees.py | 13 ++ .../api/v1/requests_app/test_requests_api.py | 88 +++++++++ .../tests/realtime/test_websocket_events.py | 8 +- frontend/public/favicon-source.svg | 47 +++++ frontend/src/app/departments/[id]/page.tsx | 4 +- frontend/src/app/favicon.ico | Bin 15086 -> 12912 bytes frontend/src/app/messages/[chatId]/page.tsx | 53 ++--- frontend/src/app/messages/page.tsx | 8 +- frontend/src/app/requests/page.tsx | 11 ++ frontend/src/app/users/[id]/page.tsx | 182 ++++++++++++++---- frontend/src/components/AppShell.tsx | 8 +- .../components/messages/ChatDialogHeader.tsx | 2 +- .../components/messages/ChatMessageItem.tsx | 118 ++++++++++-- .../messages/MessageActionsMenu.tsx | 15 +- .../requests/RequestDetailModal.tsx | 88 ++++++++- .../components/requests/RequestListItem.tsx | 11 +- .../src/components/requests/requestActions.ts | 38 ++++ .../users/ChangeUserPositionModal.tsx | 105 ++++++++++ frontend/src/hooks/useUserDetailPage.ts | 65 ++++++- frontend/src/lib/api/employees.ts | 1 + frontend/src/types/api.ts | 5 + 30 files changed, 840 insertions(+), 181 deletions(-) create mode 100644 frontend/public/favicon-source.svg create mode 100644 frontend/src/components/requests/requestActions.ts create mode 100644 frontend/src/components/users/ChangeUserPositionModal.tsx diff --git a/backend/api/v1/employees/views/employees.py b/backend/api/v1/employees/views/employees.py index 0ae78d11..d1ca627f 100644 --- a/backend/api/v1/employees/views/employees.py +++ b/backend/api/v1/employees/views/employees.py @@ -65,7 +65,9 @@ class EmployeeViewSet(viewsets.ModelViewSet): def get_permissions(self): if self.action == "create": return [IsAuthenticated(), AdminOrActionOrModelPerms()] - if self.action in {"update", "partial_update", "destroy"}: + if self.action == "destroy": + return [IsAuthenticated(), AdminOrActionOrModelPerms()] + if self.action in {"update", "partial_update"}: return [ IsAuthenticated(), (IsSelfOrStaff | AdminOrActionOrModelPerms)(), diff --git a/backend/api/v1/requests_app/serializers.py b/backend/api/v1/requests_app/serializers.py index d1b21d83..b649b4a6 100644 --- a/backend/api/v1/requests_app/serializers.py +++ b/backend/api/v1/requests_app/serializers.py @@ -9,6 +9,9 @@ from employees.models import Department from requests_app.enums import RequestStatus from requests_app.enums import RequestType from requests_app.models import Request +from requests_app.notifications.signals import ( + dispatch_pending_new_request_notifications, +) from rest_framework import serializers from ..employees.serializers import EmployeeBriefSerializer @@ -470,11 +473,17 @@ class RequestWriteSerializer(serializers.ModelSerializer): ) # Устанавливаем связи ManyToMany - if departments: - request_obj.departments.set(departments) + request_obj._suppress_new_request_notifications = True + try: + if departments: + request_obj.departments.set(departments) - self._set_recipients(request_obj, recipient_ids, is_cc=False) - self._set_recipients(request_obj, cc_user_ids, is_cc=True) + self._set_recipients(request_obj, recipient_ids, is_cc=False) + self._set_recipients(request_obj, cc_user_ids, is_cc=True) + finally: + request_obj._suppress_new_request_notifications = False + + dispatch_pending_new_request_notifications(request_obj) # Проверяем что сохранилось recipients_count = request_obj.recipients.count() diff --git a/backend/communications/api/serializers.py b/backend/communications/api/serializers.py index 5d497d4b..584a222b 100644 --- a/backend/communications/api/serializers.py +++ b/backend/communications/api/serializers.py @@ -631,15 +631,7 @@ class MessageListSerializer(serializers.ModelSerializer): @extend_schema_field(OpenApiTypes.OBJECT) def get_reactions_summary(self, obj): """Суммарная информация о реакциях""" - reactions = {} - for reaction in obj.reactions.all(): - emoji = reaction.emoji - if emoji not in reactions: - reactions[emoji] = {"count": 0, "users": [], "user_names": []} - reactions[emoji]["count"] += 1 - reactions[emoji]["users"].append(reaction.user_id) - reactions[emoji]["user_names"].append(reaction.user.get_full_name()) - return reactions + return obj.get_reactions_summary() class MessageDetailSerializer(serializers.ModelSerializer): diff --git a/backend/communications/api/viewsets.py b/backend/communications/api/viewsets.py index e5db6be3..9933301b 100644 --- a/backend/communications/api/viewsets.py +++ b/backend/communications/api/viewsets.py @@ -1493,19 +1493,7 @@ class MessageViewSet(viewsets.ModelViewSet): else: logger.info(f"[react] Created new reaction, id={reaction.id}") - # Пересчитываем reactions_summary - reactions_summary = {} - for r in message.reactions.select_related('user'): - if r.emoji not in reactions_summary: - reactions_summary[r.emoji] = { - 'count': 0, - 'users': [], - 'user_names': [] - } - reactions_summary[r.emoji]['count'] += 1 - reactions_summary[r.emoji]['users'].append(r.user_id) - reactions_summary[r.emoji]['user_names'].append( - r.user.get_full_name()) + reactions_summary = message.get_reactions_summary() logger.info(f"[react] reactions_summary: {reactions_summary}") @@ -1558,19 +1546,7 @@ class MessageViewSet(viewsets.ModelViewSet): status=status.HTTP_404_NOT_FOUND ) - # Пересчитываем reactions_summary - reactions_summary = {} - for r in message.reactions.select_related('user'): - if r.emoji not in reactions_summary: - reactions_summary[r.emoji] = { - 'count': 0, - 'users': [], - 'user_names': [] - } - reactions_summary[r.emoji]['count'] += 1 - reactions_summary[r.emoji]['users'].append(r.user_id) - reactions_summary[r.emoji]['user_names'].append( - r.user.get_full_name()) + reactions_summary = message.get_reactions_summary() logger.info(f"[unreact] reactions_summary: {reactions_summary}") diff --git a/backend/communications/models.py b/backend/communications/models.py index 08cb0ee1..a318d1fd 100644 --- a/backend/communications/models.py +++ b/backend/communications/models.py @@ -546,8 +546,13 @@ class Message(models.Model): Получить сводку по реакциям для этого сообщения Возвращает словарь вида: { - '👍': {'count': 3, 'users': [1, 2, 3], 'user_names': ['User1', ...]}, - '❤️': {'count': 1, 'users': [4], 'user_names': ['User4']} + '👍': { + 'count': 3, + 'users': [1, 2, 3], + 'user_names': ['User1', ...], + 'user_details': [{'id': 1, 'name': 'User1', 'avatar': '/media/...'}] + }, + '❤️': {'count': 1, 'users': [4], 'user_names': ['User4'], 'user_details': [...]} } """ # Используем related_name='reactions' из MessageReaction @@ -557,11 +562,26 @@ class Message(models.Model): for reaction in reactions_qs: emoji = reaction.emoji if emoji not in summary: - summary[emoji] = {"count": 0, "users": [], "user_names": []} + summary[emoji] = { + "count": 0, + "users": [], + "user_names": [], + "user_details": [], + } + user_name = reaction.user.get_full_name() or reaction.user.username summary[emoji]["count"] += 1 summary[emoji]["users"].append(reaction.user_id) - summary[emoji]["user_names"].append( - reaction.user.get_full_name() or reaction.user.username + summary[emoji]["user_names"].append(user_name) + summary[emoji]["user_details"].append( + { + "id": reaction.user_id, + "name": user_name, + "avatar": ( + reaction.user.avatar.url + if getattr(reaction.user, "avatar", None) + else None + ), + } ) return summary diff --git a/backend/communications/notifications/handlers.py b/backend/communications/notifications/handlers.py index 39ab2fb6..dfc66ddc 100644 --- a/backend/communications/notifications/handlers.py +++ b/backend/communications/notifications/handlers.py @@ -272,6 +272,27 @@ def _notify_comment(message): object_author = getattr(context_obj, 'employee', None) object_url = f"/requests?request={context_obj.id}" object_type = "заявки" + recipient_ids.update( + context_obj.recipients.filter(is_active=True).values_list( + "id", flat=True + ) + ) + recipient_ids.update( + context_obj.cc_users.filter(is_active=True).values_list( + "id", flat=True + ) + ) + if getattr(context_obj, "approver_id", None): + recipient_ids.add(context_obj.approver_id) + if getattr(context_obj, "sent_to_all_department", False): + from employees.models import EmployeeDepartment + + dept_employee_ids = EmployeeDepartment.objects.filter( + department__in=context_obj.departments.all(), + is_active=True, + employee__is_active=True, + ).values_list("employee_id", flat=True) + recipient_ids.update(dept_employee_ids) # 1. Уведомляем автора объекта if object_author and object_author.id != author.id: diff --git a/backend/communications/serialization.py b/backend/communications/serialization.py index bc7d8d73..dfe32938 100644 --- a/backend/communications/serialization.py +++ b/backend/communications/serialization.py @@ -221,21 +221,7 @@ def serialize_message(m) -> dict: pass # Реакции - сериализуем из связанной модели MessageReaction - reactions_summary = {} - for reaction in m.reactions.select_related("user"): - emoji = reaction.emoji - if emoji not in reactions_summary: - reactions_summary[emoji] = { - "count": 0, - "users": [], - "user_names": [], - } - reactions_summary[emoji]["count"] += 1 - reactions_summary[emoji]["users"].append(reaction.user_id) - reactions_summary[emoji]["user_names"].append( - reaction.user.get_full_name() or reaction.user.username - ) - data["reactions_summary"] = reactions_summary + data["reactions_summary"] = m.get_reactions_summary() # Вложения - всегда включаем поле attachments attachments = [] diff --git a/backend/requests_app/notifications/signals.py b/backend/requests_app/notifications/signals.py index 0b6df519..b0affa28 100644 --- a/backend/requests_app/notifications/signals.py +++ b/backend/requests_app/notifications/signals.py @@ -22,6 +22,18 @@ logger = logging.getLogger(__name__) _pending_new_requests = set() +def dispatch_pending_new_request_notifications(instance: Request) -> None: + """Отправляет отложенные уведомления о новой заявке ровно один раз. + + Используется, когда recipients/cc_users выставляются пачкой в serializer + и m2m-сигналы нужно временно подавить, чтобы не отправлять уведомление + до установки полного списка адресатов. + """ + if instance.id in _pending_new_requests: + _pending_new_requests.discard(instance.id) + notify_new_request(instance) + + @receiver(post_save, sender=Request) def create_request_notifications(sender, instance, created, **kwargs): """ @@ -93,6 +105,8 @@ def notify_on_recipients_changed(sender, instance, action, **kwargs): Срабатывает когда recipients устанавливаются через .set(), .add() и т.д. """ try: + if getattr(instance, "_suppress_new_request_notifications", False): + return # Проверяем что это завершение операции установки recipients if action == "post_add" and instance.id in _pending_new_requests: logger.info( @@ -120,6 +134,8 @@ def notify_on_cc_users_changed(sender, instance, action, **kwargs): также отправляем уведомления. """ try: + if getattr(instance, "_suppress_new_request_notifications", False): + return # Если это завершение добавления cc_users И заявление все еще ожидает # уведомлений if action == "post_add" and instance.id in _pending_new_requests: diff --git a/backend/tests/api/v1/communications/test_communications_api.py b/backend/tests/api/v1/communications/test_communications_api.py index e3051087..1f3a1311 100644 --- a/backend/tests/api/v1/communications/test_communications_api.py +++ b/backend/tests/api/v1/communications/test_communications_api.py @@ -518,6 +518,8 @@ class TestMessageViewSet: def test_react_to_message(self, auth_client, private_chat, user1): """Добавление реакции на сообщение""" + user1.avatar = "avatars/reaction-user1.jpg" + user1.save(update_fields=["avatar"]) message = Message.objects.create( chat=private_chat, author=user1, content="React to this" ) @@ -529,6 +531,13 @@ class TestMessageViewSet: assert response.status_code == status.HTTP_200_OK assert response.data["ok"] is True assert "👍" in response.data["reactions_summary"] + assert response.data["reactions_summary"]["👍"]["user_details"] == [ + { + "id": user1.id, + "name": user1.get_full_name(), + "avatar": user1.avatar.url, + } + ] def test_unreact_to_message(self, auth_client, private_chat, user1): """Удаление реакции с сообщения""" diff --git a/backend/tests/api/v1/employees/test_employees.py b/backend/tests/api/v1/employees/test_employees.py index 236d6147..5a6ce9a9 100644 --- a/backend/tests/api/v1/employees/test_employees.py +++ b/backend/tests/api/v1/employees/test_employees.py @@ -321,6 +321,19 @@ def test_destroy_requires_admin(api_client: APIClient): assert resp.status_code == status.HTTP_204_NO_CONTENT assert not Employee.objects.filter(pk=victim.pk).exists() + +def test_destroy_self_forbidden_for_regular_user(api_client: APIClient): + me = make_user("self-delete@example.com") + api_client.force_authenticate(user=me) + url = reverse("api:v1:employees-detail", args=[me.pk]) + + resp = api_client.delete(url) + assert resp.status_code in ( + status.HTTP_403_FORBIDDEN, + status.HTTP_401_UNAUTHORIZED, + ) + assert Employee.objects.filter(pk=me.pk).exists() + # ---------- action: me (GET/PATCH) ---------- def test_me_get_returns_current_user(api_client: APIClient): diff --git a/backend/tests/api/v1/requests_app/test_requests_api.py b/backend/tests/api/v1/requests_app/test_requests_api.py index 5c3138a1..11d16e9e 100644 --- a/backend/tests/api/v1/requests_app/test_requests_api.py +++ b/backend/tests/api/v1/requests_app/test_requests_api.py @@ -4,6 +4,7 @@ from typing import Any import pytest from django.db import models +from notifications.models import Notification from rest_framework.test import APIClient from requests_app.enums import RequestStatus, RequestType @@ -182,6 +183,36 @@ def test_create_regular_user_and_admin_forced_to_self( ) +def test_create_request_sends_notification_to_cc_users( + auth_client, regular_user: models.Model, make_user +) -> None: + """При создании заявки через API пользователь в копии получает request_new.""" + recipient = make_user(email="create-cc-recipient@example.com") + cc_user = make_user(email="create-cc-user@example.com") + + Notification.objects.all().delete() + + resp = auth_client(regular_user).post( + API_BASE, + data={ + "type": RequestType.OTHER, + "title": "Заявка с копией", + "comment": "Проверка уведомления для копии", + "recipient_ids": [recipient.id], + "cc_user_ids": [cc_user.id], + }, + format="json", + ) + + assert resp.status_code == 201 + cc_notification = Notification.objects.filter( + recipient=cc_user, + verb="request_new", + ).first() + assert cc_notification is not None + assert cc_notification.data["is_cc"] is True + + def test_create_draft_allows_minimal_payload( auth_client, regular_user: models.Model ) -> None: @@ -338,6 +369,33 @@ def test_admin_can_decide_only_if_explicitly_added_to_recipients( assert resp.json()["status"] == RequestStatus.REJECTED +def test_cc_user_receives_status_notification( + auth_client, make_user, make_request +) -> None: + """Пользователь в копии получает уведомление о решении по заявке.""" + owner = make_user(email="status-cc-owner@example.com") + recipient = make_user(email="status-cc-recipient@example.com") + cc_user = make_user(email="status-cc-user@example.com") + + req = make_request(employee=owner, status=RequestStatus.PENDING) + req.recipients.add(recipient) + req.cc_users.add(cc_user) + + Notification.objects.all().delete() + + resp = auth_client(recipient).post( + f"{API_BASE}{req.id}/approve/", data={}, format="json" + ) + + assert resp.status_code == 200 + cc_notification = Notification.objects.filter( + recipient=cc_user, + verb="request_approved", + ).first() + assert cc_notification is not None + assert cc_notification.data["new_status"] == RequestStatus.APPROVED + + def test_draft_is_visible_only_to_author( auth_client, admin_user: models.Model, make_user, make_request ) -> None: @@ -512,6 +570,36 @@ def test_comments_available_to_participants_only( ).status_code == 403 +def test_cc_user_receives_comment_notification( + auth_client, make_user, make_request +) -> None: + """Пользователь в копии получает уведомление о новом комментарии к заявке.""" + owner = make_user(email="comment-cc-owner@example.com") + recipient = make_user(email="comment-cc-recipient@example.com") + cc_user = make_user(email="comment-cc-user@example.com") + + req = make_request(employee=owner) + req.recipients.add(recipient) + req.cc_users.add(cc_user) + + Notification.objects.all().delete() + + resp = auth_client(recipient).post( + f"{API_BASE}{req.id}/comments/", + data={"text": "Комментарий для проверки уведомлений"}, + format="json", + ) + + assert resp.status_code == 201 + cc_notification = Notification.objects.filter( + recipient=cc_user, + verb="commented", + ).first() + assert cc_notification is not None + assert cc_notification.data["object_type"] == "Request" + assert cc_notification.data["object_id"] == req.id + + def test_detail_exposes_can_decide_only_for_direct_recipients( auth_client, admin_user: models.Model, make_user, make_request ) -> None: diff --git a/backend/tests/realtime/test_websocket_events.py b/backend/tests/realtime/test_websocket_events.py index 6a219468..c9a70013 100644 --- a/backend/tests/realtime/test_websocket_events.py +++ b/backend/tests/realtime/test_websocket_events.py @@ -275,7 +275,12 @@ class TestReactionWebSocketEvents: '👍': { 'count': 1, 'users': [user2.id], - 'user_names': [user2.get_full_name()] + 'user_names': [user2.get_full_name()], + 'user_details': [{ + 'id': user2.id, + 'name': user2.get_full_name(), + 'avatar': None, + }], } } @@ -298,6 +303,7 @@ class TestReactionWebSocketEvents: assert response1["emoji"] == '👍' assert response1["user_id"] == user2.id assert '👍' in response1["reactions_summary"] + assert response1["reactions_summary"]["👍"]["user_details"][0]["id"] == user2.id # User2 тоже должен получить response2 = await comm2.receive_json_from(timeout=5) diff --git a/frontend/public/favicon-source.svg b/frontend/public/favicon-source.svg new file mode 100644 index 00000000..81f6422e --- /dev/null +++ b/frontend/public/favicon-source.svg @@ -0,0 +1,47 @@ + + + diff --git a/frontend/src/app/departments/[id]/page.tsx b/frontend/src/app/departments/[id]/page.tsx index ae57f351..e37aedbf 100644 --- a/frontend/src/app/departments/[id]/page.tsx +++ b/frontend/src/app/departments/[id]/page.tsx @@ -11,7 +11,7 @@ import { ChevronRight, Crown, Link2, - MessageSquare, + MessageCircle, PencilLine, Plus, Search, @@ -996,7 +996,7 @@ export default function DepartmentDetailPage() { disabled={openingDepartmentChat} className="app-action-primary inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-50" > - + Перейти в чат отдела diff --git a/frontend/src/app/favicon.ico b/frontend/src/app/favicon.ico index 5a0f9fdb77e0cf485baffa9f679ead92fbe820d1..4fde426aaa0d974afe12693e3428051eab0525fa 100644 GIT binary patch literal 12912 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 E0Wl~8D*ylh literal 15086 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 AGXMYp diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index 468e6f21..b4f48e96 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -8,7 +8,6 @@ import ChatDialogHeader from "@/components/messages/ChatDialogHeader"; import ChatMediaPreviewModal from "@/components/messages/ChatMediaPreviewModal"; import ChatMessageItem, { MediaPreview } from "@/components/messages/ChatMessageItem"; import ChatSearchPanel from "@/components/messages/ChatSearchPanel"; -import MessageActionsMenu from "@/components/messages/MessageActionsMenu"; import MessageReadersModal from "@/components/messages/MessageReadersModal"; import MessageComposer from "@/components/messages/MessageComposer"; import ReactionPickerModal from "@/components/messages/ReactionPickerModal"; @@ -29,8 +28,6 @@ import ScrollableMessageList, { ScrollableMessageListInner } from "@/components/ /* ─── types ─── */ type ReplyTarget = { id: number; author: string; preview: string }; -type MessageActionsAnchor = { x: number; y: number }; - /* ─── constants ─── */ const RECENT_REACTIONS_KEY = "eusrr_recent_reactions"; @@ -152,7 +149,6 @@ function MessageDialogPageContent() { const [editingMessageId, setEditingMessageId] = useState(null); const [replyTo, setReplyTo] = useState(null); const [expandedReplyActionForId, setExpandedReplyActionForId] = useState(null); - const [actionsMenuAnchor, setActionsMenuAnchor] = useState<{ x: number; y: number } | null>(null); const [isReadersModalOpen, setIsReadersModalOpen] = useState(false); const [reactionPickerForMessageId, setReactionPickerForMessageId] = useState(null); const [showComposerEmojiPicker, setShowComposerEmojiPicker] = useState(false); @@ -210,11 +206,6 @@ function MessageDialogPageContent() { /* ── action menu derived ── */ const selectedActionMessage = expandedReplyActionForId ? messagesById.get(expandedReplyActionForId) || null : null; - const selectedActionCanManage = Boolean( - selectedActionMessage && user?.id && !selectedActionMessage.is_deleted && - (selectedActionMessage.author_id === user.id || selectedActionMessage.author?.id === user.id || selectedActionMessage.sender?.id === user.id), - ); - const selectedActionCanReply = Boolean(selectedActionMessage && !selectedActionMessage.is_deleted); const selectedActionRecentReactions = useMemo(() => uniqueEmoji(recentReactions).slice(0, MAX_RECENT_REACTIONS), [recentReactions]); const selectedActionReaders = selectedActionMessage?.read_by || []; const sendingComposer = Boolean(editingMessageId && sending); @@ -257,6 +248,8 @@ function MessageDialogPageContent() { : await apiClient.reactToMessage(message.id, emoji); pushRecentReaction(emoji); updateMessageReactionsSummary(message.id, response.reactions_summary); + setExpandedReplyActionForId(null); + setReactionPickerForMessageId(null); } catch (e) { console.error("Ошибка реакции:", e); } @@ -589,7 +582,6 @@ function MessageDialogPageContent() { setReplyTo({ id: message.id, author, preview: getMessagePreviewText(message) }); setExpandedReplyActionForId(null); setIsReadersModalOpen(false); - setActionsMenuAnchor(null); requestAnimationFrame(focusMessageInput); }; @@ -615,19 +607,17 @@ function MessageDialogPageContent() { if (editingMessageId === message.id) { setEditingMessageId(null); setMessageText(""); } setExpandedReplyActionForId(null); setIsReadersModalOpen(false); - setActionsMenuAnchor(null); } catch (e) { console.error("Ошибка удаления сообщения:", e); } }; - const handleToggleMessageActions = useCallback((messageId: number, anchor: MessageActionsAnchor) => { + const handleToggleMessageActions = useCallback((messageId: number) => { setExpandedReplyActionForId(prev => { - if (prev === messageId) { setIsReadersModalOpen(false); setActionsMenuAnchor(null); return null; } + if (prev === messageId) { setIsReadersModalOpen(false); return null; } setIsReadersModalOpen(false); setReactionPickerForMessageId(null); setShowComposerEmojiPicker(false); - setActionsMenuAnchor(anchor); return messageId; }); }, []); @@ -694,7 +684,6 @@ function MessageDialogPageContent() { const openSearchPanel = useCallback(() => { setIsSearchOpen(true); setExpandedReplyActionForId(null); - setActionsMenuAnchor(null); setReactionPickerForMessageId(null); setShowComposerEmojiPicker(false); scroll.setIsDateNavigatorOpen(false); @@ -845,7 +834,6 @@ function MessageDialogPageContent() { if (jumped) { highlightFoundMessage(result.message_id); setExpandedReplyActionForId(null); - setActionsMenuAnchor(null); setReactionPickerForMessageId(null); setIsReadersModalOpen(false); } @@ -860,7 +848,6 @@ function MessageDialogPageContent() { if (jumped) { highlightFoundMessage(messageId); setExpandedReplyActionForId(null); - setActionsMenuAnchor(null); setReactionPickerForMessageId(null); setIsReadersModalOpen(false); } @@ -909,7 +896,6 @@ function MessageDialogPageContent() { if (!t) return; if (t.closest("[data-actions-menu='true'],[data-actions-trigger='true'],[data-reaction-picker='true'],.reaction-picker-modal,[data-composer-emoji='true'],[data-date-navigator='true'],[data-date-trigger='true']")) return; setExpandedReplyActionForId(null); - setActionsMenuAnchor(null); setReactionPickerForMessageId(null); setShowComposerEmojiPicker(false); scroll.setIsDateNavigatorOpen(false); @@ -923,7 +909,6 @@ function MessageDialogPageContent() { if (e.key === "Escape") { setMediaPreview(null); setExpandedReplyActionForId(null); - setActionsMenuAnchor(null); setReactionPickerForMessageId(null); setShowComposerEmojiPicker(false); scroll.setIsDateNavigatorOpen(false); @@ -1056,7 +1041,7 @@ function MessageDialogPageContent() { autoScrollToBottom={!cm.hasMoreNewer && !cm.historyNavigationMode} autoScrollToBottomOnMount={!cm.hasMoreNewer && !cm.historyNavigationMode} scrollBehavior="smooth" - className="min-h-0 flex-1 bg-[var(--surface-secondary)] p-3" + className="min-h-0 flex-1 bg-transparent p-3 lg:bg-[var(--surface-secondary)]" > {cm.messagesLoading ? (

Загрузка сообщений...

@@ -1102,6 +1087,13 @@ function MessageDialogPageContent() { onUseOriginalImage={handleUseOriginalImage} onReact={handleReact} hasMyReaction={hasMyReaction} + recentReactions={selectedActionRecentReactions} + onQuickReact={handleReact} + onOpenReactionPicker={(msg) => setReactionPickerForMessageId(msg.id)} + onShowAllReaders={() => setIsReadersModalOpen(true)} + onReply={handleReplyToMessage} + onEdit={handleStartEditMessage} + onDelete={handleDeleteMessage} /> ); @@ -1131,7 +1123,7 @@ function MessageDialogPageContent() { )} {/* Composer area */} -
+
{isTyping && (
@@ -1162,7 +1154,6 @@ function MessageDialogPageContent() { onToggleEmojiPicker={() => { setShowComposerEmojiPicker(prev => !prev); setExpandedReplyActionForId(null); - setActionsMenuAnchor(null); setReactionPickerForMessageId(null); }} onSelectEmoji={(emoji) => { appendEmojiToComposer(emoji); setShowComposerEmojiPicker(false); }} @@ -1190,23 +1181,6 @@ function MessageDialogPageContent() { {mediaPreview ? setMediaPreview(null)} /> : null} - {expandedReplyActionForId && actionsMenuAnchor && selectedActionMessage ? ( - { handleReact(selectedActionMessage, emoji); setExpandedReplyActionForId(null); setIsReadersModalOpen(false); setActionsMenuAnchor(null); }} - onOpenReactionPicker={() => setReactionPickerForMessageId(selectedActionMessage.id)} - onShowAllReaders={() => setIsReadersModalOpen(true)} - onReply={() => handleReplyToMessage(selectedActionMessage)} - onEdit={() => handleStartEditMessage(selectedActionMessage)} - onDelete={() => handleDeleteMessage(selectedActionMessage)} - /> - ) : null} - {selectedActionMessage ? ( setIsReadersModalOpen(false)} readers={selectedActionReaders} /> ) : null} @@ -1219,7 +1193,6 @@ function MessageDialogPageContent() { if (msg) void handleReact(msg, emoji); setReactionPickerForMessageId(null); setExpandedReplyActionForId(null); - setActionsMenuAnchor(null); }} /> ) : null} diff --git a/frontend/src/app/messages/page.tsx b/frontend/src/app/messages/page.tsx index 67246a19..3d1445d3 100644 --- a/frontend/src/app/messages/page.tsx +++ b/frontend/src/app/messages/page.tsx @@ -4,9 +4,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AppShell } from "../../components/AppShell"; import { apiClient } from "@/lib/api"; import { resolveMediaUrl } from "@/lib/url"; -import { ChatIdentity, getChatAvatar, getChatInitials, getChatTitle } from "@/lib/messages/chatUtils"; +import { ChatIdentity, getChatAvatar, getChatInitials, getChatTitle, isDepartmentCommentsChat } from "@/lib/messages/chatUtils"; import type { Chat } from "@/types/api"; -import { Search, MessageCircle, Pin, BellOff, Filter, Plus, X, Users, Globe, Radio, Image as ImageIcon, Megaphone, MessageSquare } from "lucide-react"; +import { Search, MessageCircle, Pin, BellOff, Filter, Plus, X, Users, Globe, Radio, Image as ImageIcon, Megaphone, MessageSquare, Building2 } from "lucide-react"; import Link from "next/link"; import Image from "next/image"; import { useUser } from "@/contexts/UserContext"; @@ -56,7 +56,9 @@ function getChatTypeIcon(chat: Chat) { case 'announcement': return ; case 'comments': - return ; + return isDepartmentCommentsChat(chat) + ? + : ; default: return null; } diff --git a/frontend/src/app/requests/page.tsx b/frontend/src/app/requests/page.tsx index 0cf6f05b..4a00943c 100644 --- a/frontend/src/app/requests/page.tsx +++ b/frontend/src/app/requests/page.tsx @@ -138,10 +138,21 @@ function RequestsPageContent() { )} { + screen.closeDetailsRequest(); + h.openEdit(request); + }} onPreviewAttachment={h.setAttachmentPreview} + onReject={h.handleReject} request={h.detailsRequest} /> diff --git a/frontend/src/app/users/[id]/page.tsx b/frontend/src/app/users/[id]/page.tsx index 2e04a340..55f9e352 100644 --- a/frontend/src/app/users/[id]/page.tsx +++ b/frontend/src/app/users/[id]/page.tsx @@ -2,9 +2,12 @@ import Link from "next/link"; import Image from "next/image"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { + BriefcaseBusiness, + ChevronRight, + History, Mail, MessageCircle, Pencil, @@ -12,6 +15,7 @@ import { } from "lucide-react"; import { AppShell } from "../../../components/AppShell"; +import ChangeUserPositionModal from "@/components/users/ChangeUserPositionModal"; import EditUserProfileModal from "@/components/users/EditUserProfileModal"; import EmployeeActionModal from "@/components/users/EmployeeActionModal"; import EmployeeActionsTimeline from "@/components/users/EmployeeActionsTimeline"; @@ -61,23 +65,32 @@ export default function UserDetailPage() { handleAvatarChange, handleCloseActionModal, handleCloseEditModal, + handleClosePositionModal, handleCopyToClipboard, handleDeleteAction, handleEditAction, handleOpenActionModal, handleOpenEditModal, + handleOpenPositionModal, handleSaveAction, handleSaveEdit, + handleSavePosition, handleStartChat, initials, isActionModalOpen, isEditModalOpen, + isPositionModalOpen, latestAction, loading, person, + positionValue, + positions, + positionsError, + positionsLoading, setActionField, setAvatarFailed, setEditField, + setPositionValue, sortedActions, } = useUserDetailPage(userId, currentUser); @@ -101,6 +114,8 @@ export default function UserDetailPage() { const [skillsLoading, setSkillsLoading] = useState(false); const [skillsSaving, setSkillsSaving] = useState(false); const [skillsError, setSkillsError] = useState(null); + const [profileMenuOpen, setProfileMenuOpen] = useState(false); + const profileMenuRef = useRef(null); useEffect(() => { setPersonSkills(person?.skills || []); @@ -138,6 +153,29 @@ export default function UserDetailPage() { }; }, []); + useEffect(() => { + if (!profileMenuOpen) return; + + const handlePointerDown = (event: MouseEvent) => { + if (profileMenuRef.current && !profileMenuRef.current.contains(event.target as Node)) { + setProfileMenuOpen(false); + } + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setProfileMenuOpen(false); + } + }; + + document.addEventListener("mousedown", handlePointerDown); + document.addEventListener("keydown", handleEscape); + return () => { + document.removeEventListener("mousedown", handlePointerDown); + document.removeEventListener("keydown", handleEscape); + }; + }, [profileMenuOpen]); + const contactRows = useMemo(() => { if (!person) return []; @@ -309,12 +347,80 @@ export default function UserDetailPage() { - {latestAction.action_display || latestAction.action} - + {latestAction ? ( + + {latestAction.action_display || latestAction.action} + + ) : null} + {(canEdit || canManageActions) ? ( + <> + + {profileMenuOpen ? ( +
+ {canEdit ? ( + + ) : null} + {canManageActions ? ( + + ) : null} + {canManageActions ? ( + + ) : null} +
+ ) : null} + + ) : null} +
) : null } avatar={ @@ -347,38 +453,29 @@ export default function UserDetailPage() { /> ) : undefined } - headerActions={ - canEdit ? ( - - ) : undefined - } actionRow={ - currentUser && currentUser.id !== person.id ? ( + currentUser && (currentUser.id !== person.id || canEdit) ? ( <> - - {person.phone_number ? ( - - - Позвонить - + {currentUser.id !== person.id ? ( + <> + + {person.phone_number ? ( + + + Позвонить + + ) : null} + ) : null} ) : undefined @@ -473,6 +570,19 @@ export default function UserDetailPage() { person={person} /> + + void; onOpenCalendar: () => void; }; @@ -47,7 +48,7 @@ const navItems = [ // { href: "/finances", label: "Финансы", icon: Wallet }, ]; -function Header({ mobileNavPlacement, onOpenLeftNav, onOpenCalendar }: HeaderProps) { +function Header({ mobileNavPlacement, suppressMobileChrome = false, onOpenLeftNav, onOpenCalendar }: HeaderProps) { const router = useRouter(); const { user, logout } = useUser(); const [searchQuery, setSearchQuery] = useState(""); @@ -113,7 +114,7 @@ function Header({ mobileNavPlacement, onOpenLeftNav, onOpenCalendar }: HeaderPro isBottomMobileNav ? "fixed inset-x-0 bottom-0 border-t lg:sticky lg:top-0 lg:bottom-auto lg:border-b lg:border-t-0" : "sticky top-0 border-b" - }`} + } ${suppressMobileChrome ? "hidden lg:block" : ""} shrink-0`} >
+
setIsMobileLeftNavOpen(true)} onOpenCalendar={() => setIsMobileCalendarOpen(true)} /> diff --git a/frontend/src/components/messages/ChatDialogHeader.tsx b/frontend/src/components/messages/ChatDialogHeader.tsx index add83a0a..a2343fd1 100644 --- a/frontend/src/components/messages/ChatDialogHeader.tsx +++ b/frontend/src/components/messages/ChatDialogHeader.tsx @@ -83,7 +83,7 @@ export default function ChatDialogHeader({ const subtitle = getChatSubtitle(chat); return ( -
+
; useOriginalImage: Record; - onToggleActions: (messageId: number, anchor: { x: number; y: number }) => void; + onToggleActions: (messageId: number) => void; onJumpToReply: (messageId: number) => void; onOpenMediaPreview: (preview: MediaPreview) => void; onAttachmentLoad: (attachmentId: number) => void; @@ -66,12 +69,26 @@ type ChatMessageItemProps = { onUseOriginalImage: (attachmentId: number) => void; onReact: (message: Message, emoji: string) => void; hasMyReaction: (message: Message, emoji: string) => boolean; + recentReactions: string[]; + onQuickReact: (message: Message, emoji: string) => void; + onOpenReactionPicker: (message: Message) => void; + onShowAllReaders?: (message: Message) => void; + onReply: (message: Message) => void; + onEdit: (message: Message) => void; + onDelete: (message: Message) => void; }; function getMessageAuthorLabel(message: Message): string { return message.author_name || message.author?.last_name || message.sender?.last_name || "Сотрудник"; } +function formatReactionInitials(name: string): string { + const parts = String(name || "").trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "?"; + if (parts.length === 1) return parts[0].slice(0, 1).toUpperCase(); + return `${parts[0].slice(0, 1)}${parts[1].slice(0, 1)}`.toUpperCase(); +} + function MessageDeliveryStatus({ sendState, isRead }: { sendState?: Message["send_state"]; isRead: boolean }) { if (sendState === "pending") { return ( @@ -128,7 +145,15 @@ export default function ChatMessageItem({ onUseOriginalImage, onReact, hasMyReaction, + recentReactions, + onQuickReact, + onOpenReactionPicker, + onShowAllReaders, + onReply, + onEdit, + onDelete, }: ChatMessageItemProps) { + const [reactionPopoverEmoji, setReactionPopoverEmoji] = useState(null); const currentDate = getMessageDate(message); const replyPreviewMessage = getReplyPreview(message, repliedMessage); const replyToId = getReplyToId(message); @@ -141,6 +166,29 @@ export default function ChatMessageItem({ const hasActions = canReply || canManage; const isRead = Boolean(message.is_read); const sendState = message.send_state; + const reactionUsers = useMemo(() => { + const summary = message.reactions_summary || {}; + return Object.fromEntries( + Object.entries(summary).map(([emoji, meta]) => { + const names = Array.isArray(meta.user_names) ? meta.user_names : []; + const userIds = Array.isArray(meta.users) ? meta.users : []; + return [ + emoji, + Array.isArray(meta.user_details) && meta.user_details.length > 0 + ? meta.user_details.map((person, index) => ({ + id: person.id ?? userIds[index] ?? `${emoji}-${index}-${person.name}`, + name: person.name, + avatar: person.avatar || null, + })) + : names.map((name, index) => ({ + id: userIds[index] ?? `${emoji}-${index}-${name}`, + name, + avatar: null, + })), + ]; + }), + ) as Record>; + }, [message.reactions_summary]); return (
@@ -171,10 +219,7 @@ export default function ChatMessageItem({ + + {showReactionPopover ? ( +
+

Отреагировали

+
+ {reactedUsers.map((person) => ( +
+ +

{person.name}

+
+ ))} +
+
+ ) : null} +
); })}
) : null} + + {isActionsOpen ? ( + onQuickReact(message, emoji)} + onOpenReactionPicker={() => onOpenReactionPicker(message)} + onShowAllReaders={onShowAllReaders ? () => onShowAllReaders(message) : undefined} + onReply={() => onReply(message)} + onEdit={() => onEdit(message)} + onDelete={() => onDelete(message)} + /> + ) : null}
diff --git a/frontend/src/components/messages/MessageActionsMenu.tsx b/frontend/src/components/messages/MessageActionsMenu.tsx index 375091d6..bcf7a65d 100644 --- a/frontend/src/components/messages/MessageActionsMenu.tsx +++ b/frontend/src/components/messages/MessageActionsMenu.tsx @@ -5,11 +5,11 @@ 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; + isMine?: boolean; recentReactions: string[]; onQuickReact: (emoji: string) => void; onOpenReactionPicker: () => void; @@ -20,11 +20,11 @@ type MessageActionsMenuProps = { }; export default function MessageActionsMenu({ - anchor, currentUserId, message, canReply, canManage, + isMine, recentReactions, onQuickReact, onOpenReactionPicker, @@ -33,21 +33,18 @@ export default function MessageActionsMenu({ onEdit, onDelete, }: MessageActionsMenuProps) { - const isMine = Boolean( + const resolvedIsMine = Boolean( + isMine ?? ( currentUserId && (message.author_id === currentUserId || message.author?.id === currentUserId || message.sender?.id === currentUserId) + ) ); const readers = message.read_by || []; return (
{canReply ? (
diff --git a/frontend/src/components/requests/RequestDetailModal.tsx b/frontend/src/components/requests/RequestDetailModal.tsx index 16d9c0b3..445205b8 100644 --- a/frontend/src/components/requests/RequestDetailModal.tsx +++ b/frontend/src/components/requests/RequestDetailModal.tsx @@ -7,28 +7,47 @@ import { } from "@/hooks/useRequestsPage"; import { formatDate, formatDateTime } from "@/lib/shared"; import type { Request } from "@/types/api"; -import { Paperclip } from "lucide-react"; +import { Ban, Paperclip, Pencil, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react"; +import { getRequestActionState } from "./requestActions"; import { RequestUserBadge } from "./RequestUserBadge"; type RequestDetailModalProps = { + actionError?: string | null; + busyKey: string | null; currentUserId?: number | null; departmentNameMap: Map; + isFinal: (status?: string) => boolean; + onApprove: (requestId: number) => void | Promise; + onCancel: (requestId: number) => void | Promise; onClose: () => void; + onDelete: (requestId: number) => void | Promise; + onEdit: (request: Request) => void; onPreviewAttachment: (preview: RequestAttachmentPreview) => void; + onReject: (requestId: number) => void | Promise; request: Request | null; }; export function RequestDetailModal({ + actionError, + busyKey, currentUserId, departmentNameMap, + isFinal, + onApprove, + onCancel, onClose, + onDelete, + onEdit, onPreviewAttachment, + onReject, request, }: RequestDetailModalProps) { if (!request) return null; const author = request.employee || request.created_by; const approver = request.approver || request.assigned_to; + const actionState = getRequestActionState(request, currentUserId, isFinal); + const { canCancel, canDelete, canEdit, canProcess } = actionState; const status = statusMeta[String(request.status || "").toLowerCase()] ?? defaultStatusMeta; const typeLabel = requestTypeLabels[String(request.type || request.request_type || "").toLowerCase()] || String(request.type || request.request_type || "Другое"); @@ -43,6 +62,73 @@ export function RequestDetailModal({ onClose={onClose} title="Полная информация по заявлению" size="lg" + footer={( +
+ {actionError ? ( +
+ {actionError} +
+ ) : null} + {(canProcess || canCancel || canEdit || canDelete) ? ( +
+ {canProcess ? ( + <> + + + + ) : null} + {canCancel ? ( + + ) : null} + {canEdit ? ( + + ) : null} + {canDelete ? ( + + ) : null} +
+ ) : null} +
+ )} >
diff --git a/frontend/src/components/requests/RequestListItem.tsx b/frontend/src/components/requests/RequestListItem.tsx index 07758b73..32ed7056 100644 --- a/frontend/src/components/requests/RequestListItem.tsx +++ b/frontend/src/components/requests/RequestListItem.tsx @@ -21,6 +21,7 @@ import { import type { Ref } from "react"; import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls"; import { RequestAvatar } from "./RequestAvatar"; +import { getRequestActionState } from "./requestActions"; import { RequestUserBadge } from "./RequestUserBadge"; type RequestListItemProps = { @@ -85,19 +86,13 @@ export function RequestListItem({ const requestAuthor = request.employee || request.created_by; const authorName = displayUserName(requestAuthor); const authorFallback = (requestAuthor?.first_name?.[0] || requestAuthor?.last_name?.[0] || "?").toUpperCase(); - const statusKey = String(request.status || "").toLowerCase(); + const actionState = getRequestActionState(request, currentUserId, isFinal); + const { canCancel, canComment, canDelete, canEdit, canProcess, hasSecondaryActions, statusKey } = actionState; const typeKey = String(request.type || request.request_type || "").toLowerCase(); const status = statusMeta[statusKey] ?? defaultStatusMeta; const authorLink = requestAuthor ? userProfileLink(requestAuthor, currentUserId) : null; const typeLabel = requestTypeLabels[typeKey] || String(request.type || request.request_type || "Другое"); const title = request.display_title || request.title || "Без заголовка"; - const canProcess = Boolean(statusKey === "pending" && request.can_decide); - const isAuthor = Boolean(requestAuthor?.id && currentUserId && requestAuthor.id === currentUserId); - const canCancel = Boolean(isAuthor && !isFinal(statusKey)); - const canEdit = Boolean(isAuthor && !isFinal(statusKey)); - const canDelete = Boolean(isAuthor && !isFinal(statusKey)); - const hasSecondaryActions = canCancel || canEdit || canDelete; - const canComment = statusKey !== "draft"; const departmentLabels = (request.departments || []) .map((id) => departmentNameMap.get(Number(id)) || `Отдел #${id}`) .join(", "); diff --git a/frontend/src/components/requests/requestActions.ts b/frontend/src/components/requests/requestActions.ts new file mode 100644 index 00000000..a5806eb3 --- /dev/null +++ b/frontend/src/components/requests/requestActions.ts @@ -0,0 +1,38 @@ +import type { Request } from "@/types/api"; + +export type RequestActionState = { + canProcess: boolean; + canCancel: boolean; + canEdit: boolean; + canDelete: boolean; + canComment: boolean; + hasSecondaryActions: boolean; + isAuthor: boolean; + statusKey: string; +}; + +export function getRequestActionState( + request: Request, + currentUserId: number | null | undefined, + isFinal: (status?: string) => boolean, +): RequestActionState { + const requestAuthor = request.employee || request.created_by; + const statusKey = String(request.status || "").toLowerCase(); + const canProcess = Boolean(statusKey === "pending" && request.can_decide); + const isAuthor = Boolean(requestAuthor?.id && currentUserId && requestAuthor.id === currentUserId); + const canCancel = Boolean(isAuthor && !isFinal(statusKey)); + const canEdit = Boolean(isAuthor && !isFinal(statusKey)); + const canDelete = Boolean(isAuthor && !isFinal(statusKey)); + const canComment = statusKey !== "draft"; + + return { + canProcess, + canCancel, + canEdit, + canDelete, + canComment, + hasSecondaryActions: canCancel || canEdit || canDelete, + isAuthor, + statusKey, + }; +} diff --git a/frontend/src/components/users/ChangeUserPositionModal.tsx b/frontend/src/components/users/ChangeUserPositionModal.tsx new file mode 100644 index 00000000..80e953b9 --- /dev/null +++ b/frontend/src/components/users/ChangeUserPositionModal.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { Modal } from "@/components/ui"; +import type { Position, User } from "@/types/api"; + +type ChangeUserPositionModalProps = { + isOpen: boolean; + onClose: () => void; + onSave: () => void; + onPositionChange: (value: string) => void; + positions: Position[]; + positionsLoading: boolean; + positionValue: string; + actionLoading: string | null; + person: User | null; + error?: string | null; +}; + +export default function ChangeUserPositionModal({ + isOpen, + onClose, + onSave, + onPositionChange, + positions, + positionsLoading, + positionValue, + actionLoading, + person, + error, +}: ChangeUserPositionModalProps) { + const isSaving = actionLoading === "position"; + + const footer = ( +
+ + +
+ ); + + return ( + +
+
+
+

+ {person?.last_name} {person?.first_name} {person?.patronymic || ""} +

+

+ Текущая должность: {person?.position?.name || "Не указана"} +

+
+ + + + {positionsLoading ? ( +

Загрузка должностей...

+ ) : null} + + {error ? ( +

{error}

+ ) : null} +
+
+
+ ); +} diff --git a/frontend/src/hooks/useUserDetailPage.ts b/frontend/src/hooks/useUserDetailPage.ts index 996e392b..de727e31 100644 --- a/frontend/src/hooks/useUserDetailPage.ts +++ b/frontend/src/hooks/useUserDetailPage.ts @@ -17,7 +17,7 @@ import { type UserProfileTextField, sortEmployeeActions, } from "@/lib/users/userDetailUtils"; -import type { Chat, EmployeeAction, User } from "@/types/api"; +import type { Chat, EmployeeAction, Position, User } from "@/types/api"; type ChatWithMemberIds = Chat & { member_ids?: number[] }; @@ -36,9 +36,14 @@ export function useUserDetailPage(userId: number, currentUser: User | null) { const [copySuccess, setCopySuccess] = useState(null); const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isActionModalOpen, setIsActionModalOpen] = useState(false); + const [isPositionModalOpen, setIsPositionModalOpen] = useState(false); const [editForm, setEditForm] = useState(createEmptyEditForm); const [actionForm, setActionForm] = useState(createEmptyActionForm); const [actionLoading, setActionLoading] = useState(null); + const [positions, setPositions] = useState([]); + const [positionsLoading, setPositionsLoading] = useState(false); + const [positionsError, setPositionsError] = useState(null); + const [positionValue, setPositionValue] = useState(""); const refreshPerson = useCallback(async () => { const data = await apiClient.getEmployee(userId) as User; @@ -211,6 +216,33 @@ export function useUserDetailPage(userId: number, currentUser: User | null) { setEditForm((current) => ({ ...current, avatarFile: null, avatarPreview: null })); }, []); + const handleOpenPositionModal = useCallback(async () => { + if (!person) return; + + setPositionValue(person.position?.id ? String(person.position.id) : ""); + setPositionsError(null); + setIsPositionModalOpen(true); + + if (positions.length > 0 || positionsLoading) return; + + try { + setPositionsLoading(true); + const response = await apiClient.getPositions() as Position[]; + setPositions(response); + } catch (loadError) { + setPositionsError( + getErrorMessage(loadError, "Не удалось загрузить список должностей"), + ); + } finally { + setPositionsLoading(false); + } + }, [person, positions.length, positionsLoading]); + + const handleClosePositionModal = useCallback(() => { + setIsPositionModalOpen(false); + setPositionsError(null); + }, []); + const handleAvatarChange = useCallback((event: ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; @@ -276,6 +308,26 @@ export function useUserDetailPage(userId: number, currentUser: User | null) { } }, [editForm, handleCloseEditModal, person, refreshPerson, userId]); + const handleSavePosition = useCallback(async () => { + if (!person || !userId) return; + + setActionLoading("position"); + setPositionsError(null); + try { + await apiClient.updateEmployee(userId, { + position_id: positionValue ? Number(positionValue) : null, + }); + await refreshPerson(); + handleClosePositionModal(); + } catch (saveError) { + setPositionsError( + getErrorMessage(saveError, "Не удалось изменить должность"), + ); + } finally { + setActionLoading(null); + } + }, [handleClosePositionModal, person, positionValue, refreshPerson, userId]); + const canEdit = Boolean(currentUser && ( currentUser.auth?.is_staff || currentUser.auth?.is_superuser || @@ -310,23 +362,32 @@ export function useUserDetailPage(userId: number, currentUser: User | null) { handleAvatarChange, handleCloseActionModal, handleCloseEditModal, + handleClosePositionModal, handleCopyToClipboard: copyToClipboard, handleDeleteAction, handleEditAction, handleOpenActionModal, handleOpenEditModal, + handleOpenPositionModal, handleSaveAction, handleSaveEdit, + handleSavePosition, handleStartChat, initials, isActionModalOpen, isEditModalOpen, + isPositionModalOpen, latestAction, loading, person, + positionValue, + positions, + positionsError, + positionsLoading, setActionField, setAvatarFailed, setEditField, + setPositionValue, sortedActions, }; -} \ No newline at end of file +} diff --git a/frontend/src/lib/api/employees.ts b/frontend/src/lib/api/employees.ts index a34259a9..622d4b02 100644 --- a/frontend/src/lib/api/employees.ts +++ b/frontend/src/lib/api/employees.ts @@ -24,6 +24,7 @@ export function createEmployeesApi(request: RequestFn) { return request(`/api/v1/employees/${qs ? '?' + qs : ''}`); }, getEmployee: (id: number | string) => request(`/api/v1/employees/${id}/`), + getPositions: () => request('/api/v1/positions/'), addEmployeeSkill: ( id: number | string, data: { skill_id?: number; name?: string }, diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index b9b9d0f9..f74d3dc9 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -646,6 +646,11 @@ export interface Message { count: number; users?: number[]; user_names?: string[]; + user_details?: Array<{ + id: number; + name: string; + avatar?: string | null; + }>; }>; }