refactor(frontend): align employee profile surfaces

This commit is contained in:
lizerk 2026-04-12 06:59:42 +03:00
parent c56fdbb915
commit b7b09740cb
5 changed files with 101 additions and 204 deletions

View File

@ -317,6 +317,21 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
color: var(--accent-primary-strong);
}
.app-status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
min-height: 1.75rem;
flex-shrink: 0;
border-radius: 9999px;
padding: 0.25rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
line-height: 1;
white-space: nowrap;
}
.app-avatar-frame {
border: 1px solid var(--border-subtle);
background: var(--surface-secondary);

View File

@ -15,7 +15,10 @@ import { AppShell } from "@/components/AppShell";
import { useUser } from "@/contexts/UserContext";
import { apiClient } from "@/lib/api";
import { resolveMediaUrl } from "@/lib/url";
import { getWorkDuration } from "@/lib/users/userDetailUtils";
import {
getEmployeeActionTone,
getWorkDuration,
} from "@/lib/users/userDetailUtils";
import type { DirectoryLoginResult } from "@/types/api";
function formatDate(value?: string): string {
@ -85,32 +88,6 @@ function truncateText(value?: string, maxLength = 120) {
return `${value.slice(0, maxLength).trimEnd()}…`;
}
function getActionTone(action?: string) {
switch (action) {
case "on_leave":
case "on_maternity":
return {
badgeClass: "app-feedback-warning",
lineClass: "bg-amber-400/80",
};
case "dismissed":
return {
badgeClass: "app-feedback-danger",
lineClass: "bg-red-400/80",
};
case "transferred":
return {
badgeClass: "app-selected",
lineClass: "bg-sky-400/80",
};
default:
return {
badgeClass: "app-feedback-success",
lineClass: "bg-emerald-400/80",
};
}
}
function SectionTitle({
title,
icon,
@ -184,26 +161,6 @@ export default function ProfilePage() {
[showAllActions, sortedActions],
);
const normalizedSkillName = skillName.trim().toLowerCase();
const selectedSkillIds = useMemo(
() => new Set(profileSkills.map((skill) => skill.id)),
[profileSkills],
);
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],
);
useEffect(() => {
setDirectoryLogin(user?.username?.trim() || null);
setDirectoryLoginError(null);
@ -391,46 +348,46 @@ export default function ProfilePage() {
return (
<AppShell>
<div className="mx-auto max-w-5xl space-y-5">
<section className="app-surface rounded-[28px] p-6">
<div className="mb-5 flex items-start justify-between gap-4">
<div className="mx-auto max-w-5xl space-y-4">
<section className="app-surface rounded-[28px] p-5">
<div className="mb-4 flex items-start justify-between gap-4">
<p className="app-card-caption">Мой профиль</p>
{currentAction ? (
<span
className={`${getActionTone(currentAction.action).badgeClass} inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium`}
className={`app-status-pill ${getEmployeeActionTone(currentAction.action).badgeClass}`}
>
{currentAction.action_display || currentAction.action}
</span>
) : null}
</div>
<div className="space-y-5">
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-full app-avatar-frame">
<div className="h-20 w-20 shrink-0 overflow-hidden rounded-full app-avatar-frame">
{user.avatar ? (
<Image
src={resolveMediaUrl(user.avatar)}
alt={fullName}
width={96}
height={96}
width={80}
height={80}
className="h-full w-full object-cover"
unoptimized
/>
) : (
<div className="app-avatar-fallback flex h-full w-full items-center justify-center text-3xl font-semibold">
<div className="app-avatar-fallback flex h-full w-full items-center justify-center text-2xl font-semibold">
{initials(user.first_name, user.last_name)}
</div>
)}
</div>
<div className="min-w-0 flex-1">
<h1 className="text-3xl font-semibold leading-tight text-[var(--foreground)]">
<h1 className="text-[2rem] font-semibold leading-tight text-[var(--foreground)]">
{fullName}
</h1>
<p className="app-text-muted mt-2 text-base">
<p className="app-text-muted mt-1.5 text-sm">
{formatBirthdayWithYear(user.birth_date) || "Не указана"}
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<span className="app-text-muted text-base">
<div className="mt-2 flex flex-wrap items-center gap-2">
<span className="app-text-muted text-sm">
{user.position?.name || "Должность не указана"}
</span>
{!!user.departments?.length && (
@ -445,8 +402,6 @@ export default function ProfilePage() {
</div>
<div className="app-surface-muted rounded-2xl p-4">
<div className="mb-4">
</div>
<div>
<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">
@ -577,7 +532,6 @@ export default function ProfilePage() {
? "Загружаем..."
: directoryLogin || "Не найден"}
</p>
</div>
<button
type="button"
@ -599,15 +553,15 @@ export default function ProfilePage() {
<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="app-surface-muted rounded-2xl p-3.5">
<div className="flex flex-col gap-2.5 md:flex-row">
<div className="min-w-0 flex-1">
<input
list="profile-skills-list"
value={skillName}
onChange={(event) => setSkillName(event.target.value)}
placeholder="Добавить навык"
className="app-input w-full rounded-xl px-4 py-3 text-sm"
className="app-input w-full rounded-xl px-4 py-2.5 text-sm"
disabled={skillsSaving}
/>
<datalist id="profile-skills-list">
@ -620,7 +574,7 @@ export default function ProfilePage() {
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"
className="app-action-primary inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
<Plus size={16} />
Добавить
@ -631,7 +585,7 @@ export default function ProfilePage() {
<p className="mt-3 text-sm text-red-400">{skillsError}</p>
) : null}
<div className="mt-4">
<div className="mt-3">
{profileSkills.length ? (
<div className="flex flex-wrap gap-2">
{profileSkills.map((skill) => (
@ -653,29 +607,8 @@ export default function ProfilePage() {
)}
</div>
{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-2.5 py-1 text-xs font-medium disabled:opacity-50"
>
{skill.name}
</button>
))}
</div>
</div>
) : null}
{skillsLoading ? (
<p className="app-text-muted mt-3 text-sm">Загружаем навыки...</p>
<p className="app-text-muted mt-2.5 text-sm">Загружаем навыки...</p>
) : null}
</div>
</section>
@ -683,8 +616,8 @@ export default function ProfilePage() {
<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 xl:grid-cols-3">
<div className="px-4 py-4">
<div className="grid md:grid-cols-2">
<div className="px-4 py-3.5">
<p className="text-sm font-semibold text-[var(--foreground)]">
В компании
</p>
@ -692,7 +625,7 @@ export default function ProfilePage() {
{getWorkDuration(user.date_joined) || "—"}
</p>
</div>
<div className="border-t border-[var(--border-subtle)] px-4 py-4 md:border-l md:border-t-0">
<div className="border-t border-[var(--border-subtle)] px-4 py-3.5 md:border-l md:border-t-0">
<p className="text-sm font-semibold text-[var(--foreground)]">
Дата найма
</p>
@ -703,7 +636,7 @@ export default function ProfilePage() {
</div>
<div className="border-t border-[var(--border-subtle)]" />
<div className="grid md:grid-cols-2">
<div className="px-4 py-4">
<div className="px-4 py-3.5">
<p className="text-sm font-semibold text-[var(--foreground)]">
Профиль создан
</p>
@ -711,7 +644,7 @@ export default function ProfilePage() {
{formatDate(user.created_at)}
</p>
</div>
<div className="border-t border-[var(--border-subtle)] px-4 py-4 md:border-l md:border-t-0">
<div className="border-t border-[var(--border-subtle)] px-4 py-3.5 md:border-l md:border-t-0">
<p className="text-sm font-semibold text-[var(--foreground)]">
Последний вход
</p>
@ -741,30 +674,32 @@ export default function ProfilePage() {
}
/>
{sortedActions.length ? (
<div className="space-y-3">
<div className="space-y-2.5">
{!showAllActions && sortedActions.length > 3 ? (
<p className="app-text-muted text-sm">
Последние {visibleActions.length} из {sortedActions.length}
</p>
) : null}
{visibleActions.map((action) => {
const tone = getActionTone(action.action);
const tone = getEmployeeActionTone(action.action);
const isCurrentAction = action.id === currentActionId;
return (
<div key={action.id} className="relative pl-5">
<span
className={`absolute bottom-0 left-1.5 top-0 w-px ${tone.lineClass}`}
className="absolute bottom-0 left-1.5 top-0 w-px"
style={{ backgroundColor: tone.lineColor }}
/>
{isCurrentAction ? (
<span
className={`absolute left-0 top-2 h-3.5 w-3.5 rounded-full border-4 border-[var(--surface-primary)] ${tone.lineClass}`}
className="absolute left-0 top-2 h-3.5 w-3.5 rounded-full border-4 border-[var(--surface-primary)]"
style={{ backgroundColor: tone.lineColor }}
/>
) : null}
<div className="app-surface-muted rounded-2xl px-4 py-3">
<div className="app-surface-muted rounded-2xl px-4 py-2.5">
<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-2.5 py-0.5 text-xs font-medium`}
className={`app-status-pill ${tone.badgeClass}`}
>
{action.action_display || action.action}
</span>

View File

@ -27,6 +27,7 @@ import { resolveMediaUrl } from "@/lib/url";
import {
formatBirthday,
formatPhoneForLink,
getEmployeeActionTone,
getWorkDuration,
} from "@/lib/users/userDetailUtils";
@ -69,20 +70,6 @@ function normalizeWhatsApp(value: string) {
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,
@ -198,24 +185,6 @@ export default function UserDetailPage() {
};
}, []);
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
@ -336,7 +305,7 @@ export default function UserDetailPage() {
return (
<AppShell>
<div className="mx-auto max-w-5xl space-y-5">
<div className="mx-auto max-w-5xl space-y-4">
<div className="flex items-center justify-between gap-4">
<Link
href="/employees"
@ -357,33 +326,33 @@ export default function UserDetailPage() {
</section>
) : person ? (
<>
<section className="app-surface rounded-[28px] p-6">
<div className="mb-5 flex items-start justify-between gap-4">
<section className="app-surface rounded-[28px] p-5">
<div className="mb-4 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`}
className={`app-status-pill ${getEmployeeActionTone(latestAction.action).badgeClass}`}
>
{latestAction.action_display || latestAction.action}
</span>
) : null}
</div>
<div className="space-y-5">
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-full app-avatar-frame">
<div className="h-20 w-20 shrink-0 overflow-hidden rounded-full app-avatar-frame">
{avatarUrl && !avatarFailed ? (
<Image
src={avatarUrl}
alt={fullName}
width={96}
height={96}
width={80}
height={80}
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">
<div className="app-avatar-fallback flex h-full w-full items-center justify-center text-2xl font-semibold">
{initials}
</div>
)}
@ -392,17 +361,17 @@ export default function UserDetailPage() {
<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)]">
<h1 className="text-[2rem] 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">
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<span className="app-text-muted text-sm">
{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)]"
className="app-pill inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)]"
>
{primaryDepartment.name}
</Link>
@ -411,16 +380,6 @@ export default function UserDetailPage() {
</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"
@ -436,7 +395,7 @@ export default function UserDetailPage() {
</div>
{currentUser && currentUser.id !== person.id ? (
<div className="mt-4 flex flex-wrap gap-2">
<div className="mt-3 flex flex-wrap gap-2">
<button
onClick={handleStartChat}
disabled={creatingChat}
@ -578,27 +537,6 @@ export default function UserDetailPage() {
)}
</div>
{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">
Загружаем навыки...

View File

@ -2,8 +2,7 @@
import { History, Pencil, Trash2 } from "lucide-react";
import {
getEmployeeActionBadgeClass,
getEmployeeActionBorderColor,
getEmployeeActionTone,
} from "@/lib/users/userDetailUtils";
import type { EmployeeAction } from "@/types/api";
@ -73,19 +72,18 @@ export default function EmployeeActionsTimeline({
{sortedActions.map((action) => {
const isCurrent = latestActionId === action.id;
const deleteKey = `delete-${action.id}`;
const tone = getEmployeeActionBadgeClass(action.action);
const lineColor = getEmployeeActionBorderColor(action.action);
const tone = getEmployeeActionTone(action.action);
return (
<div key={action.id} className="relative pl-5">
<span
className="absolute bottom-0 left-1.5 top-0 w-px"
style={{ backgroundColor: lineColor }}
style={{ backgroundColor: tone.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 }}
style={{ backgroundColor: tone.lineColor }}
/>
) : null}
<div className="app-surface-muted rounded-2xl px-4 py-3">
@ -93,7 +91,7 @@ export default function EmployeeActionsTimeline({
<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}`}
className={`app-status-pill ${tone.badgeClass}`}
>
{action.action_display || action.action}
</span>

View File

@ -122,32 +122,43 @@ export function formatBirthday(birthDate?: string): string | null {
return new Date(birthDate).toLocaleDateString("ru-RU", { day: "numeric", month: "long" });
}
export function getEmployeeActionBadgeClass(actionType?: string): string {
export function getEmployeeActionTone(actionType?: string): {
badgeClass: string;
lineColor: string;
} {
switch (actionType) {
case "on_leave":
return "bg-yellow-100 text-yellow-700";
case "on_maternity":
return "bg-purple-100 text-purple-700";
return {
badgeClass: "app-feedback-warning",
lineColor: "#f59e0b",
};
case "transferred":
return "bg-blue-100 text-blue-700";
return {
badgeClass: "app-selected",
lineColor: "#38bdf8",
};
case "dismissed":
return "bg-red-100 text-red-700";
return {
badgeClass: "app-feedback-danger",
lineColor: "#ef4444",
};
case "returned_from_leave":
case "returned_from_maternity":
case "rehired":
case "hired":
default:
return "bg-green-100 text-green-700";
return {
badgeClass: "app-feedback-success",
lineColor: "#22c55e",
};
}
}
export function getEmployeeActionBadgeClass(actionType?: string): string {
return getEmployeeActionTone(actionType).badgeClass;
}
export function getEmployeeActionBorderColor(actionType?: string): string {
switch (actionType) {
case "on_leave":
return "#eab308";
case "on_maternity":
return "#a855f7";
case "transferred":
return "#3b82f6";
case "dismissed":
return "#ef4444";
default:
return "#22c55e";
}
}
return getEmployeeActionTone(actionType).lineColor;
}