Corp/frontend/src/components/users/ProfileSections.tsx

1485 lines
57 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import Link from "next/link";
import { createPortal } from "react-dom";
import {
useEffect,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import {
Award,
CalendarClock,
Check,
ChevronDown,
CircleHelp,
Clock3,
Crown,
Gem,
Info,
Link2,
Loader2,
Pencil,
Plus,
Shield,
Sparkles,
Trophy,
X,
} from "lucide-react";
import { apiClient } from "@/lib/api";
import { HorizontalChipScroller } from "@/components/ui/HorizontalChipScroller";
import { Modal } from "@/components/ui";
import type { EmployeeWorkSchedule } from "@/lib/api/attendance";
import type {
EmployeeAttendanceStatus,
EmployeeDepartment,
EmployeePersonnelState,
EmployeeSkillProgress,
Skill,
SkillRank,
SkillRarity,
} from "@/types/api";
import { getEmployeeActionTone } from "@/lib/users/userDetailUtils";
export type ProfileContactRow = {
key: string;
label: string;
value: string;
icon: ReactNode;
onClick?: () => void;
copied?: boolean;
action?: ReactNode;
};
export type ProfileInfoItem = {
label: string;
value: string;
};
const skillRarityStyles: Record<SkillRarity, string> = {
unrated: "border-[var(--border-subtle)]",
common: "border-slate-500/60",
uncommon: "border-emerald-500/60",
rare: "border-sky-500/70",
epic: "border-violet-500/70",
legendary: "border-amber-400/80",
secret: "border-fuchsia-400/80 bg-fuchsia-500/5",
};
const skillRarityBadgeStyles: Record<SkillRarity, string> = {
unrated: "bg-slate-500/10 text-slate-400",
common: "bg-slate-500/15 text-slate-300",
uncommon: "bg-emerald-500/15 text-emerald-300",
rare: "bg-sky-500/15 text-sky-300",
epic: "bg-violet-500/15 text-violet-300",
legendary: "bg-amber-400/15 text-amber-300",
secret: "bg-fuchsia-400/15 text-fuchsia-200",
};
const skillRankOrder: Record<SkillRank, number> = {
none: 0,
bronze: 1,
silver: 2,
gold: 3,
ruby: 4,
diamond: 5,
};
const skillRarityOrder: Record<SkillRarity, number> = {
unrated: 0,
common: 1,
uncommon: 2,
rare: 3,
epic: 4,
legendary: 5,
secret: 6,
};
function sortProfileSkills(skills: Skill[]) {
return [...skills].sort((left, right) => {
const rankDifference = skillRankOrder[right.rank || "none"] - skillRankOrder[left.rank || "none"];
if (rankDifference) return rankDifference;
const experienceDifference = (right.experience || 0) - (left.experience || 0);
if (experienceDifference) return experienceDifference;
const rarityDifference = skillRarityOrder[right.rarity || "unrated"] - skillRarityOrder[left.rarity || "unrated"];
if (rarityDifference) return rarityDifference;
return left.name.localeCompare(right.name, "ru");
});
}
function SkillRankMark({ rank = "none", size = 14 }: { rank?: SkillRank; size?: number }) {
const props = { size, strokeWidth: 2.2, "aria-hidden": true };
if (rank === "bronze") return <Shield {...props} className="text-amber-700" />;
if (rank === "silver") return <Award {...props} className="text-slate-300" />;
if (rank === "gold") return <Trophy {...props} className="text-amber-400" />;
if (rank === "ruby") return <Gem {...props} className="text-rose-400" />;
if (rank === "diamond") return <Sparkles {...props} className="text-cyan-300" />;
return <span className="h-2 w-2 rounded-full border border-dashed border-[var(--muted-foreground)]" />;
}
function formatSkillEventDate(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(date);
}
const workScheduleDays = [
{ value: "Monday", label: "Пн" },
{ value: "Tuesday", label: "Вт" },
{ value: "Wednesday", label: "Ср" },
{ value: "Thursday", label: "Чт" },
{ value: "Friday", label: "Пт" },
{ value: "Saturday", label: "Сб" },
{ value: "Sunday", label: "Вс" },
];
const defaultWorkdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"];
function compactDays(days: string[]) {
const labels = workScheduleDays
.filter((day) => days.includes(day.value))
.map((day) => day.label);
return labels.length ? labels.join(", ") : "Не указаны";
}
function normalizeScheduleTime(value?: string) {
if (!value) return "";
return value.slice(0, 5);
}
function defaultSchedule(employeeId: number): EmployeeWorkSchedule {
return {
id: null,
employee_id: employeeId,
start_time: "08:00",
end_time: "17:00",
expected_hours: 9,
workdays: [...defaultWorkdays],
date_overrides: [],
is_active: false,
is_default: true,
updated_by: null,
created_at: null,
updated_at: null,
};
}
type WorkScheduleApiClient = typeof apiClient & {
request: <T>(endpoint: string, options?: RequestInit) => Promise<T>;
getEmployeeWorkSchedule?: unknown;
updateEmployeeWorkSchedule?: unknown;
};
function getEmployeeWorkSchedule(employeeId: number) {
const client = apiClient as WorkScheduleApiClient;
if (typeof client.getEmployeeWorkSchedule === "function") {
return client.getEmployeeWorkSchedule(employeeId) as Promise<EmployeeWorkSchedule>;
}
return client.request<EmployeeWorkSchedule>(
`/api/v1/attendance/work-schedules/${employeeId}/`,
);
}
function updateEmployeeWorkSchedule(
employeeId: number,
data: Partial<EmployeeWorkSchedule>,
) {
const client = apiClient as WorkScheduleApiClient;
if (typeof client.updateEmployeeWorkSchedule === "function") {
return client.updateEmployeeWorkSchedule(employeeId, data) as Promise<EmployeeWorkSchedule>;
}
return client.request<EmployeeWorkSchedule>(
`/api/v1/attendance/work-schedules/${employeeId}/`,
{
method: "PATCH",
body: JSON.stringify(data),
},
);
}
function SectionTitle({
title,
action,
}: {
title: string;
action?: ReactNode;
}) {
return (
<div className="mb-4 flex items-start justify-between gap-4">
<h2 className="app-card-caption">{title}</h2>
{action}
</div>
);
}
export function ProfileHeroCard({
caption,
statusBadge,
avatar,
fullName,
secondaryLine,
roleText,
departmentBadge,
infoBadge,
headerActions,
actionRow,
bottomPanel,
}: {
caption: string;
statusBadge?: ReactNode;
avatar: ReactNode;
fullName: string;
secondaryLine?: string | null;
roleText: string;
departmentBadge?: ReactNode;
infoBadge?: ReactNode;
headerActions?: ReactNode;
actionRow?: ReactNode;
bottomPanel?: ReactNode;
}) {
return (
<section className="app-surface rounded-2xl p-5 sm:p-6">
<div className="mb-4 flex items-start justify-between gap-4">
<p className="app-card-caption">{caption}</p>
{statusBadge}
</div>
<div className="space-y-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
{avatar}
<div className="min-w-0 flex-1">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-start gap-1.5">
<h1 className="app-text-wrap min-w-0 text-[2rem] font-semibold leading-tight text-[var(--foreground)]">
{fullName}
</h1>
{infoBadge}
</div>
{secondaryLine ? (
<p className="app-text-muted mt-1.5 text-sm">{secondaryLine}</p>
) : null}
<div className="mt-2 flex flex-wrap items-center gap-2">
<span className="app-text-muted text-sm">{roleText}</span>
{departmentBadge}
</div>
</div>
{headerActions ? <div className="flex shrink-0 items-center gap-2">{headerActions}</div> : null}
</div>
{actionRow ? <div className="mt-3 flex flex-wrap gap-2">{actionRow}</div> : null}
</div>
</div>
{bottomPanel}
</div>
</section>
);
}
export function ProfileAttendanceStatusPill({
status,
}: {
status?: EmployeeAttendanceStatus | null;
}) {
if (!status) return null;
return (
<span
className="app-status-pill app-badge inline-flex max-w-full items-center gap-1.5"
title="Последняя запись визита"
>
<Clock3 size={13} className="shrink-0" />
<span className="truncate">
{status.display}
</span>
</span>
);
}
export function ProfileContactsPanel({
rows,
emptyText = "Контакты не указаны",
}: {
rows: ProfileContactRow[];
emptyText?: string;
}) {
return (
<div className="app-surface-muted rounded-2xl px-3 py-1 sm:p-4">
{rows.length ? (
rows.map((row, index) => (
<div key={row.key}>
{index > 0 ? <div className="mx-1 border-t border-[var(--border-subtle)] sm:mx-4" /> : null}
<div className="flex items-center gap-3 px-1 py-3 sm:items-start sm:px-4 sm:py-4">
<span className="app-badge flex h-9 w-9 shrink-0 items-center justify-center rounded-xl sm:mt-0.5 sm:h-10 sm:w-10">
{row.icon}
</span>
{row.onClick ? (
<button
type="button"
onClick={row.onClick}
className="min-w-0 flex-1 text-left"
title="Скопировать"
>
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-[var(--foreground)]">
{row.label}
</p>
{row.copied ? (
<span className="app-text-muted text-xs">Скопировано</span>
) : null}
</div>
<p
className="app-text-muted mt-1 truncate text-sm transition hover:text-[var(--accent-primary)] sm:whitespace-normal sm:break-words"
title={row.value}
>
{row.value}
</p>
</button>
) : (
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-[var(--foreground)]">
{row.label}
</p>
{row.copied ? (
<span className="app-text-muted text-xs">Скопировано</span>
) : null}
</div>
<p
className="app-text-muted mt-1 truncate text-sm sm:whitespace-normal sm:break-words"
title={row.value}
>
{row.value}
</p>
</div>
)}
{row.action ? <div className="shrink-0 self-center">{row.action}</div> : null}
</div>
</div>
))
) : (
<p className="app-text-muted px-4 py-2 text-sm">{emptyText}</p>
)}
</div>
);
}
export function ProfileInfoBadge({
items,
}: {
items: ProfileInfoItem[];
}) {
const [open, setOpen] = useState(false);
const [menuPosition, setMenuPosition] = useState<{
left: number;
top: number;
width: number;
} | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node;
if (!triggerRef.current?.contains(target) && !menuRef.current?.contains(target)) {
setOpen(false);
setMenuPosition(null);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setOpen(false);
setMenuPosition(null);
triggerRef.current?.focus();
}
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
useLayoutEffect(() => {
if (!open) return;
const updatePosition = () => {
const trigger = triggerRef.current;
const menu = menuRef.current;
if (!trigger || !menu) return;
const margin = 12;
const gap = 8;
const triggerRect = trigger.getBoundingClientRect();
const menuRect = menu.getBoundingClientRect();
const width = Math.min(288, window.innerWidth - margin * 2);
const left = Math.min(
Math.max(triggerRect.left, margin),
window.innerWidth - width - margin,
);
const fitsBelow = triggerRect.bottom + gap + menuRect.height <= window.innerHeight - margin;
const top = fitsBelow
? triggerRect.bottom + gap
: Math.max(margin, triggerRect.top - gap - menuRect.height);
setMenuPosition({ left, top, width });
};
updatePosition();
window.addEventListener("resize", updatePosition);
window.addEventListener("scroll", updatePosition, true);
return () => {
window.removeEventListener("resize", updatePosition);
window.removeEventListener("scroll", updatePosition, true);
};
}, [open]);
if (!items.length) return null;
return (
<>
<button
ref={triggerRef}
type="button"
onClick={() => {
setMenuPosition(null);
setOpen((current) => !current);
}}
aria-label="Показать информацию о сотруднике"
aria-haspopup="dialog"
aria-expanded={open}
title="Информация о сотруднике"
className={`${open ? "app-accent-text" : "app-text-muted"} mt-1 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition hover:bg-[var(--surface-tertiary)] hover:text-[var(--foreground)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--accent-primary)]`}
>
<Info size={17} aria-hidden="true" />
</button>
{open && typeof document !== "undefined"
? createPortal(
<div
ref={menuRef}
role="dialog"
aria-label="Информация о сотруднике"
data-app-popover-root="profile-information"
className="app-card fixed z-[110] max-h-[calc(100dvh-1.5rem)] overflow-y-auto rounded-xl p-1.5 shadow-xl"
style={{
left: menuPosition?.left ?? 0,
top: menuPosition?.top ?? 0,
width: menuPosition?.width ?? 288,
visibility: menuPosition ? "visible" : "hidden",
}}
>
{items.map((item, index) => (
<div
key={item.label}
className={`${index > 0 ? "border-t border-[var(--border-subtle)]" : ""} px-3 py-2.5`}
>
<p className="text-xs font-medium text-[var(--foreground)]">
{item.label}
</p>
<p className="app-text-muted mt-0.5 text-sm">{item.value}</p>
</div>
))}
</div>,
document.body,
)
: null}
</>
);
}
export function ProfileWorkScheduleCard({
title = "График работы",
employeeId,
canEdit = false,
personnelState,
}: {
title?: string;
employeeId: number;
canEdit?: boolean;
personnelState?: EmployeePersonnelState | null;
}) {
const [schedule, setSchedule] = useState<EmployeeWorkSchedule>(() =>
defaultSchedule(employeeId),
);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editing, setEditing] = useState(false);
const [expanded, setExpanded] = useState(false);
const [error, setError] = useState<string | null>(null);
const [form, setForm] = useState({
start_time: "08:00",
end_time: "17:00",
expected_hours: "9",
workdays: [...defaultWorkdays],
});
useEffect(() => {
let mounted = true;
async function loadSchedule() {
try {
setLoading(true);
setError(null);
const data = await getEmployeeWorkSchedule(employeeId);
if (!mounted) return;
setSchedule(data);
setForm({
start_time: normalizeScheduleTime(data.start_time) || "08:00",
end_time: normalizeScheduleTime(data.end_time) || "17:00",
expected_hours: String(data.expected_hours ?? 9),
workdays: data.workdays?.length ? [...data.workdays] : [...defaultWorkdays],
});
} catch (loadError) {
if (!mounted) return;
setError(String((loadError as Error)?.message || "Не удалось загрузить график"));
} finally {
if (mounted) {
setLoading(false);
}
}
}
void loadSchedule();
return () => {
mounted = false;
};
}, [employeeId]);
const startTime = normalizeScheduleTime(schedule.start_time) || "08:00";
const endTime = normalizeScheduleTime(schedule.end_time) || "17:00";
const expectedHours = Number(schedule.expected_hours || 0) || 9;
const workdays = schedule.workdays?.length ? schedule.workdays : defaultWorkdays;
const stateTone = personnelState
? getEmployeeActionTone(personnelState.status)
: null;
const toggleFormWorkday = (day: string) => {
setForm((current) => ({
...current,
workdays: current.workdays.includes(day)
? current.workdays.filter((item) => item !== day)
: [...current.workdays, day],
}));
};
const saveSchedule = async () => {
if (saving) return;
try {
setSaving(true);
setError(null);
const updated = await updateEmployeeWorkSchedule(employeeId, {
start_time: form.start_time,
end_time: form.end_time,
expected_hours: Number(form.expected_hours) || 0,
workdays: form.workdays,
date_overrides: schedule.date_overrides || [],
is_active: true,
});
setSchedule(updated);
setEditing(false);
} catch (saveError) {
setError(String((saveError as Error)?.message || "Не удалось сохранить график"));
} finally {
setSaving(false);
}
};
return (
<section className="app-surface rounded-2xl">
<div className="flex min-w-0 items-center gap-2 px-4 py-3.5 sm:px-5">
<button
type="button"
onClick={() => setExpanded((current) => !current)}
aria-expanded={expanded}
className="flex min-w-0 flex-1 items-center gap-3 text-left"
>
<span className="app-icon-box flex h-9 w-9 shrink-0 items-center justify-center rounded-xl">
{loading ? <Loader2 size={17} className="animate-spin" /> : <CalendarClock size={17} />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-[var(--foreground)]">
{title}
</span>
<span className="app-text-muted mt-0.5 block truncate text-xs">
{loading
? "Загрузка..."
: `${startTime} — ${endTime} · ${expectedHours.toLocaleString("ru-RU")} ч.`}
</span>
</span>
</button>
{canEdit ? (
<button
type="button"
onClick={() => {
setExpanded(true);
setEditing((current) => !current);
}}
className="app-action-ghost flex h-8 w-8 shrink-0 items-center justify-center rounded-md"
title="Редактировать график"
aria-label="Редактировать график"
>
{editing ? <X size={15} /> : <Pencil size={15} />}
</button>
) : null}
<button
type="button"
onClick={() => setExpanded((current) => !current)}
aria-label={expanded ? "Свернуть график работы" : "Развернуть график работы"}
aria-expanded={expanded}
className="app-text-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-lg transition hover:bg-[var(--surface-tertiary)] hover:text-[var(--foreground)]"
>
<ChevronDown
size={18}
className={`transition-transform ${expanded ? "rotate-180" : ""}`}
/>
</button>
</div>
{expanded ? (
<div className="border-t border-[var(--border-subtle)] px-4 py-4 sm:px-5">
{!loading && editing ? (
<div className="space-y-3 rounded-xl border border-[var(--border-subtle)] p-3">
<div className="grid gap-2 sm:grid-cols-3">
<label className="block">
<span className="app-card-caption mb-1 block">Начало</span>
<input
type="time"
value={form.start_time}
onChange={(event) =>
setForm((current) => ({ ...current, start_time: event.target.value }))
}
className="app-input w-full rounded-lg px-3 py-2 text-sm"
/>
</label>
<label className="block">
<span className="app-card-caption mb-1 block">Конец</span>
<input
type="time"
value={form.end_time}
onChange={(event) =>
setForm((current) => ({ ...current, end_time: event.target.value }))
}
className="app-input w-full rounded-lg px-3 py-2 text-sm"
/>
</label>
<label className="block">
<span className="app-card-caption mb-1 block">Часов</span>
<input
type="number"
min="1"
max="24"
step="0.25"
value={form.expected_hours}
onChange={(event) =>
setForm((current) => ({
...current,
expected_hours: event.target.value,
}))
}
className="app-input w-full rounded-lg px-3 py-2 text-sm"
/>
</label>
</div>
<div className="flex flex-wrap gap-2">
{workScheduleDays.map((day) => {
const active = form.workdays.includes(day.value);
return (
<button
key={day.value}
type="button"
onClick={() => toggleFormWorkday(day.value)}
className={active
? "app-action-primary rounded-lg px-3 py-2 text-xs font-medium"
: "app-action-secondary rounded-lg px-3 py-2 text-xs font-medium"
}
>
{day.label}
</button>
);
})}
</div>
<div className="flex flex-wrap justify-end gap-2">
<button
type="button"
onClick={() => setEditing(false)}
className="app-action-secondary rounded-xl px-4 py-2 text-sm font-medium"
>
Отмена
</button>
<button
type="button"
onClick={() => void saveSchedule()}
disabled={saving}
className="app-action-primary inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{saving ? <Loader2 size={15} className="animate-spin" /> : <Check size={15} />}
Сохранить
</button>
</div>
</div>
) : null}
{!loading && !editing ? (
<>
<div className="mb-3 flex min-w-0 items-center justify-between gap-2">
<p className="app-text-muted min-w-0 truncate text-xs">
Рабочие дни: <span className="text-[var(--foreground)]">{compactDays(workdays)}</span>
</p>
{schedule.is_default ? (
<span className="app-status-pill app-feedback-warning max-w-28 shrink-0 truncate whitespace-nowrap">
По умолчанию
</span>
) : personnelState ? (
<span className={`app-status-pill max-w-28 shrink-0 truncate whitespace-nowrap ${stateTone?.badgeClass || ""}`}>
{personnelState.label || personnelState.status}
</span>
) : (
<span className="app-status-pill app-feedback-success shrink-0 whitespace-nowrap">Активен</span>
)}
</div>
<div className="flex flex-wrap gap-1.5">
{workScheduleDays.map((day) => {
const active = workdays.includes(day.value);
return (
<span
key={day.value}
title={`${day.label}: ${active ? "рабочий день" : "выходной"}`}
className={[
"inline-flex h-8 w-8 items-center justify-center rounded-lg border text-xs font-semibold",
active
? "border-[color:color-mix(in_srgb,var(--accent-primary)_45%,var(--border-subtle))] bg-[color:color-mix(in_srgb,var(--accent-primary)_16%,transparent)] text-[var(--accent-primary)]"
: "border-[var(--border-subtle)] bg-[var(--surface-secondary)] text-[var(--muted-foreground)]",
].join(" ")}
>
{day.label}
</span>
);
})}
</div>
</>
) : null}
{error ? (
<p className="mt-3 text-sm text-red-400">{error}</p>
) : null}
</div>
) : null}
</section>
);
}
export function ProfileSkillsInline({
employeeId,
title = "Навыки",
inputValue,
onInputChange,
onSubmit,
submitDisabled,
inputDisabled,
availableSkills,
skills,
error,
loading,
onRemoveSkill,
removeDisabled,
emptyText = "Навыки не указаны",
onSkillUpdated,
}: {
employeeId: number;
title?: string;
inputValue: string;
onInputChange: (value: string) => void;
onSubmit: () => void;
submitDisabled: boolean;
inputDisabled?: boolean;
availableSkills: Skill[];
skills: Skill[];
error?: string | null;
loading?: boolean;
onRemoveSkill?: (skillId: number) => void;
removeDisabled?: boolean;
emptyText?: string;
onSkillUpdated?: () => unknown | Promise<unknown>;
}) {
const [modalOpen, setModalOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const [progressOpen, setProgressOpen] = useState(false);
const [progressLoading, setProgressLoading] = useState(false);
const [progress, setProgress] = useState<EmployeeSkillProgress | null>(null);
const [progressError, setProgressError] = useState<string | null>(null);
const [endorsementReason, setEndorsementReason] = useState("");
const [endorsementSaving, setEndorsementSaving] = useState(false);
const listId = `${title.toLowerCase().replace(/\s+/g, "-")}-skills-list`;
const sortedSkills = sortProfileSkills(skills);
const editableSkills = skills.filter((skill) => !skill.is_secret);
const openSkillProgress = async (skill: Skill) => {
setProgressOpen(true);
setProgressLoading(true);
setProgressError(null);
setProgress(null);
setEndorsementReason("");
try {
const response = await apiClient.getEmployeeSkillProgress(employeeId, skill.id);
setProgress(response as EmployeeSkillProgress);
} catch (requestError) {
setProgressError(
requestError instanceof Error ? requestError.message : "Не удалось загрузить прогресс навыка",
);
} finally {
setProgressLoading(false);
}
};
const endorseSkill = async () => {
if (!progress || endorsementReason.trim().length < 8 || endorsementSaving) return;
setEndorsementSaving(true);
setProgressError(null);
try {
await apiClient.endorseEmployeeSkill(employeeId, progress.skill_id, {
reason: endorsementReason.trim(),
});
const refreshed = await apiClient.getEmployeeSkillProgress(employeeId, progress.skill_id);
setProgress(refreshed as EmployeeSkillProgress);
setEndorsementReason("");
await onSkillUpdated?.();
} catch (requestError) {
setProgressError(
requestError instanceof Error ? requestError.message : "Не удалось подтвердить навык",
);
} finally {
setEndorsementSaving(false);
}
};
return (
<>
<div>
<div className="flex min-w-0 items-center gap-2">
<HorizontalChipScroller
key={sortedSkills
.map(
(skill) =>
`${skill.id}:${skill.rank}:${skill.experience}:${skill.rarity}:${skill.anti_streak}:${skill.penalty_levels}`,
)
.join("|")}
className="min-w-0 flex-1"
ariaLabel="Навыки сотрудника"
fadeColor="var(--surface-primary)"
>
{sortedSkills.length ? (
sortedSkills.map((skill) => (
<button
key={skill.id}
type="button"
onClick={() => void openSkillProgress(skill)}
className={`app-pill app-badge-interactive inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1.5 text-xs font-medium ${
skillRarityStyles[skill.rarity || "unrated"]
}`}
title={`${skill.rank_display || "Без ранга"} · ${skill.rarity_display || "Редкость не определена"}`}
aria-label={`${skill.name}. Ранг: ${skill.rank_display || "Без ранга"}. Редкость: ${skill.rarity_display || "Не определена"}`}
>
<SkillRankMark rank={skill.rank} />
<span>{skill.name}</span>
<span
className={`rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none ${
skillRarityBadgeStyles[skill.rarity || "unrated"]
}`}
>
{skill.rarity_display || "Не определена"}
</span>
{(skill.anti_streak || 0) > 0 ? (
<span
className="rounded-full bg-red-500/15 px-1.5 py-0.5 text-[10px] font-semibold leading-none text-red-200"
title={`${skill.anti_streak_label || "Антисерия"}: ${skill.anti_streak}/${skill.anti_streak_limit}`}
>
! {skill.anti_streak}/{skill.anti_streak_limit}
</span>
) : null}
{(skill.penalty_levels || 0) > 0 ? (
<span
className="rounded-full bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-semibold leading-none text-amber-200"
title={`Ранг снижен на ${skill.penalty_levels} уровней`}
>
−{skill.penalty_levels}
</span>
) : null}
</button>
))
) : (
<span className="app-text-muted whitespace-nowrap px-1 text-xs">
{loading ? "Загружаем навыки..." : emptyText}
</span>
)}
</HorizontalChipScroller>
<button
type="button"
onClick={() => setModalOpen(true)}
className="app-pill app-badge-interactive flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
title="Управление навыками"
aria-label="Управление навыками"
>
<Plus size={15} />
</button>
</div>
</div>
<Modal
isOpen={modalOpen}
onClose={() => {
setModalOpen(false);
setHelpOpen(false);
}}
title={
<span className="inline-flex items-center gap-1.5">
<span>{title}</span>
<button
type="button"
onClick={() => setHelpOpen(true)}
className="app-accent-text inline-flex h-5 w-5 items-center justify-center rounded-full transition hover:bg-[var(--surface-tertiary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-primary)]"
aria-label="Открыть справку о навыках"
aria-haspopup="dialog"
aria-expanded={helpOpen}
title="Как работают навыки"
>
<CircleHelp size={14} strokeWidth={2} />
</button>
</span>
}
size="sm"
closeOnClickOutside
>
<div className="space-y-4">
<div className="flex min-w-0 gap-2">
<input
list={listId}
value={inputValue}
onChange={(event) => onInputChange(event.target.value)}
placeholder="Название навыка"
className="app-input min-w-0 flex-1 rounded-xl px-3 py-2.5 text-sm"
disabled={inputDisabled}
onKeyDown={(event) => {
if (event.key === "Enter" && !submitDisabled) {
event.preventDefault();
onSubmit();
}
}}
/>
<datalist id={listId}>
{availableSkills.map((skill) => (
<option key={skill.id} value={skill.name} />
))}
</datalist>
<button
type="button"
onClick={onSubmit}
disabled={submitDisabled}
className="app-action-primary flex h-[42px] w-[42px] shrink-0 items-center justify-center rounded-xl disabled:opacity-50"
title="Добавить навык"
aria-label="Добавить навык"
>
<Plus size={17} />
</button>
</div>
{error ? <p className="text-sm text-red-400">{error}</p> : null}
<div>
<p className="app-card-caption mb-2">Добавленные навыки</p>
{editableSkills.length ? (
<div className="flex flex-wrap gap-2">
{editableSkills.map((skill) =>
onRemoveSkill ? (
<button
key={skill.id}
type="button"
onClick={() => onRemoveSkill(skill.id)}
disabled={removeDisabled}
className="app-pill inline-flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-xs font-medium transition hover:border-red-400/50 hover:text-red-300 disabled:opacity-50"
title="Удалить навык"
>
<span>{skill.name}</span>
<X size={14} />
</button>
) : (
<span
key={skill.id}
className="app-pill inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium"
>
{skill.name}
</span>
),
)}
</div>
) : (
<p className="app-text-muted text-sm">{emptyText}</p>
)}
</div>
{loading ? (
<p className="app-text-muted text-sm">Загружаем навыки...</p>
) : null}
</div>
</Modal>
<Modal
isOpen={helpOpen}
onClose={() => setHelpOpen(false)}
title="Как работают навыки"
size="sm"
stackLevel={1}
closeOnClickOutside
>
<div className="space-y-3 text-sm">
<section className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
<h3 className="font-semibold text-[var(--foreground)]">Как добавлять</h3>
<ul className="app-text-muted mt-2 space-y-1.5 text-xs leading-relaxed">
<li>• Один навык — одна конкретная компетенция или технология.</li>
<li>• Выбирайте существующее название, если оно уже есть в списке.</li>
<li>• Не объединяйте навыки и не указывайте должность или уровень владения.</li>
</ul>
<p className="app-text-muted mt-2 text-xs">
Например: «Python», «1С», «Электромонтаж».
</p>
</section>
<section className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
<h3 className="font-semibold text-[var(--foreground)]">Опыт и ранг</h3>
<p className="app-text-muted mt-1 text-xs leading-relaxed">
Ранг показывает личный уровень сотрудника в конкретном навыке. Опыт начисляется за завершённые задачи с этим навыком и подтверждения коллег.
</p>
<div className="mt-3 grid grid-cols-2 gap-1.5 text-xs sm:grid-cols-3">
{[
{ rank: "none" as const, label: "Без ранга", xp: "0 XP" },
{ rank: "bronze" as const, label: "Бронза", xp: "100 XP" },
{ rank: "silver" as const, label: "Серебро", xp: "300 XP" },
{ rank: "gold" as const, label: "Золото", xp: "800 XP" },
{ rank: "ruby" as const, label: "Рубин", xp: "1 800 XP" },
{ rank: "diamond" as const, label: "Бриллиант", xp: "4 000 XP" },
].map((item) => (
<div
key={item.rank}
className="app-surface flex min-w-0 items-center gap-2 rounded-lg border border-[var(--border-subtle)] px-2.5 py-2"
>
<SkillRankMark rank={item.rank} />
<div className="min-w-0">
<p className="truncate font-medium text-[var(--foreground)]">{item.label}</p>
<p className="app-text-muted text-[10px]">{item.xp}</p>
</div>
</div>
))}
</div>
<p className="app-text-muted mt-2 text-[11px] leading-relaxed">
Завершённая задача даёт исполнителю 30 XP, участнику — 15 XP. Подтверждение коллеги — 5–15 XP в зависимости от его роли.
</p>
</section>
<section className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
<h3 className="font-semibold text-[var(--foreground)]">Редкость</h3>
<p className="app-text-muted mt-1 text-xs leading-relaxed">
Редкость относится к самому навыку и показывает, насколько редко он встречается среди подтверждённых обладателей в компании. Ранг при этом остаётся личным для каждого сотрудника.
</p>
<p className="app-text-muted mt-1 text-[11px] leading-relaxed">
Сотрудник считается подтверждённым обладателем навыка после достижения 50 XP — ранг «Бронза» для этого не требуется.
</p>
<p className="app-text-muted mt-1 text-[11px] leading-relaxed">
Секретные навыки открываются системой по рабочим показателям: их нельзя добавить, удалить или подтвердить вручную, а их редкость не зависит от числа обладателей.
</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{[
{ rarity: "legendary" as const, label: "Легендарный · 1" },
{ rarity: "epic" as const, label: "Эпический · 2–3" },
{ rarity: "rare" as const, label: "Редкий · 4–9" },
{ rarity: "uncommon" as const, label: "Необычный · 10–24" },
{ rarity: "common" as const, label: "Обычный · 25+" },
{ rarity: "secret" as const, label: "Секретный · системное условие" },
].map((item) => (
<span
key={item.rarity}
className={`rounded-full px-2 py-1 text-[11px] font-semibold ${skillRarityBadgeStyles[item.rarity]}`}
>
{item.label}
</span>
))}
</div>
</section>
</div>
</Modal>
<Modal
isOpen={progressOpen}
onClose={() => {
if (endorsementSaving) return;
setProgressOpen(false);
setProgress(null);
setProgressError(null);
setEndorsementReason("");
}}
title={progress?.skill_name || "Прогресс навыка"}
size="sm"
closeOnClickOutside={!endorsementSaving}
>
{progressLoading ? (
<div className="py-12 text-center">
<Loader2 size={24} className="mx-auto animate-spin text-sky-400" />
</div>
) : progress ? (
<div className="space-y-4">
<div className={`app-surface-muted rounded-xl border p-4 ${skillRarityStyles[progress.rarity]}`}>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<div className="app-surface flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-[var(--border-subtle)]">
<SkillRankMark rank={progress.rank} size={21} />
</div>
<div className="min-w-0">
<p className="font-semibold text-[var(--foreground)]">{progress.rank_display}</p>
<p className="app-text-muted text-xs">
{progress.is_secret
? `${progress.rarity_display} системный навык`
: `${progress.rarity_display} · у ${progress.confirmed_holders} сотрудников`}
</p>
</div>
</div>
<span className="app-badge shrink-0 rounded-full px-2.5 py-1 text-xs font-semibold">
{progress.is_secret ? "Автоматически" : `${progress.experience} XP`}
</span>
</div>
{progress.is_secret && progress.progress_label ? (
<p className="app-text-muted mt-3 text-xs">{progress.progress_label}</p>
) : null}
{progress.is_secret && (progress.anti_streak || 0) > 0 ? (
<div className="mt-3 rounded-xl border border-red-400/40 bg-red-500/10 px-3 py-2.5 text-xs text-red-200">
<p className="font-semibold">
{progress.anti_streak_label || "Антисерия"}: {progress.anti_streak}/
{progress.anti_streak_limit}
</p>
<p className="mt-1 text-red-200/80">
{progress.anti_streak_remaining === 1
? "Следующий случай понизит ранг."
: `До понижения ранга: ${progress.anti_streak_remaining}.`}
</p>
</div>
) : null}
{progress.is_secret && (progress.penalty_levels || 0) > 0 ? (
<div className="mt-3 rounded-xl border border-amber-400/40 bg-amber-500/10 px-3 py-2.5 text-xs text-amber-200">
<p className="font-semibold">
Ранг снижен на {progress.penalty_levels} ур.
</p>
<p className="mt-1 text-amber-200/80">
Восстановление: {progress.recovery_streak || 0}/
{progress.recovery_streak_target} положительных дней.
</p>
</div>
) : null}
<div className="mt-4 h-2 overflow-hidden rounded-full bg-[var(--surface-primary)]">
<div
className="h-full rounded-full bg-[var(--accent-primary)] transition-[width]"
style={{ width: `${progress.rank_progress_percent}%` }}
/>
</div>
<div className="app-text-muted mt-1.5 flex justify-between gap-3 text-[11px]">
<span>
{progress.rank_progress_percent}% {progress.is_secret ? "до следующего ранга" : "текущего ранга"}
</span>
<span>
{progress.next_rank_display
? progress.is_secret
? `Следующий: ${progress.next_rank_display}`
: `До «${progress.next_rank_display}»: ${progress.experience_to_next_rank} XP`
: "Высший ранг"}
</span>
</div>
{progress.is_secret && progress.next_rank_requirements?.length ? (
<div className="mt-3 grid gap-1.5">
{progress.next_rank_requirements.map((requirement) => (
<div
key={requirement.key}
className="app-surface flex items-center justify-between gap-3 rounded-lg border border-[var(--border-subtle)] px-2.5 py-2 text-xs"
>
<span className="min-w-0 truncate text-[var(--foreground)]">
{requirement.label}
</span>
<span className={requirement.complete ? "text-emerald-300" : "app-text-muted"}>
{requirement.current}/{requirement.target}
</span>
</div>
))}
</div>
) : null}
</div>
{progress.can_endorse ? (
<div className="space-y-2">
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">
Где сотрудник применил навык
</span>
<textarea
value={endorsementReason}
onChange={(event) => setEndorsementReason(event.target.value)}
rows={3}
className="app-input w-full rounded-xl px-3 py-2 text-sm"
placeholder="Опишите конкретную работу или результат"
/>
</label>
<button
type="button"
onClick={() => void endorseSkill()}
disabled={endorsementSaving || endorsementReason.trim().length < 8}
className="app-action-primary inline-flex w-full items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
{endorsementSaving ? <Loader2 size={15} className="animate-spin" /> : <Check size={15} />}
Подтвердить навык
</button>
<p className="app-text-muted text-center text-[11px]">
Вес подтверждения зависит от вашей роли: 5–15 XP.
</p>
</div>
) : progress.endorsement_available_at ? (
<p className="app-surface-muted rounded-xl border border-[var(--border-subtle)] px-3 py-2 text-xs text-[var(--muted-foreground)]">
Повторное подтверждение доступно после {formatSkillEventDate(progress.endorsement_available_at)}.
</p>
) : null}
<div>
<p className="app-card-caption mb-2">
{progress.is_secret ? "Системный прогресс" : "История опыта"}
</p>
{progress.events.length ? (
<div className="max-h-60 space-y-2 overflow-y-auto pr-1">
{progress.events.map((event) => (
<div key={event.id} className="app-surface-muted rounded-xl border border-[var(--border-subtle)] px-3 py-2.5">
<div className="flex items-center justify-between gap-3">
<span className="truncate text-sm font-medium text-[var(--foreground)]">
{event.source_display}
</span>
<span className="shrink-0 text-xs font-semibold text-emerald-400">+{event.points} XP</span>
</div>
{event.reason ? <p className="app-text-muted mt-1 text-xs">{event.reason}</p> : null}
<p className="app-text-muted mt-1 text-[11px]">
{[event.actor_name, formatSkillEventDate(event.created_at)].filter(Boolean).join(" · ")}
</p>
</div>
))}
</div>
) : (
<p className="app-surface-muted rounded-xl border border-dashed border-[var(--border-subtle)] px-3 py-5 text-center text-xs text-[var(--muted-foreground)]">
{progress.is_secret
? "Навык обновляется автоматически по подтверждённым данным посещаемости."
: "Начислений пока нет"}
</p>
)}
</div>
{progressError ? <p className="text-sm text-red-400">{progressError}</p> : null}
</div>
) : (
<p className="py-8 text-center text-sm text-red-400">
{progressError || "Прогресс навыка недоступен"}
</p>
)}
</Modal>
</>
);
}
export function ProfileDepartmentBadge({
label,
departments,
}: {
label: string;
departments: EmployeeDepartment[];
}) {
const [open, setOpen] = useState(false);
const [menuPosition, setMenuPosition] = useState<{
left: number;
top: number;
width: number;
} | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node;
if (!triggerRef.current?.contains(target) && !menuRef.current?.contains(target)) {
setOpen(false);
setMenuPosition(null);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setOpen(false);
setMenuPosition(null);
triggerRef.current?.focus();
}
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
useLayoutEffect(() => {
if (!open) return;
const updatePosition = () => {
const trigger = triggerRef.current;
const menu = menuRef.current;
if (!trigger || !menu) return;
const margin = 12;
const gap = 8;
const triggerRect = trigger.getBoundingClientRect();
const menuRect = menu.getBoundingClientRect();
const width = Math.min(320, window.innerWidth - margin * 2);
const left = Math.min(
Math.max(triggerRect.left, margin),
window.innerWidth - width - margin,
);
const fitsBelow = triggerRect.bottom + gap + menuRect.height <= window.innerHeight - margin;
const top = fitsBelow
? triggerRect.bottom + gap
: Math.max(margin, triggerRect.top - gap - menuRect.height);
setMenuPosition({ left, top, width });
};
updatePosition();
window.addEventListener("resize", updatePosition);
window.addEventListener("scroll", updatePosition, true);
return () => {
window.removeEventListener("resize", updatePosition);
window.removeEventListener("scroll", updatePosition, true);
};
}, [open]);
if (!departments.length) return null;
if (departments.length === 1) {
const department = departments[0];
return (
<Link
href={`/departments/${department.id}`}
className="app-pill app-badge-interactive inline-flex h-7 max-w-full items-center rounded-full px-2.5 text-xs font-medium transition hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)]"
title={department.role_name
? `${department.name} · ${department.role_name}`
: department.name}
>
<span className="truncate">{label}</span>
</Link>
);
}
return (
<>
<button
ref={triggerRef}
type="button"
onClick={() => {
setMenuPosition(null);
setOpen((current) => !current);
}}
aria-label={`${label}. Показать отделы`}
aria-haspopup="menu"
aria-expanded={open}
className={`${open ? "app-selected app-accent-text" : "app-pill"} app-badge-interactive inline-flex h-7 max-w-full items-center gap-1 rounded-full px-2.5 text-xs font-medium transition`}
>
<span className="truncate">{label}</span>
<ChevronDown
size={13}
className={`shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
aria-hidden="true"
/>
</button>
{open && typeof document !== "undefined"
? createPortal(
<div
ref={menuRef}
role="menu"
aria-label="Отделы сотрудника"
data-app-popover-root="profile-departments"
className="app-card fixed z-[110] max-h-[calc(100dvh-1.5rem)] overflow-y-auto rounded-xl p-1.5 shadow-xl"
style={{
left: menuPosition?.left ?? 0,
top: menuPosition?.top ?? 0,
width: menuPosition?.width ?? 320,
visibility: menuPosition ? "visible" : "hidden",
}}
>
{departments.map((department) => (
<Link
key={department.id}
href={`/departments/${department.id}`}
role="menuitem"
onClick={() => {
setOpen(false);
setMenuPosition(null);
}}
className="flex min-w-0 items-center gap-2.5 rounded-lg px-3 py-2.5 text-left transition hover:bg-[var(--surface-tertiary)]"
>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-[var(--foreground)]">
{department.name}
</span>
{department.role_name ? (
<span className="app-text-muted mt-0.5 block truncate text-xs">
{department.role_name}
</span>
) : null}
</span>
{department.is_head ? (
<span
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-[color:color-mix(in_srgb,#f59e0b_42%,var(--border-subtle))] bg-[color:color-mix(in_srgb,#f59e0b_14%,transparent)] text-[#f59e0b]"
title="Руководитель отдела"
>
<Crown size={13} />
</span>
) : null}
{department.via_assignment ? (
<span
className="app-icon-box flex h-7 w-7 shrink-0 items-center justify-center rounded-lg"
title="Роль назначена без прямого членства"
>
<Link2 size={13} />
</span>
) : null}
<ChevronDown
size={14}
className="app-text-muted -rotate-90 shrink-0"
aria-hidden="true"
/>
</Link>
))}
</div>,
document.body,
)
: null}
</>
);
}