refactor(ui): polish chat, requests, and profile actions

This commit is contained in:
lizerk 2026-04-14 23:11:32 +03:00
parent 70d2c55348
commit 35a7250183
30 changed files with 840 additions and 181 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = []

View File

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

View File

@ -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):
"""Удаление реакции с сообщения"""

View File

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

View File

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

View File

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

View File

@ -0,0 +1,47 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
fill="none"
>
<path
fill="#1DB6F2"
fill-rule="evenodd"
d="M52 62
C52 56 86 52 128 52
C170 52 204 56 204 62
V117
H230
V106
H244
V150
H230
V139
H204
V194
C204 200 170 204 128 204
C86 204 52 200 52 194
V139
H26
V150
H12
V106
H26
V117
H52
Z
M98 103
A25 25 0 1 0 98 153
A25 25 0 1 0 98 103
Z
M158 103
A25 25 0 1 0 158 153
A25 25 0 1 0 158 103
Z"
clip-rule="evenodd"
/>
</svg>

After

Width:  |  Height:  |  Size: 464 B

View File

@ -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"
>
<MessageSquare size={16} />
<MessageCircle size={16} />
Перейти в чат отдела
</button>
</div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -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<number | null>(null);
const [replyTo, setReplyTo] = useState<ReplyTarget | null>(null);
const [expandedReplyActionForId, setExpandedReplyActionForId] = useState<number | null>(null);
const [actionsMenuAnchor, setActionsMenuAnchor] = useState<{ x: number; y: number } | null>(null);
const [isReadersModalOpen, setIsReadersModalOpen] = useState(false);
const [reactionPickerForMessageId, setReactionPickerForMessageId] = useState<number | null>(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 ? (
<p className="app-text-muted text-center text-sm">Загрузка сообщений...</p>
@ -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}
/>
</React.Fragment>
);
@ -1131,7 +1123,7 @@ function MessageDialogPageContent() {
)}
{/* Composer area */}
<div className="app-divider app-header shrink-0 border-t px-4 pt-3 pb-[calc(env(safe-area-inset-bottom)+0.25rem)] lg:px-0 lg:pb-0">
<div className="app-divider shrink-0 border-t bg-transparent px-4 pt-3 pb-[calc(env(safe-area-inset-bottom)+0.25rem)] lg:app-header lg:px-0 lg:pb-0">
{isTyping && (
<div className="app-text-muted mb-2 flex items-center gap-2 text-xs italic">
<div className="flex gap-1">
@ -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 ? <ChatMediaPreviewModal preview={mediaPreview} onClose={() => setMediaPreview(null)} /> : null}
{expandedReplyActionForId && actionsMenuAnchor && selectedActionMessage ? (
<MessageActionsMenu
anchor={actionsMenuAnchor}
currentUserId={user?.id}
message={selectedActionMessage}
canReply={selectedActionCanReply}
canManage={selectedActionCanManage}
recentReactions={selectedActionRecentReactions}
onQuickReact={(emoji) => { 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 ? (
<MessageReadersModal isOpen={isReadersModalOpen} onClose={() => setIsReadersModalOpen(false)} readers={selectedActionReaders} />
) : null}
@ -1219,7 +1193,6 @@ function MessageDialogPageContent() {
if (msg) void handleReact(msg, emoji);
setReactionPickerForMessageId(null);
setExpandedReplyActionForId(null);
setActionsMenuAnchor(null);
}}
/>
) : null}

View File

@ -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 <Megaphone size={10} className="app-accent-text" />;
case 'comments':
return <MessageSquare size={10} className="app-accent-text" />;
return isDepartmentCommentsChat(chat)
? <Building2 size={10} className="app-accent-text" />
: <MessageSquare size={10} className="app-accent-text" />;
default:
return null;
}

View File

