feat(frontend): align employee profile flows

This commit is contained in:
lizerk 2026-04-12 06:27:18 +03:00
parent 06bcd0c862
commit c56fdbb915
7 changed files with 821 additions and 299 deletions

View File

@ -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"}:

View File

@ -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()

View File

@ -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-*` токенов в одном блоке

View File

@ -397,7 +397,7 @@ export default function ProfilePage() {
<p className="app-card-caption">Мой профиль</p>
{currentAction ? (
<span
className={`${getActionTone(currentAction.action).badgeClass} inline-flex items-center rounded-full px-3 py-1 text-sm font-medium`}
className={`${getActionTone(currentAction.action).badgeClass} inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium`}
>
{currentAction.action_display || currentAction.action}
</span>
@ -434,7 +434,7 @@ export default function ProfilePage() {
{user.position?.name || "Должность не указана"}
</span>
{!!user.departments?.length && (
<span className="app-pill inline-flex items-center rounded-full px-3 py-1 text-sm font-medium">
<span className="app-pill inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium">
{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="Удалить навык"
>
<span>{skill.name}</span>
@ -660,15 +660,15 @@ export default function ProfilePage() {
</p>
<div className="flex flex-wrap gap-2">
{suggestedSkills.map((skill) => (
<button
key={skill.id}
type="button"
onClick={() => void handleAddSkill(skill)}
disabled={skillsSaving}
className="app-action-secondary rounded-full px-3 py-2 text-sm font-medium disabled:opacity-50"
>
{skill.name}
</button>
<button
key={skill.id}
type="button"
onClick={() => void handleAddSkill(skill)}
disabled={skillsSaving}
className="app-action-secondary rounded-full px-2.5 py-1 text-xs font-medium disabled:opacity-50"
>
{skill.name}
</button>
))}
</div>
</div>
@ -764,7 +764,7 @@ export default function ProfilePage() {
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<span
className={`${tone.badgeClass} inline-flex rounded-full px-3 py-1 text-sm font-medium`}
className={`${tone.badgeClass} inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium`}
>
{action.action_display || action.action}
</span>

View File

@ -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 (
<div className="mb-4 flex items-start justify-between gap-4">
<h2 className="app-card-caption">{title}</h2>
{action}
</div>
);
}
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<string | null>(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: <Mail size={18} />,
}
: null,
person.phone_number
? {
key: "phone",
label: "Телефон",
value: person.phone_number,
href: `tel:${formatPhoneForLink(person.phone_number)}`,
icon: <Phone size={18} />,
}
: null,
person.telegram
? {
key: "telegram",
label: "Telegram",
value: person.telegram,
href: normalizeTelegram(person.telegram),
icon: <MessageCircle size={18} />,
}
: null,
person.whatsapp
? {
key: "whatsapp",
label: "WhatsApp",
value: person.whatsapp,
href: normalizeWhatsApp(person.whatsapp),
icon: <MessageCircle size={18} />,
}
: null,
person.wechat
? {
key: "wechat",
label: "WeChat",
value: person.wechat,
href: null,
icon: <MessageCircle size={18} />,
}
: 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 (
<AppShell>
<div className="flex items-center justify-center py-16">
<p className="app-text-muted text-sm">Перенаправляем в профиль...</p>
</div>
</AppShell>
);
}
return (
<AppShell>
<div className="space-y-4">
<Link href="/employees" className="inline-flex items-center rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50">
← К списку сотрудников
</Link>
<div className="mx-auto max-w-5xl space-y-5">
<div className="flex items-center justify-between gap-4">
<Link
href="/employees"
className="app-action-secondary inline-flex items-center justify-center rounded-xl px-4 py-2.5 text-sm font-medium"
>
← К списку сотрудников
</Link>
</div>
{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-[24px] 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 ? (
<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-[24px] p-6 text-center">
<p className="text-sm text-red-400">{error}</p>
</section>
) : person ? (
<>
<section className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="flex items-start gap-4">
<div className="flex h-20 w-20 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-xl font-semibold text-white">
{avatarUrl && !avatarFailed ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={avatarUrl}
alt={fullName}
className="h-full w-full object-cover"
onError={() => setAvatarFailed(true)}
/>
) : (
<span>{initials}</span>
)}
<section className="app-surface rounded-[28px] p-6">
<div className="mb-5 flex items-start justify-between gap-4">
<p className="app-card-caption">Профиль сотрудника</p>
{latestAction ? (
<span
className={`${getActionTone(latestAction.action)} inline-flex items-center rounded-full px-3 py-1 text-sm font-medium`}
>
{latestAction.action_display || latestAction.action}
</span>
) : null}
</div>
<div className="space-y-5">
<div className="flex items-start gap-4">
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-full app-avatar-frame">
{avatarUrl && !avatarFailed ? (
<Image
src={avatarUrl}
alt={fullName}
width={96}
height={96}
className="h-full w-full object-cover"
onError={() => setAvatarFailed(true)}
unoptimized
/>
) : (
<div className="app-avatar-fallback flex h-full w-full items-center justify-center text-3xl font-semibold">
{initials}
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<h1 className="text-3xl font-semibold leading-tight text-[var(--foreground)]">
{fullName}
</h1>
<div className="mt-2 flex flex-wrap items-center gap-2">
<span className="app-text-muted text-base">
{person.position?.name || "Должность не указана"}
</span>
{primaryDepartment ? (
<Link
href={`/departments/${primaryDepartment.id}`}
className="app-pill inline-flex items-center rounded-full px-3 py-1 text-sm font-medium transition hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)]"
>
{primaryDepartment.name}
</Link>
) : null}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{canManageActions ? (
<button
type="button"
onClick={handleOpenActionModal}
className="app-action-primary inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium"
>
<Plus size={16} />
Событие
</button>
) : null}
{canEdit ? (
<button
type="button"
onClick={handleOpenEditModal}
className="app-action-secondary inline-flex h-10 w-10 items-center justify-center rounded-xl"
aria-label="Редактировать профиль"
title="Редактировать профиль"
>
<Pencil size={16} />
</button>
) : null}
</div>
</div>
{currentUser && currentUser.id !== person.id ? (
<div className="mt-4 flex flex-wrap gap-2">
<button
onClick={handleStartChat}
disabled={creatingChat}
className="app-action-primary inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
<MessageCircle size={16} />
{creatingChat ? "Загрузка..." : "Написать"}
</button>
{person.phone_number ? (
<a
href={`tel:${formatPhoneForLink(person.phone_number)}`}
className="app-action-secondary inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium"
>
<Phone size={16} />
Позвонить
</a>
) : null}
</div>
) : null}
</div>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start gap-2">
<h1 className="flex-1 text-xl font-bold text-gray-900">{fullName}</h1>
{canEdit && (
<button
type="button"
onClick={handleOpenEditModal}
className="flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 bg-white text-gray-600 transition hover:bg-gray-50 hover:text-sky-700"
aria-label="Редактировать профиль"
title="Редактировать профиль"
>
<Pencil size={16} />
</button>
<div className="app-surface-muted rounded-2xl p-4">
<div>
{contactRows.length ? (
contactRows.map((contact, index) => (
<div key={contact.key}>
{index > 0 ? (
<div className="mx-4 border-t border-[var(--border-subtle)]" />
) : null}
<div className="flex items-start gap-3 px-4 py-4">
<span className="app-badge mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl">
{contact.icon}
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-[var(--foreground)]">
{contact.label}
</p>
{contact.href ? (
<a
href={contact.href}
target={
contact.key === "telegram" ||
contact.key === "whatsapp"
? "_blank"
: undefined
}
rel={
contact.key === "telegram" ||
contact.key === "whatsapp"
? "noreferrer"
: undefined
}
className="app-text-muted mt-1 block truncate text-sm hover:text-[var(--accent-primary)]"
>
{contact.value}
</a>
) : (
<p className="app-text-muted mt-1 truncate text-sm">
{contact.value}
</p>
)}
</div>
<button
type="button"
onClick={() =>
handleCopyToClipboard(contact.value, contact.key)
}
className="app-action-secondary inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl"
title="Скопировать"
>
{copySuccess === contact.key ? (
<Check size={14} />
) : (
<Copy size={14} />
)}
</button>
</div>
</div>
))
) : (
<p className="app-text-muted px-4 py-2 text-sm">
Контакты не указаны
</p>
)}
</div>
<div className="mt-1 flex flex-wrap items-center gap-2">
<p className="text-sm text-gray-600">{person.position?.name || "—"}</p>
{latestAction && (
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${getEmployeeActionBadgeClass(latestAction.action)}`}>
{latestAction.action_display || latestAction.action}
</span>
)}
{canManageActions && (
<button
onClick={handleOpenActionModal}
className="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700 transition hover:bg-gray-200"
>
+ Событие
</button>
)}
</div>
{person.departments && person.departments.length > 0 && (
<p className="mt-1 text-sm text-gray-500">{person.departments[0].name}</p>
)}
{currentUser && currentUser.id !== person.id && (
<div className="mt-3 flex flex-wrap gap-2">
<button
onClick={handleStartChat}
disabled={creatingChat}
className="inline-flex items-center gap-2 rounded-lg bg-sky-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-50"
>
<MessageCircle size={16} />
{creatingChat ? "Загрузка..." : "Написать"}
</button>
{person.phone_number && (
<a
href={`tel:${formatPhoneForLink(person.phone_number)}`}
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
<Phone size={16} />
Позвонить
</a>
)}
</div>
)}
</div>
</div>
</section>
<div className="grid gap-4 lg:grid-cols-2">
<section className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
<h2 className="mb-3 text-sm font-semibold text-gray-900">Контакты</h2>
<div className="space-y-2">
{person.email && (
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1 flex items-center gap-2">
<Mail size={16} className="flex-shrink-0 text-gray-400" />
<a href={`mailto:${person.email}`} className="truncate text-sm text-sky-600 hover:underline">
{person.email}
</a>
</div>
<button
onClick={() => handleCopyToClipboard(person.email, "email")}
className="ml-2 flex-shrink-0 rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
>
{copySuccess === "email" ? <Check size={14} /> : <Copy size={14} />}
</button>
</div>
)}
<section className="app-surface rounded-[24px] p-5">
<SectionTitle title="Навыки" />
<div className="app-surface-muted rounded-2xl p-4">
<div className="flex flex-col gap-3 md:flex-row">
<div className="min-w-0 flex-1">
<input
list="employee-skills-list"
value={skillName}
onChange={(event) => setSkillName(event.target.value)}
placeholder="Добавить навык"
className="app-input w-full rounded-xl px-4 py-3 text-sm"
disabled={skillsSaving}
/>
<datalist id="employee-skills-list">
{availableSkills.map((skill) => (
<option key={skill.id} value={skill.name} />
))}
</datalist>
</div>
<button
type="button"
onClick={() => void handleAddSkill()}
disabled={!skillName.trim() || skillsSaving}
className="app-action-primary inline-flex items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-medium disabled:opacity-50"
>
<Plus size={16} />
Добавить
</button>
</div>
{person.phone_number && (
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1 flex items-center gap-2">
<Phone size={16} className="flex-shrink-0 text-gray-400" />
<a href={`tel:${formatPhoneForLink(person.phone_number)}`} className="truncate text-sm text-sky-600 hover:underline">
{person.phone_number}
</a>
</div>
<button
onClick={() => handleCopyToClipboard(person.phone_number ?? "", "phone")}
className="ml-2 flex-shrink-0 rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
>
{copySuccess === "phone" ? <Check size={14} /> : <Copy size={14} />}
</button>
</div>
)}
{skillsError ? (
<p className="mt-3 text-sm text-red-400">{skillsError}</p>
) : null}
{person.telegram && (
<div className="flex items-center gap-2">
<MessageCircle size={16} className="flex-shrink-0 text-gray-400" />
<a
href={`https://t.me/${person.telegram.replace("@", "")}`}
target="_blank"
rel="noopener noreferrer"
className="truncate text-sm text-sky-600 hover:underline"
>
{person.telegram}
</a>
<span className="ml-auto text-xs text-gray-400">Telegram</span>
<div className="mt-4">
{personSkills.length ? (
<div className="flex flex-wrap gap-2">
{personSkills.map((skill) => (
<span
key={skill.id}
className="app-pill inline-flex items-center rounded-full px-3 py-2 text-sm font-medium"
>
{skill.name}
</span>
))}
</div>
)}
{person.whatsapp && (
<div className="flex items-center gap-2">
<Phone size={16} className="flex-shrink-0 text-gray-400" />
<a
href={`https://wa.me/${formatPhoneForLink(person.whatsapp)}`}
target="_blank"
rel="noopener noreferrer"
className="truncate text-sm text-sky-600 hover:underline"
>
{person.whatsapp}
</a>
<span className="ml-auto text-xs text-gray-400">WhatsApp</span>
</div>
)}
{person.wechat && (
<div className="flex items-center gap-2">
<MessageCircle size={16} className="flex-shrink-0 text-gray-400" />
<span className="truncate text-sm text-gray-700">{person.wechat}</span>
<span className="ml-auto text-xs text-gray-400">WeChat</span>
</div>
)}
{!person.email && !person.phone_number && !person.telegram && !person.whatsapp && !person.wechat && (
<p className="text-sm text-gray-500">Нет контактов</p>
) : (
<p className="app-text-muted text-sm">Навыки не указаны</p>
)}
</div>
</section>
<section className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900">
<Award size={16} />
Навыки
</h2>
{person.skills && person.skills.length > 0 ? (
<div className="flex flex-wrap gap-2">
{person.skills.map((skill) => (
<span
key={skill.id}
className="rounded-lg bg-gray-100 px-2.5 py-1 text-sm text-gray-700"
{suggestedSkills.length ? (
<div className="mt-4 border-t border-[var(--border-subtle)] pt-4">
<p className="mb-3 text-sm font-semibold text-[var(--foreground)]">
Доступные навыки
</p>
<div className="flex flex-wrap gap-2">
{suggestedSkills.map((skill) => (
<button
key={skill.id}
type="button"
onClick={() => void handleAddSkill(skill)}
disabled={skillsSaving}
className="app-action-secondary rounded-full px-3 py-2 text-sm font-medium disabled:opacity-50"
>
{skill.name}
</button>
))}
</div>
</div>
) : null}
{skillsLoading ? (
<p className="app-text-muted mt-3 text-sm">
Загружаем навыки...
</p>
) : null}
</div>
</section>
<section className="app-surface rounded-[24px] p-5">
<SectionTitle title="Информация" />
<div className="app-surface-muted overflow-hidden rounded-2xl">
<div className="grid md:grid-cols-2">
<div className="px-4 py-4">
<p className="text-sm font-semibold text-[var(--foreground)]">
В компании
</p>
<p className="app-text-muted mt-1 text-sm">
{getWorkDuration(person.date_joined) || "—"}
</p>
</div>
<div className="border-t border-[var(--border-subtle)] px-4 py-4 md:border-l md:border-t-0">
<p className="text-sm font-semibold text-[var(--foreground)]">
Дата найма
</p>
<p className="app-text-muted mt-1 text-sm">
{formatDate(person.date_joined)}
</p>
</div>
</div>
<div className="border-t border-[var(--border-subtle)]" />
<div className="grid md:grid-cols-2">
<div className="px-4 py-4">
<p className="text-sm font-semibold text-[var(--foreground)]">
День рождения
</p>
<p className="app-text-muted mt-1 text-sm">
{formatBirthday(person.birth_date) || "—"}
</p>
</div>
<div className="border-t border-[var(--border-subtle)] px-4 py-4 md:border-l md:border-t-0">
<p className="text-sm font-semibold text-[var(--foreground)]">
Последний вход
</p>
<p className="app-text-muted mt-1 text-sm">
{formatDateTime(person.last_login)}
</p>
</div>
</div>
</div>
</section>
{person.departments?.length ? (
<section className="app-surface rounded-[24px] p-5">
<SectionTitle title="Отделы" />
<div className="app-surface-muted rounded-2xl p-4">
<div className="space-y-3">
{person.departments.map((department) => (
<Link
key={department.id}
href={`/departments/${department.id}`}
className="app-surface block rounded-2xl px-4 py-3 transition hover:border-[var(--accent-primary)]"
>
{skill.name}
</span>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-[var(--foreground)]">
{department.name}
</p>
{department.role_name ? (
<p className="app-text-muted mt-1 text-sm">
{department.role_name}
</p>
) : null}
</div>
{department.is_head ? (
<span className="app-pill-active inline-flex rounded-full px-3 py-1 text-xs font-medium">
Руководитель
</span>
) : null}
</div>
</Link>
))}
</div>
) : (
<p className="text-sm text-gray-500">Навыки не указаны</p>
)}
</section>
{(person.date_joined || person.birth_date) && (
<section className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100 lg:col-span-2">
<h2 className="mb-3 text-sm font-semibold text-gray-900">Информация</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{person.date_joined && (
<>
<div className="flex items-center gap-2 text-sm">
<Clock size={16} className="text-gray-400" />
<div>
<p className="text-xs text-gray-500">В компании</p>
<p className="font-medium text-gray-900">{getWorkDuration(person.date_joined)}</p>
</div>
</div>
<div className="flex items-center gap-2 text-sm">
<Calendar size={16} className="text-gray-400" />
<div>
<p className="text-xs text-gray-500">Дата найма</p>
<p className="font-medium text-gray-900">
{new Date(person.date_joined).toLocaleDateString("ru-RU")}
</p>
</div>
</div>
</>
)}
{person.birth_date && (
<div className="flex items-center gap-2 text-sm">
<Calendar size={16} className="text-gray-400" />
<div>
<p className="text-xs text-gray-500">День рождения</p>
<p className="font-medium text-gray-900">{formatBirthday(person.birth_date)}</p>
</div>
</div>
)}
</div>
</section>
)}
</div>
{person.departments && person.departments.length > 0 && (
<section className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900">
<Building2 size={16} />
Отделы
</h2>
<div className="space-y-2">
{person.departments.map((department) => (
<Link
key={department.id}
href={`/departments/${department.id}`}
className="flex items-center justify-between rounded-lg bg-gray-50 px-3 py-2 transition hover:bg-gray-100"
>
<div>
<p className="text-sm font-medium text-gray-900">{department.name}</p>
{department.role_name && (
<p className="text-xs text-gray-500">{department.role_name}</p>
)}
</div>
{department.is_head && (
<span className="rounded-full bg-sky-100 px-2 py-0.5 text-xs font-medium text-sky-700">
Руководитель
</span>
)}
</Link>
))}
</div>
</section>
)}
) : null}
<EmployeeActionsTimeline
actionLoading={actionLoading}

View File

@ -1,7 +1,10 @@
"use client";
import { History, Pencil, Trash2 } from "lucide-react";
import { getEmployeeActionBadgeClass, getEmployeeActionBorderColor } from "@/lib/users/userDetailUtils";
import {
getEmployeeActionBadgeClass,
getEmployeeActionBorderColor,
} from "@/lib/users/userDetailUtils";
import type { EmployeeAction } from "@/types/api";
type EmployeeActionsTimelineProps = {
@ -15,6 +18,16 @@ type EmployeeActionsTimelineProps = {
sortedActions: EmployeeAction[];
};
function formatActionDate(value: string) {
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",
});
}
export default function EmployeeActionsTimeline({
actionLoading,
canManageActions,
@ -30,65 +43,81 @@ export default function EmployeeActionsTimeline({
}
return (
<section className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
<div className="mb-3 flex items-center justify-between">
<h2 className="flex items-center gap-2 text-sm font-semibold text-gray-900">
<History size={16} />
История кадровых событий
</h2>
<section className="app-surface rounded-[24px] p-5">
<div className="mb-4 flex items-start justify-between gap-4">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">
{sortedActions.length} {sortedActions.length === 1 ? "событие" : sortedActions.length < 5 ? "события" : "событий"}
<History size={18} className="app-text-muted" />
<h2 className="app-card-caption">Кадровые события</h2>
</div>
<div className="flex items-center gap-2">
<span className="app-text-muted text-sm">
{sortedActions.length}{" "}
{sortedActions.length === 1
? "событие"
: sortedActions.length < 5
? "события"
: "событий"}
</span>
{canManageActions && (
{canManageActions ? (
<button
onClick={onAddAction}
className="rounded-lg bg-sky-100 px-2 py-1 text-xs font-medium text-sky-700 hover:bg-sky-200 transition"
className="app-action-secondary rounded-xl px-3 py-2 text-sm font-medium"
>
+ Добавить
</button>
)}
) : null}
</div>
</div>
<div className="space-y-3">
{sortedActions.map((action) => {
const isCurrent = latestActionId === action.id;
const deleteKey = `delete-${action.id}`;
const tone = getEmployeeActionBadgeClass(action.action);
const lineColor = getEmployeeActionBorderColor(action.action);
return (
<div
key={action.id}
className="flex gap-3 border-l-2 py-1 pl-3"
style={{ borderColor: getEmployeeActionBorderColor(action.action) }}
>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${getEmployeeActionBadgeClass(action.action)}`}>
<div key={action.id} className="relative pl-5">
<span
className="absolute bottom-0 left-1.5 top-0 w-px"
style={{ backgroundColor: lineColor }}
/>
{isCurrent ? (
<span
className="absolute left-0 top-2 h-3.5 w-3.5 rounded-full border-4 border-[var(--surface-primary)]"
style={{ backgroundColor: lineColor }}
/>
) : null}
<div className="app-surface-muted rounded-2xl px-4 py-3">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span
className={`inline-flex rounded-full px-3 py-1 text-sm font-medium ${tone}`}
>
{action.action_display || action.action}
</span>
{isCurrent && (
<span className="text-xs text-gray-500">текущий</span>
)}
{isCurrent ? (
<span className="app-text-muted text-xs font-medium">
текущий
</span>
) : null}
</div>
{action.comment && (
<p className="mt-1 text-sm text-gray-600">{action.comment}</p>
)}
{action.comment ? (
<p className="app-text-muted mt-2 text-sm leading-6">
{action.comment}
</p>
) : null}
</div>
<div className="flex items-center gap-2">
<time className="flex-shrink-0 text-xs text-gray-500">
{new Date(action.date).toLocaleDateString("ru-RU", {
day: "numeric",
month: "short",
year: "numeric",
})}
<div className="flex shrink-0 items-start gap-2">
<time className="app-text-muted pt-1 text-sm">
{formatActionDate(action.date)}
</time>
{canManageActions && (
{canManageActions ? (
<div className="flex gap-1">
<button
onClick={() => onEditAction(action)}
className="rounded p-1 text-gray-400 transition hover:bg-gray-100 hover:text-sky-600"
className="app-action-secondary inline-flex h-9 w-9 items-center justify-center rounded-xl"
title="Редактировать"
disabled={actionLoading === deleteKey}
>
@ -96,18 +125,18 @@ export default function EmployeeActionsTimeline({
</button>
<button
onClick={() => onDeleteAction(action.id)}
className="rounded p-1 text-gray-400 transition hover:bg-red-50 hover:text-red-600"
className="app-action-secondary inline-flex h-9 w-9 items-center justify-center rounded-xl text-red-400 hover:border-red-400/40 hover:bg-red-500/10 hover:text-red-300"
title="Удалить"
disabled={actionLoading === deleteKey}
>
{actionLoading === deleteKey ? (
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-red-600 border-t-transparent" />
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-red-400 border-t-transparent" />
) : (
<Trash2 size={14} />
)}
</button>
</div>
)}
) : null}
</div>
</div>
</div>
@ -117,4 +146,4 @@ export default function EmployeeActionsTimeline({
</div>
</section>
);
}
}

View File

@ -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<string, any>) =>
request(`/api/v1/employees/${id}/`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
uploadEmployeeAvatar: (id: number | string, file: File) => {