408 lines
13 KiB
TypeScript
408 lines
13 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useMemo, useState, type ChangeEvent } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { apiClient } from "@/lib/api";
|
||
import { resolveMediaUrl } from "@/lib/url";
|
||
import {
|
||
createEmptyActionForm,
|
||
createEmptyEditForm,
|
||
employeeActionTypes,
|
||
getLatestEmployeeAction,
|
||
getUserFullName,
|
||
getUserInitials,
|
||
isTemporaryEmployeeAction,
|
||
type EmployeeActionField,
|
||
type EmployeeActionForm,
|
||
type UserProfileEditForm,
|
||
type UserProfileTextField,
|
||
sortEmployeeActions,
|
||
} from "@/lib/users/userDetailUtils";
|
||
import type { Chat, EmployeeAction, Position, User } from "@/types/api";
|
||
|
||
type ChatWithMemberIds = Chat & { member_ids?: number[] };
|
||
|
||
const getErrorMessage = (error: unknown, fallback: string): string => (
|
||
String((error as Error)?.message || fallback)
|
||
);
|
||
|
||
export function useUserDetailPage(userId: number, currentUser: User | null) {
|
||
const router = useRouter();
|
||
|
||
const [person, setPerson] = useState<User | null>(null);
|
||
const [avatarFailed, setAvatarFailed] = useState(false);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [creatingChat, setCreatingChat] = useState(false);
|
||
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;
|
||
setPerson(data);
|
||
return data;
|
||
}, [userId]);
|
||
|
||
useEffect(() => {
|
||
async function loadUser() {
|
||
if (!userId || Number.isNaN(userId)) {
|
||
setError("Некорректный идентификатор пользователя");
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setLoading(true);
|
||
setError(null);
|
||
await refreshPerson();
|
||
} catch (loadError) {
|
||
setError(getErrorMessage(loadError, "Не удалось загрузить пользователя"));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
void loadUser();
|
||
}, [refreshPerson, userId]);
|
||
|
||
useEffect(() => {
|
||
setAvatarFailed(false);
|
||
}, [person?.avatar, person?.id]);
|
||
|
||
const fullName = useMemo(() => getUserFullName(person), [person]);
|
||
|
||
const latestAction = useMemo(() => getLatestEmployeeAction(person?.actions), [person?.actions]);
|
||
const sortedActions = useMemo(() => sortEmployeeActions(person?.actions), [person?.actions]);
|
||
|
||
const avatarUrl = resolveMediaUrl(person?.avatar);
|
||
const initials = getUserInitials(person);
|
||
|
||
const copyToClipboard = useCallback(async (text: string, label: string) => {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
setCopySuccess(label);
|
||
window.setTimeout(() => setCopySuccess(null), 2000);
|
||
} catch (copyError) {
|
||
console.error("Ошибка копирования:", copyError);
|
||
}
|
||
}, []);
|
||
|
||
const handleStartChat = useCallback(async () => {
|
||
if (!person || !currentUser || creatingChat) return;
|
||
|
||
try {
|
||
setCreatingChat(true);
|
||
|
||
const allChats = await apiClient.getAllChats() as ChatWithMemberIds[];
|
||
const existingChat = allChats.find((chat) => {
|
||
if (chat.type !== "private") return false;
|
||
|
||
const memberIds = Array.isArray(chat.member_ids) ? chat.member_ids : [];
|
||
return memberIds.length === 2 && memberIds.includes(currentUser.id) && memberIds.includes(person.id);
|
||
});
|
||
|
||
if (existingChat) {
|
||
router.push(`/messages/${existingChat.id}`);
|
||
return;
|
||
}
|
||
|
||
const chat = await apiClient.createChat({
|
||
type: "private",
|
||
name: "Диалог",
|
||
participants: [person.id],
|
||
}) as Chat;
|
||
router.push(`/messages/${chat.id}`);
|
||
} catch (chatError) {
|
||
console.error("Ошибка создания чата:", chatError);
|
||
alert("Не удалось открыть чат");
|
||
} finally {
|
||
setCreatingChat(false);
|
||
}
|
||
}, [creatingChat, currentUser, person, router]);
|
||
|
||
const handleOpenActionModal = useCallback(() => {
|
||
setActionForm(createEmptyActionForm());
|
||
setIsActionModalOpen(true);
|
||
}, []);
|
||
|
||
const handleCloseActionModal = useCallback(() => {
|
||
setIsActionModalOpen(false);
|
||
setActionForm(createEmptyActionForm());
|
||
}, []);
|
||
|
||
const handleEditAction = useCallback((action: EmployeeAction) => {
|
||
setActionForm({
|
||
editingId: action.id,
|
||
type: action.action,
|
||
date: action.date.split("T")[0],
|
||
dateTo: action.date_to || "",
|
||
comment: action.comment || "",
|
||
});
|
||
setIsActionModalOpen(true);
|
||
}, []);
|
||
|
||
const handleDeleteAction = useCallback(async (actionId: number) => {
|
||
if (!confirm("Вы уверены, что хотите удалить это событие?")) return;
|
||
|
||
setActionLoading(`delete-${actionId}`);
|
||
try {
|
||
await apiClient.deleteEmployeeAction(actionId);
|
||
await refreshPerson();
|
||
} catch (deleteError) {
|
||
console.error("Ошибка при удалении события:", deleteError);
|
||
alert("Не удалось удалить событие");
|
||
} finally {
|
||
setActionLoading(null);
|
||
}
|
||
}, [refreshPerson]);
|
||
|
||
const handleSaveAction = useCallback(async () => {
|
||
if (!person || !userId || !actionForm.type) return;
|
||
|
||
setActionLoading("action");
|
||
try {
|
||
if (actionForm.editingId) {
|
||
await apiClient.updateEmployeeAction(actionForm.editingId, {
|
||
action: actionForm.type,
|
||
date: actionForm.date,
|
||
date_to: isTemporaryEmployeeAction(actionForm.type)
|
||
? actionForm.dateTo || null
|
||
: null,
|
||
comment: actionForm.comment.trim() || undefined,
|
||
});
|
||
} else {
|
||
await apiClient.createEmployeeAction({
|
||
employee: userId,
|
||
action: actionForm.type,
|
||
date: actionForm.date,
|
||
date_to: isTemporaryEmployeeAction(actionForm.type)
|
||
? actionForm.dateTo || null
|
||
: null,
|
||
comment: actionForm.comment.trim() || undefined,
|
||
});
|
||
}
|
||
|
||
await refreshPerson();
|
||
handleCloseActionModal();
|
||
} catch (saveError) {
|
||
console.error("Ошибка при создании события:", saveError);
|
||
alert("Не удалось сохранить событие");
|
||
} finally {
|
||
setActionLoading(null);
|
||
}
|
||
}, [actionForm, handleCloseActionModal, person, refreshPerson, userId]);
|
||
|
||
const handleOpenEditModal = useCallback(() => {
|
||
if (!person) return;
|
||
|
||
setEditForm({
|
||
firstName: person.first_name || "",
|
||
lastName: person.last_name || "",
|
||
patronymic: person.patronymic || "",
|
||
email: person.email || "",
|
||
phone: person.phone_number || "",
|
||
telegram: person.telegram || "",
|
||
whatsapp: person.whatsapp || "",
|
||
wechat: person.wechat || "",
|
||
avatarFile: null,
|
||
avatarPreview: null,
|
||
});
|
||
setIsEditModalOpen(true);
|
||
}, [person]);
|
||
|
||
const handleCloseEditModal = useCallback(() => {
|
||
setIsEditModalOpen(false);
|
||
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;
|
||
|
||
if (file.size > 5 * 1024 * 1024) {
|
||
alert("Файл слишком большой. Максимальный размер: 5MB");
|
||
return;
|
||
}
|
||
|
||
if (!file.type.startsWith("image/")) {
|
||
alert("Можно загружать только изображения");
|
||
return;
|
||
}
|
||
|
||
setEditForm((current) => ({ ...current, avatarFile: file }));
|
||
|
||
const reader = new FileReader();
|
||
reader.onloadend = () => {
|
||
setEditForm((current) => ({
|
||
...current,
|
||
avatarFile: file,
|
||
avatarPreview: typeof reader.result === "string" ? reader.result : null,
|
||
}));
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}, []);
|
||
|
||
const setEditField = useCallback(<K extends UserProfileTextField>(field: K, value: UserProfileEditForm[K]) => {
|
||
setEditForm((current) => ({ ...current, [field]: value }));
|
||
}, []);
|
||
|
||
const setActionField = useCallback(<K extends EmployeeActionField>(field: K, value: EmployeeActionForm[K]) => {
|
||
setActionForm((current) => {
|
||
const next = { ...current, [field]: value };
|
||
if (field === "type" && !isTemporaryEmployeeAction(String(value))) {
|
||
next.dateTo = "";
|
||
}
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
const handleSaveEdit = useCallback(async () => {
|
||
if (!person || !userId) return;
|
||
|
||
setActionLoading("edit");
|
||
try {
|
||
await apiClient.updateEmployee(userId, {
|
||
first_name: editForm.firstName.trim(),
|
||
last_name: editForm.lastName.trim(),
|
||
patronymic: editForm.patronymic.trim() || undefined,
|
||
email: editForm.email.trim(),
|
||
phone_number: editForm.phone.trim() || undefined,
|
||
telegram: editForm.telegram.trim() || undefined,
|
||
whatsapp: editForm.whatsapp.trim() || undefined,
|
||
wechat: editForm.wechat.trim() || undefined,
|
||
});
|
||
|
||
if (editForm.avatarFile) {
|
||
await apiClient.uploadEmployeeAvatar(userId, editForm.avatarFile);
|
||
}
|
||
|
||
await refreshPerson();
|
||
handleCloseEditModal();
|
||
} catch (saveError) {
|
||
console.error("Ошибка при обновлении сотрудника:", saveError);
|
||
alert("Не удалось обновить данные");
|
||
} finally {
|
||
setActionLoading(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 ||
|
||
currentUser.id === userId
|
||
));
|
||
|
||
const canManageActions = Boolean(currentUser && (
|
||
currentUser.auth?.is_staff ||
|
||
currentUser.auth?.is_superuser
|
||
));
|
||
|
||
const canViewActions = Boolean(currentUser && (
|
||
currentUser.auth?.is_staff ||
|
||
currentUser.auth?.is_superuser ||
|
||
currentUser.id === userId
|
||
));
|
||
|
||
return {
|
||
actionForm,
|
||
actionLoading,
|
||
actionTypes: employeeActionTypes,
|
||
avatarFailed,
|
||
avatarUrl,
|
||
canEdit,
|
||
canManageActions,
|
||
canViewActions,
|
||
copySuccess,
|
||
creatingChat,
|
||
editForm,
|
||
error,
|
||
fullName,
|
||
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,
|
||
};
|
||
}
|