Refactor application workflows and update related components

This commit is contained in:
lizerk 2026-08-25 12:03:22 +03:00
parent f3d2d2fe44
commit 5fc8b4d7af
21 changed files with 8659 additions and 267 deletions

View File

@ -0,0 +1,151 @@
import fs from "node:fs/promises";
import { FileBlob, SpreadsheetFile } from "@oai/artifact-tool";
const sourcePath = "/home/lizerk/Загрузки/График(2).xlsx";
const outputDir = "/home/lizerk/Dev/EUSRR/outputs/attendance_hire_report_20260825";
const mode = process.argv[2] ?? "inspect";
const input = await FileBlob.load(sourcePath);
const workbook = await SpreadsheetFile.importXlsx(input);
if (mode === "inspect") {
const summary = await workbook.inspect({
kind: "workbook,sheet,table,region",
maxChars: 20000,
tableMaxRows: 12,
tableMaxCols: 18,
tableMaxCellChars: 120,
});
console.log(summary.ndjson);
const sheets = await workbook.inspect({ kind: "sheet", include: "id,name", maxChars: 5000 });
console.log(sheets.ndjson);
await fs.mkdir(outputDir, { recursive: true });
for (const sheet of workbook.worksheets.items) {
const safeName = sheet.name.replaceAll(/[^\p{L}\p{N}_-]+/gu, "_");
const preview = await workbook.render({
sheetName: sheet.name,
autoCrop: "all",
scale: 1,
format: "png",
});
await fs.writeFile(`${outputDir}/source_${safeName}.png`, new Uint8Array(await preview.arrayBuffer()));
}
}
if (mode === "analyze") {
const monthMap = new Map([
["январь", 1], ["февраль", 2], ["март", 3], ["апрель", 4],
["май", 5], ["июнь", 6], ["июль", 7], ["август", 8],
["сентябрь", 9], ["октябрь", 10], ["ноябрь", 11], ["декабрь", 12],
]);
const clean = (value) => String(value ?? "").replace(/[\u00a0\s]+/g, " ").trim();
const columnLetter = (columnNumber) => {
let n = columnNumber;
let result = "";
while (n > 0) {
n--;
result = String.fromCharCode(65 + (n % 26)) + result;
n = Math.floor(n / 26);
}
return result;
};
const report = [];
for (const sheet of workbook.worksheets.items) {
const match = sheet.name.match(/(20\d{2})/);
if (!match) continue;
const sheetYear = Number(match[1]);
const used = sheet.getUsedRange();
const values = used.values;
for (let r = 0; r < values.length; r++) {
const monthName = clean(values[r][0]).toLocaleLowerCase("ru-RU");
const month = monthMap.get(monthName);
if (!month) continue;
const headerYear = Number(clean(values[r - 1]?.[0]));
const year = Number.isInteger(headerYear) && headerYear >= 2000 && headerYear <= 2100
? headerYear
: sheetYear;
const days = (values[r - 1] ?? []).slice(1).map((v) => Number(clean(v)) || null);
const employees = [];
for (let rr = r + 1; rr < values.length; rr++) {
const rawName = clean(values[rr][0]);
if (!rawName) break;
const lowered = rawName.toLocaleLowerCase("ru-RU");
if (monthMap.has(lowered)) break;
const marks = values[rr].slice(1, 1 + days.length).map(clean);
const firstMarkIndex = marks.findIndex((v, i) => v && days[i]);
employees.push({
row: rr + 1,
name: rawName,
firstDay: firstMarkIndex >= 0 ? days[firstMarkIndex] : null,
firstMark: firstMarkIndex >= 0 ? marks[firstMarkIndex] : null,
nonblankMarks: marks.filter(Boolean).length,
});
}
report.push({ sheet: sheet.name, year, month, monthName, headerRow: r + 1, employees });
}
}
console.log(JSON.stringify(report, null, 2));
await fs.writeFile(`${outputDir}/analysis.json`, JSON.stringify(report, null, 2));
const people = new Map();
for (const block of report) {
for (const employee of block.employees) {
const key = employee.name.toLocaleLowerCase("ru-RU");
const firstListDate = new Date(Date.UTC(block.year, block.month - 1, 1));
const firstMarkDate = employee.firstDay
? new Date(Date.UTC(block.year, block.month - 1, employee.firstDay))
: null;
const current = people.get(key) ?? {
name: employee.name,
variants: new Set(),
firstListDate: null,
firstListSheet: null,
firstListRow: null,
firstMarkDate: null,
firstMark: null,
firstMarkSheet: null,
firstMarkCell: null,
};
current.variants.add(employee.name);
if (!current.firstListDate || firstListDate < current.firstListDate) {
current.firstListDate = firstListDate;
current.firstListSheet = block.sheet;
current.firstListRow = employee.row;
}
if (firstMarkDate && (!current.firstMarkDate || firstMarkDate < current.firstMarkDate)) {
current.firstMarkDate = firstMarkDate;
current.firstMark = employee.firstMark;
current.firstMarkSheet = block.sheet;
const markIndex = employee.firstDay
? report.find((item) => item === block).employees.find((item) => item.row === employee.row)
: null;
const sourceColumn = markIndex
? ((block.month && employee.firstDay) ? null : null)
: null;
const days = [];
// Source day columns are determined from the day header row; find the first matching date column.
const sourceSheet = workbook.worksheets.getItem(block.sheet);
const sourceValues = sourceSheet.getUsedRange().values;
for (let c = 1; c < sourceValues[block.headerRow - 2].length; c++) {
if (Number(clean(sourceValues[block.headerRow - 2][c])) === employee.firstDay && clean(sourceValues[employee.row - 1][c])) {
days.push(c + 1);
}
}
current.firstMarkCell = `${columnLetter(days[0] ?? 2)}${employee.row}`;
}
people.set(key, current);
}
}
const peopleSummary = [...people.values()]
.map((person) => ({
...person,
variants: [...person.variants],
firstListDate: person.firstListDate?.toISOString().slice(0, 10) ?? null,
firstMarkDate: person.firstMarkDate?.toISOString().slice(0, 10) ?? null,
}))
.sort((a, b) => (a.firstMarkDate ?? a.firstListDate).localeCompare(b.firstMarkDate ?? b.firstListDate) || a.name.localeCompare(b.name, "ru"));
await fs.writeFile(`${outputDir}/people_summary.json`, JSON.stringify(peopleSummary, null, 2));
}

View File

