refactor(frontend): align profile sections and users list
This commit is contained in:
parent
fe040e2da9
commit
79b66c5884
@ -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
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
})
|
||||
|
||||
@ -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({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user