refactor(frontend): align profile sections and users list

This commit is contained in:
lizerk 2026-04-13 14:20:51 +03:00
parent fe040e2da9
commit 79b66c5884
3 changed files with 167 additions and 126 deletions

View File

@ -30,8 +30,8 @@
| `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, `/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 |
| `users/profile` | `mixed` | хорошие profile sections, hero-like anatomy, action timeline, переиспользуемые section-level components, `/profile` и `/users/[id]` теперь оба опираются на page-level controllers, modals подтянуты к app-style | profile cluster уже выровнен структурно, но hero/section character всё ещё требует финальной product-tuning и часть user detail blocks остаётся локально особенной | держать как reference по profile anatomy и завершить alignment-pass точечно, без превращения его в request-like layout |
| `users/directory` | `mixed` | `users/page.tsx` уже живёт в app-surface language, сценарии user/employee navigation простые и понятные | `employees/page.tsx` и часть directory flows ещё не доведены до того же visual contract, cluster всё ещё не полностью симметричен | после profile cluster пройтись по employee directory и довести весь people-list слой до одного 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 без крупного редизайна |
| `departments` | `mixed` | page/detail screens небольшие и уже живут в app-shell language | не хватает единых richer patterns между list/detail, зона выглядит недоразвитой по сравнению с остальными модулями | low-cost pass после крупных зон: выровнять richness и reuse |
@ -42,16 +42,16 @@
Рациональный порядок после текущего audit:
1. `users/profile` + `users/directory`
2. `home/feed` + `notifications`
3. `documents` + `documents/dashboard`
4. `equipment`
5. `procurement`
6. `messages`
7. `settings`
8. отдельный audit по `calendar`
9. `departments`
10. `auth/legal`
1. `home/feed` + `notifications`
2. `documents` + `documents/dashboard`
3. `equipment`
4. `procurement`
5. `messages`
6. `settings`
7. отдельный audit по `calendar`
8. `departments`
9. `auth/legal`
10. завершение `users/profile` + `users/directory` residual pass
Логика порядка:
@ -69,16 +69,22 @@
- `users/profile`
- `users/directory`
Статус:
- базовый structural + modal + directory-shell pass уже сделан
Цель:
- подтянуть people/profile cluster к общему product feel без потери profile anatomy
Что делать:
Что осталось:
- `/profile` уже переведён на page-level controller по аналогии с `useUserDetailPage`
- `EditUserProfileModal` и `EmployeeActionModal` уже подтянуты к app-style primitives
- нормализовать `ProfileSections` по геометрии без уничтожения hero/section contract
- привести `users/page.tsx` к app-surface language
- `ProfileSections` уже нормализованы по базовой геометрии без слома anatomy
- `users/page.tsx` уже переведён на app-surface language
- отдельно добить `employees/page.tsx` и остаточные локальные blocks в `/users/[id]`
- при необходимости сделать финальный visual-tuning hero density после ручного review
### Волна 2. Operational pages with high leverage

View File

