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 5a0f9fdb..4fde426a 100644 Binary files a/frontend/src/app/favicon.ico and b/frontend/src/app/favicon.ico differ 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; + }>; }>; }