refactor(frontend): align profile page controllers
This commit is contained in:
parent
55f3171636
commit
fe040e2da9
@ -202,6 +202,8 @@
|
||||
- `users/EditUserProfileModal.tsx`
|
||||
- `users/EmployeeActionModal.tsx`
|
||||
- `users/EmployeeActionsTimeline.tsx`
|
||||
- `hooks/useProfilePage.ts`
|
||||
- `hooks/useUserDetailPage.ts`
|
||||
|
||||
Использовать как референс для:
|
||||
|
||||
@ -209,6 +211,7 @@
|
||||
- profile sections
|
||||
- info cards
|
||||
- action timeline
|
||||
- page-level profile controllers, которые держат loading/derived state/API orchestration вне page JSX
|
||||
|
||||
### Messages
|
||||
|
||||
|
||||
@ -30,7 +30,7 @@
|
||||
| `procurement` | `needs refactor` | крупные панели и часть detail blocks уже читаются неплохо, есть shared-like detail content | большой page-file, gray/white legacy в stats/suppliers/forms, смешение operational page и старых local panels | нормализовать page panels, suppliers/stats/forms и только потом решать дальнейшую декомпозицию |
|
||||
| `calendar` | `legacy-heavy` | богатая функциональность, много reusable доменных компонентов | historical layering, legacy-термины и неоднородные модалки | отдельный audit до системного refactor |
|
||||
| `messages` | `mixed` | зрелая interaction model, сильный composer и message item, app-style в основных экранах в целом выдержан | очень доменно-специфичный UX, крупные page-files, часть settings/create/chat-experiment flows ещё шумят по state/legacy dialogs | использовать выборочно как reference, без механического копирования в другие зоны; дальше резать state-heavy pages |
|
||||
| `users/profile` | `needs refactor` | хорошие profile sections, hero-like anatomy, action timeline, переиспользуемые section-level components | профиль визуально ощущается как screen из другого проекта, `/profile` и `/users/[id]` архитектурно несимметричны, модалки и часть users-list screens живут в gray/white legacy language | держать как reference по profile anatomy, но сделать отдельный alignment-pass по people/profile cluster без превращения его в request-like layout |
|
||||
| `users/profile` | `needs refactor` | хорошие profile sections, hero-like anatomy, action timeline, переиспользуемые section-level components, `/profile` и `/users/[id]` теперь оба опираются на page-level controllers | профиль всё ещё визуально ощущается как screen из другого проекта, `ProfileSections` держат custom hero geometry, users modals и часть users-list screens ещё тянут cluster назад | держать как reference по profile anatomy, но продолжить alignment-pass по people/profile cluster без превращения его в request-like layout |
|
||||
| `users/directory` | `needs refactor` | простые сценарии, понятные employee/user navigation flows | `users/page.tsx` и часть directory screens всё ещё в white/gray card language, визуально выпадают из app surfaces | после profile cluster пройтись по user/employee lists и привести их к общему app-shell contract |
|
||||
| `home/feed` | `mixed` | сильный app-shell fit, хорошая operational density, уже много app primitives | page очень большой, комментарии и post-compose logic перегружены, встречаются legacy `rounded-md` fragments | использовать как reference по feed density, но разрезать modal/comments/post flows и дочистить geometry |
|
||||
| `notifications` | `mixed` | близок к operational app-style, фильтры и list hierarchy уже читаются системно | page-level logic пока монолитная, нет выделенного feature controller, есть место для уплотнения и симметрии с messages/requests | после people cluster сделать короткий structural pass без крупного редизайна |
|
||||
@ -75,8 +75,8 @@
|
||||
|
||||
Что делать:
|
||||
|
||||
- вынести controller для `/profile` по аналогии с `useUserDetailPage`
|
||||
- убрать gray/white modal/forms debt из `EditUserProfileModal` и `EmployeeActionModal`
|
||||
- `/profile` уже переведён на page-level controller по аналогии с `useUserDetailPage`
|
||||
- `EditUserProfileModal` и `EmployeeActionModal` уже подтянуты к app-style primitives
|
||||
- нормализовать `ProfileSections` по геометрии без уничтожения hero/section contract
|
||||
- привести `users/page.tsx` к app-surface language
|
||||
|
||||
|
||||
@ -1,13 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Mail,
|
||||
MessageCircle,
|
||||
Phone,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { Mail, MessageCircle, Phone, RefreshCw } from "lucide-react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import EmployeeActionsTimeline from "@/components/users/EmployeeActionsTimeline";
|
||||
@ -18,378 +12,83 @@ import {
|
||||
ProfileInfoCard,
|
||||
ProfileSkillsCard,
|
||||
type ProfileContactRow,
|
||||
type ProfileInfoItem,
|
||||
} from "@/components/users/ProfileSections";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { resolveMediaUrl } from "@/lib/url";
|
||||
import {
|
||||
formatBirthdayWithYear,
|
||||
formatProfileDate,
|
||||
formatProfileDateTime,
|
||||
getEmployeeActionTone,
|
||||
getLatestEmployeeAction,
|
||||
getWorkDuration,
|
||||
getUserFullName,
|
||||
normalizeTelegramLink,
|
||||
normalizeWhatsAppLink,
|
||||
sortEmployeeActions,
|
||||
} from "@/lib/users/userDetailUtils";
|
||||
import type { DirectoryLoginResult } from "@/types/api";
|
||||
useProfilePage,
|
||||
type ProfileContactEntry,
|
||||
} from "@/hooks/useProfilePage";
|
||||
import { formatBirthdayWithYear, getEmployeeActionTone } from "@/lib/users/userDetailUtils";
|
||||
import { resolveMediaUrl } from "@/lib/url";
|
||||
|
||||
function initials(firstName?: string, lastName?: string) {
|
||||
return `${lastName?.[0] || ""}${firstName?.[0] || ""}`.trim() || "П";
|
||||
}
|
||||
|
||||
function getContactIcon(entry: ProfileContactEntry) {
|
||||
switch (entry.kind) {
|
||||
case "email":
|
||||
return <Mail size={18} />;
|
||||
case "phone":
|
||||
return <Phone size={18} />;
|
||||
case "directory-login":
|
||||
return (
|
||||
<RefreshCw
|
||||
size={18}
|
||||
className={entry.refreshing ? "animate-spin" : ""}
|
||||
/>
|
||||
);
|
||||
case "telegram":
|
||||
case "whatsapp":
|
||||
case "wechat":
|
||||
default:
|
||||
return <MessageCircle size={18} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user, loading } = useUser();
|
||||
const [copiedContactKey, setCopiedContactKey] = useState<string | null>(null);
|
||||
const [directoryLogin, setDirectoryLogin] = useState<string | null>(null);
|
||||
const [directoryLoginLoading, setDirectoryLoginLoading] = useState(false);
|
||||
const [directoryLoginRefreshing, setDirectoryLoginRefreshing] =
|
||||
useState(false);
|
||||
const [directoryLoginError, setDirectoryLoginError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [availableSkills, setAvailableSkills] = useState<
|
||||
Array<{ id: number; name: string; description?: string }>
|
||||
>([]);
|
||||
const [profileSkills, setProfileSkills] = useState<
|
||||
Array<{ id: number; name: string; description?: string }>
|
||||
>([]);
|
||||
const [skillsLoading, setSkillsLoading] = useState(false);
|
||||
const [skillsSaving, setSkillsSaving] = useState(false);
|
||||
const [skillName, setSkillName] = useState("");
|
||||
const [skillsError, setSkillsError] = useState<string | null>(null);
|
||||
|
||||
const fullName = useMemo(() => {
|
||||
return getUserFullName(user) || "Пользователь";
|
||||
}, [user]);
|
||||
|
||||
const currentAction = useMemo(() => {
|
||||
return getLatestEmployeeAction(user?.actions);
|
||||
}, [user?.actions]);
|
||||
|
||||
const sortedActions = useMemo(() => {
|
||||
return sortEmployeeActions(user?.actions);
|
||||
}, [user?.actions]);
|
||||
|
||||
useEffect(() => {
|
||||
setDirectoryLogin(user?.username?.trim() || null);
|
||||
setDirectoryLoginError(null);
|
||||
}, [user?.username]);
|
||||
|
||||
useEffect(() => {
|
||||
setProfileSkills(user?.skills || []);
|
||||
}, [user?.skills]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
async function loadDirectoryLogin() {
|
||||
if (!user?.is_ldap_managed || user.username?.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setDirectoryLoginLoading(true);
|
||||
setDirectoryLoginError(null);
|
||||
const response =
|
||||
(await apiClient.getDirectoryLogin()) as DirectoryLoginResult;
|
||||
if (!mounted) return;
|
||||
setDirectoryLogin(response.username?.trim() || null);
|
||||
} catch (error: any) {
|
||||
if (!mounted) return;
|
||||
setDirectoryLoginError(
|
||||
String(error?.message || "Не удалось получить логин из каталога"),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setDirectoryLoginLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadDirectoryLogin();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [user?.id, user?.is_ldap_managed, user?.username]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
async function loadSkills() {
|
||||
try {
|
||||
setSkillsLoading(true);
|
||||
const response = (await apiClient.getSkills()) as Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
}>;
|
||||
if (!mounted) return;
|
||||
setAvailableSkills(response);
|
||||
} catch (error: any) {
|
||||
if (!mounted) return;
|
||||
setSkillsError(
|
||||
String(error?.message || "Не удалось загрузить список навыков"),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setSkillsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadSkills();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleRefreshDirectoryLogin = async () => {
|
||||
try {
|
||||
setDirectoryLoginRefreshing(true);
|
||||
setDirectoryLoginError(null);
|
||||
const response =
|
||||
(await apiClient.refreshDirectoryLogin()) as DirectoryLoginResult;
|
||||
setDirectoryLogin(response.username?.trim() || null);
|
||||
} catch (error: any) {
|
||||
setDirectoryLoginError(
|
||||
String(error?.message || "Не удалось обновить логин из каталога"),
|
||||
);
|
||||
} finally {
|
||||
setDirectoryLoginRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyContact = async (value: string, key: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopiedContactKey(key);
|
||||
window.setTimeout(() => setCopiedContactKey((current) => (current === key ? null : current)), 2000);
|
||||
} catch (error) {
|
||||
console.error("Ошибка копирования:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSkills = async (skillIds: number[]) => {
|
||||
try {
|
||||
setSkillsSaving(true);
|
||||
setSkillsError(null);
|
||||
const response = (await apiClient.updateCurrentUserProfile({
|
||||
skills_ids: skillIds,
|
||||
})) as {
|
||||
skills?: Array<{ id: number; name: string; description?: string }>;
|
||||
};
|
||||
setProfileSkills(response.skills || []);
|
||||
} catch (error: any) {
|
||||
setSkillsError(
|
||||
String(error?.message || "Не удалось обновить навыки"),
|
||||
);
|
||||
} finally {
|
||||
setSkillsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveSkill = async (skillId: number) => {
|
||||
if (!user) return;
|
||||
const nextIds = profileSkills
|
||||
.map((skill) => skill.id)
|
||||
.filter((id) => id !== skillId);
|
||||
await saveSkills(nextIds);
|
||||
};
|
||||
|
||||
const handleAddSkill = async (forcedSkill?: { id: number; name: string }) => {
|
||||
if (!user) return;
|
||||
const rawName = forcedSkill?.name || skillName;
|
||||
const trimmedName = rawName.trim();
|
||||
if (!trimmedName || skillsSaving) return;
|
||||
|
||||
try {
|
||||
setSkillsSaving(true);
|
||||
setSkillsError(null);
|
||||
|
||||
const existingSkill =
|
||||
forcedSkill ||
|
||||
availableSkills.find(
|
||||
(skill) => skill.name.trim().toLowerCase() === trimmedName.toLowerCase(),
|
||||
);
|
||||
|
||||
let skillId = existingSkill?.id;
|
||||
|
||||
if (!skillId) {
|
||||
const createdSkill = (await apiClient.createSkill({
|
||||
name: trimmedName,
|
||||
})) as { id: number; name: string; description?: string };
|
||||
skillId = createdSkill.id;
|
||||
setAvailableSkills((current) =>
|
||||
[...current, createdSkill].sort((left, right) =>
|
||||
left.name.localeCompare(right.name, "ru"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const nextIds = Array.from(
|
||||
new Set([...profileSkills.map((skill) => skill.id), skillId]),
|
||||
);
|
||||
|
||||
const response = (await apiClient.updateCurrentUserProfile({
|
||||
skills_ids: nextIds,
|
||||
})) as {
|
||||
skills?: Array<{ id: number; name: string; description?: string }>;
|
||||
};
|
||||
setProfileSkills(response.skills || []);
|
||||
setSkillName("");
|
||||
} catch (error: any) {
|
||||
setSkillsError(
|
||||
String(error?.message || "Не удалось добавить навык"),
|
||||
);
|
||||
} finally {
|
||||
setSkillsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const departmentBadge = useMemo(() => {
|
||||
if (!user?.departments?.length) return null;
|
||||
const label = user.departments.length === 1
|
||||
? user.departments[0].name
|
||||
: `${user.departments.length} отделов`;
|
||||
|
||||
return (
|
||||
<ProfileDepartmentBadge
|
||||
label={label}
|
||||
href={user.departments.length === 1 ? `/departments/${user.departments[0].id}` : undefined}
|
||||
/>
|
||||
);
|
||||
}, [user?.departments]);
|
||||
|
||||
const contactRows = useMemo<ProfileContactRow[]>(() => {
|
||||
if (!user) return [];
|
||||
|
||||
const rows: ProfileContactRow[] = [];
|
||||
|
||||
const email = user.email;
|
||||
if (email) {
|
||||
rows.push({
|
||||
key: "email",
|
||||
label: "Почта",
|
||||
value: email,
|
||||
icon: <Mail size={18} />,
|
||||
onClick: () => void handleCopyContact(email, "email"),
|
||||
copied: copiedContactKey === "email",
|
||||
});
|
||||
}
|
||||
|
||||
const phone = user.phone_number;
|
||||
if (phone) {
|
||||
rows.push({
|
||||
key: "phone",
|
||||
label: "Телефон",
|
||||
value: phone,
|
||||
icon: <Phone size={18} />,
|
||||
onClick: () => void handleCopyContact(phone, "phone"),
|
||||
copied: copiedContactKey === "phone",
|
||||
});
|
||||
}
|
||||
|
||||
const telegram = user.telegram;
|
||||
if (telegram) {
|
||||
rows.push({
|
||||
key: "telegram",
|
||||
label: "Telegram",
|
||||
value: telegram,
|
||||
icon: <MessageCircle size={18} />,
|
||||
onClick: () => void handleCopyContact(normalizeTelegramLink(telegram), "telegram"),
|
||||
copied: copiedContactKey === "telegram",
|
||||
});
|
||||
}
|
||||
|
||||
const whatsapp = user.whatsapp;
|
||||
if (whatsapp) {
|
||||
rows.push({
|
||||
key: "whatsapp",
|
||||
label: "WhatsApp",
|
||||
value: whatsapp,
|
||||
icon: <MessageCircle size={18} />,
|
||||
onClick: () => void handleCopyContact(normalizeWhatsAppLink(whatsapp), "whatsapp"),
|
||||
copied: copiedContactKey === "whatsapp",
|
||||
});
|
||||
}
|
||||
|
||||
const wechat = user.wechat;
|
||||
if (wechat) {
|
||||
rows.push({
|
||||
key: "wechat",
|
||||
label: "WeChat",
|
||||
value: wechat,
|
||||
icon: <MessageCircle size={18} />,
|
||||
onClick: () => void handleCopyContact(wechat, "wechat"),
|
||||
copied: copiedContactKey === "wechat",
|
||||
});
|
||||
}
|
||||
|
||||
if (user.is_ldap_managed || directoryLoginLoading || directoryLoginError || directoryLogin) {
|
||||
const directoryLoginValue = directoryLoginLoading
|
||||
? "Загружаем..."
|
||||
: directoryLogin || "Не найден";
|
||||
rows.push({
|
||||
key: "directory-login",
|
||||
label: "Логин в каталоге",
|
||||
value: directoryLoginValue,
|
||||
icon: (
|
||||
<RefreshCw
|
||||
size={18}
|
||||
className={directoryLoginRefreshing ? "animate-spin" : ""}
|
||||
/>
|
||||
),
|
||||
onClick: directoryLogin && !directoryLoginLoading
|
||||
? () => void handleCopyContact(directoryLogin, "directory-login")
|
||||
: undefined,
|
||||
copied: copiedContactKey === "directory-login",
|
||||
action: (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRefreshDirectoryLogin()}
|
||||
disabled={directoryLoginRefreshing}
|
||||
className="app-action-secondary shrink-0 rounded-xl px-3 py-2 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
Обновить
|
||||
</button>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}, [
|
||||
copiedContactKey,
|
||||
directoryLogin,
|
||||
directoryLoginLoading,
|
||||
directoryLoginRefreshing,
|
||||
directoryLoginError,
|
||||
const {
|
||||
availableSkills,
|
||||
contactEntries,
|
||||
currentAction,
|
||||
departmentSummary,
|
||||
fullName,
|
||||
infoItems,
|
||||
loading,
|
||||
profileSkills,
|
||||
skillName,
|
||||
skillsError,
|
||||
skillsLoading,
|
||||
skillsSaving,
|
||||
sortedActions,
|
||||
user,
|
||||
]);
|
||||
handleAddSkill,
|
||||
handleCopyContact,
|
||||
handleRefreshDirectoryLogin,
|
||||
handleRemoveSkill,
|
||||
onInputSkillName,
|
||||
} = useProfilePage();
|
||||
|
||||
const infoItems = useMemo<ProfileInfoItem[]>(() => ([
|
||||
{
|
||||
label: "В компании",
|
||||
value: getWorkDuration(user?.date_joined) || "—",
|
||||
},
|
||||
{
|
||||
label: "Дата найма",
|
||||
value: formatProfileDate(user?.date_joined),
|
||||
},
|
||||
{
|
||||
label: "Профиль создан",
|
||||
value: formatProfileDate(user?.created_at),
|
||||
},
|
||||
{
|
||||
label: "Последний вход",
|
||||
value: formatProfileDateTime(user?.last_login),
|
||||
},
|
||||
]), [user?.created_at, user?.date_joined, user?.last_login]);
|
||||
const contactRows: ProfileContactRow[] = contactEntries.map((entry) => ({
|
||||
key: entry.key,
|
||||
label: entry.label,
|
||||
value: entry.value,
|
||||
icon: getContactIcon(entry),
|
||||
onClick:
|
||||
entry.canCopy && entry.copyValue
|
||||
? () => void handleCopyContact(entry.copyValue!, entry.key)
|
||||
: undefined,
|
||||
copied: entry.copied,
|
||||
action: entry.canRefresh ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRefreshDirectoryLogin()}
|
||||
disabled={entry.refreshing}
|
||||
className="app-action-secondary shrink-0 rounded-xl px-3 py-2 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
Обновить
|
||||
</button>
|
||||
) : undefined,
|
||||
}));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@ -423,7 +122,7 @@ export default function ProfilePage() {
|
||||
) : null
|
||||
}
|
||||
avatar={
|
||||
<div className="h-20 w-20 shrink-0 overflow-hidden rounded-full app-avatar-frame">
|
||||
<div className="app-avatar-frame h-20 w-20 shrink-0 overflow-hidden rounded-full">
|
||||
{user.avatar ? (
|
||||
<Image
|
||||
src={resolveMediaUrl(user.avatar)}
|
||||
@ -443,13 +142,20 @@ export default function ProfilePage() {
|
||||
fullName={fullName}
|
||||
secondaryLine={formatBirthdayWithYear(user.birth_date) || "Не указана"}
|
||||
roleText={user.position?.name || "Должность не указана"}
|
||||
departmentBadge={departmentBadge}
|
||||
departmentBadge={
|
||||
departmentSummary ? (
|
||||
<ProfileDepartmentBadge
|
||||
label={departmentSummary.label}
|
||||
href={departmentSummary.href}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
bottomPanel={<ProfileContactsPanel rows={contactRows} />}
|
||||
/>
|
||||
|
||||
<ProfileSkillsCard
|
||||
inputValue={skillName}
|
||||
onInputChange={setSkillName}
|
||||
onInputChange={onInputSkillName}
|
||||
onSubmit={() => void handleAddSkill()}
|
||||
submitDisabled={!skillName.trim() || skillsSaving}
|
||||
inputDisabled={skillsSaving}
|
||||
|
||||
@ -27,7 +27,6 @@ import {
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import { useUserDetailPage } from "@/hooks/useUserDetailPage";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { resolveMediaUrl } from "@/lib/url";
|
||||
import {
|
||||
formatBirthdayWithYear,
|
||||
formatProfileDate,
|
||||
@ -120,10 +119,10 @@ export default function UserDetailPage() {
|
||||
}>;
|
||||
if (!mounted) return;
|
||||
setAvailableSkills(response);
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
if (!mounted) return;
|
||||
setSkillsError(
|
||||
String(error?.message || "Не удалось загрузить список навыков"),
|
||||
String((error as Error)?.message || "Не удалось загрузить список навыков"),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
@ -265,9 +264,9 @@ export default function UserDetailPage() {
|
||||
}
|
||||
|
||||
setSkillName("");
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
setSkillsError(
|
||||
String(error?.message || "Не удалось добавить навык"),
|
||||
String((error as Error)?.message || "Не удалось добавить навык"),
|
||||
);
|
||||
} finally {
|
||||
setSkillsSaving(false);
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import type { ChangeEvent } from "react";
|
||||
|
||||
import { Modal } from "@/components/ui";
|
||||
import type {
|
||||
UserProfileEditForm,
|
||||
UserProfileTextField,
|
||||
} from "@/lib/users/userDetailUtils";
|
||||
import type { User } from "@/types/api";
|
||||
import type { UserProfileEditForm, UserProfileTextField } from "@/lib/users/userDetailUtils";
|
||||
|
||||
type EditUserProfileModalProps = {
|
||||
actionLoading: string | null;
|
||||
@ -19,6 +24,44 @@ type EditUserProfileModalProps = {
|
||||
person: User | null;
|
||||
};
|
||||
|
||||
type TextFieldProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
type?: "email" | "tel" | "text";
|
||||
value: string;
|
||||
};
|
||||
|
||||
function TextField({
|
||||
id,
|
||||
label,
|
||||
onChange,
|
||||
placeholder,
|
||||
required = false,
|
||||
type = "text",
|
||||
value,
|
||||
}: TextFieldProps) {
|
||||
return (
|
||||
<label htmlFor={id} className="block">
|
||||
<span className="mb-2 block text-sm font-medium text-[var(--foreground)]">
|
||||
{label}
|
||||
{required ? " *" : ""}
|
||||
</span>
|
||||
<input
|
||||
id={id}
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="app-input w-full rounded-lg px-4 py-2.5 text-sm"
|
||||
maxLength={150}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EditUserProfileModal({
|
||||
actionLoading,
|
||||
avatarFailed,
|
||||
@ -32,23 +75,30 @@ export default function EditUserProfileModal({
|
||||
onTextFieldChange,
|
||||
person,
|
||||
}: EditUserProfileModalProps) {
|
||||
const isSaving = actionLoading === "edit";
|
||||
|
||||
const footerContent = (
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={actionLoading === "edit"}
|
||||
className="flex-1 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 disabled:opacity-50"
|
||||
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={actionLoading === "edit" || !form.firstName.trim() || !form.lastName.trim() || !form.email.trim()}
|
||||
className="flex-1 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-700 disabled:opacity-50"
|
||||
disabled={
|
||||
isSaving ||
|
||||
!form.firstName.trim() ||
|
||||
!form.lastName.trim() ||
|
||||
!form.email.trim()
|
||||
}
|
||||
className="app-action-primary flex-1 rounded-lg px-4 py-2.5 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === "edit" ? "Сохранение..." : "Сохранить"}
|
||||
{isSaving ? "Сохранение..." : "Сохранить"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@ -59,36 +109,49 @@ export default function EditUserProfileModal({
|
||||
onClose={onClose}
|
||||
title="Редактировать профиль"
|
||||
size="lg"
|
||||
closeOnEsc={actionLoading !== "edit"}
|
||||
closeOnEsc={!isSaving}
|
||||
footer={footerContent}
|
||||
>
|
||||
{person && (
|
||||
{person ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Аватар
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-20 w-20 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-xl font-semibold text-white">
|
||||
<section className="app-surface-muted rounded-xl p-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div className="app-avatar-frame flex h-20 w-20 items-center justify-center overflow-hidden rounded-full shrink-0">
|
||||
{form.avatarPreview ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
<Image
|
||||
src={form.avatarPreview}
|
||||
alt="Preview"
|
||||
width={80}
|
||||
height={80}
|
||||
className="h-full w-full object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : avatarUrl && !avatarFailed ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt="Avatar"
|
||||
width={80}
|
||||
height={80}
|
||||
className="h-full w-full object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<span>{initials}</span>
|
||||
<span className="app-avatar-fallback flex h-full w-full items-center justify-center text-xl font-semibold">
|
||||
{initials}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<label className="cursor-pointer rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50">
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-[var(--foreground)]">
|
||||
Аватар
|
||||
</p>
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
Максимальный размер: 5MB. Форматы: JPG, PNG, GIF
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="app-action-secondary inline-flex cursor-pointer items-center justify-center rounded-lg px-4 py-2.5 text-sm font-medium">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
@ -98,133 +161,86 @@ export default function EditUserProfileModal({
|
||||
Загрузить
|
||||
</label>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Максимальный размер: 5MB. Форматы: JPG, PNG, GIF
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<label htmlFor="edit-last-name" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Фамилия *
|
||||
</label>
|
||||
<input
|
||||
<section className="app-surface-muted rounded-xl p-4">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<TextField
|
||||
id="edit-last-name"
|
||||
type="text"
|
||||
label="Фамилия"
|
||||
required
|
||||
value={form.lastName}
|
||||
onChange={(event) => onTextFieldChange("lastName", event.target.value)}
|
||||
onChange={(value) => onTextFieldChange("lastName", 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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="edit-first-name" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Имя *
|
||||
</label>
|
||||
<input
|
||||
<TextField
|
||||
id="edit-first-name"
|
||||
type="text"
|
||||
label="Имя"
|
||||
required
|
||||
value={form.firstName}
|
||||
onChange={(event) => onTextFieldChange("firstName", event.target.value)}
|
||||
onChange={(value) => onTextFieldChange("firstName", 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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="edit-patronymic" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Отчество
|
||||
</label>
|
||||
<input
|
||||
<TextField
|
||||
id="edit-patronymic"
|
||||
type="text"
|
||||
label="Отчество"
|
||||
value={form.patronymic}
|
||||
onChange={(event) => onTextFieldChange("patronymic", event.target.value)}
|
||||
onChange={(value) => onTextFieldChange("patronymic", 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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="edit-email" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Email *
|
||||
</label>
|
||||
<input
|
||||
<section className="app-surface-muted rounded-xl p-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<TextField
|
||||
id="edit-email"
|
||||
label="Email"
|
||||
required
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(event) => onTextFieldChange("email", event.target.value)}
|
||||
onChange={(value) => onTextFieldChange("email", 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="edit-phone" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Телефон
|
||||
</label>
|
||||
<input
|
||||
<TextField
|
||||
id="edit-phone"
|
||||
label="Телефон"
|
||||
type="tel"
|
||||
value={form.phone}
|
||||
onChange={(event) => onTextFieldChange("phone", event.target.value)}
|
||||
onChange={(value) => onTextFieldChange("phone", 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<label htmlFor="edit-telegram" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Telegram
|
||||
</label>
|
||||
<input
|
||||
<section className="app-surface-muted rounded-xl p-4">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<TextField
|
||||
id="edit-telegram"
|
||||
type="text"
|
||||
label="Telegram"
|
||||
value={form.telegram}
|
||||
onChange={(event) => onTextFieldChange("telegram", event.target.value)}
|
||||
onChange={(value) => onTextFieldChange("telegram", 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="edit-whatsapp" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
WhatsApp
|
||||
</label>
|
||||
<input
|
||||
<TextField
|
||||
id="edit-whatsapp"
|
||||
label="WhatsApp"
|
||||
type="tel"
|
||||
value={form.whatsapp}
|
||||
onChange={(event) => onTextFieldChange("whatsapp", event.target.value)}
|
||||
onChange={(value) => onTextFieldChange("whatsapp", 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="edit-wechat" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
WeChat
|
||||
</label>
|
||||
<input
|
||||
<TextField
|
||||
id="edit-wechat"
|
||||
type="text"
|
||||
label="WeChat"
|
||||
value={form.wechat}
|
||||
onChange={(event) => onTextFieldChange("wechat", event.target.value)}
|
||||
onChange={(value) => onTextFieldChange("wechat", 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Modal } from "@/components/ui";
|
||||
import type { EmployeeActionField, EmployeeActionForm } from "@/lib/users/userDetailUtils";
|
||||
import type {
|
||||
EmployeeActionField,
|
||||
EmployeeActionForm,
|
||||
} from "@/lib/users/userDetailUtils";
|
||||
|
||||
type EmployeeActionOption = {
|
||||
value: string;
|
||||
@ -27,23 +30,25 @@ export default function EmployeeActionModal({
|
||||
onFieldChange,
|
||||
onSave,
|
||||
}: EmployeeActionModalProps) {
|
||||
const isSaving = actionLoading === "action";
|
||||
|
||||
const footerContent = (
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={actionLoading === "action"}
|
||||
className="flex-1 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 disabled:opacity-50"
|
||||
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={actionLoading === "action" || !form.type || !form.date}
|
||||
className="flex-1 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-700 disabled:opacity-50"
|
||||
disabled={isSaving || !form.type || !form.date}
|
||||
className="app-action-primary flex-1 rounded-lg px-4 py-2.5 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === "action" ? "Сохранение..." : "Сохранить"}
|
||||
{isSaving ? "Сохранение..." : "Сохранить"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@ -54,56 +59,62 @@ export default function EmployeeActionModal({
|
||||
onClose={onClose}
|
||||
title={form.editingId ? "Редактировать событие" : "Кадровое событие"}
|
||||
size="sm"
|
||||
closeOnEsc={actionLoading !== "action"}
|
||||
closeOnEsc={!isSaving}
|
||||
footer={footerContent}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="action-type" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Тип события *
|
||||
<section className="app-surface-muted rounded-xl p-4">
|
||||
<label htmlFor="action-type" className="block">
|
||||
<span className="mb-2 block text-sm font-medium text-[var(--foreground)]">
|
||||
Тип события *
|
||||
</span>
|
||||
<select
|
||||
id="action-type"
|
||||
value={form.type}
|
||||
onChange={(event) => onFieldChange("type", event.target.value)}
|
||||
className="app-select w-full rounded-lg px-4 py-2.5 text-sm"
|
||||
>
|
||||
<option value="">Выберите тип события</option>
|
||||
{actionTypes.map((type) => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<select
|
||||
id="action-type"
|
||||
value={form.type}
|
||||
onChange={(event) => onFieldChange("type", event.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"
|
||||
>
|
||||
<option value="">Выберите тип события</option>
|
||||
{actionTypes.map((type) => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div>
|
||||
<label htmlFor="action-date" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Дата *
|
||||
<section className="app-surface-muted rounded-xl p-4">
|
||||
<label htmlFor="action-date" className="block">
|
||||
<span className="mb-2 block text-sm font-medium text-[var(--foreground)]">
|
||||
Дата *
|
||||
</span>
|
||||
<input
|
||||
id="action-date"
|
||||
type="date"
|
||||
value={form.date}
|
||||
onChange={(event) => onFieldChange("date", event.target.value)}
|
||||
className="app-input w-full rounded-lg px-4 py-2.5 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
id="action-date"
|
||||
type="date"
|
||||
value={form.date}
|
||||
onChange={(event) => onFieldChange("date", event.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"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div>
|
||||
<label htmlFor="action-comment" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Комментарий
|
||||
<section className="app-surface-muted rounded-xl p-4">
|
||||
<label htmlFor="action-comment" className="block">
|
||||
<span className="mb-2 block text-sm font-medium text-[var(--foreground)]">
|
||||
Комментарий
|
||||
</span>
|
||||
<textarea
|
||||
id="action-comment"
|
||||
value={form.comment}
|
||||
onChange={(event) => onFieldChange("comment", event.target.value)}
|
||||
placeholder="Дополнительная информация..."
|
||||
rows={4}
|
||||
className="app-input w-full rounded-xl px-4 py-2.5 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<textarea
|
||||
id="action-comment"
|
||||
value={form.comment}
|
||||
onChange={(event) => onFieldChange("comment", event.target.value)}
|
||||
placeholder="Дополнительная информация..."
|
||||
rows={3}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
444
frontend/src/hooks/useProfilePage.ts
Normal file
444
frontend/src/hooks/useProfilePage.ts
Normal file
@ -0,0 +1,444 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import {
|
||||
formatProfileDate,
|
||||
formatProfileDateTime,
|
||||
getLatestEmployeeAction,
|
||||
getUserFullName,
|
||||
getWorkDuration,
|
||||
normalizeTelegramLink,
|
||||
normalizeWhatsAppLink,
|
||||
sortEmployeeActions,
|
||||
} from "@/lib/users/userDetailUtils";
|
||||
import type { DirectoryLoginResult, EmployeeAction } from "@/types/api";
|
||||
|
||||
type SkillOption = {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type ProfileContactKind =
|
||||
| "email"
|
||||
| "phone"
|
||||
| "telegram"
|
||||
| "whatsapp"
|
||||
| "wechat"
|
||||
| "directory-login";
|
||||
|
||||
export type ProfileContactEntry = {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
kind: ProfileContactKind;
|
||||
copied: boolean;
|
||||
copyValue?: string;
|
||||
canCopy?: boolean;
|
||||
canRefresh?: boolean;
|
||||
refreshing?: boolean;
|
||||
};
|
||||
|
||||
export type ProfileInfoEntry = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type ProfileDepartmentSummary = {
|
||||
label: string;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
export type ProfilePageController = {
|
||||
availableSkills: SkillOption[];
|
||||
contactEntries: ProfileContactEntry[];
|
||||
currentAction: EmployeeAction | null;
|
||||
departmentSummary: ProfileDepartmentSummary | null;
|
||||
fullName: string;
|
||||
infoItems: ProfileInfoEntry[];
|
||||
loading: boolean;
|
||||
profileSkills: SkillOption[];
|
||||
skillName: string;
|
||||
skillsError: string | null;
|
||||
skillsLoading: boolean;
|
||||
skillsSaving: boolean;
|
||||
sortedActions: EmployeeAction[];
|
||||
user: ReturnType<typeof useUser>["user"];
|
||||
handleAddSkill: (forcedSkill?: Pick<SkillOption, "id" | "name">) => Promise<void>;
|
||||
handleCopyContact: (value: string, key: string) => Promise<void>;
|
||||
handleRefreshDirectoryLogin: () => Promise<void>;
|
||||
handleRemoveSkill: (skillId: number) => Promise<void>;
|
||||
onInputSkillName: (value: string) => void;
|
||||
};
|
||||
|
||||
const getErrorMessage = (error: unknown, fallback: string) =>
|
||||
String((error as Error)?.message || fallback);
|
||||
|
||||
export function useProfilePage(): ProfilePageController {
|
||||
const { user, loading } = useUser();
|
||||
const [copiedContactKey, setCopiedContactKey] = useState<string | null>(null);
|
||||
const [directoryLogin, setDirectoryLogin] = useState<string | null>(null);
|
||||
const [directoryLoginLoading, setDirectoryLoginLoading] = useState(false);
|
||||
const [directoryLoginRefreshing, setDirectoryLoginRefreshing] = useState(false);
|
||||
const [directoryLoginError, setDirectoryLoginError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [availableSkills, setAvailableSkills] = useState<SkillOption[]>([]);
|
||||
const [profileSkills, setProfileSkills] = useState<SkillOption[]>([]);
|
||||
const [skillsLoading, setSkillsLoading] = useState(false);
|
||||
const [skillsSaving, setSkillsSaving] = useState(false);
|
||||
const [skillName, setSkillName] = useState("");
|
||||
const [skillsError, setSkillsError] = useState<string | null>(null);
|
||||
|
||||
const fullName = useMemo(
|
||||
() => getUserFullName(user) || "Пользователь",
|
||||
[user],
|
||||
);
|
||||
const currentAction = useMemo(
|
||||
() => getLatestEmployeeAction(user?.actions),
|
||||
[user?.actions],
|
||||
);
|
||||
const sortedActions = useMemo(
|
||||
() => sortEmployeeActions(user?.actions),
|
||||
[user?.actions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setDirectoryLogin(user?.username?.trim() || null);
|
||||
setDirectoryLoginError(null);
|
||||
}, [user?.username]);
|
||||
|
||||
useEffect(() => {
|
||||
setProfileSkills(user?.skills || []);
|
||||
}, [user?.skills]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
async function loadDirectoryLogin() {
|
||||
if (!user?.is_ldap_managed || user.username?.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setDirectoryLoginLoading(true);
|
||||
setDirectoryLoginError(null);
|
||||
const response =
|
||||
(await apiClient.getDirectoryLogin()) as DirectoryLoginResult;
|
||||
if (!mounted) return;
|
||||
setDirectoryLogin(response.username?.trim() || null);
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
setDirectoryLoginError(
|
||||
getErrorMessage(error, "Не удалось получить логин из каталога"),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setDirectoryLoginLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadDirectoryLogin();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [user?.id, user?.is_ldap_managed, user?.username]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
async function loadSkills() {
|
||||
try {
|
||||
setSkillsLoading(true);
|
||||
setSkillsError(null);
|
||||
const response = (await apiClient.getSkills()) as SkillOption[];
|
||||
if (!mounted) return;
|
||||
setAvailableSkills(response);
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
setSkillsError(
|
||||
getErrorMessage(error, "Не удалось загрузить список навыков"),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setSkillsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadSkills();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleRefreshDirectoryLogin = useCallback(async () => {
|
||||
try {
|
||||
setDirectoryLoginRefreshing(true);
|
||||
setDirectoryLoginError(null);
|
||||
const response =
|
||||
(await apiClient.refreshDirectoryLogin()) as DirectoryLoginResult;
|
||||
setDirectoryLogin(response.username?.trim() || null);
|
||||
} catch (error) {
|
||||
setDirectoryLoginError(
|
||||
getErrorMessage(error, "Не удалось обновить логин из каталога"),
|
||||
);
|
||||
} finally {
|
||||
setDirectoryLoginRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCopyContact = useCallback(async (value: string, key: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopiedContactKey(key);
|
||||
window.setTimeout(
|
||||
() =>
|
||||
setCopiedContactKey((current) => (current === key ? null : current)),
|
||||
2000,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Ошибка копирования:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const saveSkills = useCallback(async (skillIds: number[]) => {
|
||||
try {
|
||||
setSkillsSaving(true);
|
||||
setSkillsError(null);
|
||||
const response = (await apiClient.updateCurrentUserProfile({
|
||||
skills_ids: skillIds,
|
||||
})) as { skills?: SkillOption[] };
|
||||
setProfileSkills(response.skills || []);
|
||||
} catch (error) {
|
||||
setSkillsError(getErrorMessage(error, "Не удалось обновить навыки"));
|
||||
} finally {
|
||||
setSkillsSaving(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRemoveSkill = useCallback(
|
||||
async (skillId: number) => {
|
||||
if (!user) return;
|
||||
const nextIds = profileSkills
|
||||
.map((skill) => skill.id)
|
||||
.filter((id) => id !== skillId);
|
||||
await saveSkills(nextIds);
|
||||
},
|
||||
[profileSkills, saveSkills, user],
|
||||
);
|
||||
|
||||
const handleAddSkill = useCallback(
|
||||
async (forcedSkill?: Pick<SkillOption, "id" | "name">) => {
|
||||
if (!user) return;
|
||||
|
||||
const rawName = forcedSkill?.name || skillName;
|
||||
const trimmedName = rawName.trim();
|
||||
if (!trimmedName || skillsSaving) return;
|
||||
|
||||
try {
|
||||
setSkillsSaving(true);
|
||||
setSkillsError(null);
|
||||
|
||||
const existingSkill =
|
||||
forcedSkill ||
|
||||
availableSkills.find(
|
||||
(skill) =>
|
||||
skill.name.trim().toLowerCase() === trimmedName.toLowerCase(),
|
||||
);
|
||||
|
||||
let skillId = existingSkill?.id;
|
||||
|
||||
if (!skillId) {
|
||||
const createdSkill = (await apiClient.createSkill({
|
||||
name: trimmedName,
|
||||
})) as SkillOption;
|
||||
skillId = createdSkill.id;
|
||||
setAvailableSkills((current) =>
|
||||
[...current, createdSkill].sort((left, right) =>
|
||||
left.name.localeCompare(right.name, "ru"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const nextIds = Array.from(
|
||||
new Set([...profileSkills.map((skill) => skill.id), skillId]),
|
||||
);
|
||||
const response = (await apiClient.updateCurrentUserProfile({
|
||||
skills_ids: nextIds,
|
||||
})) as { skills?: SkillOption[] };
|
||||
|
||||
setProfileSkills(response.skills || []);
|
||||
setSkillName("");
|
||||
} catch (error) {
|
||||
setSkillsError(getErrorMessage(error, "Не удалось добавить навык"));
|
||||
} finally {
|
||||
setSkillsSaving(false);
|
||||
}
|
||||
},
|
||||
[availableSkills, profileSkills, skillName, skillsSaving, user],
|
||||
);
|
||||
|
||||
const departmentSummary = useMemo<ProfileDepartmentSummary | null>(() => {
|
||||
if (!user?.departments?.length) return null;
|
||||
|
||||
return {
|
||||
label:
|
||||
user.departments.length === 1
|
||||
? user.departments[0].name
|
||||
: `${user.departments.length} отделов`,
|
||||
href:
|
||||
user.departments.length === 1
|
||||
? `/departments/${user.departments[0].id}`
|
||||
: undefined,
|
||||
};
|
||||
}, [user?.departments]);
|
||||
|
||||
const contactEntries = useMemo<ProfileContactEntry[]>(() => {
|
||||
if (!user) return [];
|
||||
|
||||
const entries: ProfileContactEntry[] = [];
|
||||
|
||||
if (user.email) {
|
||||
entries.push({
|
||||
key: "email",
|
||||
label: "Почта",
|
||||
value: user.email,
|
||||
kind: "email",
|
||||
copied: copiedContactKey === "email",
|
||||
copyValue: user.email,
|
||||
canCopy: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (user.phone_number) {
|
||||
entries.push({
|
||||
key: "phone",
|
||||
label: "Телефон",
|
||||
value: user.phone_number,
|
||||
kind: "phone",
|
||||
copied: copiedContactKey === "phone",
|
||||
copyValue: user.phone_number,
|
||||
canCopy: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (user.telegram) {
|
||||
entries.push({
|
||||
key: "telegram",
|
||||
label: "Telegram",
|
||||
value: user.telegram,
|
||||
kind: "telegram",
|
||||
copied: copiedContactKey === "telegram",
|
||||
copyValue: normalizeTelegramLink(user.telegram),
|
||||
canCopy: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (user.whatsapp) {
|
||||
entries.push({
|
||||
key: "whatsapp",
|
||||
label: "WhatsApp",
|
||||
value: user.whatsapp,
|
||||
kind: "whatsapp",
|
||||
copied: copiedContactKey === "whatsapp",
|
||||
copyValue: normalizeWhatsAppLink(user.whatsapp),
|
||||
canCopy: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (user.wechat) {
|
||||
entries.push({
|
||||
key: "wechat",
|
||||
label: "WeChat",
|
||||
value: user.wechat,
|
||||
kind: "wechat",
|
||||
copied: copiedContactKey === "wechat",
|
||||
copyValue: user.wechat,
|
||||
canCopy: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
user.is_ldap_managed ||
|
||||
directoryLoginLoading ||
|
||||
directoryLoginError ||
|
||||
directoryLogin
|
||||
) {
|
||||
entries.push({
|
||||
key: "directory-login",
|
||||
label: "Логин в каталоге",
|
||||
value: directoryLoginLoading ? "Загружаем..." : directoryLogin || "Не найден",
|
||||
kind: "directory-login",
|
||||
copied: copiedContactKey === "directory-login",
|
||||
copyValue: directoryLogin || undefined,
|
||||
canCopy: Boolean(directoryLogin && !directoryLoginLoading),
|
||||
canRefresh: true,
|
||||
refreshing: directoryLoginRefreshing,
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}, [
|
||||
copiedContactKey,
|
||||
directoryLogin,
|
||||
directoryLoginError,
|
||||
directoryLoginLoading,
|
||||
directoryLoginRefreshing,
|
||||
user,
|
||||
]);
|
||||
|
||||
const infoItems = useMemo<ProfileInfoEntry[]>(
|
||||
() => [
|
||||
{
|
||||
label: "В компании",
|
||||
value: getWorkDuration(user?.date_joined) || "—",
|
||||
},
|
||||
{
|
||||
label: "Дата найма",
|
||||
value: formatProfileDate(user?.date_joined),
|
||||
},
|
||||
{
|
||||
label: "Профиль создан",
|
||||
value: formatProfileDate(user?.created_at),
|
||||
},
|
||||
{
|
||||
label: "Последний вход",
|
||||
value: formatProfileDateTime(user?.last_login),
|
||||
},
|
||||
],
|
||||
[user?.created_at, user?.date_joined, user?.last_login],
|
||||
);
|
||||
|
||||
const onInputSkillName = useCallback((value: string) => {
|
||||
setSkillName(value);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
availableSkills,
|
||||
contactEntries,
|
||||
currentAction,
|
||||
departmentSummary,
|
||||
fullName,
|
||||
infoItems,
|
||||
loading,
|
||||
profileSkills,
|
||||
skillName,
|
||||
skillsError,
|
||||
skillsLoading,
|
||||
skillsSaving,
|
||||
sortedActions,
|
||||
user,
|
||||
handleAddSkill,
|
||||
handleCopyContact,
|
||||
handleRefreshDirectoryLogin,
|
||||
handleRemoveSkill,
|
||||
onInputSkillName,
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user