@ -1,11 +1,15 @@
"use client";
import Link from "next/link";
import { Search, Users } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { AppShell } from "../../components/AppShell";
import { apiClient } from "@/lib/api";
import type { User } from "@/types/api";
import { Search, Users } from "lucide-react";
const getErrorMessage = (error: unknown, fallback: string) =>
String((error as Error)?.message || fallback);
export default function UsersPage() {
const [users, setUsers] = useState<User[]>([]);
@ -20,73 +24,104 @@ export default function UsersPage() {
setError(null);
const response = await apiClient.getEmployees({ limit: 200 });
setUsers(response.results || []);
} catch (e: any) {
setError(String(e?.message || "Не удалось загрузить пользователей"));
} catch (error: unknown) {
setError(getErrorMessage(error, "Не удалось загрузить пользователей"));
} finally {
setLoading(false);
}
}
loadUsers();
void loadUsers();
}, []);
const filteredUsers = useMemo(() => {
const q = search.trim().toLowerCase();
const sorted = [...users].sort((a, b) => {
const aName = `${a.last_name || ""} ${a.first_name || ""}`.trim().toLowerCase();
const bName = `${b.last_name || ""} ${b.first_name || ""}`.trim().toLowerCase();
return aName.localeCompare(bName, "ru");
const query = search.trim().toLowerCase();
const sorted = [...users].sort((left, right) => {
const leftName = `${left.last_name || ""} ${left.first_name || ""}`
.trim()
.toLowerCase();
const rightName = `${right.last_name || ""} ${right.first_name || ""}`
.trim()
.toLowerCase();
return leftName.localeCompare(rightName, "ru");
});
if (!q) return sorted;
if (!query) return sorted;
return sorted.filter((u) => {
const fullName = `${u.last_name || ""} ${u.first_name || ""} ${u.patronymic || ""}`.toLowerCase();
const email = (u.email || "").toLowerCase();
const phone = (u.phone_number || "").toLowerCase();
const position = (u.position?.name || "").toLowerCase();
return fullName.includes(q) || email.includes(q) || phone.includes(q) || position.includes(q);
return sorted.filter((user) => {
const fullName =
`${user.last_name || ""} ${user.first_name || ""} ${user.patronymic || ""}`.toLowerCase();
const email = (user.email || "").toLowerCase();
const phone = (user.phone_number || "").toLowerCase();
const position = (user.position?.name || "").toLowerCase();
return (
fullName.includes(query) ||
email.includes(query) ||
phone.includes(query) ||
position.includes(query)
);
});
}, [users, search]);
}, [search, users]);
return (
<AppShell>
{loading ? (
<div className="rounded-2xl bg-white p-8 text-center shadow-sm ring-1 ring-gray-100">
<div className="mb-4 inline-block h-8 w-8 animate-spin rounded-full border-4 border-sky-400 border-t-transparent" />
<p className="text-sm text-gray-500">Загрузка пользователей...</p>
</div>
<section className="app-surface rounded-2xl p-8 text-center">
<div className="mb-4 inline-block h-8 w-8 animate-spin rounded-full border-4 border-[var(--border-subtle)] border-t-[var(--accent-primary)]" />
<p className="app-text-muted text-sm">Загрузка пользователей...</p>
</section>
) : error ? (
<div className="rounded-2xl bg-red-50 p-6 text-center">
<p className="text-sm text-red-800">{error}</p>
</div>
<section className="app-surface rounded-2xl p-6 text-center">
<p className="text-sm text-red-400">{error}</p>
</section>
) : (
<section className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
<section className="app-surface rounded-2xl p-4">
<div className="relative mb-4">
<Search size={16} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<Search
size={16}
className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2"
/>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
onChange={(event) => setSearch(event.target.value)}
placeholder="Поиск пользователя"
className="w-full rounded-lg border border-gray-200 bg-gray-50 py-2.5 pl-9 pr-3 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm"
/>
</div>
<div className="space-y-2">
{filteredUsers.length === 0 ? (
<div className="rounded-xl bg-gray-50 p-8 text-center">
<Users size={22} className="mx-auto mb-2 text-gray-400" />
<p className="text-sm text-gray-500">Пользователи не найдены</p>
<div className="app-surface-muted rounded-xl p-8 text-center">
<Users size={22} className="app-text-muted mx-auto mb-2" />
<p className="app-text-muted text-sm">Пользователи не найдены</p>
</div>
) : (
filteredUsers.map((u) => {
const fullName = `${u.last_name || ""} ${u.first_name || ""} ${u.patronymic || ""}`.trim() || "Пользователь";
const departmentsText = (u.departments || []).map((d) => d.name).filter(Boolean).join(", ");
filteredUsers.map((user) => {
const fullName =
`${user.last_name || ""} ${user.first_name || ""} ${user.patronymic || ""}`.trim() ||
"Пользователь";
const departmentsText = (user.departments || [])
.map((department) => department.name)
.filter(Boolean)
.join(", ");
return (
<Link key={u.id} href={`/users/${u.id}`} className="block rounded-xl border border-gray-100 bg-white p-3 transition hover:bg-gray-50">
<p className="text-sm font-semibold text-gray-900">{fullName}</p>
<p className="mt-1 text-xs text-gray-500">{u.position?.name || "Сотрудник"}</p>
{departmentsText ? <p className="mt-1 text-xs text-gray-500">{departmentsText}</p> : null}
<Link
key={user.id}
href={`/users/${user.id}`}
className="app-surface-muted block rounded-xl p-3 transition hover:border-[var(--border-strong)] hover:bg-[var(--surface-elevated)]"
>
<p className="text-sm font-semibold text-[var(--foreground)]">
{fullName}
</p>
<p className="app-text-muted mt-1 text-xs">
{user.position?.name || "Сотрудник"}
</p>
{departmentsText ? (
<p className="app-text-muted mt-1 text-xs">
{departmentsText}
</p>
) : null}
</Link>
);
})

View File

@ -59,18 +59,18 @@ export function ProfileHeroCard({
bottomPanel?: ReactNode;
}) {
return (
<section className="app-surface rounded-[28px] p-5">
<section className="app-surface rounded-2xl p-5 sm:p-6">
<div className="mb-4 flex items-start justify-between gap-4">
<p className="app-card-caption">{caption}</p>
{statusBadge}
</div>
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
{avatar}
<div className="min-w-0 flex-1">
<div className="flex items-start gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start">
<div className="min-w-0 flex-1">
<h1 className="app-text-wrap text-[2rem] font-semibold leading-tight text-[var(--foreground)]">
{fullName}
@ -166,7 +166,7 @@ export function ProfileInfoCard({
items: ProfileInfoItem[];
}) {
return (
<section className="app-surface rounded-[24px] p-5">
<section className="app-surface rounded-2xl p-5">
<SectionTitle title={title} />
<div className="app-surface-muted overflow-hidden rounded-2xl">
<div className="grid md:grid-cols-2">
@ -221,78 +221,78 @@ export function ProfileSkillsCard({
removeDisabled?: boolean;
emptyText?: string;
}) {
const listId = `${title.toLowerCase().replace(/\s+/g, "-")}-skills-list`;
const listId = `${title.toLowerCase().replace(/\s+/g, "-")}-skills-list`;
return (
<section className="app-surface rounded-[24px] p-5">
<SectionTitle title={title} />
<div className="app-surface-muted rounded-2xl p-3.5">
<div className="flex flex-col gap-2.5 md:flex-row">
<div className="min-w-0 flex-1">
<input
list={listId}
value={inputValue}
onChange={(event) => onInputChange(event.target.value)}
placeholder="Добавить навык"
className="app-input w-full rounded-xl px-4 py-2.5 text-sm"
disabled={inputDisabled}
/>
<datalist id={listId}>
{availableSkills.map((skill) => (
<option key={skill.id} value={skill.name} />
))}
</datalist>
</div>
<button
type="button"
onClick={onSubmit}
disabled={submitDisabled}
className="app-action-primary inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
<Plus size={16} />
Добавить
</button>
return (
<section className="app-surface rounded-2xl p-5">
<SectionTitle title={title} />
<div className="app-surface-muted rounded-2xl p-3.5">
<div className="flex flex-col gap-2.5 md:flex-row">
<div className="min-w-0 flex-1">
<input
list={listId}
value={inputValue}
onChange={(event) => onInputChange(event.target.value)}
placeholder="Добавить навык"
className="app-input w-full rounded-xl px-4 py-2.5 text-sm"
disabled={inputDisabled}
/>
<datalist id={listId}>
{availableSkills.map((skill) => (
<option key={skill.id} value={skill.name} />
))}
</datalist>
</div>
{error ? <p className="mt-3 text-sm text-red-400">{error}</p> : null}
<div className="mt-3">
{skills.length ? (
<div className="flex flex-wrap gap-2">
{skills.map((skill) =>
onRemoveSkill ? (
<button
key={skill.id}
type="button"
onClick={() => onRemoveSkill(skill.id)}
disabled={removeDisabled}
className="app-pill inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] disabled:opacity-50"
title="Удалить навык"
>
<span>{skill.name}</span>
<X size={14} />
</button>
) : (
<span
key={skill.id}
className="app-pill inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium"
>
{skill.name}
</span>
),
)}
</div>
) : (
<p className="app-text-muted text-sm">{emptyText}</p>
)}
</div>
{loading ? (
<p className="app-text-muted mt-2.5 text-sm">Загружаем навыки...</p>
) : null}
<button
type="button"
onClick={onSubmit}
disabled={submitDisabled}
className="app-action-primary inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
<Plus size={16} />
Добавить
</button>
</div>
</section>
);
{error ? <p className="mt-3 text-sm text-red-400">{error}</p> : null}
<div className="mt-3">
{skills.length ? (
<div className="flex flex-wrap gap-2">
{skills.map((skill) =>
onRemoveSkill ? (
<button
key={skill.id}
type="button"
onClick={() => onRemoveSkill(skill.id)}
disabled={removeDisabled}
className="app-pill inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] disabled:opacity-50"
title="Удалить навык"
>
<span>{skill.name}</span>
<X size={14} />
</button>
) : (
<span
key={skill.id}
className="app-pill inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium"
>
{skill.name}
</span>
),
)}
</div>
) : (
<p className="app-text-muted text-sm">{emptyText}</p>
)}
</div>
{loading ? (
<p className="app-text-muted mt-2.5 text-sm">Загружаем навыки...</p>
) : null}
</div>
</section>
);
}
export function ProfileDepartmentBadge({