@ -1,5 +1,10 @@
from .fields import RecipientIDsField
from .read import RequestReadSerializer
from .read import RequestProfileSummarySerializer, RequestReadSerializer
from .write import RequestWriteSerializer
__all__ = ["RecipientIDsField", "RequestReadSerializer", "RequestWriteSerializer"]
__all__ = [
"RecipientIDsField",
"RequestProfileSummarySerializer",
"RequestReadSerializer",
"RequestWriteSerializer",
]

View File

@ -167,3 +167,27 @@ class RequestReadSerializer(serializers.ModelSerializer):
kind=TaskLinkedObjectKind.REQUEST,
user=user,
)
class RequestProfileSummarySerializer(serializers.ModelSerializer):
"""Компактное представление заявления для профиля сотрудника."""
display_title = serializers.CharField(read_only=True)
duration_minutes = serializers.IntegerField(read_only=True)
class Meta:
model = Request
fields = (
"id",
"type",
"title",
"display_title",
"status",
"date_from",
"date_to",
"time_from",
"time_to",
"duration_minutes",
"created_at",
"updated_at",
)

View File

@ -37,6 +37,7 @@ class RequestCoreMixin:
"reject": IsRecipientOfRequest,
"cancel": IsRequestAuthor,
"statistics": IsAuthenticated,
"employee_profile": IsAuthenticated,
"recipient_options": IsAuthenticated,
"retrieve": CanViewRequest,
"employee_statistics": CanViewRequest,

View File

