refactor(ui): polish chat, requests, and profile actions
This commit is contained in:
parent
70d2c55348
commit
35a7250183
@ -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)(),
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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}")
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 = []
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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):
|
||||
"""Удаление реакции с сообщения"""
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
47
frontend/public/favicon-source.svg
Normal file
47
frontend/public/favicon-source.svg
Normal 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 |
@ -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 |
@ -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}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
|
||||
@ -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="К списку чатов"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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(", ");
|
||||
|
||||
38
frontend/src/components/requests/requestActions.ts
Normal file
38
frontend/src/components/requests/requestActions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
105
frontend/src/components/users/ChangeUserPositionModal.tsx
Normal file
105
frontend/src/components/users/ChangeUserPositionModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -646,6 +646,11 @@ export interface Message {
|
||||
count: number;
|
||||
users?: number[];
|
||||
user_names?: string[];
|
||||
user_details?: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
avatar?: string | null;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user