refactor(frontend): finish people cluster alignment

This commit is contained in:
lizerk 2026-04-13 14:25:00 +03:00
parent 79b66c5884
commit d437f63d02
3 changed files with 156 additions and 115 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` | `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 |
| `users/profile` | `mixed` | хорошие profile sections, hero-like anatomy, action timeline, переиспользуемые section-level components, `/profile` и `/users/[id]` опираются на page-level controllers, modals подтянуты к app-style, локальные detail blocks выровнены по геометрии | profile cluster уже собран в одну систему, но hero/section character всё ещё требует ручного visual review перед тем, как считать его зрелым reference | держать как reference по profile anatomy и возвращаться только на точечный visual-tuning без новой structural волны |
| `users/directory` | `mixed` | `users/page.tsx` и `employees/page.tsx` уже живут в app-surface language, сценарии user/employee navigation выровнены и читаются как один cluster | список людей всё ещё проще и беднее по interaction density, чем основные рабочие модули, поэтому это не полноценный stylistic reference | считать baseline-pass завершённым и трогать дальше только при появлении richer directory requirements |
| `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 |
@ -51,7 +51,6 @@
7. отдельный audit по `calendar`
8. `departments`
9. `auth/legal`
10. завершение `users/profile` + `users/directory` residual pass
Логика порядка:
@ -71,20 +70,20 @@
Статус:
- базовый structural + modal + directory-shell pass уже сделан
- wave 1 baseline pass завершён
Цель:
- подтянуть people/profile cluster к общему product feel без потери profile anatomy
Что осталось:
Что зафиксировано:
- `/profile` уже переведён на page-level controller по аналогии с `useUserDetailPage`
- `EditUserProfileModal` и `EmployeeActionModal` уже подтянуты к app-style primitives
- `ProfileSections` уже нормализованы по базовой геометрии без слома anatomy
- `users/page.tsx` уже переведён на app-surface language
- отдельно добить `employees/page.tsx` и остаточные локальные blocks в `/users/[id]`
- при необходимости сделать финальный visual-tuning hero density после ручного review
- `employees/page.tsx` и остаточные локальные blocks в `/users/[id]` уже приведены к тому же cluster contract
- дальнейший проход нужен только если после ручного review выяснится, что hero density всё ещё визуально выбивается
### Волна 2. Operational pages with high leverage

View File

@ -1,14 +1,26 @@
"use client";
import { AppShell } from "../../components/AppShell";
import { apiClient } from "@/lib/api";
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState, type MouseEvent } from "react";
import { MessageCircle } from "lucide-react";
import { AppShell } from "../../components/AppShell";
import { useUser } from "@/contexts/UserContext";
import { apiClient } from "@/lib/api";
import { resolveMediaUrl } from "@/lib/url";
import type { Chat, User } from "@/types/api";
type PrivateChat = Chat & { member_ids?: number[] };
const getErrorMessage = (error: unknown, fallback: string) =>
String((error as Error)?.message || fallback);
function getInitials(employee: User) {
return `${employee.last_name?.[0] || ""}${employee.first_name?.[0] || ""}`;
}
export default function EmployeesPage() {
const router = useRouter();
const { user: currentUser } = useUser();
@ -22,7 +34,7 @@ export default function EmployeesPage() {
const loaderRef = useRef<HTMLDivElement | null>(null);
const isFetchingRef = useRef(false);
const loadEmployees = async (pageToLoad: number, append: boolean) => {
async function loadEmployees(pageToLoad: number, append: boolean) {
if (isFetchingRef.current) return;
try {
@ -34,69 +46,73 @@ export default function EmployeesPage() {
}
setError(null);
const response = await apiClient.getEmployees({
page: pageToLoad,
const response = await apiClient.getEmployees({
page: pageToLoad,
limit: 20,
is_active: true
is_active: true,
});
const nextChunk = response.results || [];
setEmployees((prev) => (append ? [...prev, ...nextChunk] : nextChunk));
setEmployees((current) =>
append ? [...current, ...nextChunk] : nextChunk,
);
setHasMore(Boolean(response.next));
setPage(pageToLoad);
} catch (err: unknown) {
console.error("Ошибка загрузки сотрудников:", err);
setError("Не удалось загрузить список сотрудников");
} catch (error: unknown) {
console.error("Ошибка загрузки сотрудников:", error);
setError(getErrorMessage(error, "Не удалось загрузить список сотрудников"));
} finally {
isFetchingRef.current = false;
setLoading(false);
setLoadingMore(false);
}
};
}
useEffect(() => {
loadEmployees(1, false);
void loadEmployees(1, false);
}, []);
// Создание чата с сотрудником
const handleStartChat = async (employee: User, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!currentUser || creatingChatFor === employee.id) return;
if (currentUser.id === employee.id) return; // Не создаем чат с собой
async function handleStartChat(employee: User, event: MouseEvent<HTMLButtonElement>) {
event.preventDefault();
event.stopPropagation();
if (!currentUser || creatingChatFor === employee.id || currentUser.id === employee.id) {
return;
}
setCreatingChatFor(employee.id);
try {
// Ищем существующий приватный чат с этим пользователем
const allChats = await apiClient.getAllChats();
const existingChat = allChats.find((chat: Chat & { member_ids?: number[] }) => {
if (chat.type !== 'private') return false;
const memberIds: number[] = chat.member_ids || [];
return memberIds.length === 2 &&
memberIds.includes(currentUser.id) &&
memberIds.includes(employee.id);
const allChats = (await apiClient.getAllChats()) as PrivateChat[];
const existingChat = allChats.find((chat) => {
if (chat.type !== "private") return false;
const memberIds = Array.isArray(chat.member_ids) ? chat.member_ids : [];
return (
memberIds.length === 2 &&
memberIds.includes(currentUser.id) &&
memberIds.includes(employee.id)
);
});
if (existingChat) {
router.push(`/messages/${existingChat.id}`);
} else {
const chat = await apiClient.createChat({
type: 'private',
name: 'Диалог',
participants: [employee.id]
});
router.push(`/messages/${chat.id}`);
return;
}
} catch (err: unknown) {
console.error("Ошибка создания чата:", err);
const chat = await apiClient.createChat({
type: "private",
name: "Диалог",
participants: [employee.id],
});
router.push(`/messages/${chat.id}`);
} catch (error: unknown) {
console.error("Ошибка создания чата:", error);
alert("Не удалось открыть чат");
} finally {
setCreatingChatFor(null);
}
};
}
useEffect(() => {
if (!hasMore || loading) return;
@ -109,13 +125,13 @@ export default function EmployeesPage() {
const first = entries[0];
if (!first?.isIntersecting) return;
if (isFetchingRef.current || loadingMore || !hasMore) return;
loadEmployees(page + 1, true);
void loadEmployees(page + 1, true);
},
{
root: null,
rootMargin: "300px 0px",
threshold: 0.1,
}
},
);
observer.observe(target);
@ -123,17 +139,15 @@ export default function EmployeesPage() {
return () => {
observer.disconnect();
};
}, [page, hasMore, loading, loadingMore]);
}, [hasMore, loading, loadingMore, page]);
if (loading) {
return (
<AppShell>
<div className="flex items-center justify-center py-12">
<div className="text-center">
<div className="mb-4 inline-block h-8 w-8 animate-spin rounded-full border-4 border-sky-400 border-t-transparent"></div>
<p className="app-text-muted text-sm">Загрузка...</p>
</div>
</div>
<section className="app-surface rounded-2xl p-8 text-center">
<div className="mx-auto mb-4 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>
</AppShell>
);
}
@ -141,72 +155,100 @@ export default function EmployeesPage() {
if (error) {
return (
<AppShell>
<div className="app-feedback-danger rounded-2xl p-6 text-center">
<p className="text-sm">{error}</p>
</div>
<section className="app-surface rounded-2xl p-6 text-center">
<p className="text-sm text-red-400">{error}</p>
</section>
</AppShell>
);
}
const sortedEmployees = [...employees].sort((a, b) => {
const nameA = `${a.last_name} ${a.first_name}`.trim();
const nameB = `${b.last_name} ${b.first_name}`.trim();
return nameA.localeCompare(nameB, "ru");
const sortedEmployees = [...employees].sort((left, right) => {
const leftName = `${left.last_name || ""} ${left.first_name || ""}`.trim();
const rightName = `${right.last_name || ""} ${right.first_name || ""}`.trim();
return leftName.localeCompare(rightName, "ru");
});
return (
<AppShell>
<div className="space-y-3">
{sortedEmployees.map((employee) => {
const fullName = `${employee.last_name} ${employee.first_name} ${employee.patronymic || ''}`.trim();
const initials = `${employee.last_name?.[0] || ''}${employee.first_name?.[0] || ''}`;
const position = employee.position?.name || 'Сотрудник';
const isCurrentUser = currentUser?.id === employee.id;
<section className="app-surface rounded-2xl p-4">
<div className="space-y-2">
{sortedEmployees.map((employee) => {
const fullName =
`${employee.last_name || ""} ${employee.first_name || ""} ${employee.patronymic || ""}`.trim();
const initials = getInitials(employee);
const position = employee.position?.name || "Сотрудник";
const isCurrentUser = currentUser?.id === employee.id;
return (
<div key={employee.id} className="app-surface-elevated flex items-center gap-4 rounded-2xl p-4">
<Link href={`/users/${employee.id}`} className="flex flex-1 items-center gap-4 transition hover:opacity-80">
<div className="flex h-12 w-12 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white">
{employee.avatar ? (
<img src={employee.avatar} alt={fullName} className="h-full w-full object-cover" />
) : (
initials
)}
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-[var(--foreground)]">{fullName}</p>
<p className="app-text-muted text-xs">{position}</p>
{employee.email && (
<p className="app-text-muted mt-1 text-xs">{employee.email}</p>
)}
</div>
</Link>
{!isCurrentUser && (
<button
onClick={(e) => handleStartChat(employee, e)}
disabled={creatingChatFor === employee.id}
className="app-icon-button flex h-10 w-10 items-center justify-center rounded-full disabled:opacity-50"
title="Написать сообщение"
return (
<div
key={employee.id}
className="app-surface-muted flex items-center gap-4 rounded-xl p-3 transition hover:border-[var(--border-strong)] hover:bg-[var(--surface-elevated)]"
>
<Link
href={`/users/${employee.id}`}
className="flex min-w-0 flex-1 items-center gap-4"
>
{creatingChatFor === employee.id ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-[var(--accent-primary)] border-t-transparent" />
) : (
<MessageCircle size={18} />
)}
</button>
)}
</div>
);
})}
<div className="app-avatar-frame flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-full">
{employee.avatar ? (
<Image
src={resolveMediaUrl(employee.avatar)}
alt={fullName || "Сотрудник"}
width={48}
height={48}
className="h-full w-full object-cover"
unoptimized
/>
) : (
<span className="app-avatar-fallback flex h-full w-full items-center justify-center text-sm font-semibold">
{initials}
</span>
)}
</div>
<div className="min-w-0 flex-1">
<p className="app-text-wrap text-sm font-semibold text-[var(--foreground)]">
{fullName || "Сотрудник"}
</p>
<p className="app-text-muted text-xs">{position}</p>
{employee.email ? (
<p className="app-text-wrap app-text-muted mt-1 text-xs">
{employee.email}
</p>
) : null}
</div>
</Link>
<div ref={loaderRef} className="py-2">
{loadingMore ? (
<p className="app-text-muted text-center text-xs">Подгружаем сотрудников...</p>
) : !hasMore && employees.length > 0 ? (
<p className="app-text-muted text-center text-xs">Все сотрудники загружены</p>
) : null}
{!isCurrentUser ? (
<button
type="button"
onClick={(event) => void handleStartChat(employee, event)}
disabled={creatingChatFor === employee.id}
className="app-icon-button flex h-10 w-10 shrink-0 items-center justify-center rounded-full disabled:opacity-50"
title="Написать сообщение"
>
{creatingChatFor === employee.id ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-[var(--accent-primary)] border-t-transparent" />
) : (
<MessageCircle size={18} />
)}
</button>
) : null}
</div>
);
})}
<div ref={loaderRef} className="py-2">
{loadingMore ? (
<p className="app-text-muted text-center text-xs">
Подгружаем сотрудников...
</p>
) : !hasMore && employees.length > 0 ? (
<p className="app-text-muted text-center text-xs">
Все сотрудники загружены
</p>
) : null}
</div>
</div>
</div>
</section>
</AppShell>
);
}

View File

@ -296,12 +296,12 @@ export default function UserDetailPage() {
</div>
{loading ? (
<section className="app-surface rounded-[24px] p-8 text-center">
<section className="app-surface rounded-2xl p-8 text-center">
<div className="mx-auto mb-4 h-10 w-10 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 ? (
<section className="app-surface rounded-[24px] p-6 text-center">
<section className="app-surface rounded-2xl p-6 text-center">
<p className="text-sm text-red-400">{error}</p>
</section>
) : person ? (
@ -407,7 +407,7 @@ export default function UserDetailPage() {
<ProfileInfoCard items={infoItems} />
{person.departments?.length ? (
<section className="app-surface rounded-[24px] p-5">
<section className="app-surface rounded-2xl p-5">
<div className="mb-4">
<h2 className="app-card-caption">Отделы</h2>
</div>