Refactor application workflows and update related components
This commit is contained in:
parent
f3d2d2fe44
commit
5fc8b4d7af
151
.codex-tmp/attendance-hire-report/build_report.mjs
Normal file
151
.codex-tmp/attendance-hire-report/build_report.mjs
Normal 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));
|
||||
}
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
@ -37,6 +37,7 @@ class RequestCoreMixin:
|
||||
"reject": IsRecipientOfRequest,
|
||||
"cancel": IsRequestAuthor,
|
||||
"statistics": IsAuthenticated,
|
||||
"employee_profile": IsAuthenticated,
|
||||
"recipient_options": IsAuthenticated,
|
||||
"retrieve": CanViewRequest,
|
||||
"employee_statistics": CanViewRequest,
|
||||
|
||||
@ -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):
|
||||
"""Возвращает сводную статистику по заявлениям сотрудника.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)]`}
|
||||
>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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;
|
||||
|
||||
7309
outputs/attendance_hire_report_20260825/analysis.json
Normal file
7309
outputs/attendance_hire_report_20260825/analysis.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
outputs/attendance_hire_report_20260825/source_2024год.png
Normal file
BIN
outputs/attendance_hire_report_20260825/source_2024год.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
BIN
outputs/attendance_hire_report_20260825/source_2025год.png
Normal file
BIN
outputs/attendance_hire_report_20260825/source_2025год.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 MiB |
BIN
outputs/attendance_hire_report_20260825/source_2026год.png
Normal file
BIN
outputs/attendance_hire_report_20260825/source_2026год.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
BIN
outputs/attendance_hire_report_20260825/source_Лист1.png
Normal file
BIN
outputs/attendance_hire_report_20260825/source_Лист1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 605 B |
BIN
outputs/attendance_hire_report_20260825/source_Лист2.png
Normal file
BIN
outputs/attendance_hire_report_20260825/source_Лист2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 615 B |
Loading…
x
Reference in New Issue
Block a user