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 ? ( -
-

{error}

-
+
+

{error}

+
) : person ? ( <> -
-
-
- {avatarUrl && !avatarFailed ? ( - // eslint-disable-next-line @next/next/no-img-element - {fullName} setAvatarFailed(true)} - /> - ) : ( - {initials} - )} +
+
+

Профиль сотрудника

+ {latestAction ? ( + + {latestAction.action_display || latestAction.action} + + ) : null} +
+ +
+
+
+ {avatarUrl && !avatarFailed ? ( + {fullName} 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} + /> + + {availableSkills.map((skill) => ( + +
+ +
- {person.phone_number && ( -
- - -
- )} + {skillsError ? ( +

{skillsError}

+ ) : null} - {person.telegram && ( -
- - - {person.telegram} - - Telegram +
+ {personSkills.length ? ( +
+ {personSkills.map((skill) => ( + + {skill.name} + + ))}
- )} - - {person.whatsapp && ( -
- - - {person.whatsapp} - - 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) => {