From f28eda69291c1f62912570a6fde7226eb0ff327e Mon Sep 17 00:00:00 2001 From: lizerk Date: Mon, 16 Mar 2026 18:21:24 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86?= =?UTF-8?q?=D1=8B=20=D1=81=D0=BE=D1=82=D1=80=D1=83=D0=B4=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B8=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BD=D0=B0=20ChatMembership?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Полная миграция на ChatMembership для всех типов чатов - Удалены проверки participants M2M поля - Добавлена обработка participants при создании чатов - API методы для создания/редактирования/удаления кадровых событий Frontend: - Редизайн страницы пользователя с lucide-react иконами - Добавлено редактирование профиля (ФИО, контакты, аватар) - Функциональные кадровые события (создание/редактирование/удаление) - История кадровых событий с цветовой кодировкой - Исправлено создание дубликатов чатов (поиск по member_ids) - Удалены визуальные статусные индикаторы (is_active ≠ online) - Добавлен статус сотрудника из последнего action Fixes: - type 'private' вместо 'direct' для личных чатов - ChatMembership проверки в user_can_access_chat() - member_ids в ChatListSerializer для поиска чатов --- backend/communications/api/serializers.py | 13 +- backend/communications/api/viewsets.py | 53 +- backend/communications/consumers.py | 23 +- backend/communications/utils.py | 48 +- frontend/src/app/employees/page.tsx | 72 +- frontend/src/app/messages/[chatId]/page.tsx | 9 +- frontend/src/app/messages/page.tsx | 13 - frontend/src/app/page.tsx | 7 - frontend/src/app/profile/page.tsx | 1 - frontend/src/app/users/[id]/page.tsx | 1008 ++++++++++++++++++- frontend/src/components/AppShell.tsx | 6 - frontend/src/lib/api.ts | 71 +- frontend/src/types/api.ts | 14 +- 13 files changed, 1157 insertions(+), 181 deletions(-) diff --git a/backend/communications/api/serializers.py b/backend/communications/api/serializers.py index 0c62ac4a..ec86508c 100644 --- a/backend/communications/api/serializers.py +++ b/backend/communications/api/serializers.py @@ -42,6 +42,7 @@ class ChatListSerializer(serializers.ModelSerializer): last_message = serializers.SerializerMethodField() unread_count = serializers.SerializerMethodField() participant_names = serializers.SerializerMethodField() + member_ids = serializers.SerializerMethodField() is_pinned = serializers.SerializerMethodField() notifications_enabled = serializers.SerializerMethodField() last_read_message_id = serializers.SerializerMethodField() @@ -59,7 +60,7 @@ class ChatListSerializer(serializers.ModelSerializer): # DEPRECATED: для обратной совместимости 'is_main', # Messages & status - 'last_message', 'unread_count', 'participant_names', + 'last_message', 'unread_count', 'participant_names', 'member_ids', 'is_pinned', 'notifications_enabled', 'last_read_message_id' ] @@ -86,13 +87,17 @@ class ChatListSerializer(serializers.ModelSerializer): return None def get_participant_names(self, obj): - """Имена участников (для приватных чатов) - MIGRATION: Используем memberships вместо participants - """ + """Имена участников (для приватных чатов)""" if obj.type == 'private': active_members = obj.memberships.filter(is_active=True).select_related('user')[:5] return [m.user.get_full_name() for m in active_members] return [] + + def get_member_ids(self, obj): + """ID участников (из prefetch, для поиска существующего чата)""" + return list( + obj.memberships.filter(is_active=True).values_list('user_id', flat=True) + ) def get_is_pinned(self, obj): """Закреплен ли чат для текущего пользователя (из prefetch)""" diff --git a/backend/communications/api/viewsets.py b/backend/communications/api/viewsets.py index 95f2607f..a8846ebf 100644 --- a/backend/communications/api/viewsets.py +++ b/backend/communications/api/viewsets.py @@ -89,7 +89,6 @@ class ChatViewSet(viewsets.ModelViewSet): user = self.request.user from django.contrib.contenttypes.models import ContentType - # MIGRATION: Используем только memberships вместо participants queryset = Chat.objects.filter( Q(memberships__user=user, memberships__is_active=True) | Q(include_all_users=True) @@ -97,7 +96,6 @@ class ChatViewSet(viewsets.ModelViewSet): ).select_related( 'created_by', 'context_content_type' ).prefetch_related( - 'participants', # Prefetch ChatMembership с информацией о пользователе Prefetch( 'memberships', @@ -132,31 +130,38 @@ class ChatViewSet(viewsets.ModelViewSet): # Устанавливаем создателя chat = serializer.save(created_by=request.user) - # MIGRATION: Создаем membership для всех типов кроме глобальных (убрали participants.add) - if chat.type != 'global': - # Для чатов с управлением участниками создаем membership с ролью admin - if chat.type in ['group', 'channel', 'announcement']: - ChatMembership.objects.create( - chat=chat, - user=request.user, - role='admin', - invited_by=request.user, - is_active=True - ) - # Для других типов (private, direct) создаем обычного участника - else: - ChatMembership.objects.create( - chat=chat, - user=request.user, - role='member', - invited_by=request.user, - is_active=True - ) - - # Для глобальных чатов устанавливаем include_all_users=True if chat.type == 'global': chat.include_all_users = True chat.save() + else: + role = 'admin' if chat.type in ['group', 'channel', 'announcement'] else 'member' + ChatMembership.objects.create( + chat=chat, + user=request.user, + role=role, + invited_by=request.user, + is_active=True + ) + + # Добавляем остальных участников из запроса + participant_ids = request.data.get('participants', []) + if participant_ids: + for user_id in participant_ids: + if user_id == request.user.id: + continue + try: + participant = User.objects.get(id=user_id) + ChatMembership.objects.get_or_create( + chat=chat, + user=participant, + defaults={ + 'role': 'member', + 'invited_by': request.user, + 'is_active': True + } + ) + except User.DoesNotExist: + logger.warning(f"User {user_id} not found when creating chat {chat.id}") headers = self.get_success_headers(serializer.data) return Response( diff --git a/backend/communications/consumers.py b/backend/communications/consumers.py index 9d2f09a8..37b30506 100644 --- a/backend/communications/consumers.py +++ b/backend/communications/consumers.py @@ -551,7 +551,6 @@ class ChatConsumerMixin: chat_ids = list( Chat.objects.filter( Q(type="global") - | Q(type="private", participants=user) | Q(id__in=membership_chat_ids) | Q(include_all_users=True) ).values_list("id", flat=True).distinct() @@ -582,21 +581,21 @@ class ChatConsumerMixin: return True if chat.type == "private": - return chat.participants.filter(pk=user.pk).exists() - + return ChatMembership.objects.filter( + chat=chat, user=user, is_active=True + ).exists() + if chat.type == "group": - return ( - chat.participants.filter(pk=user.pk).exists() - or ChatMembership.objects.filter(chat=chat, user=user).exists() - ) - + return ChatMembership.objects.filter( + chat=chat, user=user, is_active=True + ).exists() + if chat.type in ("channel", "announcement", "comments"): if chat.include_all_users: return user.is_active - return ( - chat.participants.filter(pk=user.pk).exists() - or ChatMembership.objects.filter(chat=chat, user=user).exists() - ) + return ChatMembership.objects.filter( + chat=chat, user=user, is_active=True + ).exists() return False diff --git a/backend/communications/utils.py b/backend/communications/utils.py index e0ebe7fa..5812c8ca 100644 --- a/backend/communications/utils.py +++ b/backend/communications/utils.py @@ -62,9 +62,9 @@ def user_can_access_chat(chat: Chat, user) -> bool: Правила доступа по типам чатов: - global: доступ всем - - private: только участники (participants) - - group: участники (participants) или ChatMembership - - channel/announcement: include_all_users или participants/membership + - private: активное ChatMembership + - group: активное ChatMembership (или context-based resolver) + - channel/announcement: include_all_users или активное ChatMembership - comments: context-based доступ через get_participants() Args: @@ -84,10 +84,9 @@ def user_can_access_chat(chat: Chat, user) -> bool: return True if chat.type == "private": - # Личные чаты только через participants - # Обходим prefetch cache используя прямой запрос - from .models import Chat - return Chat.objects.filter(pk=chat.pk, participants=user).exists() + return ChatMembership.objects.filter( + chat=chat, user=user, is_active=True + ).exists() if chat.type == "group": # Групповые чаты с контекстным объектом используют resolver @@ -102,34 +101,21 @@ def user_can_access_chat(chat: Chat, user) -> bool: f"[user_can_access_chat] Failed to resolve for chat " f"{chat.id}: {e}" ) - - # Обычные групповые чаты через participants ИЛИ ChatMembership - from .models import Chat - in_participants = Chat.objects.filter( - pk=chat.pk, participants=user + return ChatMembership.objects.filter( + chat=chat, user=user, is_active=True ).exists() - in_membership = ChatMembership.objects.filter( - chat=chat, user=user - ).exists() - return in_participants or in_membership if chat.type in ("channel", "announcement"): - # Для каналов и объявлений может быть include_all_users или membership if chat.include_all_users: return user.is_active - # Проверяем и participants и membership для гибкости - from .models import Chat - return ( - Chat.objects.filter(pk=chat.pk, participants=user).exists() - or ChatMembership.objects.filter(chat=chat, user=user).exists() - ) + return ChatMembership.objects.filter( + chat=chat, user=user, is_active=True + ).exists() if chat.type == "comments": - # Чаты-комментарии ВСЕГДА используют context-based доступ + # Чаты-комментарии используют context-based доступ через get_participants() if chat.context_object_id: try: - # get_participants() вызовет resolver который учитывает - # права доступа к контекстному объекту participants = chat.get_participants() return participants.filter(pk=user.pk).exists() except Exception as e: @@ -140,12 +126,8 @@ def user_can_access_chat(chat: Chat, user) -> bool: f"comments chat {chat.id}: {e}" ) return False - - # Fallback для старых чатов без контекста - from .models import Chat - return ( - Chat.objects.filter(pk=chat.pk, participants=user).exists() - or ChatMembership.objects.filter(chat=chat, user=user).exists() - ) + return ChatMembership.objects.filter( + chat=chat, user=user, is_active=True + ).exists() return False diff --git a/frontend/src/app/employees/page.tsx b/frontend/src/app/employees/page.tsx index 2876c711..8f840fbc 100644 --- a/frontend/src/app/employees/page.tsx +++ b/frontend/src/app/employees/page.tsx @@ -13,10 +13,11 @@ export default function EmployeesPage() { const [error, setError] = useState(null); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); + const [showOnlyActive, setShowOnlyActive] = useState(false); const loaderRef = useRef(null); const isFetchingRef = useRef(false); - const loadEmployees = async (pageToLoad: number, append: boolean) => { + const loadEmployees = async (pageToLoad: number, append: boolean, activeOnly: boolean) => { if (isFetchingRef.current) return; try { @@ -28,7 +29,11 @@ export default function EmployeesPage() { } setError(null); - const response = await apiClient.getEmployees({ page: pageToLoad, limit: 20 }); + const response = await apiClient.getEmployees({ + page: pageToLoad, + limit: 20, + ...(activeOnly && { is_active: true }) + }); const nextChunk = response.results || []; setEmployees((prev) => (append ? [...prev, ...nextChunk] : nextChunk)); @@ -45,8 +50,8 @@ export default function EmployeesPage() { }; useEffect(() => { - loadEmployees(1, false); - }, []); + loadEmployees(1, false, showOnlyActive); + }, [showOnlyActive]); useEffect(() => { if (!hasMore || loading) return; @@ -59,7 +64,7 @@ export default function EmployeesPage() { const first = entries[0]; if (!first?.isIntersecting) return; if (isFetchingRef.current || loadingMore || !hasMore) return; - loadEmployees(page + 1, true); + loadEmployees(page + 1, true, showOnlyActive); }, { root: null, @@ -106,43 +111,50 @@ export default function EmployeesPage() { return ( +
+ + +
+
{sortedEmployees.map((employee) => { const fullName = `${employee.last_name} ${employee.first_name} ${employee.patronymic || ''}`.trim(); const initials = `${employee.last_name?.[0] || ''}${employee.first_name?.[0] || ''}`; const position = employee.position?.name || 'Сотрудник'; - const isOnline = employee.is_active; - const statusText = isOnline ? 'Активен' : 'Неактивен'; - const dotColor = isOnline ? 'bg-green-500' : 'bg-gray-400'; - const statusColor = isOnline ? 'text-green-600' : 'text-gray-500'; return ( -
-
- {employee.avatar ? ( - {fullName} - ) : ( - initials - )} -
- {isOnline ? ( - - ) : null} +
+ {employee.avatar ? ( + {fullName} + ) : ( + initials + )}

{fullName}

{position}

-
- - {statusText} - {employee.email && ( - <> - • - {employee.email} - - )} -
+ {employee.email && ( +

{employee.email}

+ )}
); diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index 91f6d711..5901b82e 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -1188,12 +1188,8 @@ export default function MessageDialogPage() { )}
-
+

{getChatTitle(chat, user?.id)}

- {/* Индикатор WebSocket соединения */} - {isConnected && ( - - )}

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

@@ -1302,9 +1298,6 @@ export default function MessageDialogPage() { getMessageInitials(message) )}
- {message.author?.is_active ? ( - - ) : null}
) : null} diff --git a/frontend/src/app/messages/page.tsx b/frontend/src/app/messages/page.tsx index a77fd735..7b892d24 100644 --- a/frontend/src/app/messages/page.tsx +++ b/frontend/src/app/messages/page.tsx @@ -161,15 +161,6 @@ function getChatSortTimestamp(chat: Chat): number { return Number.isNaN(chatCreatedTs) ? 0 : chatCreatedTs; } -function isChatOnline(chat: Chat, currentUserId?: number): boolean { - const chatKind = chat.chat_type || chat.type; - if (chatKind !== "direct" && chatKind !== "private") return false; - const otherParticipant = (chat.participants || []) - .filter((p): p is Exclude => typeof p === "object" && p !== null) - .find((p) => p.id !== currentUserId); - return Boolean(otherParticipant?.is_active); -} - export default function MessagesPage() { const { user } = useUser(); const router = useRouter(); @@ -710,10 +701,6 @@ export default function MessagesPage() { {getChatTypeIcon(chat)} - {/* Онлайн-статус */} - {isChatOnline(chat, user?.id) ? ( - - ) : null} {/* Счетчик непрочитанных */} {(chat.unread_count ?? 0) > 0 ? ( diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 34e8a4ad..6c6b8b16 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -534,10 +534,6 @@ export default function Home() { const authorInitials = post.author ? `${post.author.last_name?.[0] || ''}${post.author.first_name?.[0] || ''}` : 'А'; - const isAuthorOnline = - typeof post.author?.is_active === 'boolean' - ? post.author.is_active - : Boolean(post.author?.id && user?.id && post.author.id === user.id && user.is_active); const postText = (post.body || post.content || "").trim(); @@ -564,9 +560,6 @@ export default function Home() { authorInitials )} - {isAuthorOnline ? ( - - ) : null}

{authorName}

diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index dec9755e..50bfdac6 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -203,7 +203,6 @@ export default function ProfilePage() { ) : (
{initials}
)} - {user.is_active ? : null}
(); + const router = useRouter(); + const { user: currentUser } = useUser(); const userId = Number(params?.id); const [person, setPerson] = useState(null); const [avatarFailed, setAvatarFailed] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [creatingChat, setCreatingChat] = useState(false); + const [copySuccess, setCopySuccess] = useState(null); + + // Состояния для модального окна редактирования + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isActionModalOpen, setIsActionModalOpen] = useState(false); + const [editFirstName, setEditFirstName] = useState(""); + const [editLastName, setEditLastName] = useState(""); + const [editPatronymic, setEditPatronymic] = useState(""); + const [editEmail, setEditEmail] = useState(""); + const [editPhone, setEditPhone] = useState(""); + const [editTelegram, setEditTelegram] = useState(""); + const [editWhatsApp, setEditWhatsApp] = useState(""); + const [editWeChat, setEditWeChat] = useState(""); + const [editAvatarFile, setEditAvatarFile] = useState(null); + const [editAvatarPreview, setEditAvatarPreview] = useState(null); + const [actionLoading, setActionLoading] = useState(null); + + // Состояния для модального окна кадровых событий + const [actionType, setActionType] = useState(""); + const [actionDate, setActionDate] = useState(new Date().toISOString().split('T')[0]); + const [actionComment, setActionComment] = useState(""); + const [editingActionId, setEditingActionId] = useState(null); useEffect(() => { async function loadUser() { @@ -48,70 +75,977 @@ export default function UserDetailPage() { return `${person.last_name || ""} ${person.first_name || ""} ${person.patronymic || ""}`.trim() || "Пользователь"; }, [person]); + // Последний статус сотрудника + const latestAction = useMemo(() => { + if (!person?.actions || person.actions.length === 0) return null; + return person.actions.reduce((latest, action) => + new Date(action.date) > new Date(latest.date) ? action : latest + ); + }, [person?.actions]); + + // Отсортированная история событий (от новых к старым) + const sortedActions = useMemo(() => { + if (!person?.actions || person.actions.length === 0) return []; + return [...person.actions].sort((a, b) => + new Date(b.date).getTime() - new Date(a.date).getTime() + ); + }, [person?.actions]); + const avatarUrl = resolveMediaUrl(person?.avatar); const initials = `${person?.last_name?.[0] || ""}${person?.first_name?.[0] || ""}` || "П"; + // Копирование в буфер обмена + const copyToClipboard = async (text: string, label: string) => { + try { + await navigator.clipboard.writeText(text); + setCopySuccess(label); + setTimeout(() => setCopySuccess(null), 2000); + } catch (err) { + console.error("Ошибка копирования:", err); + } + }; + + // Создание чата с пользователем + const handleStartChat = async () => { + if (!person || !currentUser || creatingChat) return; + + try { + setCreatingChat(true); + + // Сначала ищем существующий директ-чат с этим пользователем + const chatsResponse = await apiClient.getChats(); + const allChats = chatsResponse.results || chatsResponse; + + // Ищем приватный чат где участники - только мы и нужный пользователь + const existingChat = allChats.find((chat: any) => { + if (chat.type !== 'private') return false; + + const memberIds: number[] = chat.member_ids || []; + return memberIds.length === 2 && + memberIds.includes(currentUser.id) && + memberIds.includes(person.id); + }); + + if (existingChat) { + // Если чат уже существует - переходим в него + router.push(`/messages/${existingChat.id}`); + } else { + // Если не существует - создаем новый + const chat = await apiClient.createChat({ + type: 'private', + name: 'Диалог', + participants: [person.id] + }); + router.push(`/messages/${chat.id}`); + } + } catch (err: any) { + console.error("Ошибка создания чата:", err); + alert("Не удалось открыть чат"); + } finally { + setCreatingChat(false); + } + }; + + // Форматирование телефона для ссылок + const formatPhoneForLink = (phone: string) => { + return phone.replace(/[^0-9+]/g, ''); + }; + + // Вычисление стажа + const getWorkDuration = (dateJoined?: string) => { + if (!dateJoined) return null; + const start = new Date(dateJoined); + const now = new Date(); + const diffMs = now.getTime() - start.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + const years = Math.floor(diffDays / 365); + const months = Math.floor((diffDays % 365) / 30); + + const parts = []; + if (years > 0) parts.push(`${years} ${years === 1 ? 'год' : years < 5 ? 'года' : 'лет'}`); + if (months > 0) parts.push(`${months} ${months === 1 ? 'месяц' : months < 5 ? 'месяца' : 'месяцев'}`); + + return parts.join(' ') || 'меньше месяца'; + }; + + // Форматирование даты рождения (без года) + const formatBirthday = (birthDate?: string) => { + if (!birthDate) return null; + const date = new Date(birthDate); + return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' }); + }; + + // Закрытие модального окна действий + const handleCloseActionModal = () => { + setIsActionModalOpen(false); + setActionType(""); + setActionDate(new Date().toISOString().split('T')[0]); + setActionComment(""); + setEditingActionId(null); + }; + + // Открытие модального окна действий для создания + const handleOpenActionModal = () => { + setEditingActionId(null); + setActionType(""); + setActionDate(new Date().toISOString().split('T')[0]); + setActionComment(""); + setIsActionModalOpen(true); + }; + + // Открытие модального окна для редактирования события + const handleEditAction = (action: any) => { + setEditingActionId(action.id); + setActionType(action.action); + setActionDate(action.date); + setActionComment(action.comment || ""); + setIsActionModalOpen(true); + }; + + // Удаление кадрового события + const handleDeleteAction = async (actionId: number) => { + if (!confirm('Вы уверены, что хотите удалить это событие?')) return; + + setActionLoading(`delete-${actionId}`); + try { + await apiClient.deleteEmployeeAction(actionId); + + // Перезагружаем данные + const refreshedData = await apiClient.getEmployee(userId); + setPerson(refreshedData); + } catch (err: any) { + console.error("Ошибка при удалении события:", err); + alert("Не удалось удалить событие"); + } finally { + setActionLoading(null); + } + }; + + // Сохранение кадрового события + const handleSaveAction = async () => { + if (!person || !userId || !actionType) return; + + setActionLoading("action"); + try { + if (editingActionId) { + // Редактирование существующего события + await apiClient.updateEmployeeAction(editingActionId, { + action: actionType, + date: actionDate, + comment: actionComment.trim() || undefined, + }); + } else { + // Создание нового события + await apiClient.createEmployeeAction({ + employee: userId, + action: actionType, + date: actionDate, + comment: actionComment.trim() || undefined, + }); + } + + // Перезагружаем данные сотрудника + const refreshedData = await apiClient.getEmployee(userId); + setPerson(refreshedData); + + handleCloseActionModal(); + } catch (err: any) { + console.error("Ошибка при создании события:", err); + alert("Не удалось сохранить событие"); + } finally { + setActionLoading(null); + } + }; + + // Открытие модального окна редактирования + const handleOpenEditModal = () => { + if (!person) return; + setEditFirstName(person.first_name || ""); + setEditLastName(person.last_name || ""); + setEditPatronymic(person.patronymic || ""); + setEditEmail(person.email || ""); + setEditPhone(person.phone_number || ""); + setEditTelegram(person.telegram || ""); + setEditWhatsApp(person.whatsapp || ""); + setEditWeChat(person.wechat || ""); + setEditAvatarFile(null); + setEditAvatarPreview(null); + setIsEditModalOpen(true); + }; + + // Закрытие модального окна + const handleCloseEditModal = () => { + setIsEditModalOpen(false); + setEditAvatarFile(null); + setEditAvatarPreview(null); + }; + + // Обработка выбора аватара + const handleAvatarChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + // Проверка размера (макс 5MB) + if (file.size > 5 * 1024 * 1024) { + alert("Файл слишком большой. Максимальный размер: 5MB"); + return; + } + + // Проверка типа + if (!file.type.startsWith("image/")) { + alert("Можно загружать только изображения"); + return; + } + + setEditAvatarFile(file); + + // Создаём preview + const reader = new FileReader(); + reader.onloadend = () => { + setEditAvatarPreview(reader.result as string); + }; + reader.readAsDataURL(file); + }; + + // Сохранение изменений + const handleSaveEdit = async () => { + if (!person || !userId) return; + + setActionLoading("edit"); + try { + // Обновляем данные сотрудника + const updatedData = await apiClient.updateEmployee(userId, { + first_name: editFirstName.trim(), + last_name: editLastName.trim(), + patronymic: editPatronymic.trim() || undefined, + email: editEmail.trim(), + phone_number: editPhone.trim() || undefined, + telegram: editTelegram.trim() || undefined, + whatsapp: editWhatsApp.trim() || undefined, + wechat: editWeChat.trim() || undefined, + }); + + // Загружаем аватар если выбран + if (editAvatarFile) { + await apiClient.uploadEmployeeAvatar(userId, editAvatarFile); + } + + // Перезагружаем данные сотрудника + const refreshedData = await apiClient.getEmployee(userId); + setPerson(refreshedData); + + handleCloseEditModal(); + } catch (err: any) { + console.error("Ошибка при обновлении сотрудника:", err); + alert("Не удалось обновить данные"); + } finally { + setActionLoading(null); + } + }; + + // Проверка прав на редактирование + const canEdit = currentUser && ( + currentUser.auth?.is_staff || + currentUser.auth?.is_superuser || + currentUser.id === userId + ); + + // Проверка прав на управление кадровыми событиями (только админы) + const canManageActions = currentUser && ( + currentUser.auth?.is_staff || + currentUser.auth?.is_superuser + ); + + // Список типов кадровых событий + const actionTypes = [ + { value: 'hired', label: 'Принят' }, + { value: 'dismissed', label: 'Уволен' }, + { value: 'on_leave', label: 'В отпуске' }, + { value: 'returned_from_leave', label: 'Вернулся из отпуска' }, + { value: 'on_maternity', label: 'В декрете' }, + { value: 'returned_from_maternity', label: 'Вернулся из декрета' }, + { value: 'transferred', label: 'Переведен' }, + { value: 'rehired', label: 'Восстановлен' }, + ]; + return (
- ← К списку пользователей + ← К списку сотрудников {loading ? (
-

Загрузка пользователя...

+

Загрузка...

) : error ? (

{error}

) : person ? ( -
-
-
- {avatarUrl && !avatarFailed ? ( - {fullName} setAvatarFailed(true)} - /> - ) : ( - {initials} - )} -
+ <> + {/* Основная информация */} +
+
+
+ {avatarUrl && !avatarFailed ? ( + {fullName} setAvatarFailed(true)} + /> + ) : ( + {initials} + )} +
-
-

{fullName}

-

Должность: {person.position?.name || "—"}

-

Email: {person.email || "—"}

-

Телефон: {person.phone_number || "—"}

- {person.telegram ?

Telegram: {person.telegram}

: null} - {person.whatsapp ?

WhatsApp: {person.whatsapp}

: null} - {person.wechat ?

WeChat: {person.wechat}

: null} +
+
+

{fullName}

+ {canEdit && ( + + )} +
+
+

{person.position?.name || "—"}

+ {latestAction && ( + + {latestAction.action_display || latestAction.action} + + )} + {canManageActions && ( + + )} +
+ {person.departments && person.departments.length > 0 && ( +

{person.departments[0].name}

+ )} + + {currentUser && currentUser.id !== person.id && ( +
+ + + {person.phone_number && ( + + + Позвонить + + )} +
+ )} +
+
+ + {/* Контакты и детали */} +
+ {/* Контакты */} +
+

Контакты

+
+ {person.email && ( +
+ + +
+ )} + + {person.phone_number && ( +
+ + +
+ )} + + {person.telegram && ( +
+ + + {person.telegram} + + Telegram +
+ )} + + {person.whatsapp && ( +
+ + + {person.whatsapp} + + WhatsApp +
+ )} + + {person.wechat && ( +
+ + {person.wechat} + WeChat +
+ )} + + {!person.email && !person.phone_number && !person.telegram && !person.whatsapp && !person.wechat && ( +

Нет контактов

+ )} +
+
+ + {/* Навыки */} +
+

+ + Навыки +

+ {person.skills && person.skills.length > 0 ? ( +
+ {person.skills.map((skill) => ( + + {skill.name} + + ))} +
+ ) : ( +

Навыки не указаны

+ )} +
+ + {/* Информация */} + {(person.date_joined || person.birth_date) && ( +
+

Информация

+
+ {person.date_joined && ( + <> +
+ +
+

В компании

+

+ {getWorkDuration(person.date_joined)} +

+
+
+
+ +
+

Дата найма

+

+ {new Date(person.date_joined).toLocaleDateString('ru-RU')} +

+
+
+ + )} + + {person.birth_date && ( +
+ +
+

День рождения

+

+ {formatBirthday(person.birth_date)} +

+
+
+ )} +
+
+ )}
-
-

Отделы

- {(person.departments || []).length === 0 ? ( -

Не указаны

- ) : ( + {/* Отделы */} + {person.departments && person.departments.length > 0 && ( +
+

+ + Отделы +

- {(person.departments || []).map((d) => ( - - {d.name} - {d.is_head ? Руководитель : null} + {person.departments.map((dept) => ( + +
+

{dept.name}

+ {dept.role_name && ( +

{dept.role_name}

+ )} +
+ {dept.is_head && ( + + Руководитель + + )} ))}
- )} -
-
+ + )} + + {/* История кадровых событий */} + {sortedActions.length > 0 && ( +
+
+

+ + История кадровых событий +

+
+ + {sortedActions.length} {sortedActions.length === 1 ? 'событие' : sortedActions.length < 5 ? 'события' : 'событий'} + + {canManageActions && ( + + )} +
+
+
+ {sortedActions.map((action, index) => ( +
+
+
+
+
+ + {action.action_display || action.action} + + {index === 0 && ( + текущий + )} +
+ {action.comment && ( +

{action.comment}

+ )} +
+
+ + {canManageActions && ( +
+ + +
+ )} +
+
+
+
+ ))} +
+
+ )} + ) : null}
+ + {/* Модальное окно редактирования */} + {isEditModalOpen && person && ( +
+
+
+

Редактировать профиль

+ +
+ +
+ {/* Аватар */} +
+ +
+
+ {editAvatarPreview ? ( + Preview + ) : avatarUrl && !avatarFailed ? ( + Avatar + ) : ( + {initials} + )} +
+ +
+

+ Максимальный размер: 5MB. Форматы: JPG, PNG, GIF +

+
+ + {/* ФИО */} +
+
+ + setEditLastName(e.target.value)} + placeholder="Иванов" + className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20" + maxLength={150} + /> +
+ +
+ + setEditFirstName(e.target.value)} + placeholder="Иван" + className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20" + maxLength={150} + /> +
+ +
+ + setEditPatronymic(e.target.value)} + placeholder="Иванович" + className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20" + maxLength={150} + /> +
+
+ + {/* Контакты */} +
+
+ + setEditEmail(e.target.value)} + placeholder="ivan@example.com" + className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20" + /> +
+ +
+ + setEditPhone(e.target.value)} + placeholder="+7 999 123-45-67" + className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20" + /> +
+
+ + {/* Мессенджеры */} +
+
+ + setEditTelegram(e.target.value)} + placeholder="@username" + className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20" + /> +
+ +
+ + setEditWhatsApp(e.target.value)} + placeholder="+7 999 123-45-67" + className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20" + /> +
+ +
+ + setEditWeChat(e.target.value)} + placeholder="username" + className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20" + /> +
+
+
+ + {/* Кнопки */} +
+ + +
+
+
+ )} + + {/* Модальное окно создания кадрового события */} + {isActionModalOpen && person && ( +
+
+
+

+ {editingActionId ? 'Редактировать событие' : 'Кадровое событие'} +

+ +
+ +
+ {/* Тип события */} +
+ + +
+ + {/* Дата */} +
+ + setActionDate(e.target.value)} + className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20" + /> +
+ + {/* Комментарий */} +
+ +