diff --git a/frontend/src/app/users/[id]/page.tsx b/frontend/src/app/users/[id]/page.tsx index d5bf49fe..ca87d762 100644 --- a/frontend/src/app/users/[id]/page.tsx +++ b/frontend/src/app/users/[id]/page.tsx @@ -1,390 +1,73 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; -import { useParams, useRouter } from "next/navigation"; import Link from "next/link"; -import { Mail, Phone, MessageCircle, Copy, Check, Building2, Award, Calendar, Clock, Pencil, X, History, Trash2 } from "lucide-react"; +import { useParams } from "next/navigation"; +import { + Award, + Building2, + Calendar, + Check, + Clock, + Copy, + Mail, + MessageCircle, + Pencil, + Phone, +} from "lucide-react"; import { AppShell } from "../../../components/AppShell"; -import { apiClient } from "@/lib/api"; -import { resolveMediaUrl } from "@/lib/url"; +import EditUserProfileModal from "@/components/users/EditUserProfileModal"; +import EmployeeActionModal from "@/components/users/EmployeeActionModal"; +import EmployeeActionsTimeline from "@/components/users/EmployeeActionsTimeline"; import { useUser } from "@/contexts/UserContext"; -import type { User } from "@/types/api"; +import { useUserDetailPage } from "@/hooks/useUserDetailPage"; +import { + formatBirthday, + formatPhoneForLink, + getEmployeeActionBadgeClass, + getWorkDuration, +} from "@/lib/users/userDetailUtils"; export default function UserDetailPage() { const params = useParams<{ id: string }>(); - 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() { - if (!userId || Number.isNaN(userId)) { - setError("Некорректный идентификатор пользователя"); - setLoading(false); - return; - } - try { - setLoading(true); - setError(null); - const data = await apiClient.getEmployee(userId); - setPerson(data); - } catch (e: any) { - setError(String(e?.message || "Не удалось загрузить пользователя")); - } finally { - setLoading(false); - } - } - - loadUser(); - }, [userId]); - - useEffect(() => { - setAvatarFailed(false); - }, [person?.avatar, person?.id]); - - const fullName = useMemo(() => { - if (!person) return ""; - return `${person.last_name || ""} ${person.first_name || ""} ${person.patronymic || ""}`.trim() || "Пользователь"; - }, [person]); - - // Последний статус сотрудника (только события которые уже наступили) - const latestAction = useMemo(() => { - if (!person?.actions || person.actions.length === 0) return null; - - const now = new Date(); - // Фильтруем только события которые уже наступили - const pastActions = person.actions.filter(action => - new Date(action.date) <= now - ); - - if (pastActions.length === 0) return null; - - // Из них берём последнее - return pastActions.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 canViewActions = currentUser && ( - currentUser.auth?.is_staff || - currentUser.auth?.is_superuser || - currentUser.id === userId // Сам пользователь может видеть свою историю - ); - - // Список типов кадровых событий - 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: 'Восстановлен' }, - ]; + const { + actionForm, + actionLoading, + actionTypes, + avatarFailed, + avatarUrl, + canEdit, + canManageActions, + canViewActions, + copySuccess, + creatingChat, + editForm, + error, + fullName, + handleAvatarChange, + handleCloseActionModal, + handleCloseEditModal, + handleCopyToClipboard, + handleDeleteAction, + handleEditAction, + handleOpenActionModal, + handleOpenEditModal, + handleSaveAction, + handleSaveEdit, + handleStartChat, + initials, + isActionModalOpen, + isEditModalOpen, + latestAction, + loading, + person, + setActionField, + setAvatarFailed, + setEditField, + sortedActions, + } = useUserDetailPage(userId, currentUser); return ( @@ -404,11 +87,11 @@ export default function UserDetailPage() { ) : person ? ( <> - {/* Основная информация */}
{avatarUrl && !avatarFailed ? ( + // eslint-disable-next-line @next/next/no-img-element {fullName} -
+

{fullName}

{canEdit && ( @@ -438,20 +121,14 @@ export default function UserDetailPage() {

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

{latestAction && ( - + {latestAction.action_display || latestAction.action} )} {canManageActions && ( @@ -469,9 +146,9 @@ export default function UserDetailPage() { className="inline-flex items-center gap-2 rounded-lg bg-sky-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-50" > - {creatingChat ? 'Загрузка...' : 'Написать'} + {creatingChat ? "Загрузка..." : "Написать"} - + {person.phone_number && (
- {/* Контакты и детали */}
- {/* Контакты */}

Контакты

{person.email && (
-
- - +
)} {person.phone_number && (
-
- - +
)} {person.telegram && (
- + {person.telegram} @@ -544,12 +219,12 @@ export default function UserDetailPage() { {person.whatsapp && (
- + {person.whatsapp} @@ -559,8 +234,8 @@ export default function UserDetailPage() { {person.wechat && (
- - {person.wechat} + + {person.wechat} WeChat
)} @@ -571,7 +246,6 @@ export default function UserDetailPage() {
- {/* Навыки */}

@@ -593,7 +267,6 @@ export default function UserDetailPage() { )}

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

Информация

@@ -604,9 +277,7 @@ export default function UserDetailPage() {

В компании

-

- {getWorkDuration(person.date_joined)} -

+

{getWorkDuration(person.date_joined)}

@@ -614,21 +285,19 @@ export default function UserDetailPage() {

Дата найма

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

)} - + {person.birth_date && (

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

-

- {formatBirthday(person.birth_date)} -

+

{formatBirthday(person.birth_date)}

)} @@ -637,7 +306,6 @@ export default function UserDetailPage() { )} - {/* Отделы */} {person.departments && person.departments.length > 0 && (

@@ -645,19 +313,19 @@ export default function UserDetailPage() { Отделы

- {person.departments.map((dept) => ( + {person.departments.map((department) => (
-

{dept.name}

- {dept.role_name && ( -

{dept.role_name}

+

{department.name}

+ {department.role_name && ( +

{department.role_name}

)}
- {dept.is_head && ( + {department.is_head && ( Руководитель @@ -668,406 +336,43 @@ export default function UserDetailPage() {
)} - {/* История кадровых событий */} - {canViewActions && sortedActions.length > 0 && ( -
-
-

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

-
- - {sortedActions.length} {sortedActions.length === 1 ? 'событие' : sortedActions.length < 5 ? 'события' : 'событий'} - - {canManageActions && ( - - )} -
-
-
- {sortedActions.map((action, index) => { - // Проверяем является ли это событие текущим (последнее из наступивших) - const isCurrent = latestAction?.id === action.id; - - return ( -
-
-
-
-
- - {action.action_display || action.action} - - {isCurrent && ( - текущий - )} -
- {action.comment && ( -

{action.comment}

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

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

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

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

-
+ setEditField(field, value)} + person={person} + /> - {/* ФИО */} -
-
- - 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" - /> -
- - {/* Комментарий */} -
- -