Update application functionality

This commit is contained in:
lizerk 2026-08-25 12:30:29 +03:00
parent 5fc8b4d7af
commit 9d25de9336
13 changed files with 5780 additions and 347 deletions

View File

@ -1,5 +1,5 @@
import fs from "node:fs/promises";
import { FileBlob, SpreadsheetFile } from "@oai/artifact-tool";
import { FileBlob, SpreadsheetFile, Workbook } from "@oai/artifact-tool";
const sourcePath = "/home/lizerk/Загрузки/График(2).xlsx";
const outputDir = "/home/lizerk/Dev/EUSRR/outputs/attendance_hire_report_20260825";
@ -81,6 +81,7 @@ if (mode === "analyze") {
name: rawName,
firstDay: firstMarkIndex >= 0 ? days[firstMarkIndex] : null,
firstMark: firstMarkIndex >= 0 ? marks[firstMarkIndex] : null,
firstColumn: firstMarkIndex >= 0 ? firstMarkIndex + 2 : null,
nonblankMarks: marks.filter(Boolean).length,
});
}
@ -119,22 +120,7 @@ if (mode === "analyze") {
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}`;
current.firstMarkCell = `${columnLetter(employee.firstColumn)}${employee.row}`;
}
people.set(key, current);
}
@ -149,3 +135,287 @@ if (mode === "analyze") {
.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));
}
if (mode === "build") {
const people = JSON.parse(await fs.readFile(`${outputDir}/people_summary.json`, "utf8"));
const outputPath = `${outputDir}/Отчет_о_датах_найма.xlsx`;
const previewDir = `${outputDir}/previews`;
await fs.mkdir(previewDir, { recursive: true });
const candidateGroups = [
["Миша Омельянов", "Михаил Омельянов", "Сокращённое и полное имя, одинаковая фамилия"],
["Константин Макаев", "Костя Макаев", "Сокращённое и полное имя, одинаковая фамилия"],
["Даниил Зебольд", "Данил Зебольд", "Разное написание имени, одинаковая фамилия"],
["Саша Елисеев", "Елисеев Александр", "Сокращённое/полное имя и обратный порядок"],
["Анатолий Меджитов", "Анатолий Меджидов", "Вероятная опечатка в фамилии"],
["Тимур", "Тимур Попов", "Краткая и полная запись"],
["Святослав", "Ястребов Святослав", "Краткая и полная запись"],
["Лера", "Лера Коптева", "Краткая и полная запись"],
["Сергей К", "Сергей Карнаухов", "Сокращённая фамилия"],
["Артёмка", "Артем", "Уменьшительная и полная форма"],
["Русик", "Руслан Сухойванов", "Возможная краткая и полная запись"],
["Виталий", "Виталик", "Полная и разговорная форма"],
];
const candidateByName = new Map();
candidateGroups.forEach(([left, right], index) => {
candidateByName.set(left, `Группа ${index + 1}: возможно «${right}»`);
candidateByName.set(right, `Группа ${index + 1}: возможно «${left}»`);
});
const reportBook = Workbook.create();
const reportSheet = reportBook.worksheets.add("Отчёт");
const sourceSheet = reportBook.worksheets.add("Исходные данные");
const checkSheet = reportBook.worksheets.add("Проверка имён");
reportSheet.showGridLines = false;
sourceSheet.showGridLines = false;
checkSheet.showGridLines = false;
const parsedDate = (iso) => iso ? new Date(`${iso}T00:00:00Z`) : null;
const hireDate = (person) => parsedDate(person.firstMarkDate ?? person.firstListDate);
const markedCount = people.filter((person) => person.firstMarkDate).length;
const fallbackCount = people.length - markedCount;
const dates = people.map(hireDate).filter(Boolean);
const minDate = new Date(Math.min(...dates));
const maxDate = new Date(Math.max(...dates));
reportSheet.mergeCells("A1:H1");
reportSheet.getRange("A1").values = [["Отчёт о датах найма сотрудников"]];
reportSheet.mergeCells("A2:H2");
reportSheet.getRange("A2").values = [[
"Дата найма = первая дата с непустой отметкой посещаемости. Если отметок нет — первое появление имени в месячном списке (строка отмечена для проверки).",
]];
reportSheet.getRange("A4").values = [["Записей"]];
reportSheet.getRange("C4").values = [["Подтверждено отметкой"]];
reportSheet.getRange("E4").values = [["Требует проверки"]];
reportSheet.getRange("G4").values = [["Период найма"]];
const reportFirstRow = 9;
const reportLastRow = reportFirstRow + people.length - 1;
reportSheet.getRange("B4").formulas = [[`=COUNTA(B${reportFirstRow}:B${reportLastRow})`]];
reportSheet.getRange("D4").formulas = [[`=COUNT('Исходные данные'!D3:D${people.length + 2})`]];
reportSheet.getRange("F4").formulas = [["=B4-D4"]];
reportSheet.getRange("H4").values = [[`${minDate.toLocaleDateString("ru-RU", { timeZone: "UTC" })} — ${maxDate.toLocaleDateString("ru-RU", { timeZone: "UTC" })}`]];
reportSheet.getRange("A8:H8").values = [[
"№", "Сотрудник", "Дата найма", "Основание", "Первое появление в списке", "Первая отметка", "Источник", "Примечание",
]];
reportSheet.getRange(`B${reportFirstRow}:B${reportLastRow}`).values = people.map((person) => [person.name]);
reportSheet.getRange(`A${reportFirstRow}`).formulas = [["=ROW()-8"]];
reportSheet.getRange(`A${reportFirstRow}:A${reportLastRow}`).fillDown();
const reportFormulas = people.map((person, index) => {
const sourceRow = index + 3;
return [
`=IF('Исходные данные'!D${sourceRow}<>"",'Исходные данные'!D${sourceRow},'Исходные данные'!C${sourceRow})`,
`=IF('Исходные данные'!D${sourceRow}<>"","Первая отметка","Только имя в списке — проверить")`,
`='Исходные данные'!C${sourceRow}`,
`=IF('Исходные данные'!E${sourceRow}="","",'Исходные данные'!E${sourceRow})`,
`='Исходные данные'!F${sourceRow}&"!"&'Исходные данные'!G${sourceRow}`,
`=IF('Исходные данные'!I${sourceRow}="","",'Исходные данные'!I${sourceRow})`,
];
});
reportSheet.getRange(`C${reportFirstRow}:H${reportLastRow}`).formulas = reportFormulas;
reportSheet.getRange("A1:H1").format = {
fill: "#17365D",
font: { name: "Aptos Display", size: 20, bold: true, color: "#FFFFFF" },
horizontalAlignment: "left",
verticalAlignment: "center",
};
reportSheet.getRange("A1:H1").format.rowHeight = 34;
reportSheet.getRange("A2:H2").format = {
fill: "#DCE6F1",
font: { name: "Aptos", size: 10, color: "#17365D" },
wrapText: true,
verticalAlignment: "center",
};
reportSheet.getRange("A2:H2").format.rowHeight = 42;
reportSheet.getRange("A4:H4").format = {
fill: "#EAF1F8",
font: { name: "Aptos", size: 10, bold: true, color: "#17365D" },
verticalAlignment: "center",
borders: { preset: "outside", style: "thin", color: "#9FBAD0" },
};
reportSheet.getRange("B4,D4,F4,H4").format = {
fill: "#FFFFFF",
font: { name: "Aptos Display", size: 12, bold: true, color: "#17365D" },
horizontalAlignment: "center",
borders: { preset: "outside", style: "thin", color: "#9FBAD0" },
};
reportSheet.getRange("A8:H8").format = {
fill: "#2F75B5",
font: { name: "Aptos", size: 10, bold: true, color: "#FFFFFF" },
horizontalAlignment: "center",
verticalAlignment: "center",
wrapText: true,
borders: { preset: "inside", style: "thin", color: "#B4C6E7" },
};
reportSheet.getRange("A8:H8").format.rowHeight = 34;
reportSheet.getRange(`A${reportFirstRow}:H${reportLastRow}`).format = {
font: { name: "Aptos", size: 10, color: "#1F2937" },
verticalAlignment: "center",
borders: { insideHorizontal: { style: "thin", color: "#D9E2F3" } },
};
reportSheet.getRange(`A${reportFirstRow}:A${reportLastRow}`).format.horizontalAlignment = "center";
reportSheet.getRange(`C${reportFirstRow}:C${reportLastRow}`).format.horizontalAlignment = "center";
reportSheet.getRange(`E${reportFirstRow}:E${reportLastRow}`).format.horizontalAlignment = "center";
reportSheet.getRange(`C${reportFirstRow}:C${reportLastRow}`).setNumberFormat("yyyy-mm-dd");
reportSheet.getRange(`E${reportFirstRow}:E${reportLastRow}`).setNumberFormat("yyyy-mm");
reportSheet.getRange(`D${reportFirstRow}:H${reportLastRow}`).format.wrapText = true;
reportSheet.getRange(`D${reportFirstRow}:D${reportLastRow}`).conditionalFormats.add("containsText", {
text: "проверить",
format: { fill: "#FFF2CC", font: { color: "#9C5700", bold: true } },
});
reportSheet.getRange(`H${reportFirstRow}:H${reportLastRow}`).conditionalFormats.addCustom(
`=LEN($H${reportFirstRow})>0`,
{ fill: "#FCE4D6", font: { color: "#843C0C" } },
);
reportSheet.getRange(`A8:H${reportLastRow}`).format.autofitRows();
reportSheet.getRange("A:A").format.columnWidth = 6;
reportSheet.getRange("B:B").format.columnWidth = 24;
reportSheet.getRange("C:C").format.columnWidth = 14;
reportSheet.getRange("D:D").format.columnWidth = 27;
reportSheet.getRange("E:E").format.columnWidth = 20;
reportSheet.getRange("F:F").format.columnWidth = 15;
reportSheet.getRange("G:G").format.columnWidth = 18;
reportSheet.getRange("H:H").format.columnWidth = 34;
reportSheet.freezePanes.freezeRows(8);
reportSheet.tables.add(`A8:H${reportLastRow}`, true, "HireDateReportTable");
sourceSheet.mergeCells("A1:I1");
sourceSheet.getRange("A1").values = [["Извлечённые первые появления из файла «График(2).xlsx»"]];
sourceSheet.getRange("A2:I2").values = [[
"№", "Имя как в таблице", "Первый месяц в списке", "Первая дата с отметкой", "Первая отметка", "Лист", "Ячейка", "Варианты написания", "Контроль",
]];
sourceSheet.getRange(`A3:I${people.length + 2}`).values = people.map((person, index) => [
index + 1,
person.name,
parsedDate(person.firstListDate),
parsedDate(person.firstMarkDate),
person.firstMark ?? "",
person.firstMarkSheet ?? person.firstListSheet,
person.firstMarkCell ?? `A${person.firstListRow}`,
person.variants.join("; "),
candidateByName.get(person.name) ?? "",
]);
sourceSheet.getRange("A1:I1").format = {
fill: "#17365D",
font: { name: "Aptos Display", size: 16, bold: true, color: "#FFFFFF" },
verticalAlignment: "center",
};
sourceSheet.getRange("A1:I1").format.rowHeight = 30;
sourceSheet.getRange("A2:I2").format = {
fill: "#5B9BD5",
font: { name: "Aptos", size: 10, bold: true, color: "#FFFFFF" },
wrapText: true,
horizontalAlignment: "center",
verticalAlignment: "center",
};
sourceSheet.getRange("A2:I2").format.rowHeight = 42;
sourceSheet.getRange(`A3:I${people.length + 2}`).format = {
font: { name: "Aptos", size: 10, color: "#1F2937" },
borders: { insideHorizontal: { style: "thin", color: "#D9E2F3" } },
verticalAlignment: "center",
};
sourceSheet.getRange(`C3:D${people.length + 2}`).setNumberFormat("yyyy-mm-dd");
sourceSheet.getRange(`I3:I${people.length + 2}`).format.wrapText = true;
sourceSheet.getRange("A:A").format.columnWidth = 6;
sourceSheet.getRange("B:B").format.columnWidth = 25;
sourceSheet.getRange("C:D").format.columnWidth = 22;
sourceSheet.getRange("E:E").format.columnWidth = 15;
sourceSheet.getRange("F:G").format.columnWidth = 14;
sourceSheet.getRange("H:H").format.columnWidth = 25;
sourceSheet.getRange("I:I").format.columnWidth = 38;
sourceSheet.freezePanes.freezeRows(2);
sourceSheet.tables.add(`A2:I${people.length + 2}`, true, "ExtractedHireDataTable");
checkSheet.mergeCells("A1:G1");
checkSheet.getRange("A1").values = [["Проверка возможных дублей имён"]];
checkSheet.mergeCells("A2:G2");
checkSheet.getRange("A2").values = [["Варианты ниже не объединены автоматически. Укажите решение в последнем столбце."]];
checkSheet.getRange("A4:G4").values = [["Группа", "Вариант 1", "Дата найма", "Вариант 2", "Дата найма", "Причина", "Решение"]];
const personByName = new Map(people.map((person) => [person.name, person]));
checkSheet.getRange(`A5:G${candidateGroups.length + 4}`).values = candidateGroups.map(([left, right, reason], index) => [
index + 1,
left,
hireDate(personByName.get(left)),
right,
hireDate(personByName.get(right)),
reason,
"Не решено",
]);
checkSheet.getRange("A1:G1").format = {
fill: "#17365D",
font: { name: "Aptos Display", size: 16, bold: true, color: "#FFFFFF" },
verticalAlignment: "center",
};
checkSheet.getRange("A2:G2").format = {
fill: "#FFF2CC",
font: { name: "Aptos", size: 10, color: "#7F6000" },
verticalAlignment: "center",
};
checkSheet.getRange("A4:G4").format = {
fill: "#5B9BD5",
font: { name: "Aptos", size: 10, bold: true, color: "#FFFFFF" },
wrapText: true,
horizontalAlignment: "center",
verticalAlignment: "center",
};
checkSheet.getRange(`A5:G${candidateGroups.length + 4}`).format = {
font: { name: "Aptos", size: 10, color: "#1F2937" },
borders: { insideHorizontal: { style: "thin", color: "#D9E2F3" } },
verticalAlignment: "center",
};
checkSheet.getRange(`C5:C${candidateGroups.length + 4}`).setNumberFormat("yyyy-mm-dd");
checkSheet.getRange(`E5:E${candidateGroups.length + 4}`).setNumberFormat("yyyy-mm-dd");
checkSheet.getRange(`G5:G${candidateGroups.length + 4}`).dataValidation = {
rule: { type: "list", values: ["Не решено", "Один сотрудник", "Разные сотрудники"] },
};
checkSheet.getRange(`G5:G${candidateGroups.length + 4}`).conditionalFormats.add("containsText", {
text: "Не решено",
format: { fill: "#FFF2CC", font: { color: "#9C5700", bold: true } },
});
checkSheet.getRange("A:A").format.columnWidth = 9;
checkSheet.getRange("B:B").format.columnWidth = 23;
checkSheet.getRange("C:C").format.columnWidth = 14;
checkSheet.getRange("D:D").format.columnWidth = 23;
checkSheet.getRange("E:E").format.columnWidth = 14;
checkSheet.getRange("F:F").format.columnWidth = 38;
checkSheet.getRange("G:G").format.columnWidth = 22;
checkSheet.getRange(`F5:G${candidateGroups.length + 4}`).format.wrapText = true;
checkSheet.freezePanes.freezeRows(4);
checkSheet.tables.add(`A4:G${candidateGroups.length + 4}`, true, "PossibleNameDuplicatesTable");
const reportInspect = await reportBook.inspect({
kind: "table",
range: `Отчёт!A1:H${Math.min(reportLastRow, 20)}`,
include: "values,formulas",
tableMaxRows: 20,
tableMaxCols: 8,
maxChars: 12000,
});
console.log(reportInspect.ndjson);
const sourceInspect = await reportBook.inspect({
kind: "table",
range: "Исходные данные!A1:I12",
include: "values,formulas",
tableMaxRows: 12,
tableMaxCols: 9,
maxChars: 8000,
});
console.log(sourceInspect.ndjson);
const errors = await reportBook.inspect({
kind: "match",
searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A",
options: { useRegex: true, maxResults: 300 },
summary: "final formula error scan",
maxChars: 6000,
});
console.log(errors.ndjson);
for (const sheetName of ["Отчёт", "Исходные данные", "Проверка имён"]) {
const preview = await reportBook.render({ sheetName, autoCrop: "all", scale: 1, format: "png" });
await fs.writeFile(`${previewDir}/${sheetName.replaceAll("ё", "е")}.png`, new Uint8Array(await preview.arrayBuffer()));
}
const output = await SpreadsheetFile.exportXlsx(reportBook);
await output.save(outputPath);
console.log(JSON.stringify({ outputPath, people: people.length, markedCount, fallbackCount, candidateGroups: candidateGroups.length }));
}

View File

@ -1,7 +1,7 @@
"use client";
import Image from "next/image";
import { Mail, MessageCircle, Phone, RefreshCw } from "lucide-react";
import { KeyRound, Mail, MessageCircle, Phone, RefreshCw } from "lucide-react";
import { AppShell } from "@/components/AppShell";
import EmployeeAttendanceCard from "@/components/attendance/EmployeeAttendanceCard";
@ -13,7 +13,7 @@ import {
ProfileDepartmentBadge,
ProfileHeroCard,
ProfileInfoBadge,
ProfileSkillsCard,
ProfileSkillsInline,
ProfileWorkScheduleCard,
type ProfileContactRow,
} from "@/components/users/ProfileSections";
@ -35,12 +35,7 @@ function getContactIcon(entry: ProfileContactEntry) {
case "phone":
return <Phone size={18} />;
case "directory-login":
return (
<RefreshCw
size={18}
className={entry.refreshing ? "animate-spin" : ""}
/>
);
return <KeyRound size={18} />;
case "telegram":
case "whatsapp":
case "wechat":
@ -87,9 +82,11 @@ export default function ProfilePage() {
type="button"
onClick={() => void handleRefreshDirectoryLogin()}
disabled={entry.refreshing}
className="app-action-secondary shrink-0 rounded-xl px-3 py-2 text-sm font-medium disabled:opacity-50"
className="app-action-secondary inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-sm font-medium disabled:opacity-50 sm:w-auto sm:gap-1.5 sm:px-3"
title="Обновить логин"
>
Обновить
<RefreshCw size={14} className={entry.refreshing ? "animate-spin" : ""} />
<span className="hidden sm:inline">Обновить</span>
</button>
) : undefined,
}));
@ -160,22 +157,25 @@ export default function ProfilePage() {
) : undefined
}
infoBadge={<ProfileInfoBadge items={infoItems} />}
bottomPanel={<ProfileContactsPanel rows={contactRows} />}
/>
<ProfileSkillsCard
inputValue={skillName}
onInputChange={onInputSkillName}
onSubmit={() => void handleAddSkill()}
submitDisabled={!skillName.trim() || skillsSaving}
inputDisabled={skillsSaving}
availableSkills={availableSkills}
skills={profileSkills}
error={skillsError}
loading={skillsLoading}
onRemoveSkill={(skillId) => void handleRemoveSkill(skillId)}
removeDisabled={skillsSaving}
emptyText="Навыки пока не указаны"
bottomPanel={
<div className="space-y-3">
<ProfileContactsPanel rows={contactRows} />
<ProfileSkillsInline
inputValue={skillName}
onInputChange={onInputSkillName}
onSubmit={() => void handleAddSkill()}
submitDisabled={!skillName.trim() || skillsSaving}
inputDisabled={skillsSaving}
availableSkills={availableSkills}
skills={profileSkills}
error={skillsError}
loading={skillsLoading}
onRemoveSkill={(skillId) => void handleRemoveSkill(skillId)}
removeDisabled={skillsSaving}
emptyText="Навыки пока не указаны"
/>
</div>
}
/>
<EmployeeActionsTimeline

View File

@ -30,7 +30,7 @@ import {
ProfileDepartmentBadge,
ProfileHeroCard,
ProfileInfoBadge,
ProfileSkillsCard,
ProfileSkillsInline,
ProfileWorkScheduleCard,
type ProfileContactRow,
type ProfileInfoItem,
@ -511,10 +511,24 @@ export default function UserDetailPage() {
) : undefined
}
bottomPanel={
<ProfileContactsPanel
rows={contactRows}
emptyText="Контакты не указаны"
/>
<div className="space-y-3">
<ProfileContactsPanel
rows={contactRows}
emptyText="Контакты не указаны"
/>
<ProfileSkillsInline
inputValue={skillName}
onInputChange={setSkillName}
onSubmit={() => void handleAddSkill()}
submitDisabled={!skillName.trim() || skillsSaving}
inputDisabled={skillsSaving}
availableSkills={availableSkills}
skills={personSkills}
error={skillsError}
loading={skillsLoading}
emptyText="Навыки не указаны"
/>
</div>
}
/>
@ -552,19 +566,6 @@ export default function UserDetailPage() {
)}
</section>
<ProfileSkillsCard
inputValue={skillName}
onInputChange={setSkillName}
onSubmit={() => void handleAddSkill()}
submitDisabled={!skillName.trim() || skillsSaving}
inputDisabled={skillsSaving}
availableSkills={availableSkills}
skills={personSkills}
error={skillsError}
loading={skillsLoading}
emptyText="Навыки не указаны"
/>
<EmployeeActionsTimeline
actionLoading={actionLoading}
canManageActions={canManageActions}

View File

@ -1,8 +1,9 @@
"use client";
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
AlertTriangle,
CalendarDays,
Camera,
ChevronDown,
ChevronRight,
@ -25,7 +26,9 @@ import { CommentComposer, CommentDeleteButton } from "@/components/shared/Commen
import { ViewportAwareMenu } from "@/components/shared/ViewportAwareMenu";
import TaskLinkPill from "@/components/tasks/TaskLinkPill";
import { RelatedTaskLinks } from "@/components/tasks/RelatedTaskLinks";
import { HorizontalChipScroller } from "@/components/ui/HorizontalChipScroller";
import { Modal } from "@/components/ui";
import { ProjectSelectMenu } from "@/components/ui/ProjectSelectMenu";
import { useUser } from "@/contexts/UserContext";
import { apiClient } from "@/lib/api";
import { displayUserName, formatDate } from "@/lib/shared";
@ -53,6 +56,7 @@ const attendancePeriods = [
] as const;
type AttendancePeriod = (typeof attendancePeriods)[number]["value"];
type AttendanceRecordFilter = "all" | "workdays" | "issues" | "overtime";
function toDateInputValue(date: Date) {
return date.toISOString().slice(0, 10);
@ -570,8 +574,10 @@ export default function EmployeeAttendanceCard({
const [period, setPeriod] = useState<AttendancePeriod>("week");
const [periodStart, setPeriodStart] = useState(defaultRange.start);
const [periodEnd, setPeriodEnd] = useState(defaultRange.end);
const [recordFilter, setRecordFilter] = useState<AttendanceRecordFilter>("all");
const [loading, setLoading] = useState(false);
const [loadingMode, setLoadingMode] = useState<"saved" | "analyze" | null>(null);
const [cardExpanded, setCardExpanded] = useState(false);
const [busyKey, setBusyKey] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<AttendanceAnalysisResponse | null>(null);
@ -645,6 +651,23 @@ export default function EmployeeAttendanceCard({
};
}, [employeeActions, records]);
const filteredRecords = useMemo(() => {
if (recordFilter === "all") return records;
if (recordFilter === "overtime") {
return records.filter((record) => record.is_overtime);
}
if (recordFilter === "issues") {
return records.filter((record) => {
const personnelMeta = getPersonnelDayMeta(employeeActions, record.date, record);
return issueLabels(record, personnelMeta).length > 0;
});
}
return records.filter((record) => {
const personnelMeta = getPersonnelDayMeta(employeeActions, record.date, record);
return (record.effective_is_workday ?? record.is_workday) && !personnelMeta?.nonWorking;
});
}, [employeeActions, recordFilter, records]);
async function analyzeAttendance() {
setError(null);
@ -686,6 +709,7 @@ export default function EmployeeAttendanceCard({
function handlePeriodChange(nextPeriod: AttendancePeriod) {
setPeriod(nextPeriod);
setRecordFilter("all");
setError(null);
setResult(null);
setExpandedRecordKey(null);
@ -740,11 +764,6 @@ export default function EmployeeAttendanceCard({
};
}, [employeeId, periodEnd, periodStart]);
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
void analyzeAttendance();
}
function updateRecordCommentCount(recordId: number, count: number) {
setResult((current) => {
if (!current?.records) return current;
@ -993,100 +1012,124 @@ export default function EmployeeAttendanceCard({
return (
<>
<section className="app-surface rounded-2xl p-4 sm:p-5">
<form onSubmit={handleSubmit} className="mb-4 space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 pr-2">
<h2 className="app-card-caption">Посещаемость</h2>
<p className="app-text-muted mt-2 text-sm">Анализ проходов по сотруднику</p>
<section className="app-surface rounded-2xl">
<div className="flex min-w-0 items-center gap-2 px-4 py-3.5 sm:px-5">
<button
type="button"
onClick={() => setCardExpanded((current) => !current)}
aria-expanded={cardExpanded}
className="flex min-w-0 flex-1 items-center gap-3 text-left"
>
<span className="app-icon-box flex h-9 w-9 shrink-0 items-center justify-center rounded-xl">
{loading ? <Loader2 size={17} className="animate-spin" /> : <Clock size={17} />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-[var(--foreground)]">
Посещаемость
</span>
<span className="app-text-muted mt-0.5 block truncate text-xs">
{`Записей: ${summary.total} · Проблем: ${summary.issueDays}`}
</span>
</span>
</button>
<ProjectSelectMenu
value={period}
options={attendancePeriods}
onChange={(nextPeriod) => {
handlePeriodChange(nextPeriod);
if (nextPeriod === "custom") setCardExpanded(true);
}}
ariaLabel="Период посещаемости"
variant="badge"
triggerIcon={<CalendarDays size={14} />}
menuWidth={208}
/>
<div ref={actionsMenuRef} className="relative shrink-0">
<button
type="button"
onClick={() => setActionsMenuOpen((current) => !current)}
className="app-action-ghost flex h-8 w-8 items-center justify-center rounded-md"
aria-label="Действия посещаемости"
aria-expanded={actionsMenuOpen}
title="Действия посещаемости"
aria-haspopup="menu"
>
<ChevronRight
size={15}
className={`transition-transform duration-200 ${actionsMenuOpen ? "rotate-90" : ""}`}
/>
</button>
{actionsMenuOpen ? (
<ViewportAwareMenu className="app-menu absolute right-0 top-full z-20 mt-2 w-64 rounded-xl py-1.5">
<button
type="button"
disabled={loading}
onClick={() => {
setActionsMenuOpen(false);
setCardExpanded(true);
void analyzeAttendance();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)] disabled:cursor-not-allowed disabled:opacity-60"
>
{loading ? (
<Loader2 size={14} className="app-text-muted animate-spin" />
) : (
<RefreshCw size={14} className="app-text-muted" />
)}
Обновить
</button>
{canManageAttendance ? (
<>
<div className="my-1 border-t border-[var(--border-subtle)]" />
<button
type="button"
onClick={() => {
setActionsMenuOpen(false);
openEditModal();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Edit3 size={14} className="app-text-muted" />
Редактировать записи
</button>
<button
type="button"
onClick={() => {
setActionsMenuOpen(false);
openAliasModal();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Tag size={14} className="app-text-muted" />
Добавить алиас
</button>
</>
) : null}
</ViewportAwareMenu>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
<div ref={actionsMenuRef} className="relative">
<button
type="button"
onClick={() => setActionsMenuOpen((current) => !current)}
className="app-action-ghost flex h-9 w-9 items-center justify-center rounded-md"
aria-label="Действия посещаемости"
aria-expanded={actionsMenuOpen}
title="Действия посещаемости"
aria-haspopup="menu"
>
<ChevronRight
size={15}
className={`transition-transform duration-200 ${actionsMenuOpen ? "rotate-90" : ""}`}
/>
</button>
{actionsMenuOpen ? (
<ViewportAwareMenu className="app-menu absolute right-0 top-full z-20 mt-2 w-64 rounded-xl py-1.5">
<button
type="submit"
disabled={loading}
onClick={() => setActionsMenuOpen(false)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)] disabled:cursor-not-allowed disabled:opacity-60"
>
{loading ? (
<Loader2 size={14} className="app-text-muted animate-spin" />
) : (
<RefreshCw size={14} className="app-text-muted" />
)}
Обновить
</button>
{canManageAttendance ? (
<>
<div className="my-1 border-t border-[var(--border-subtle)]" />
<button
type="button"
onClick={() => {
setActionsMenuOpen(false);
openEditModal();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Edit3 size={14} className="app-text-muted" />
Редактировать записи
</button>
<button
type="button"
onClick={() => {
setActionsMenuOpen(false);
openAliasModal();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Tag size={14} className="app-text-muted" />
Добавить алиас
</button>
</>
) : null}
</ViewportAwareMenu>
) : null}
</div>
</div>
<button
type="button"
onClick={() => setCardExpanded((current) => !current)}
aria-label={cardExpanded ? "Свернуть посещаемость" : "Развернуть посещаемость"}
aria-expanded={cardExpanded}
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 ${cardExpanded ? "rotate-180" : ""}`}
/>
</button>
</div>
<div className="app-surface-muted rounded-xl p-3">
<div className="flex flex-wrap gap-2">
{attendancePeriods.map((periodOption) => {
const active = period === periodOption.value;
return (
<button
key={periodOption.value}
type="button"
onClick={() => handlePeriodChange(periodOption.value)}
className={active
? "app-action-primary rounded-lg px-3 py-2 text-xs font-medium"
: "app-action-secondary rounded-lg px-3 py-2 text-xs font-medium"
}
>
{periodOption.label}
</button>
);
})}
</div>
{cardExpanded ? (
<div className="border-t border-[var(--border-subtle)] px-4 py-4 sm:px-5">
<div className="mb-3">
{period === "custom" ? (
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<label className="block">
<span className="app-card-caption mb-1 block">Начало</span>
<input
@ -1107,31 +1150,42 @@ export default function EmployeeAttendanceCard({
</label>
</div>
) : (
<p className="app-text-muted mt-2 px-1 text-xs">
<p className="app-text-muted px-1 text-xs">
{periodStart} — {periodEnd}
</p>
)}
</div>
</form>
<div className="grid grid-cols-[repeat(auto-fit,minmax(7.5rem,1fr))] gap-2">
<div className="app-surface-muted rounded-lg px-3 py-2">
<p className="app-card-caption break-words leading-tight">Записей</p>
<p className="text-lg font-semibold text-[var(--foreground)]">{summary.total}</p>
</div>
<div className="app-surface-muted rounded-lg px-3 py-2">
<p className="app-card-caption break-words leading-tight">Рабочих</p>
<p className="text-lg font-semibold text-[var(--foreground)]">{summary.workdays}</p>
</div>
<div className="app-surface-muted rounded-lg px-3 py-2">
<p className="app-card-caption break-words leading-tight">Проблем</p>
<p className="text-lg font-semibold text-[var(--foreground)]">{summary.issueDays}</p>
</div>
<div className="app-surface-muted rounded-lg px-3 py-2">
<p className="app-card-caption break-words leading-tight">Переработок</p>
<p className="text-lg font-semibold text-[var(--foreground)]">{summary.overtime}</p>
</div>
</div>
<HorizontalChipScroller ariaLabel="Фильтр записей посещаемости">
{([
{ key: "all", label: "Все", count: summary.total },
{ key: "workdays", label: "Рабочие", count: summary.workdays },
{ key: "issues", label: "Проблемы", count: summary.issueDays },
{ key: "overtime", label: "Переработки", count: summary.overtime },
] as const).map((filter) => {
const active = recordFilter === filter.key;
return (
<button
key={filter.key}
type="button"
onClick={() => setRecordFilter(filter.key)}
aria-pressed={active}
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
active ? "app-pill-active" : "app-pill"
}`}
>
<span>{filter.label}</span>
<span
className={`app-badge inline-flex items-center px-1.5 py-0.5 text-[10px] font-bold ${
active ? "app-pill-count-active" : "app-pill-count"
}`}
>
{filter.count}
</span>
</button>
);
})}
</HorizontalChipScroller>
{error ? (
<div className="mt-4 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">
@ -1149,16 +1203,20 @@ export default function EmployeeAttendanceCard({
) : null}
{!loading && result && records.length === 0 ? (
<div className="app-surface-muted rounded-xl p-6 text-center">
<p className="app-text-muted text-sm">
Сохраненных записей за период нет. Нажмите «Обновить», чтобы выполнить анализ.
</p>
</div>
<p className="app-text-muted px-1 py-3 text-sm">
Записей за период нет. Для повторного анализа используйте «Обновить» в меню действий.
</p>
) : null}
{records.length > 0 ? (
{!loading && records.length > 0 && filteredRecords.length === 0 ? (
<p className="app-text-muted px-1 py-3 text-sm">
По выбранному фильтру записей нет.
</p>
) : null}
{filteredRecords.length > 0 ? (
<div className="space-y-2">
{records.map((record, index) => {
{filteredRecords.map((record, index) => {
const recordId = getRecordId(record);
const key = recordId ? `record-${recordId}` : `${record.date || "record"}-${index}`;
const personnelMeta = getPersonnelDayMeta(employeeActions, record.date, record);
@ -1408,6 +1466,8 @@ export default function EmployeeAttendanceCard({
</div>
) : null}
</div>
</div>
) : null}
</section>
<AttendanceDayEventsModal

View File

@ -13,7 +13,7 @@ const getServerSnapshot = () => false;
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
title?: ReactNode;
children: ReactNode;
size?: "sm" | "md" | "lg" | "xl" | "full";
showCloseButton?: boolean;
@ -163,7 +163,7 @@ export function Modal({
<h3
id={titleId}
className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--foreground)] sm:text-base lg:text-lg"
title={title}
title={typeof title === "string" ? title : undefined}
>
{title}
</h3>

View File

@ -13,6 +13,7 @@ import {
CalendarClock,
Check,
ChevronDown,
CircleHelp,
Clock3,
Crown,
Info,
@ -20,10 +21,11 @@ import {
Loader2,
Pencil,
Plus,
Timer,
X,
} from "lucide-react";
import { apiClient } from "@/lib/api";
import { HorizontalChipScroller } from "@/components/ui/HorizontalChipScroller";
import { Modal } from "@/components/ui";
import type { EmployeeWorkSchedule } from "@/lib/api/attendance";
import type {
EmployeeAttendanceStatus,
@ -231,13 +233,13 @@ export function ProfileContactsPanel({
emptyText?: string;
}) {
return (
<div className="app-surface-muted rounded-2xl p-4">
<div className="app-surface-muted rounded-2xl px-3 py-1 sm:p-4">
{rows.length ? (
rows.map((row, index) => (
<div key={row.key}>
{index > 0 ? <div className="mx-4 border-t border-[var(--border-subtle)]" /> : null}
<div className="flex items-start gap-3 px-4 py-4">
<span className="app-badge mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl">
{index > 0 ? <div className="mx-1 border-t border-[var(--border-subtle)] sm:mx-4" /> : null}
<div className="flex items-center gap-3 px-1 py-3 sm:items-start sm:px-4 sm:py-4">
<span className="app-badge flex h-9 w-9 shrink-0 items-center justify-center rounded-xl sm:mt-0.5 sm:h-10 sm:w-10">
{row.icon}
</span>
{row.onClick ? (
@ -255,7 +257,10 @@ export function ProfileContactsPanel({
<span className="app-text-muted text-xs">Скопировано</span>
) : null}
</div>
<p className="app-text-wrap app-text-muted mt-1 text-sm transition hover:text-[var(--accent-primary)]">
<p
className="app-text-muted mt-1 truncate text-sm transition hover:text-[var(--accent-primary)] sm:whitespace-normal sm:break-words"
title={row.value}
>
{row.value}
</p>
</button>
@ -269,12 +274,15 @@ export function ProfileContactsPanel({
<span className="app-text-muted text-xs">Скопировано</span>
) : null}
</div>
<p className="app-text-wrap app-text-muted mt-1 text-sm">
<p
className="app-text-muted mt-1 truncate text-sm sm:whitespace-normal sm:break-words"
title={row.value}
>
{row.value}
</p>
</div>
)}
{row.action ? <div className="shrink-0">{row.action}</div> : null}
{row.action ? <div className="shrink-0 self-center">{row.action}</div> : null}
</div>
</div>
))
@ -430,6 +438,7 @@ export function ProfileWorkScheduleCard({
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editing, setEditing] = useState(false);
const [expanded, setExpanded] = useState(false);
const [error, setError] = useState<string | null>(null);
const [form, setForm] = useState({
start_time: "08:00",
@ -475,9 +484,6 @@ export function ProfileWorkScheduleCard({
const endTime = normalizeScheduleTime(schedule.end_time) || "17:00";
const expectedHours = Number(schedule.expected_hours || 0) || 9;
const workdays = schedule.workdays?.length ? schedule.workdays : defaultWorkdays;
const weekendDays = workScheduleDays
.map((day) => day.value)
.filter((day) => !workdays.includes(day));
const stateTone = personnelState
? getEmployeeActionTone(personnelState.status)
: null;
@ -514,47 +520,62 @@ export function ProfileWorkScheduleCard({
};
return (
<section className="app-surface rounded-2xl p-5">
<SectionTitle
title={title}
action={(
<div className="flex flex-wrap items-center justify-end gap-2">
{schedule.is_default ? (
<span className="app-status-pill app-feedback-warning">
По умолчанию
</span>
) : personnelState ? (
<span className={`app-status-pill ${stateTone?.badgeClass || ""}`}>
{personnelState.label || personnelState.status}
</span>
) : (
<span className="app-status-pill app-feedback-success">Активен</span>
)}
{canEdit ? (
<button
type="button"
onClick={() => setEditing((current) => !current)}
className="app-action-ghost flex h-8 w-8 items-center justify-center rounded-md"
title="Редактировать график"
aria-label="Редактировать график"
>
{editing ? <X size={15} /> : <Pencil size={15} />}
</button>
) : null}
</div>
)}
/>
<section className="app-surface rounded-2xl">
<div className="flex min-w-0 items-center gap-2 px-4 py-3.5 sm:px-5">
<button
type="button"
onClick={() => setExpanded((current) => !current)}
aria-expanded={expanded}
className="flex min-w-0 flex-1 items-center gap-3 text-left"
>
<span className="app-icon-box flex h-9 w-9 shrink-0 items-center justify-center rounded-xl">
{loading ? <Loader2 size={17} className="animate-spin" /> : <CalendarClock size={17} />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-[var(--foreground)]">
{title}
</span>
<span className="app-text-muted mt-0.5 block truncate text-xs">
{loading
? "Загрузка..."
: `${startTime} — ${endTime} · ${expectedHours.toLocaleString("ru-RU")} ч.`}
</span>
</span>
</button>
<div className="app-surface-muted rounded-2xl p-4">
{loading ? (
<div className="flex items-center gap-2 text-sm text-[var(--muted-foreground)]">
<Loader2 size={16} className="animate-spin" />
Загружаем график...
</div>
{canEdit ? (
<button
type="button"
onClick={() => {
setExpanded(true);
setEditing((current) => !current);
}}
className="app-action-ghost flex h-8 w-8 shrink-0 items-center justify-center rounded-md"
title="Редактировать график"
aria-label="Редактировать график"
>
{editing ? <X size={15} /> : <Pencil size={15} />}
</button>
) : null}
{!loading && editing ? (
<div className="mb-4 space-y-3 rounded-xl border border-[var(--border-subtle)] p-3">
<button
type="button"
onClick={() => setExpanded((current) => !current)}
aria-label={expanded ? "Свернуть график работы" : "Развернуть график работы"}
aria-expanded={expanded}
className="app-text-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-lg transition hover:bg-[var(--surface-tertiary)] hover:text-[var(--foreground)]"
>
<ChevronDown
size={18}
className={`transition-transform ${expanded ? "rotate-180" : ""}`}
/>
</button>
</div>
{expanded ? (
<div className="border-t border-[var(--border-subtle)] px-4 py-4 sm:px-5">
{!loading && editing ? (
<div className="space-y-3 rounded-xl border border-[var(--border-subtle)] p-3">
<div className="grid gap-2 sm:grid-cols-3">
<label className="block">
<span className="app-card-caption mb-1 block">Начало</span>
@ -635,42 +656,35 @@ export function ProfileWorkScheduleCard({
</button>
</div>
</div>
) : null}
) : null}
<div className="grid gap-3 sm:grid-cols-2">
<div className="flex items-start gap-3">
<span className="app-badge flex h-10 w-10 shrink-0 items-center justify-center rounded-xl">
<Clock3 size={18} />
</span>
<div className="min-w-0">
<p className="text-sm font-semibold text-[var(--foreground)]">
{startTime} — {endTime}
</p>
<p className="app-text-muted mt-1 text-sm">Рабочее время</p>
</div>
</div>
<div className="flex items-start gap-3">
<span className="app-badge flex h-10 w-10 shrink-0 items-center justify-center rounded-xl">
<Timer size={18} />
</span>
<div className="min-w-0">
<p className="text-sm font-semibold text-[var(--foreground)]">
{expectedHours.toLocaleString("ru-RU")} ч.
</p>
<p className="app-text-muted mt-1 text-sm">Норма в день</p>
</div>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{!loading && !editing ? (
<>
<div className="mb-3 flex min-w-0 items-center justify-between gap-2">
<p className="app-text-muted min-w-0 truncate text-xs">
Рабочие дни: <span className="text-[var(--foreground)]">{compactDays(workdays)}</span>
</p>
{schedule.is_default ? (
<span className="app-status-pill app-feedback-warning max-w-28 shrink-0 truncate whitespace-nowrap">
По умолчанию
</span>
) : personnelState ? (
<span className={`app-status-pill max-w-28 shrink-0 truncate whitespace-nowrap ${stateTone?.badgeClass || ""}`}>
{personnelState.label || personnelState.status}
</span>
) : (
<span className="app-status-pill app-feedback-success shrink-0 whitespace-nowrap">Активен</span>
)}
</div>
<div className="flex flex-wrap gap-1.5">
{workScheduleDays.map((day) => {
const active = workdays.includes(day.value);
return (
<span
key={day.value}
title={`${day.label}: ${active ? "рабочий день" : "выходной"}`}
className={[
"inline-flex h-9 w-9 items-center justify-center rounded-xl border text-xs font-semibold",
"inline-flex h-8 w-8 items-center justify-center rounded-lg border text-xs font-semibold",
active
? "border-[color:color-mix(in_srgb,var(--accent-primary)_45%,var(--border-subtle))] bg-[color:color-mix(in_srgb,var(--accent-primary)_16%,transparent)] text-[var(--accent-primary)]"
: "border-[var(--border-subtle)] bg-[var(--surface-secondary)] text-[var(--muted-foreground)]",
@ -680,42 +694,20 @@ export function ProfileWorkScheduleCard({
</span>
);
})}
</div>
<div className="mt-4 grid gap-2 text-sm sm:grid-cols-2">
<div className="rounded-xl border border-[var(--border-subtle)] px-3 py-2">
<p className="app-card-caption mb-1">Рабочие дни</p>
<p className="app-text-wrap text-[var(--foreground)]">
{compactDays(workdays)}
</p>
</div>
<div className="rounded-xl border border-[var(--border-subtle)] px-3 py-2">
<p className="app-card-caption mb-1">Выходные</p>
<p className="app-text-wrap text-[var(--foreground)]">
{compactDays(weekendDays)}
</p>
</div>
</div>
{personnelState?.date_from ? (
<div className="mt-3 flex items-start gap-2 rounded-xl border border-[var(--border-subtle)] px-3 py-2 text-sm">
<CalendarClock size={16} className="mt-0.5 shrink-0 text-[var(--muted-foreground)]" />
<p className="app-text-muted app-text-wrap">
Кадровое состояние с {personnelState.date_from}
{personnelState.date_to ? ` по ${personnelState.date_to}` : ""}
</p>
</div>
) : null}
</div>
</>
) : null}
{error ? (
<p className="mt-3 text-sm text-red-400">{error}</p>
) : null}
</div>
</div>
) : null}
</section>
);
}
export function ProfileSkillsCard({
export function ProfileSkillsInline({
title = "Навыки",
inputValue,
onInputChange,
@ -744,77 +736,162 @@ export function ProfileSkillsCard({
removeDisabled?: boolean;
emptyText?: string;
}) {
const [modalOpen, setModalOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const listId = `${title.toLowerCase().replace(/\s+/g, "-")}-skills-list`;
const helpId = `${listId}-help`;
return (
<section className="app-surface rounded-2xl p-5">
<SectionTitle title={title} />
<div className="app-surface-muted rounded-2xl p-3.5">
<div className="flex flex-col gap-2.5 md:flex-row">
<div className="min-w-0 flex-1">
<>
<div>
<div className="flex min-w-0 items-center gap-2">
<HorizontalChipScroller
className="min-w-0 flex-1"
ariaLabel="Навыки сотрудника"
fadeColor="var(--surface-primary)"
>
{skills.length ? (
skills.map((skill) => (
<span
key={skill.id}
className="app-pill inline-flex items-center rounded-full px-2.5 py-1.5 text-xs font-medium"
>
{skill.name}
</span>
))
) : (
<span className="app-text-muted whitespace-nowrap px-1 text-xs">
{loading ? "Загружаем навыки..." : emptyText}
</span>
)}
</HorizontalChipScroller>
<button
type="button"
onClick={() => setModalOpen(true)}
className="app-pill app-badge-interactive flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
title="Управление навыками"
aria-label="Управление навыками"
>
<Plus size={15} />
</button>
</div>
</div>
<Modal
isOpen={modalOpen}
onClose={() => {
setModalOpen(false);
setHelpOpen(false);
}}
title={
<span className="inline-flex items-center gap-1.5">
<span>{title}</span>
<button
type="button"
onClick={() => setHelpOpen((value) => !value)}
className="app-accent-text inline-flex h-5 w-5 items-center justify-center rounded-full transition hover:bg-[var(--surface-tertiary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-primary)]"
aria-label="Как правильно заполнять навыки"
aria-expanded={helpOpen}
aria-controls={helpId}
title="Показать правила заполнения"
>
<CircleHelp size={14} strokeWidth={2} />
</button>
</span>
}
size="sm"
closeOnClickOutside
>
<div className="space-y-4">
{helpOpen ? (
<div
id={helpId}
className="app-surface-muted rounded-xl border border-[var(--border-subtle)] px-3 py-3 text-sm"
>
<p className="font-semibold text-[var(--foreground)]">Как заполнять навыки</p>
<ul className="app-text-muted mt-2 space-y-1.5 text-xs leading-relaxed">
<li>• Один навык — одна конкретная компетенция или технология.</li>
<li>• Выбирайте существующее название из списка, если оно уже есть.</li>
<li>• Не объединяйте несколько навыков и не указывайте должность или уровень владения.</li>
</ul>
<p className="app-text-muted mt-2 text-xs">
Например: «Python», «1С», «Электромонтаж».
</p>
</div>
) : null}
<div className="flex min-w-0 gap-2">
<input
list={listId}
value={inputValue}
onChange={(event) => onInputChange(event.target.value)}
placeholder="Добавить навык"
className="app-input w-full rounded-xl px-4 py-2.5 text-sm"
placeholder="Название навыка"
className="app-input min-w-0 flex-1 rounded-xl px-3 py-2.5 text-sm"
disabled={inputDisabled}
onKeyDown={(event) => {
if (event.key === "Enter" && !submitDisabled) {
event.preventDefault();
onSubmit();
}
}}
/>
<datalist id={listId}>
{availableSkills.map((skill) => (
<option key={skill.id} value={skill.name} />
))}
</datalist>
<button
type="button"
onClick={onSubmit}
disabled={submitDisabled}
className="app-action-primary flex h-[42px] w-[42px] shrink-0 items-center justify-center rounded-xl disabled:opacity-50"
title="Добавить навык"
aria-label="Добавить навык"
>
<Plus size={17} />
</button>
</div>
<button
type="button"
onClick={onSubmit}
disabled={submitDisabled}
className="app-action-primary inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
<Plus size={16} />
Добавить
</button>
{error ? <p className="text-sm text-red-400">{error}</p> : null}
<div>
<p className="app-card-caption mb-2">Добавленные навыки</p>
{skills.length ? (
<div className="flex flex-wrap gap-2">
{skills.map((skill) =>
onRemoveSkill ? (
<button
key={skill.id}
type="button"
onClick={() => onRemoveSkill(skill.id)}
disabled={removeDisabled}
className="app-pill inline-flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-xs font-medium transition hover:border-red-400/50 hover:text-red-300 disabled:opacity-50"
title="Удалить навык"
>
<span>{skill.name}</span>
<X size={14} />
</button>
) : (
<span
key={skill.id}
className="app-pill inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium"
>
{skill.name}
</span>
),
)}
</div>
) : (
<p className="app-text-muted text-sm">{emptyText}</p>
)}
</div>
{loading ? (
<p className="app-text-muted text-sm">Загружаем навыки...</p>
) : null}
</div>
{error ? <p className="mt-3 text-sm text-red-400">{error}</p> : null}
<div className="mt-3">
{skills.length ? (
<div className="flex flex-wrap gap-2">
{skills.map((skill) =>
onRemoveSkill ? (
<button
key={skill.id}
type="button"
onClick={() => onRemoveSkill(skill.id)}
disabled={removeDisabled}
className="app-pill inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] disabled:opacity-50"
title="Удалить навык"
>
<span>{skill.name}</span>
<X size={14} />
</button>
) : (
<span
key={skill.id}
className="app-pill inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium"
>
{skill.name}
</span>
),
)}
</div>
) : (
<p className="app-text-muted text-sm">{emptyText}</p>
)}
</div>
{loading ? (
<p className="app-text-muted mt-2.5 text-sm">Загружаем навыки...</p>
) : null}
</div>
</section>
</Modal>
</>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 892 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

File diff suppressed because one or more lines are too long