refactor(frontend): finish people cluster alignment
This commit is contained in:
parent
79b66c5884
commit
d437f63d02
@ -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
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user