From c56fdbb915892e8acaa9618256019eac4dff5c29 Mon Sep 17 00:00:00 2001
From: lizerk
Date: Sun, 12 Apr 2026 06:27:18 +0300
Subject: [PATCH] feat(frontend): align employee profile flows
---
backend/api/v1/employees/views/employees.py | 30 +
.../tests/api/v1/employees/test_employees.py | 29 +
docs/frontend-style-guide.md | 77 ++
frontend/src/app/profile/page.tsx | 26 +-
frontend/src/app/users/[id]/page.tsx | 839 +++++++++++++-----
.../users/EmployeeActionsTimeline.tsx | 111 ++-
frontend/src/lib/api/employees.ts | 8 +
7 files changed, 821 insertions(+), 299 deletions(-)
create mode 100644 docs/frontend-style-guide.md
diff --git a/backend/api/v1/employees/views/employees.py b/backend/api/v1/employees/views/employees.py
index 3c4f422d..0ae78d11 100644
--- a/backend/api/v1/employees/views/employees.py
+++ b/backend/api/v1/employees/views/employees.py
@@ -16,6 +16,7 @@ from employees.models import (
EmployeeAction,
EmployeeDepartment,
RoleAssignment,
+ Skill,
)
from employees.utils import _to_bool
from rest_framework import filters, viewsets
@@ -291,6 +292,35 @@ class EmployeeViewSet(viewsets.ModelViewSet):
self.perform_update(serializer)
return Response(serializer.data, status=200)
+ @action(detail=True, methods=["post"])
+ def add_skill(self, request, pk=None):
+ """Добавить навык сотруднику.
+
+ Доступно любому аутентифицированному пользователю.
+ Можно передать либо skill_id, либо name.
+ """
+ instance = self.get_object()
+ skill_id = request.data.get("skill_id")
+ skill_name = (request.data.get("name") or "").strip()
+
+ if not skill_id and not skill_name:
+ return Response(
+ {"detail": "Нужно передать skill_id или name"},
+ status=400,
+ )
+
+ if skill_id:
+ try:
+ skill = Skill.objects.get(pk=int(skill_id))
+ except (TypeError, ValueError, Skill.DoesNotExist):
+ return Response({"detail": "Skill не найден"}, status=404)
+ else:
+ skill, _ = Skill.objects.get_or_create(name=skill_name)
+
+ instance.skills.add(skill)
+ serializer = self.get_serializer(instance)
+ return Response(serializer.data, status=200)
+
def get_serializer_context(self):
ctx = super().get_serializer_context()
if getattr(self, "action", None) in {"retrieve", "me"}:
diff --git a/backend/tests/api/v1/employees/test_employees.py b/backend/tests/api/v1/employees/test_employees.py
index ce4fce53..236d6147 100644
--- a/backend/tests/api/v1/employees/test_employees.py
+++ b/backend/tests/api/v1/employees/test_employees.py
@@ -342,3 +342,32 @@ def test_me_patch_updates_profile(api_client: APIClient):
me.refresh_from_db()
assert me.first_name == "Updated"
+
+def test_add_skill_allowed_for_any_authenticated_user(api_client: APIClient):
+ actor = make_user("actor@example.com")
+ target = make_user("target-skill@example.com")
+ skill = Skill.objects.create(name="Python")
+
+ api_client.force_authenticate(user=actor)
+ url = reverse("api:v1:employees-add-skill", args=[target.pk])
+
+ resp = api_client.post(url, {"skill_id": skill.id}, format="json")
+ assert resp.status_code == status.HTTP_200_OK
+
+ target.refresh_from_db()
+ assert target.skills.filter(pk=skill.pk).exists()
+
+
+def test_add_skill_can_create_skill_by_name(api_client: APIClient):
+ actor = make_user("actor2@example.com")
+ target = make_user("target-create-skill@example.com")
+
+ api_client.force_authenticate(user=actor)
+ url = reverse("api:v1:employees-add-skill", args=[target.pk])
+
+ resp = api_client.post(url, {"name": "TypeScript"}, format="json")
+ assert resp.status_code == status.HTTP_200_OK
+
+ created = Skill.objects.get(name="TypeScript")
+ target.refresh_from_db()
+ assert target.skills.filter(pk=created.pk).exists()
diff --git a/docs/frontend-style-guide.md b/docs/frontend-style-guide.md
new file mode 100644
index 00000000..782c0a0b
--- /dev/null
+++ b/docs/frontend-style-guide.md
@@ -0,0 +1,77 @@
+# Frontend Style Guide
+
+Короткий проектный контракт для новых экранов и рефакторинга старых.
+
+## Базовые поверхности
+
+- Внешний самостоятельный блок страницы: `app-surface`
+- Внутренний блок внутри секции: `app-surface-muted`
+- Не делать `card inside card inside card`, если можно обойтись одним внешним и одним внутренним уровнем.
+
+## Подписи секций
+
+- Верхние подписи карточек и секций: `app-card-caption`
+- Внутренние заголовки внутри блоков:
+ - `text-sm font-semibold text-[var(--foreground)]`
+- Не смешивать `app-card-caption` и обычный заголовок в одном уровне вложенности.
+
+## Кнопки
+
+- Primary action:
+ - `app-action-primary`
+- Secondary action:
+ - `app-action-secondary`
+- Danger action:
+ - secondary-style кнопка с red hover/focus, а не тяжелый красный прямоугольник по умолчанию
+- Все add/create CTA должны выглядеть одинаково:
+ - иконка `Plus`
+ - `app-action-primary`
+ - `rounded-xl`
+ - одинаковая высота и плотность
+
+## Pills и бейджи
+
+- Нейтральный chip/filter: `app-pill`
+- Активный/select/primary state: `app-pill-active` или `app-selected`
+- Статусы должны быть семантическими:
+ - success: `app-feedback-success`
+ - warning: `app-feedback-warning`
+ - danger: `app-feedback-danger`
+- Не использовать случайный синий только потому, что нужен акцент.
+
+## Формы
+
+- Текстовые поля: `app-input`
+- Select: `app-select`
+- Поисковая строка в шапке: pill-форма
+- Обычные form fields: скругленные углы, не pill
+- Не смешивать разные hover/focus-паттерны на одном экране
+
+## Профильные и read-only страницы
+
+- `/profile` и `/users/[id]`:
+ - hero сверху
+ - отдельные широкие секции ниже
+ - не дублировать settings-редактирование карандашами в нескольких местах
+- Read-only экран:
+ - минимум inline editing
+ - одно явное действие лучше, чем несколько карандашей по карточкам
+
+## Таймлайны
+
+- Линия события цветная по типу события
+- Точка только на текущем событии
+- Текущее событие определяется по дате, а не по первой записи в списке
+
+## Контакты
+
+- Контактные блоки строятся как список строк внутри `app-surface-muted`
+- Не делать отдельную маленькую карточку на каждый контакт
+- Иконки контактов нейтральны, если нет реальной причины для акцента
+
+## Чего избегать
+
+- `bg-white`, `bg-gray-*`, `ring-gray-*`, `focus:ring-sky-*` в новых и перерабатываемых экранах
+- Разных стилей add/save/delete между соседними страницами
+- Декоративных badge без логики состояния
+- Смешения old-gray-contract и `app-*` токенов в одном блоке
diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx
index a820ec50..54b70f56 100644
--- a/frontend/src/app/profile/page.tsx
+++ b/frontend/src/app/profile/page.tsx
@@ -397,7 +397,7 @@ export default function ProfilePage() {
Мой профиль
{currentAction ? (
{currentAction.action_display || currentAction.action}
@@ -434,7 +434,7 @@ export default function ProfilePage() {
{user.position?.name || "Должность не указана"}
{!!user.departments?.length && (
-
+
{user.departments.length === 1
? user.departments[0].name
: `${user.departments.length} отделов`}
@@ -640,7 +640,7 @@ export default function ProfilePage() {
type="button"
onClick={() => void handleRemoveSkill(skill.id)}
disabled={skillsSaving}
- className="app-pill inline-flex items-center gap-2 rounded-full px-3 py-2 text-sm font-medium transition hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] disabled:opacity-50"
+ 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="Удалить навык"
>
{skill.name}
@@ -660,15 +660,15 @@ export default function ProfilePage() {
{suggestedSkills.map((skill) => (
-
+
))}
@@ -764,7 +764,7 @@ export default function ProfilePage() {
{action.action_display || action.action}
diff --git a/frontend/src/app/users/[id]/page.tsx b/frontend/src/app/users/[id]/page.tsx
index ca87d762..e859b471 100644
--- a/frontend/src/app/users/[id]/page.tsx
+++ b/frontend/src/app/users/[id]/page.tsx
@@ -1,34 +1,106 @@
"use client";
import Link from "next/link";
-import { useParams } from "next/navigation";
+import Image from "next/image";
+import { useEffect, useMemo, useState } from "react";
+import { useParams, useRouter } from "next/navigation";
import {
- Award,
Building2,
- Calendar,
+ CalendarDays,
Check,
- Clock,
Copy,
Mail,
MessageCircle,
Pencil,
Phone,
+ Plus,
} from "lucide-react";
+
import { AppShell } from "../../../components/AppShell";
import EditUserProfileModal from "@/components/users/EditUserProfileModal";
import EmployeeActionModal from "@/components/users/EmployeeActionModal";
import EmployeeActionsTimeline from "@/components/users/EmployeeActionsTimeline";
import { useUser } from "@/contexts/UserContext";
import { useUserDetailPage } from "@/hooks/useUserDetailPage";
+import { apiClient } from "@/lib/api";
+import { resolveMediaUrl } from "@/lib/url";
import {
formatBirthday,
formatPhoneForLink,
- getEmployeeActionBadgeClass,
getWorkDuration,
} from "@/lib/users/userDetailUtils";
+function formatDate(value?: string): string {
+ if (!value) return "—";
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return value;
+ return date.toLocaleDateString("ru-RU", {
+ day: "2-digit",
+ month: "2-digit",
+ year: "numeric",
+ });
+}
+
+function formatDateTime(value?: string): string {
+ if (!value) return "—";
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return value;
+ return date.toLocaleString("ru-RU", {
+ day: "2-digit",
+ month: "2-digit",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+function normalizeTelegram(value: string) {
+ const trimmed = value.trim();
+ if (!trimmed) return "";
+ if (/^https?:\/\//i.test(trimmed)) return trimmed;
+ return `https://t.me/${trimmed.replace(/^@/, "")}`;
+}
+
+function normalizeWhatsApp(value: string) {
+ const trimmed = value.trim();
+ if (!trimmed) return "";
+ if (/^https?:\/\//i.test(trimmed)) return trimmed;
+ const digits = trimmed.replace(/[^\d]/g, "");
+ return digits ? `https://wa.me/${digits}` : "";
+}
+
+function getActionTone(action?: string) {
+ switch (action) {
+ case "on_leave":
+ case "on_maternity":
+ return "app-feedback-warning";
+ case "dismissed":
+ return "app-feedback-danger";
+ case "transferred":
+ return "app-selected";
+ default:
+ return "app-feedback-success";
+ }
+}
+
+function SectionTitle({
+ title,
+ action,
+}: {
+ title: string;
+ action?: React.ReactNode;
+}) {
+ return (
+
+
{title}
+ {action}
+
+ );
+}
+
export default function UserDetailPage() {
const params = useParams<{ id: string }>();
+ const router = useRouter();
const { user: currentUser } = useUser();
const userId = Number(params?.id);
@@ -69,272 +141,549 @@ export default function UserDetailPage() {
sortedActions,
} = useUserDetailPage(userId, currentUser);
+ useEffect(() => {
+ if (currentUser?.id && userId && currentUser.id === userId) {
+ router.replace("/profile");
+ }
+ }, [currentUser?.id, router, userId]);
+
+ const primaryDepartment = useMemo(
+ () => person?.departments?.[0] || null,
+ [person?.departments],
+ );
+ const [availableSkills, setAvailableSkills] = useState<
+ Array<{ id: number; name: string; description?: string }>
+ >([]);
+ const [personSkills, setPersonSkills] = useState<
+ Array<{ id: number; name: string; description?: string }>
+ >([]);
+ const [skillName, setSkillName] = useState("");
+ const [skillsLoading, setSkillsLoading] = useState(false);
+ const [skillsSaving, setSkillsSaving] = useState(false);
+ const [skillsError, setSkillsError] = useState
(null);
+
+ useEffect(() => {
+ setPersonSkills(person?.skills || []);
+ }, [person?.skills]);
+
+ 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 normalizedSkillName = skillName.trim().toLowerCase();
+ const selectedSkillIds = useMemo(
+ () => new Set(personSkills.map((skill) => skill.id)),
+ [personSkills],
+ );
+ const suggestedSkills = useMemo(
+ () =>
+ availableSkills
+ .filter((skill) => !selectedSkillIds.has(skill.id))
+ .filter((skill) =>
+ normalizedSkillName
+ ? skill.name.toLowerCase().includes(normalizedSkillName)
+ : true,
+ )
+ .slice(0, 10),
+ [availableSkills, normalizedSkillName, selectedSkillIds],
+ );
+
+ const contactRows = useMemo(
+ () =>
+ person
+ ? [
+ person.email
+ ? {
+ key: "email",
+ label: "Почта",
+ value: person.email,
+ href: `mailto:${person.email}`,
+ icon: ,
+ }
+ : null,
+ person.phone_number
+ ? {
+ key: "phone",
+ label: "Телефон",
+ value: person.phone_number,
+ href: `tel:${formatPhoneForLink(person.phone_number)}`,
+ icon: ,
+ }
+ : null,
+ person.telegram
+ ? {
+ key: "telegram",
+ label: "Telegram",
+ value: person.telegram,
+ href: normalizeTelegram(person.telegram),
+ icon: ,
+ }
+ : null,
+ person.whatsapp
+ ? {
+ key: "whatsapp",
+ label: "WhatsApp",
+ value: person.whatsapp,
+ href: normalizeWhatsApp(person.whatsapp),
+ icon: ,
+ }
+ : null,
+ person.wechat
+ ? {
+ key: "wechat",
+ label: "WeChat",
+ value: person.wechat,
+ href: null,
+ icon: ,
+ }
+ : null,
+ ].filter(Boolean) as Array<{
+ key: string;
+ label: string;
+ value: string;
+ href: string | null;
+ icon: React.ReactNode;
+ }>
+ : [],
+ [person],
+ );
+
+ const handleAddSkill = async (forcedSkill?: { id: number; name: string }) => {
+ if (!person || skillsSaving) return;
+ const rawName = forcedSkill?.name || skillName;
+ const trimmedName = rawName.trim();
+ if (!trimmedName) return;
+
+ try {
+ setSkillsSaving(true);
+ setSkillsError(null);
+
+ const existingSkill =
+ forcedSkill ||
+ availableSkills.find(
+ (skill) => skill.name.trim().toLowerCase() === trimmedName.toLowerCase(),
+ );
+
+ const response = (await apiClient.addEmployeeSkill(
+ person.id,
+ existingSkill ? { skill_id: existingSkill.id } : { name: trimmedName },
+ )) as { skills?: Array<{ id: number; name: string; description?: string }> };
+
+ setPersonSkills(response.skills || []);
+
+ if (!existingSkill && trimmedName) {
+ const created = (response.skills || []).find(
+ (skill) => skill.name.trim().toLowerCase() === trimmedName.toLowerCase(),
+ );
+ if (created) {
+ setAvailableSkills((current) =>
+ current.some((skill) => skill.id === created.id)
+ ? current
+ : [...current, created].sort((left, right) =>
+ left.name.localeCompare(right.name, "ru"),
+ ),
+ );
+ }
+ }
+
+ setSkillName("");
+ } catch (error: any) {
+ setSkillsError(
+ String(error?.message || "Не удалось добавить навык"),
+ );
+ } finally {
+ setSkillsSaving(false);
+ }
+ };
+
+ if (currentUser?.id && userId && currentUser.id === userId) {
+ return (
+
+
+
Перенаправляем в профиль...
+
+
+ );
+ }
+
return (
-
-
- ← К списку сотрудников
-
+
+
+
+ ← К списку сотрудников
+
+
{loading ? (
-
+
+
+ Загрузка сотрудника...
+
) : error ? (
-
+
) : person ? (
<>
-
-
-
- {avatarUrl && !avatarFailed ? (
- // eslint-disable-next-line @next/next/no-img-element
-

setAvatarFailed(true)}
- />
- ) : (
-
{initials}
- )}
+
+
+
Профиль сотрудника
+ {latestAction ? (
+
+ {latestAction.action_display || latestAction.action}
+
+ ) : null}
+
+
+
+
+
+ {avatarUrl && !avatarFailed ? (
+
setAvatarFailed(true)}
+ unoptimized
+ />
+ ) : (
+
+ {initials}
+
+ )}
+
+
+
+
+
+
+ {fullName}
+
+
+
+ {person.position?.name || "Должность не указана"}
+
+ {primaryDepartment ? (
+
+ {primaryDepartment.name}
+
+ ) : null}
+
+
+
+
+ {canManageActions ? (
+
+ ) : null}
+ {canEdit ? (
+
+ ) : null}
+
+
+
+ {currentUser && currentUser.id !== person.id ? (
+
+
+ {person.phone_number ? (
+
+
+ Позвонить
+
+ ) : null}
+
+ ) : null}
+
-
-
-
{fullName}
- {canEdit && (
-
+
+
+ {contactRows.length ? (
+ contactRows.map((contact, index) => (
+
+ {index > 0 ? (
+
+ ) : null}
+
+
+ {contact.icon}
+
+
+
+ {contact.label}
+
+ {contact.href ? (
+
+ {contact.value}
+
+ ) : (
+
+ {contact.value}
+
+ )}
+
+
+
+
+ ))
+ ) : (
+
+ Контакты не указаны
+
)}
-
-
{person.position?.name || "—"}
- {latestAction && (
-
- {latestAction.action_display || latestAction.action}
-
- )}
- {canManageActions && (
-
- )}
-
- {person.departments && person.departments.length > 0 && (
-
{person.departments[0].name}
- )}
-
- {currentUser && currentUser.id !== person.id && (
-
-
-
- {person.phone_number && (
-
-
- Позвонить
-
- )}
-
- )}
-
-
- Контакты
-
- {person.email && (
-
-
-
-
- )}
+
+
+
+
+
+ setSkillName(event.target.value)}
+ placeholder="Добавить навык"
+ className="app-input w-full rounded-xl px-4 py-3 text-sm"
+ disabled={skillsSaving}
+ />
+
+
+
+
- {person.phone_number && (
-
-
-
-
- )}
+ {skillsError ? (
+
{skillsError}
+ ) : null}
- {person.telegram && (
-
-
-
- {person.telegram}
-
-
Telegram
+
+ {personSkills.length ? (
+
+ {personSkills.map((skill) => (
+
+ {skill.name}
+
+ ))}
- )}
-
- {person.whatsapp && (
-
- )}
-
- {person.wechat && (
-
-
- {person.wechat}
- WeChat
-
- )}
-
- {!person.email && !person.phone_number && !person.telegram && !person.whatsapp && !person.wechat && (
-
Нет контактов
+ ) : (
+
Навыки не указаны
)}
-
-
-
-
- Навыки
-
- {person.skills && person.skills.length > 0 ? (
-
- {person.skills.map((skill) => (
-
+
+ Доступные навыки
+
+
+ {suggestedSkills.map((skill) => (
+
+ ))}
+
+
+ ) : null}
+
+ {skillsLoading ? (
+
+ Загружаем навыки...
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+ В компании
+
+
+ {getWorkDuration(person.date_joined) || "—"}
+
+
+
+
+ Дата найма
+
+
+ {formatDate(person.date_joined)}
+
+
+
+
+
+
+
+ День рождения
+
+
+ {formatBirthday(person.birth_date) || "—"}
+
+
+
+
+ Последний вход
+
+
+ {formatDateTime(person.last_login)}
+
+
+
+
+
+
+ {person.departments?.length ? (
+
+
+
+
+ {person.departments.map((department) => (
+
- {skill.name}
-
+
+
+
+ {department.name}
+
+ {department.role_name ? (
+
+ {department.role_name}
+
+ ) : null}
+
+ {department.is_head ? (
+
+ Руководитель
+
+ ) : null}
+
+
))}
- ) : (
-
Навыки не указаны
- )}
-
-
- {(person.date_joined || person.birth_date) && (
-
- Информация
-
- {person.date_joined && (
- <>
-
-
-
-
В компании
-
{getWorkDuration(person.date_joined)}
-
-
-
-
-
-
Дата найма
-
- {new Date(person.date_joined).toLocaleDateString("ru-RU")}
-
-
-
- >
- )}
-
- {person.birth_date && (
-
-
-
-
День рождения
-
{formatBirthday(person.birth_date)}
-
-
- )}
-
-
- )}
-
-
- {person.departments && person.departments.length > 0 && (
-
-
-
- Отделы
-
-
- {person.departments.map((department) => (
-
-
-
{department.name}
- {department.role_name && (
-
{department.role_name}
- )}
-
- {department.is_head && (
-
- Руководитель
-
- )}
-
- ))}
- )}
+ ) : null}
-
-
-
- История кадровых событий
-
+
+
-
- {sortedActions.length} {sortedActions.length === 1 ? "событие" : sortedActions.length < 5 ? "события" : "событий"}
+
+ Кадровые события
+
+
+
+ {sortedActions.length}{" "}
+ {sortedActions.length === 1
+ ? "событие"
+ : sortedActions.length < 5
+ ? "события"
+ : "событий"}
- {canManageActions && (
+ {canManageActions ? (
- )}
+ ) : null}
+
{sortedActions.map((action) => {
const isCurrent = latestActionId === action.id;
const deleteKey = `delete-${action.id}`;
+ const tone = getEmployeeActionBadgeClass(action.action);
+ const lineColor = getEmployeeActionBorderColor(action.action);
return (
-
-
-
-
-
-
+
+
+ {isCurrent ? (
+
+ ) : null}
+
+
+
+
+
{action.action_display || action.action}
- {isCurrent && (
- текущий
- )}
+ {isCurrent ? (
+
+ текущий
+
+ ) : null}
- {action.comment && (
-
{action.comment}
- )}
+ {action.comment ? (
+
+ {action.comment}
+
+ ) : null}
-
-
@@ -117,4 +146,4 @@ export default function EmployeeActionsTimeline({
);
-}
\ No newline at end of file
+}
diff --git a/frontend/src/lib/api/employees.ts b/frontend/src/lib/api/employees.ts
index ac337476..82c0cd70 100644
--- a/frontend/src/lib/api/employees.ts
+++ b/frontend/src/lib/api/employees.ts
@@ -24,6 +24,14 @@ export function createEmployeesApi(request: RequestFn) {
return request(`/api/v1/employees/${qs ? '?' + qs : ''}`);
},
getEmployee: (id: number | string) => request(`/api/v1/employees/${id}/`),
+ addEmployeeSkill: (
+ id: number | string,
+ data: { skill_id?: number; name?: string },
+ ) =>
+ request(`/api/v1/employees/${id}/add_skill/`, {
+ method: 'POST',
+ body: JSON.stringify(data),
+ }),
updateEmployee: (id: number | string, data: Record
) =>
request(`/api/v1/employees/${id}/`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
uploadEmployeeAvatar: (id: number | string, file: File) => {