1485 lines
57 KiB
TypeScript
1485 lines
57 KiB
TypeScript
"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}
|
||
</>
|
||
);
|
||
}
|