@ -138,10 +138,21 @@ function RequestsPageContent() {
)}
<RequestDetailModal
actionError={h.actionError}
busyKey={h.busyKey}
currentUserId={user?.id}
departmentNameMap={h.departmentNameMap}
isFinal={h.isFinal}
onApprove={h.handleApprove}
onCancel={h.handleCancel}
onClose={screen.closeDetailsRequest}
onDelete={h.handleDelete}
onEdit={(request) => {
screen.closeDetailsRequest();
h.openEdit(request);
}}
onPreviewAttachment={h.setAttachmentPreview}
onReject={h.handleReject}
request={h.detailsRequest}
/>

View File

@ -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<string | null>(null);
const [profileMenuOpen, setProfileMenuOpen] = useState(false);
const profileMenuRef = useRef<HTMLDivElement | null>(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<ProfileContactRow[]>(() => {
if (!person) return [];
@ -309,12 +347,80 @@ export default function UserDetailPage() {
<ProfileHeroCard
caption="Профиль сотрудника"
statusBadge={
latestAction ? (
<span
className={`app-status-pill ${getEmployeeActionTone(latestAction.action).badgeClass}`}
(latestAction || canEdit || canManageActions) ? (
<div
ref={profileMenuOpen ? profileMenuRef : null}
className="relative flex items-center gap-2"
>
{latestAction.action_display || latestAction.action}
</span>
{latestAction ? (
<span
className={`app-status-pill ${getEmployeeActionTone(latestAction.action).badgeClass}`}
>
{latestAction.action_display || latestAction.action}
</span>
) : null}
{(canEdit || canManageActions) ? (
<>
<button
type="button"
onClick={() => setProfileMenuOpen((current) => !current)}
className="app-action-ghost flex h-8 w-8 items-center justify-center rounded-md"
aria-label="Действия профиля"
aria-expanded={profileMenuOpen}
title="Действия профиля"
aria-haspopup="menu"
>
<ChevronRight
size={15}
className={`transition-transform duration-200 ${profileMenuOpen ? "rotate-90" : ""}`}
/>
</button>
{profileMenuOpen ? (
<div className="app-menu absolute right-0 top-full z-20 mt-2 w-64 rounded-xl py-1.5">
{canEdit ? (
<button
type="button"
onClick={() => {
setProfileMenuOpen(false);
handleOpenEditModal();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Pencil size={14} className="app-text-muted" />
Редактировать профиль
</button>
) : null}
{canManageActions ? (
<button
type="button"
onClick={() => {
setProfileMenuOpen(false);
handleOpenActionModal();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<History size={14} className="app-text-muted" />
Добавить кадровое событие
</button>
) : null}
{canManageActions ? (
<button
type="button"
onClick={() => {
setProfileMenuOpen(false);
void handleOpenPositionModal();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<BriefcaseBusiness size={14} className="app-text-muted" />
Изменить должность
</button>
) : null}
</div>
) : null}
</>
) : null}
</div>
) : null
}
avatar={
@ -347,38 +453,29 @@ export default function UserDetailPage() {
/>
) : undefined
}
headerActions={
canEdit ? (
<button
type="button"
onClick={handleOpenEditModal}
className="app-action-secondary inline-flex h-10 w-10 items-center justify-center rounded-xl"
aria-label="Редактировать профиль"
title="Редактировать профиль"
>
<Pencil size={16} />
</button>
) : undefined
}
actionRow={
currentUser && currentUser.id !== person.id ? (
currentUser && (currentUser.id !== person.id || canEdit) ? (
<>
<button
onClick={handleStartChat}
disabled={creatingChat}
className="app-action-primary inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
<MessageCircle size={16} />
{creatingChat ? "Загрузка..." : "Написать"}
</button>
{person.phone_number ? (
<a
href={`tel:${formatPhoneForLink(person.phone_number)}`}
className="app-action-secondary inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium"
>
<Phone size={16} />
Позвонить
</a>
{currentUser.id !== person.id ? (
<>
<button
onClick={handleStartChat}
disabled={creatingChat}
className="app-action-primary inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
<MessageCircle size={16} />
{creatingChat ? "Загрузка..." : "Написать"}
</button>
{person.phone_number ? (
<a
href={`tel:${formatPhoneForLink(person.phone_number)}`}
className="app-action-secondary inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium"
>
<Phone size={16} />
Позвонить
</a>
) : null}
</>
) : null}
</>
) : undefined
@ -473,6 +570,19 @@ export default function UserDetailPage() {
person={person}
/>
<ChangeUserPositionModal
actionLoading={actionLoading}
error={positionsError}
isOpen={isPositionModalOpen}
onClose={handleClosePositionModal}
onPositionChange={setPositionValue}
onSave={handleSavePosition}
person={person}
positionValue={positionValue}
positions={positions}
positionsLoading={positionsLoading}
/>
<EmployeeActionModal
actionLoading={actionLoading}
actionTypes={actionTypes}

View File

@ -27,6 +27,7 @@ type PageHeaderProps = {
type HeaderProps = {
mobileNavPlacement: "top" | "bottom";
suppressMobileChrome?: boolean;
onOpenLeftNav: () => 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`}
>
<div className="mx-auto max-w-6xl px-4 sm:px-8">
<div
@ -446,9 +447,10 @@ export function AppShell({ children }: AppShellProps) {
}
return (
<div className={`${isMessageDialogPage ? 'h-[100dvh] overflow-hidden' : 'min-h-screen'} app-shell flex flex-col ${mobileNavPlacement === "bottom" ? "pb-[calc(env(safe-area-inset-bottom)+3.75rem)] lg:pb-0" : ""}`}>
<div className={`${isMessageDialogPage ? 'h-[100dvh] overflow-hidden' : 'min-h-screen'} app-shell flex flex-col ${mobileNavPlacement === "bottom" && !isMessageDialogPage ? "pb-[calc(env(safe-area-inset-bottom)+3.75rem)] lg:pb-0" : ""}`}>
<Header
mobileNavPlacement={mobileNavPlacement}
suppressMobileChrome={isMessageDialogPage}
onOpenLeftNav={() => setIsMobileLeftNavOpen(true)}
onOpenCalendar={() => setIsMobileCalendarOpen(true)}
/>

View File

@ -83,7 +83,7 @@ export default function ChatDialogHeader({
const subtitle = getChatSubtitle(chat);
return (
<header className="app-divider app-header relative z-30 flex shrink-0 items-center gap-3 border-b px-4 pb-3 pt-3 lg:px-0 lg:pt-0">
<header className="app-divider relative z-30 flex shrink-0 items-center gap-3 border-b bg-transparent px-4 pb-3 pt-3 lg:app-header lg:px-0 lg:pt-0">
<Link
href="/messages"
aria-label="К списку чатов"

View File

@ -1,6 +1,7 @@
"use client";
import Image from "next/image";
import { useMemo, useState } from "react";
import {
AlertCircle,
Check,
@ -13,6 +14,8 @@ import {
PlayCircle,
} from "lucide-react";
import { RequestAvatar } from "@/components/requests/RequestAvatar";
import MessageActionsMenu from "@/components/messages/MessageActionsMenu";
import { resolveMediaUrl } from "@/lib/url";
import {
formatFileSize,
@ -58,7 +61,7 @@ type ChatMessageItemProps = {
canReply: boolean;
brokenMedia: Record<number, boolean>;
useOriginalImage: Record<number, boolean>;
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<string | null>(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<string, Array<{ id: number | string; name: string; avatar: string | null }>>;
}, [message.reactions_summary]);
return (
<div data-message-id={message.id} data-message-date={currentDate?.toISOString() || ""} className="mb-3 last:mb-0">
@ -171,10 +219,7 @@ export default function ChatMessageItem({
<button
type="button"
data-actions-trigger="true"
onClick={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
onToggleActions(message.id, { x: rect.right, y: rect.top });
}}
onClick={() => onToggleActions(message.id)}
className={`inline-flex h-6 w-6 items-center justify-center rounded-full border border-transparent bg-transparent text-[var(--muted-foreground)] transition hover:text-sky-600 ${
isActionsOpen ? "rotate-90" : ""
}`}
@ -411,23 +456,66 @@ export default function ChatMessageItem({
<div className="mt-2 flex flex-wrap gap-1">
{Object.entries(message.reactions_summary || {}).map(([emoji, meta]) => {
const mine = hasMyReaction(message, emoji);
const reactedUsers = reactionUsers[emoji] || [];
const showReactionPopover = reactionPopoverEmoji === emoji && reactedUsers.length > 0;
return (
<button
<div
key={`${message.id}-${emoji}`}
type="button"
onClick={() => onReact(message, emoji)}
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs transition ${
mine ? "bg-sky-100 text-sky-700 ring-1 ring-sky-300" : "app-surface-muted text-[var(--foreground)] ring-1 ring-[var(--border-subtle)] hover:bg-[var(--surface-elevated)]"
}`}
title="Реакция"
className="relative"
onMouseEnter={() => setReactionPopoverEmoji(emoji)}
onMouseLeave={() => setReactionPopoverEmoji((current) => (current === emoji ? null : current))}
>
<span>{emoji}</span>
<span>{meta.count}</span>
</button>
<button
type="button"
onClick={() => onReact(message, emoji)}
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs transition ${
mine ? "bg-sky-100 text-sky-700 ring-1 ring-sky-300" : "app-surface-muted text-[var(--foreground)] ring-1 ring-[var(--border-subtle)] hover:bg-[var(--surface-elevated)]"
}`}
title="Реакция"
>
<span>{emoji}</span>
<span>{meta.count}</span>
</button>
{showReactionPopover ? (
<div className={`app-menu absolute top-full z-20 mt-1 w-56 rounded-xl p-2 ${isMine ? "right-0" : "left-0"}`}>
<p className="app-text-muted px-1 pb-1 text-xs font-semibold">Отреагировали</p>
<div className="max-h-56 space-y-1 overflow-y-auto">
{reactedUsers.map((person) => (
<div key={person.id} className="flex items-center gap-2 rounded-lg px-1 py-1 hover:bg-[var(--surface-secondary)]">
<RequestAvatar
alt={person.name}
fallback={formatReactionInitials(person.name)}
size="sm"
src={person.avatar || undefined}
/>
<p className="truncate text-xs text-[var(--foreground)]">{person.name}</p>
</div>
))}
</div>
</div>
) : null}
</div>
);
})}
</div>
) : null}
{isActionsOpen ? (
<MessageActionsMenu
currentUserId={currentUserId}
message={message}
canReply={canReply}
canManage={canManage}
isMine={isMine}
recentReactions={recentReactions}
onQuickReact={(emoji) => onQuickReact(message, emoji)}
onOpenReactionPicker={() => onOpenReactionPicker(message)}
onShowAllReaders={onShowAllReaders ? () => onShowAllReaders(message) : undefined}
onReply={() => onReply(message)}
onEdit={() => onEdit(message)}
onDelete={() => onDelete(message)}
/>
) : null}
</div>
</div>
</div>

View File

@ -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 (
<div
data-actions-menu="true"
className="app-menu fixed z-[60] min-w-[208px] max-w-[232px] rounded-xl py-1.5"
style={{
left: anchor.x,
top: anchor.y - 8,
transform: "translate(-100%, -100%)",
}}
className={`app-menu absolute top-8 z-[30] min-w-[208px] max-w-[232px] rounded-xl py-1.5 ${resolvedIsMine ? "right-0" : "left-0"}`}
>
{canReply ? (
<div className="app-divider border-b px-2.5 pb-2 pt-1">

View File

@ -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<number, string>;
isFinal: (status?: string) => boolean;
onApprove: (requestId: number) => void | Promise<void>;
onCancel: (requestId: number) => void | Promise<void>;
onClose: () => void;
onDelete: (requestId: number) => void | Promise<void>;
onEdit: (request: Request) => void;
onPreviewAttachment: (preview: RequestAttachmentPreview) => void;
onReject: (requestId: number) => void | Promise<void>;
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={(
<div className="space-y-3">
{actionError ? (
<div className="app-feedback-danger rounded-xl px-3 py-2 text-sm">
{actionError}
</div>
) : null}
{(canProcess || canCancel || canEdit || canDelete) ? (
<div className="flex flex-wrap items-center justify-end gap-2">
{canProcess ? (
<>
<button
type="button"
onClick={() => void onApprove(request.id)}
disabled={busyKey === `approve-${request.id}`}
className="app-feedback-success inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-60"
>
<ThumbsUp size={16} />
Одобрить
</button>
<button
type="button"
onClick={() => void onReject(request.id)}
disabled={busyKey === `reject-${request.id}`}
className="app-action-danger inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-60"
>
<ThumbsDown size={16} />
Отклонить
</button>
</>
) : null}
{canCancel ? (
<button
type="button"
onClick={() => void onCancel(request.id)}
disabled={busyKey === `cancel-${request.id}`}
className="app-action-secondary inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-60"
>
<Ban size={16} />
Отменить
</button>
) : null}
{canEdit ? (
<button
type="button"
onClick={() => onEdit(request)}
className="app-action-secondary inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium"
>
<Pencil size={16} />
Редактировать
</button>
) : null}
{canDelete ? (
<button
type="button"
onClick={() => void onDelete(request.id)}
disabled={busyKey === `delete-${request.id}`}
className="app-action-danger inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-60"
>
<Trash2 size={16} />
Удалить
</button>
) : null}
</div>
) : null}
</div>
)}
>
<div className="space-y-5 text-sm text-[var(--foreground)]">
<div className="app-surface-muted rounded-xl p-4">

View File

@ -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(", ");

View File

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

View File

@ -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 = (
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
disabled={isSaving}
className="app-action-secondary flex-1 rounded-lg px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
Отмена
</button>
<button
type="button"
onClick={onSave}
disabled={isSaving || positionsLoading}
className="app-action-primary flex-1 rounded-lg px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
{isSaving ? "Сохранение..." : "Сохранить"}
</button>
</div>
);
return (
<Modal
isOpen={isOpen && !!person}
onClose={onClose}
title="Изменить должность"
size="sm"
closeOnEsc={!isSaving}
footer={footer}
>
<div className="space-y-4">
<section className="app-surface-muted rounded-xl p-4">
<div className="mb-3">
<p className="text-sm font-semibold text-[var(--foreground)]">
{person?.last_name} {person?.first_name} {person?.patronymic || ""}
</p>
<p className="app-text-muted mt-1 text-sm">
Текущая должность: {person?.position?.name || "Не указана"}
</p>
</div>
<label htmlFor="employee-position" className="block">
<span className="mb-2 block text-sm font-medium text-[var(--foreground)]">
Новая должность
</span>
<select
id="employee-position"
value={positionValue}
onChange={(event) => onPositionChange(event.target.value)}
className="app-select w-full rounded-lg px-4 py-2.5 text-sm"
disabled={positionsLoading || isSaving}
>
<option value="">Без должности</option>
{positions.map((position) => (
<option key={position.id} value={String(position.id)}>
{position.name}
</option>
))}
</select>
</label>
{positionsLoading ? (
<p className="app-text-muted mt-3 text-sm">Загрузка должностей...</p>
) : null}
{error ? (
<p className="mt-3 text-sm text-[var(--danger-foreground)]">{error}</p>
) : null}
</section>
</div>
</Modal>
);
}

View File

@ -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<string | null>(null);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isActionModalOpen, setIsActionModalOpen] = useState(false);
const [isPositionModalOpen, setIsPositionModalOpen] = useState(false);
const [editForm, setEditForm] = useState<UserProfileEditForm>(createEmptyEditForm);
const [actionForm, setActionForm] = useState<EmployeeActionForm>(createEmptyActionForm);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [positions, setPositions] = useState<Position[]>([]);
const [positionsLoading, setPositionsLoading] = useState(false);
const [positionsError, setPositionsError] = useState<string | null>(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<HTMLInputElement>) => {
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,
};
}
}

View File

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

View File

@ -646,6 +646,11 @@ export interface Message {
count: number;
users?: number[];
user_names?: string[];
user_details?: Array<{
id: number;
name: string;
avatar?: string | null;
}>;
}>;
}