refactor(frontend): align profile page controllers

This commit is contained in:
lizerk 2026-04-13 14:15:34 +03:00
parent 55f3171636
commit fe040e2da9
7 changed files with 713 additions and 534 deletions

View File

@ -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

View File

@ -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

View File

@ -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}

View File

@ -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);

View File

@ -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>
);
}
}

View File

@ -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>
);
}
}

View 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,
};
}