feat(frontend): align employee profile flows
This commit is contained in:
parent
06bcd0c862
commit
c56fdbb915
@ -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"}:
|
||||
|
||||
@ -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()
|
||||
|
||||
77
docs/frontend-style-guide.md
Normal file
77
docs/frontend-style-guide.md
Normal 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-*` токенов в одном блоке
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user