@ -1,3 +1,5 @@
import math
from django.contrib.auth import get_user_model
from django.shortcuts import get_object_or_404
from rest_framework.decorators import action
@ -9,6 +11,10 @@ from requests_app.application import (
can_view_request_statistics,
resolve_stats_window_from_query,
)
from requests_app.enums import RequestStatus
from requests_app.models import Request as EmployeeRequest
from ..serializers import RequestProfileSummarySerializer
def _validated_period(request):
@ -21,6 +27,67 @@ def _validated_period(request):
class RequestStatisticsMixin:
@action(detail=False, methods=["get"], url_path="employee-profile")
def employee_profile(self, request):
"""Последние заявления и статистика для профиля сотрудника."""
employee_id = (request.query_params.get("employee_id") or "").strip()
if not employee_id.isdigit():
raise ValidationError("Параметр employee_id обязателен.")
employee = get_object_or_404(get_user_model(), pk=int(employee_id))
is_own_profile = request.user.id == employee.id
if not is_own_profile and not can_view_request_statistics(request.user):
raise PermissionDenied("У вас нет доступа к заявлениям сотрудника.")
limit_raw = (request.query_params.get("limit") or "5").strip()
if not limit_raw.isdigit():
raise ValidationError("Параметр limit должен быть целым числом.")
limit = int(limit_raw)
if not 1 <= limit <= 20:
raise ValidationError("Параметр limit должен быть от 1 до 20.")
page_raw = (request.query_params.get("page") or "1").strip()
if not page_raw.isdigit() or int(page_raw) < 1:
raise ValidationError("Параметр page должен быть положительным целым числом.")
page = int(page_raw)
period = _validated_period(request)
start_date, end_date = resolve_stats_window_from_query(
period,
request.query_params,
)
requests = EmployeeRequest.objects.filter(employee_id=employee.id)
if not is_own_profile:
requests = requests.exclude(status=RequestStatus.DRAFT)
requests = requests.order_by("-created_at", "-id")
total_count = requests.count()
total_pages = max(1, math.ceil(total_count / limit))
if page > total_pages:
page = total_pages
offset = (page - 1) * limit
return Response(
{
"count": total_count,
"page": page,
"total_pages": total_pages,
"has_previous": page > 1,
"has_next": page < total_pages,
"results": RequestProfileSummarySerializer(
requests[offset : offset + limit],
many=True,
).data,
"statistics": build_employee_statistics(
employee_id=employee.id,
employee_name=str(employee),
period=period,
start_date=start_date,
end_date=end_date,
),
}
)
@action(detail=True, methods=["get"], url_path="employee-statistics")
def employee_statistics(self, request, pk=None):
"""Возвращает сводную статистику по заявлениям сотрудника.

View File

@ -113,6 +113,123 @@ def test_statistics_requires_privileged_access(
assert resp.status_code == 403
def test_employee_profile_returns_own_requests_and_statistics(
auth_client,
regular_user: models.Model,
make_request,
) -> None:
"""Сотрудник видит в профиле собственные заявления и сводку по ним."""
submitted = make_request(
employee=regular_user,
type_=RequestType.SICK_LEAVE,
status=RequestStatus.APPROVED,
)
draft = make_request(
employee=regular_user,
type_=RequestType.OTHER,
status=RequestStatus.DRAFT,
)
resp = auth_client(regular_user).get(
f"{API_BASE}employee-profile/?employee_id={regular_user.id}&limit=5"
)
assert resp.status_code == 200
payload = resp.json()
assert payload["count"] == 2
assert {item["id"] for item in payload["results"]} == {
submitted.id,
draft.id,
}
assert payload["statistics"]["total_submitted_requests"] == 1
assert payload["statistics"]["sick_leave_requests_count"] == 1
def test_employee_profile_rejects_unprivileged_foreign_employee(
auth_client,
regular_user: models.Model,
make_user,
) -> None:
employee = make_user(email="profile-requests-hidden@example.com")
resp = auth_client(regular_user).get(
f"{API_BASE}employee-profile/?employee_id={employee.id}"
)
assert resp.status_code == 403
def test_employee_profile_admin_sees_submitted_but_not_foreign_drafts(
auth_client,
admin_user: models.Model,
make_user,
make_request,
) -> None:
employee = make_user(email="profile-requests-admin@example.com")
submitted = make_request(
employee=employee,
type_=RequestType.DAY_OFF,
status=RequestStatus.PENDING,
)
make_request(
employee=employee,
type_=RequestType.OTHER,
status=RequestStatus.DRAFT,
)
resp = auth_client(admin_user).get(
f"{API_BASE}employee-profile/?employee_id={employee.id}"
)
assert resp.status_code == 200
payload = resp.json()
assert payload["count"] == 1
assert [item["id"] for item in payload["results"]] == [submitted.id]
assert payload["statistics"]["total_submitted_requests"] == 1
def test_employee_profile_paginates_requests(
auth_client,
regular_user: models.Model,
make_request,
) -> None:
created = [
make_request(
employee=regular_user,
type_=RequestType.OTHER,
status=RequestStatus.PENDING,
)
for _ in range(6)
]
first_page = auth_client(regular_user).get(
f"{API_BASE}employee-profile/?employee_id={regular_user.id}&limit=5&page=1"
)
second_page = auth_client(regular_user).get(
f"{API_BASE}employee-profile/?employee_id={regular_user.id}&limit=5&page=2"
)
assert first_page.status_code == 200
assert second_page.status_code == 200
first_payload = first_page.json()
second_payload = second_page.json()
assert first_payload["count"] == 6
assert first_payload["page"] == 1
assert first_payload["total_pages"] == 2
assert first_payload["has_previous"] is False
assert first_payload["has_next"] is True
assert len(first_payload["results"]) == 5
assert second_payload["page"] == 2
assert second_payload["has_previous"] is True
assert second_payload["has_next"] is False
assert len(second_payload["results"]) == 1
result_ids = {
item["id"]
for item in first_payload["results"] + second_payload["results"]
}
assert result_ids == {request.id for request in created}
def test_statistics_counts_all_employee_requests_for_admin(
auth_client,
admin_user: models.Model,

View File

@ -508,6 +508,12 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
white-space: normal;
}
.app-badge-interactive {
flex-wrap: nowrap;
overflow: hidden;
white-space: nowrap;
}
.app-badge-accent {
border-color: color-mix(in srgb, var(--accent-primary) 20%, var(--border-subtle));
background: color-mix(in srgb, var(--accent-primary) 12%, var(--surface-primary));

View File

@ -6,13 +6,13 @@ import { Mail, MessageCircle, Phone, RefreshCw } from "lucide-react";
import { AppShell } from "@/components/AppShell";
import EmployeeAttendanceCard from "@/components/attendance/EmployeeAttendanceCard";
import EmployeeActionsTimeline from "@/components/users/EmployeeActionsTimeline";
import EmployeeRequestsProfileSections from "@/components/users/EmployeeRequestsProfileSections";
import {
ProfileContactsPanel,
ProfileAttendanceStatusPill,
ProfileDepartmentBadge,
ProfileDepartmentsCard,
ProfileHeroCard,
ProfileInfoCard,
ProfileInfoBadge,
ProfileSkillsCard,
ProfileWorkScheduleCard,
type ProfileContactRow,
@ -155,10 +155,11 @@ export default function ProfilePage() {
departmentSummary ? (
<ProfileDepartmentBadge
label={departmentSummary.label}
href={departmentSummary.href}
departments={user.departments || []}
/>
) : undefined
}
infoBadge={<ProfileInfoBadge items={infoItems} />}
bottomPanel={<ProfileContactsPanel rows={contactRows} />}
/>
@ -177,12 +178,6 @@ export default function ProfilePage() {
emptyText="Навыки пока не указаны"
/>
<ProfileInfoCard items={infoItems} />
{user.departments?.length ? (
<ProfileDepartmentsCard departments={user.departments} />
) : null}
<EmployeeActionsTimeline
actionLoading={null}
canManageActions={false}
@ -190,12 +185,13 @@ export default function ProfilePage() {
latestActionId={user.personnel_state?.action_id ?? currentAction?.id ?? null}
personnelState={user.personnel_state}
sortedActions={sortedActions}
initialVisibleCount={3}
showCountLabel={false}
initialVisibleCount={5}
truncateCommentLength={120}
expandedCommentLength={240}
/>
<EmployeeRequestsProfileSections employeeId={user.id} />
<ProfileWorkScheduleCard
canEdit={Boolean(user.auth?.is_staff || user.auth?.is_superuser)}
employeeId={user.id}

View File

@ -23,13 +23,13 @@ import ChangeUserPositionModal from "@/components/users/ChangeUserPositionModal"
import EditUserProfileModal from "@/components/users/EditUserProfileModal";
import EmployeeActionModal from "@/components/users/EmployeeActionModal";
import EmployeeActionsTimeline from "@/components/users/EmployeeActionsTimeline";
import EmployeeRequestsProfileSections from "@/components/users/EmployeeRequestsProfileSections";
import {
ProfileContactsPanel,
ProfileAttendanceStatusPill,
ProfileDepartmentBadge,
ProfileDepartmentsCard,
ProfileHeroCard,
ProfileInfoCard,
ProfileInfoBadge,
ProfileSkillsCard,
ProfileWorkScheduleCard,
type ProfileContactRow,
@ -482,10 +482,11 @@ export default function UserDetailPage() {
departmentSummary ? (
<ProfileDepartmentBadge
label={departmentSummary.label}
href={departmentSummary.href}
departments={person.departments || []}
/>
) : undefined
}
infoBadge={<ProfileInfoBadge items={infoItems} />}
actionRow={
currentUser && currentUser.id !== person.id ? (
<>
@ -564,12 +565,6 @@ export default function UserDetailPage() {
emptyText="Навыки не указаны"
/>
<ProfileInfoCard items={infoItems} />
{person.departments?.length ? (
<ProfileDepartmentsCard departments={person.departments} />
) : null}
<EmployeeActionsTimeline
actionLoading={actionLoading}
canManageActions={canManageActions}
@ -580,12 +575,13 @@ export default function UserDetailPage() {
onEditAction={handleEditAction}
personnelState={person.personnel_state}
sortedActions={sortedActions}
initialVisibleCount={3}
showCountLabel={false}
initialVisibleCount={5}
truncateCommentLength={120}
expandedCommentLength={240}
/>
<EmployeeRequestsProfileSections key={person.id} employeeId={person.id} />
{canViewAttendance ? (
<>
<ProfileWorkScheduleCard

View File

@ -23,7 +23,7 @@ type ProjectSelectMenuProps<T extends string | number> = {
options: readonly ProjectSelectOption<T>[];
onChange: (value: T) => void;
ariaLabel: string;
variant?: "field" | "compact" | "responsive";
variant?: "field" | "compact" | "responsive" | "badge";
triggerIcon?: ReactNode;
disabled?: boolean;
className?: string;
@ -93,7 +93,13 @@ export function ProjectSelectMenu<T extends string | number>({
const gap = 8;
const triggerRect = trigger.getBoundingClientRect();
const menuRect = menu.getBoundingClientRect();
const preferredWidth = menuWidth ?? (variant === "compact" ? 240 : Math.max(224, triggerRect.width));
const preferredWidth = menuWidth ?? (
variant === "compact"
? 240
: variant === "badge"
? 200
: Math.max(224, triggerRect.width)
);
const width = Math.min(preferredWidth, window.innerWidth - margin * 2);
const left = Math.min(
Math.max(triggerRect.right - width, margin),
@ -128,6 +134,8 @@ export function ProjectSelectMenu<T extends string | number>({
? "relative shrink-0"
: variant === "responsive"
? "relative shrink-0 sm:min-w-52 sm:max-w-72"
: variant === "badge"
? "relative min-w-0 shrink-0"
: "relative min-w-0"
} ${className}`}
>
@ -146,17 +154,23 @@ export function ProjectSelectMenu<T extends string | number>({
title={variant !== "field" ? activeOption.label : undefined}
className={`${
open
? "app-selected app-accent-text"
? variant === "badge"
? "app-badge app-badge-interactive app-selected app-accent-text"
: "app-selected app-accent-text"
: variant === "compact"
? "app-surface-muted app-text-muted hover:bg-[var(--surface-tertiary)]"
: variant === "responsive"
? "app-surface-muted app-text-muted hover:bg-[var(--surface-tertiary)]"
: variant === "badge"
? "app-badge app-badge-interactive app-text-muted hover:bg-[var(--surface-tertiary)] hover:text-[var(--foreground)]"
: "app-input app-text-primary hover:bg-[var(--surface-tertiary)]"
} ${
variant === "compact"
? "flex h-[42px] w-[42px] items-center justify-center rounded-lg"
: variant === "responsive"
? "flex h-[42px] w-[42px] items-center justify-center rounded-lg sm:w-full sm:min-w-52 sm:justify-start sm:gap-2 sm:px-3 sm:text-sm"
: variant === "badge"
? "flex h-8 max-w-full flex-nowrap items-center gap-1.5 whitespace-nowrap rounded-full px-2.5 text-xs"
: "flex h-[42px] w-full min-w-0 items-center gap-2 rounded-lg px-3 text-sm"
} transition disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--accent-primary)]`}
>

View File

@ -1,7 +1,29 @@
"use client";
import { useMemo, useState } from "react";
import { Pencil, Plus, Trash2 } from "lucide-react";
import {
ArrowRightLeft,
Baby,
BriefcaseBusiness,
CalendarMinus,
ChevronDown,
ChevronLeft,
ChevronRight,
History,
Laptop,
LoaderCircle,
Pencil,
Plus,
RotateCcw,
Stethoscope,
Sun,
Trash2,
Undo2,
UserMinus,
UserPlus,
type LucideIcon,
} from "lucide-react";
import {
getEmployeeActionTone,
truncateText,
@ -19,11 +41,27 @@ type EmployeeActionsTimelineProps = {
personnelState?: EmployeePersonnelState | null;
sortedActions: EmployeeAction[];
initialVisibleCount?: number;
showCountLabel?: boolean;
truncateCommentLength?: number;
expandedCommentLength?: number;
};
const actionIcons: Record<string, LucideIcon> = {
working: BriefcaseBusiness,
remote: Laptop,
hired: UserPlus,
dismissed: UserMinus,
on_leave: Sun,
returned_from_leave: Undo2,
on_sick_leave: Stethoscope,
returned_from_sick_leave: Undo2,
on_day_off: CalendarMinus,
returned_from_day_off: Undo2,
on_maternity: Baby,
returned_from_maternity: Undo2,
transferred: ArrowRightLeft,
rehired: RotateCcw,
};
function formatActionDate(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
@ -35,8 +73,12 @@ function formatActionDate(value: string) {
}
function formatStateDate(value?: string | null) {
if (!value) return "";
return formatActionDate(value);
return value ? formatActionDate(value) : "";
}
function formatDateRange(dateFrom: string, dateTo?: string | null) {
const start = formatActionDate(dateFrom);
return dateTo ? `${start} — ${formatActionDate(dateTo)}` : start;
}
export default function EmployeeActionsTimeline({
@ -49,182 +91,213 @@ export default function EmployeeActionsTimeline({
onEditAction,
personnelState,
sortedActions,
initialVisibleCount,
showCountLabel = true,
initialVisibleCount = 5,
truncateCommentLength,
expandedCommentLength = truncateCommentLength,
}: EmployeeActionsTimelineProps) {
const [showAll, setShowAll] = useState(false);
const currentActionId = personnelState ? personnelState.action_id : latestActionId;
const [open, setOpen] = useState(false);
const [page, setPage] = useState(1);
const pageSize = Math.max(1, initialVisibleCount);
const totalPages = Math.max(1, Math.ceil(sortedActions.length / pageSize));
const effectivePage = Math.min(page, totalPages);
const currentActionId = personnelState?.action_id ?? latestActionId;
const showCurrentStateCard = Boolean(
personnelState
&& personnelState.status
&& personnelState.status !== "normal"
);
const canCollapse = Boolean(
initialVisibleCount && sortedActions.length > initialVisibleCount,
);
const visibleActions = useMemo(
() =>
canCollapse && !showAll
? sortedActions.slice(0, initialVisibleCount)
: sortedActions,
[canCollapse, initialVisibleCount, showAll, sortedActions],
&& personnelState.status !== "normal",
);
const visibleActions = useMemo(() => {
const start = (effectivePage - 1) * pageSize;
return sortedActions.slice(start, start + pageSize);
}, [effectivePage, pageSize, sortedActions]);
if (!canViewActions || (sortedActions.length === 0 && !showCurrentStateCard)) {
return null;
}
return (
<section className="app-surface rounded-2xl p-5">
<div className="mb-4 flex items-start justify-between gap-4">
<h2 className="app-card-caption">Кадровые события</h2>
<div className="flex items-center gap-2">
{showCountLabel ? (
<span className="app-text-muted text-sm">
{sortedActions.length}{" "}
{sortedActions.length === 1
? "событие"
: sortedActions.length < 5
? "события"
: "событий"}
</span>
) : null}
{canCollapse ? (
<button
type="button"
onClick={() => setShowAll((current) => !current)}
className="app-action-secondary rounded-xl px-3 py-2 text-sm font-medium"
>
{showAll ? "Свернуть" : `Показать все (${sortedActions.length})`}
</button>
) : null}
{canManageActions && onAddAction ? (
<button
onClick={onAddAction}
className="app-action-secondary inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium"
>
<Plus size={14} />
Добавить
</button>
) : null}
</div>
<section className="app-surface rounded-2xl">
<div className="flex min-w-0 items-center gap-2 px-4 py-4 sm:px-5">
<button
type="button"
onClick={() => setOpen((current) => !current)}
aria-expanded={open}
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">
<History size={18} />
</span>
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--foreground)]">
Кадровые события
</span>
</button>
<span className="app-badge app-badge-accent shrink-0 px-2 py-0.5 text-xs">
{sortedActions.length}
</span>
{canManageActions && onAddAction ? (
<button
type="button"
onClick={onAddAction}
className="app-action-secondary inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg px-2.5 text-xs font-medium"
>
<Plus size={14} />
<span className="hidden sm:inline">Добавить</span>
</button>
) : null}
<button
type="button"
onClick={() => setOpen((current) => !current)}
aria-label={open ? "Свернуть кадровые события" : "Развернуть кадровые события"}
aria-expanded={open}
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 ${open ? "rotate-180" : ""}`}
/>
</button>
</div>
{!showAll && canCollapse ? (
<p className="app-text-muted mb-3 text-sm">
Последние {visibleActions.length} из {sortedActions.length}
</p>
) : null}
{showCurrentStateCard && personnelState ? (
<div className="mb-4 rounded-xl border border-[var(--accent-primary)]/30 bg-[var(--accent-primary)]/10 px-4 py-3">
{(() => {
{open ? (
<div className="border-t border-[var(--border-subtle)] px-4 py-4 sm:px-5">
{showCurrentStateCard && personnelState ? (() => {
const tone = getEmployeeActionTone(personnelState.status);
const StateIcon = actionIcons[personnelState.status] || BriefcaseBusiness;
const dateFrom = formatStateDate(personnelState.date_from);
const dateTo = formatStateDate(personnelState.date_to);
return (
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<p className="app-card-caption mb-2">Текущее состояние</p>
<div className="flex flex-wrap items-center gap-2">
<span className={`app-status-pill ${tone.badgeClass}`}>
{personnelState.label || personnelState.status}
</span>
<span className="app-text-muted text-xs font-medium">
активно сейчас
</span>
</div>
</div>
{dateFrom ? (
<time className="app-text-muted shrink-0 pt-1 text-sm">
{dateFrom}
{dateTo ? ` - ${dateTo}` : ""}
</time>
) : null}
<div className="app-selected mb-3 flex min-w-0 items-center gap-3 rounded-xl px-3 py-3">
<span
className="app-avatar-fallback flex h-9 w-9 shrink-0 items-center justify-center rounded-xl"
title={personnelState.label || personnelState.status}
style={{ color: tone.lineColor }}
>
<StateIcon size={17} aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-[var(--foreground)]">
{personnelState.label || personnelState.status}
</span>
<span className="app-text-muted mt-0.5 block truncate text-xs">
Активно сейчас
{dateFrom ? ` · ${dateFrom}${dateTo ? ` — ${dateTo}` : ""}` : ""}
</span>
</span>
<span className={`app-status-pill hidden shrink-0 sm:inline-flex ${tone.badgeClass}`}>
Текущее
</span>
</div>
);
})()}
</div>
) : null}
})() : null}
<div className="space-y-3">
{visibleActions.map((action) => {
const isCurrent = currentActionId === action.id;
const deleteKey = `delete-${action.id}`;
const tone = getEmployeeActionTone(action.action);
const commentText = truncateCommentLength
? truncateText(action.comment, showAll ? expandedCommentLength : truncateCommentLength)
: action.comment;
<div className="space-y-2">
{visibleActions.map((action) => {
const isCurrent = currentActionId === action.id;
const deleteKey = `delete-${action.id}`;
const tone = getEmployeeActionTone(action.action);
const ActionIcon = actionIcons[action.action] || History;
const commentText = truncateCommentLength
? truncateText(
action.comment,
expandedCommentLength || truncateCommentLength,
)
: action.comment;
const actionLabel = action.action_display || 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: 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: tone.lineColor }}
/>
) : null}
<div className="app-surface-muted rounded-xl px-4 py-3">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className={`app-status-pill ${tone.badgeClass}`}>
{action.action_display || action.action}
return (
<div
key={action.id}
className={`${isCurrent ? "app-selected" : "app-surface-muted"} flex min-w-0 items-center gap-3 rounded-xl px-3 py-3`}
>
<span
className="app-avatar-fallback flex h-9 w-9 shrink-0 items-center justify-center rounded-xl"
title={actionLabel}
style={{ color: tone.lineColor }}
>
<ActionIcon size={17} aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-semibold text-[var(--foreground)]">
{actionLabel}
</span>
{isCurrent ? (
<span className="app-text-muted text-xs font-medium">
текущий
<span className="app-badge app-badge-accent shrink-0 px-1.5 py-0.5 text-[10px]">
Текущее
</span>
) : null}
</span>
<span className="app-text-muted mt-0.5 block truncate text-xs">
{[commentText, formatDateRange(action.date, action.date_to)]
.filter(Boolean)
.join(" · ")}
</span>
</span>
{canManageActions && onEditAction && onDeleteAction ? (
<div className="flex shrink-0 gap-1">
<button
type="button"
onClick={() => onEditAction(action)}
className="app-action-secondary inline-flex h-8 w-8 items-center justify-center rounded-lg"
title="Редактировать"
disabled={actionLoading === deleteKey}
>
<Pencil size={14} />
</button>
<button
type="button"
onClick={() => onDeleteAction(action.id)}
className="app-action-secondary inline-flex h-8 w-8 items-center justify-center rounded-lg text-red-400 hover:border-red-400/40 hover:bg-red-500/10 hover:text-red-300"
title="Удалить"
disabled={actionLoading === deleteKey}
>
{actionLoading === deleteKey ? (
<LoaderCircle size={14} className="animate-spin" />
) : (
<Trash2 size={14} />
)}
</button>
</div>
{commentText ? (
<p className="app-text-wrap app-text-muted mt-2 text-sm leading-6">
{commentText}
</p>
) : null}
</div>
<div className="flex shrink-0 items-start gap-2">
<time className="app-text-muted pt-1 text-sm">
{formatActionDate(action.date)}
{action.date_to ? ` - ${formatActionDate(action.date_to)}` : ""}
</time>
{canManageActions && onEditAction && onDeleteAction ? (
<div className="flex gap-1">
<button
onClick={() => onEditAction(action)}
className="app-action-secondary inline-flex h-9 w-9 items-center justify-center rounded-xl"
title="Редактировать"
disabled={actionLoading === deleteKey}
>
<Pencil size={14} />
</button>
<button
onClick={() => onDeleteAction(action.id)}
className="app-action-secondary inline-flex h-9 w-9 items-center justify-center rounded-xl text-red-400 hover:border-red-400/40 hover:bg-red-500/10 hover:text-red-300"
title="Удалить"
disabled={actionLoading === deleteKey}
>
{actionLoading === deleteKey ? (
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-red-400 border-t-transparent" />
) : (
<Trash2 size={14} />
)}
</button>
</div>
) : null}
</div>
) : null}
</div>
</div>
</div>
);
})}
</div>
);
})}
</div>
{totalPages > 1 ? (
<nav
aria-label="Страницы кадровых событий"
className="mt-3 flex items-center justify-center gap-2"
>
<button
type="button"
onClick={() => setPage(Math.max(1, effectivePage - 1))}
disabled={effectivePage <= 1}
aria-label="Предыдущая страница кадровых событий"
className="app-action-secondary flex h-8 w-8 items-center justify-center rounded-lg disabled:cursor-not-allowed disabled:opacity-35"
>
<ChevronLeft size={16} />
</button>
<span className="app-text-muted min-w-16 text-center text-xs tabular-nums">
{effectivePage} из {totalPages}
</span>
<button
type="button"
onClick={() => setPage(Math.min(totalPages, effectivePage + 1))}
disabled={effectivePage >= totalPages}
aria-label="Следующая страница кадровых событий"
className="app-action-secondary flex h-8 w-8 items-center justify-center rounded-lg disabled:cursor-not-allowed disabled:opacity-35"
>
<ChevronRight size={16} />
</button>
</nav>
) : null}
</div>
) : null}
</section>
);
}

View File

@ -0,0 +1,410 @@
"use client";
import Link from "next/link";
import { useEffect, useState, type ReactNode } from "react";
import {
ArrowRightLeft,
Baby,
BarChart3,
CalendarMinus,
CalendarDays,
CalendarRange,
ChevronDown,
ChevronLeft,
ChevronRight,
ClipboardList,
Clock3,
FileText,
LoaderCircle,
Stethoscope,
Sun,
UserRoundX,
type LucideIcon,
} from "lucide-react";
import { ProjectSelectMenu } from "@/components/ui/ProjectSelectMenu";
import { apiClient } from "@/lib/api";
import { ApiError } from "@/lib/api/client";
import type {
EmployeeRequestProfileSummary,
Request,
RequestEmployeeStatistics,
} from "@/types/api";
const requestTypeLabels: Record<string, string> = {
short_day_off: "Короткий отгул",
vacation: "Отпуск",
sick_leave: "Больничный",
day_off: "Отгул",
maternity: "Декрет",
transfer: "Перевод",
dismissal: "Увольнение",
other: "Другое",
};
const requestStatusMeta: Record<string, { label: string; className: string }> = {
draft: { label: "Черновик", className: "app-badge" },
pending: { label: "На рассмотрении", className: "app-feedback-warning" },
approved: { label: "Одобрено", className: "app-feedback-success" },
rejected: { label: "Отклонено", className: "app-feedback-danger" },
cancelled: { label: "Отменено", className: "app-badge" },
in_progress: { label: "В работе", className: "app-selected" },
completed: { label: "Завершено", className: "app-badge-accent" },
};
const requestTypeIcons: Record<string, LucideIcon> = {
short_day_off: Clock3,
vacation: Sun,
sick_leave: Stethoscope,
day_off: CalendarMinus,
maternity: Baby,
transfer: ArrowRightLeft,
dismissal: UserRoundX,
other: FileText,
};
type StatisticsPeriod = "all" | "year" | "month";
const statisticsPeriodOptions = [
{ value: "all", label: "Всё время", icon: <CalendarRange className="h-3.5 w-3.5" /> },
{ value: "year", label: "Текущий год", icon: <CalendarDays className="h-3.5 w-3.5" /> },
{ value: "month", label: "Текущий месяц", icon: <CalendarDays className="h-3.5 w-3.5" /> },
] satisfies ReadonlyArray<{ value: StatisticsPeriod; label: string; icon: ReactNode }>;
function formatDate(value?: string | null) {
if (!value) return "";
return new Intl.DateTimeFormat("ru-RU").format(new Date(`${value}T00:00:00`));
}
function formatRequestPeriod(request: Request) {
const dateFrom = formatDate(request.date_from);
const dateTo = formatDate(request.date_to);
if (request.type === "short_day_off") {
const timeFrom = request.time_from?.slice(0, 5);
const timeTo = request.time_to?.slice(0, 5);
return [dateFrom, timeFrom && timeTo ? `${timeFrom}–${timeTo}` : ""]
.filter(Boolean)
.join(", ");
}
if (dateFrom && dateTo && request.date_from !== request.date_to) {
return `${dateFrom} — ${dateTo}`;
}
return dateFrom || "Дата не указана";
}
function formatMinutes(value: number) {
if (!value) return "0 мин.";
const hours = Math.floor(value / 60);
const minutes = value % 60;
return [hours ? `${hours} ч.` : "", minutes ? `${minutes} мин.` : ""]
.filter(Boolean)
.join(" ");
}
function CollapsibleProfileSection({
action,
children,
icon,
open,
onToggle,
title,
}: {
action?: ReactNode;
children: ReactNode;
icon: ReactNode;
open: boolean;
onToggle: () => void;
title: string;
}) {
return (
<section className="app-surface rounded-2xl">
<div className="flex min-w-0 items-center gap-2 px-4 py-4 sm:px-5">
<button
type="button"
onClick={onToggle}
aria-expanded={open}
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">
{icon}
</span>
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--foreground)]">
{title}
</span>
</button>
{action}
<button
type="button"
onClick={onToggle}
aria-label={open ? `Свернуть раздел «${title}»` : `Развернуть раздел «${title}»`}
aria-expanded={open}
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 ${open ? "rotate-180" : ""}`}
/>
</button>
</div>
{open ? (
<div className="border-t border-[var(--border-subtle)] px-4 py-4 sm:px-5">
{children}
</div>
) : null}
</section>
);
}
function RequestList({
loading,
onPageChange,
page,
requests,
totalPages,
}: {
loading: boolean;
onPageChange: (page: number) => void;
page: number;
requests: Request[];
totalPages: number;
}) {
if (loading) {
return (
<div className="flex items-center justify-center gap-2 py-8">
<LoaderCircle size={18} className="app-text-muted animate-spin" />
<span className="app-text-muted text-sm">Загрузка заявлений...</span>
</div>
);
}
if (!requests.length) {
return (
<div className="app-surface-muted rounded-xl border border-dashed border-[var(--border-subtle)] px-4 py-6 text-center">
<p className="app-text-muted text-sm">Заявлений пока нет</p>
</div>
);
}
return (
<div>
<div className="space-y-2">
{requests.map((request) => {
const status = requestStatusMeta[request.status] || {
label: request.status,
className: "app-badge",
};
const typeLabel = requestTypeLabels[request.type || ""] || "Заявление";
const TypeIcon = requestTypeIcons[request.type || ""] || FileText;
return (
<Link
key={request.id}
href={`/requests?request=${request.id}`}
className="app-surface-muted group flex min-w-0 items-center gap-3 rounded-xl px-3 py-3 transition-colors hover:border-[var(--border-strong)]"
>
<span
className="app-avatar-fallback flex h-9 w-9 shrink-0 items-center justify-center rounded-xl"
title={typeLabel}
>
<TypeIcon size={17} aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-[var(--foreground)]">
{request.display_title || request.title || typeLabel}
</span>
<span className="app-text-muted mt-0.5 block truncate text-xs">
{typeLabel} · {formatRequestPeriod(request)}
</span>
</span>
<span className={`app-status-pill hidden shrink-0 sm:inline-flex ${status.className}`}>
{status.label}
</span>
<ChevronDown
size={16}
className="app-text-muted shrink-0 -rotate-90 transition-transform group-hover:translate-x-0.5"
/>
</Link>
);
})}
</div>
{totalPages > 1 ? (
<nav
aria-label="Страницы заявлений"
className="mt-3 flex items-center justify-center gap-2"
>
<button
type="button"
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
aria-label="Предыдущая страница заявлений"
className="app-action-secondary flex h-8 w-8 items-center justify-center rounded-lg disabled:cursor-not-allowed disabled:opacity-35"
>
<ChevronLeft size={16} />
</button>
<span className="app-text-muted min-w-16 text-center text-xs tabular-nums">
{page} из {totalPages}
</span>
<button
type="button"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
aria-label="Следующая страница заявлений"
className="app-action-secondary flex h-8 w-8 items-center justify-center rounded-lg disabled:cursor-not-allowed disabled:opacity-35"
>
<ChevronRight size={16} />
</button>
</nav>
) : null}
</div>
);
}
function StatisticsGrid({ statistics }: { statistics: RequestEmployeeStatistics }) {
const items = [
{ label: "Всего подано", value: statistics.total_submitted_requests },
{
label: "Больничные",
value: `${statistics.sick_leave_requests_count} · ${statistics.sick_leave_days} дн.`,
},
{
label: "Отгулы",
value: `${statistics.day_off_requests_count} · ${statistics.day_off_days} дн.`,
},
{
label: "Короткие отгулы",
value: `${statistics.short_day_off_requests_count} · ${formatMinutes(statistics.short_day_off_minutes)}`,
},
{ label: "Оплачиваемый отпуск", value: `${statistics.paid_vacation_days} дн.` },
{ label: "Неоплачиваемый отпуск", value: `${statistics.unpaid_vacation_days} дн.` },
{
label: "Декрет",
value: `${statistics.maternity_requests_count} · ${statistics.maternity_days} дн.`,
},
];
return (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">
{items.map((item) => (
<div key={item.label} className="app-surface-muted min-w-0 rounded-xl px-3 py-3">
<p className="app-text-muted min-h-8 text-xs leading-4">{item.label}</p>
<p className="mt-1 text-sm font-semibold text-[var(--foreground)]">{item.value}</p>
</div>
))}
</div>
);
}
export default function EmployeeRequestsProfileSections({ employeeId }: { employeeId: number }) {
const [requestsOpen, setRequestsOpen] = useState(false);
const [statisticsOpen, setStatisticsOpen] = useState(false);
const [data, setData] = useState<EmployeeRequestProfileSummary | null>(null);
const [loading, setLoading] = useState(true);
const [requestsLoading, setRequestsLoading] = useState(false);
const [requestPage, setRequestPage] = useState(1);
const [statisticsLoading, setStatisticsLoading] = useState(false);
const [statisticsPeriod, setStatisticsPeriod] = useState<StatisticsPeriod>("all");
const [error, setError] = useState<string | null>(null);
const [denied, setDenied] = useState(false);
useEffect(() => {
let cancelled = false;
apiClient.getEmployeeRequestProfile(employeeId, statisticsPeriod, 5, requestPage)
.then((result) => {
if (!cancelled) {
setData(result);
if (result.page !== requestPage) setRequestPage(result.page);
}
})
.catch((caught: unknown) => {
if (cancelled) return;
if (caught instanceof ApiError && caught.status === 403) {
setDenied(true);
return;
}
setError(caught instanceof Error ? caught.message : "Не удалось загрузить заявления.");
})
.finally(() => {
if (!cancelled) {
setLoading(false);
setRequestsLoading(false);
setStatisticsLoading(false);
}
});
return () => {
cancelled = true;
};
}, [employeeId, requestPage, statisticsPeriod]);
if (denied) return null;
const badge = loading ? (
<LoaderCircle size={16} className="app-text-muted shrink-0 animate-spin" />
) : data ? (
<span className="app-badge app-badge-accent shrink-0 px-2 py-0.5 text-xs">{data.count}</span>
) : null;
const content = error ? (
<div className="app-feedback-danger rounded-xl px-3 py-2 text-sm">{error}</div>
) : loading || !data ? (
<div className="flex items-center justify-center gap-2 py-5">
<LoaderCircle size={18} className="app-text-muted animate-spin" />
<span className="app-text-muted text-sm">Загрузка...</span>
</div>
) : null;
return (
<>
<CollapsibleProfileSection
title="Заявления"
icon={<ClipboardList size={18} />}
action={badge}
open={requestsOpen}
onToggle={() => setRequestsOpen((value) => !value)}
>
{content || <RequestList
loading={requestsLoading}
requests={data?.results || []}
page={data?.page || requestPage}
totalPages={data?.total_pages || 1}
onPageChange={(nextPage) => {
setError(null);
setRequestsLoading(true);
setRequestPage(nextPage);
}}
/>}
</CollapsibleProfileSection>
<CollapsibleProfileSection
title="Статистика по заявлениям"
icon={<BarChart3 size={18} />}
action={
loading ? (
<LoaderCircle size={16} className="app-text-muted shrink-0 animate-spin" />
) : data ? (
<ProjectSelectMenu
value={statisticsPeriod}
options={statisticsPeriodOptions}
onChange={(nextPeriod) => {
setError(null);
setStatisticsLoading(true);
setStatisticsPeriod(nextPeriod);
}}
ariaLabel="Период статистики по заявлениям"
variant="badge"
/>
) : null
}
open={statisticsOpen}
onToggle={() => setStatisticsOpen((value) => !value)}
>
{content || (statisticsLoading ? (
<div className="flex items-center justify-center gap-2 py-5">
<LoaderCircle size={18} className="app-text-muted animate-spin" />
<span className="app-text-muted text-sm">Обновляем статистику...</span>
</div>
) : data ? <StatisticsGrid statistics={data.statistics} /> : null)}
</CollapsibleProfileSection>
</>
);
}

View File

@ -1,12 +1,21 @@
"use client";
import Link from "next/link";
import { useEffect, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import {
useEffect,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import {
CalendarClock,
Check,
ChevronDown,
Clock3,
Crown,
Info,
Link2,
Loader2,
Pencil,
@ -136,6 +145,7 @@ export function ProfileHeroCard({
secondaryLine,
roleText,
departmentBadge,
infoBadge,
headerActions,
actionRow,
bottomPanel,
@ -147,6 +157,7 @@ export function ProfileHeroCard({
secondaryLine?: string | null;
roleText: string;
departmentBadge?: ReactNode;
infoBadge?: ReactNode;
headerActions?: ReactNode;
actionRow?: ReactNode;
bottomPanel?: ReactNode;
@ -165,9 +176,12 @@ export function ProfileHeroCard({
<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">
<h1 className="app-text-wrap text-[2rem] font-semibold leading-tight text-[var(--foreground)]">
{fullName}
</h1>
<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}
@ -271,37 +285,131 @@ export function ProfileContactsPanel({
);
}
export function ProfileInfoCard({
title = "Информация",
export function ProfileInfoBadge({
items,
}: {
title?: string;
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 (
<section className="app-surface rounded-2xl p-5">
<SectionTitle title={title} />
<div className="app-surface-muted overflow-hidden rounded-2xl">
<div className="grid md:grid-cols-2">
{items.map((item, index) => (
<>
<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
key={item.label}
className={[
"px-4 py-3.5",
index > 0 ? "border-t border-[var(--border-subtle)]" : "",
index % 2 === 1 ? "md:border-l" : "",
index === 1 ? "md:border-t-0" : "",
].join(" ").trim()}
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",
}}
>
<p className="text-sm font-semibold text-[var(--foreground)]">
{item.label}
</p>
<p className="app-text-muted mt-1 text-sm">{item.value}</p>
</div>
))}
</div>
</div>
</section>
{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}
</>
);
}
@ -712,79 +820,182 @@ export function ProfileSkillsCard({
export function ProfileDepartmentBadge({
label,
href,
departments,
}: {
label: string;
href?: string;
departments: EmployeeDepartment[];
}) {
if (href) {
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={href}
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)]"
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}
>
{label}
<span className="truncate">{label}</span>
</Link>
);
}
return (
<span className="app-pill inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium">
{label}
</span>
);
}
<>
<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>
export function ProfileDepartmentsCard({
title = "Отделы",
departments,
}: {
title?: string;
departments: EmployeeDepartment[];
}) {
if (!departments.length) {
return null;
}
return (
<section className="app-surface rounded-2xl p-5">
<SectionTitle title={title} />
<div className="app-surface-muted rounded-2xl p-4">
<div className="flex flex-wrap gap-2">
{departments.map((department) => (
<Link
key={department.id}
href={`/departments/${department.id}`}
className="app-badge inline-flex max-w-full items-center gap-2 rounded-full px-2 py-1.5 text-[var(--foreground)] transition hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)]"
{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",
}}
>
<span className="truncate px-1 text-sm font-medium">
{department.name}
</span>
{department.role_name ? (
<span className="app-surface inline-flex max-w-full items-center rounded-full px-2.5 py-1 text-xs font-medium text-[var(--muted-foreground)]">
<span className="truncate">{department.role_name}</span>
</span>
) : null}
{department.is_head ? (
<span
className="inline-flex items-center justify-center rounded-full border border-[color:color-mix(in_srgb,#f59e0b_42%,var(--border-subtle))] bg-[color:color-mix(in_srgb,#f59e0b_14%,transparent)] px-2.5 py-1 text-[#f59e0b]"
title="Руководитель отдела"
{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)]"
>
<Crown size={12} />
</span>
) : null}
{department.via_assignment ? (
<span
className="app-surface inline-flex items-center justify-center rounded-full px-2.5 py-1"
title="Роль в отделе без прямого членства"
>
<Link2 size={12} />
</span>
) : null}
</Link>
))}
</div>
</div>
</section>
<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}
</>
);
}

View File

@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { RequestEmployeeStatistics, User } from '@/types/api';
import type { EmployeeRequestProfileSummary, RequestEmployeeStatistics, User } from '@/types/api';
import type { RequestFn } from './utils';
export function createRequestsApi(request: RequestFn) {
@ -32,6 +32,8 @@ export function createRequestsApi(request: RequestFn) {
request(`/api/v1/requests/${requestId}/employee-statistics/?period=${period}`),
getEmployeeRequestStatistics: (employeeId: number, period: 'all' | 'year' | 'month' = 'all'): Promise<RequestEmployeeStatistics> =>
request(`/api/v1/requests/statistics/?employee_id=${employeeId}&period=${period}`),
getEmployeeRequestProfile: (employeeId: number, period: 'all' | 'year' | 'month' = 'all', limit = 5, page = 1): Promise<EmployeeRequestProfileSummary> =>
request(`/api/v1/requests/employee-profile/?employee_id=${employeeId}&period=${period}&limit=${limit}&page=${page}`),
getRequestRecipientOptions: (): Promise<{ restricted: boolean; results: User[] }> =>
request('/api/v1/requests/recipient-options/'),
createRequest: (data: Record<string, any>, saveAs?: 'draft' | 'submit') => formDataRequest('/api/v1/requests/', 'POST', data, saveAs),

View File

@ -1200,6 +1200,16 @@ export interface RequestEmployeeStatistics {
unpaid_vacation_days: number;
}
export interface EmployeeRequestProfileSummary {
count: number;
page: number;
total_pages: number;
has_previous: boolean;
has_next: boolean;
results: Request[];
statistics: RequestEmployeeStatistics;
}
// Equipment types
export interface EquipmentCategory {
id: number;

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 B