Update application functionality
This commit is contained in:
parent
5fc8b4d7af
commit
9d25de9336
@ -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 }));
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
1367
outputs/attendance_hire_report_20260825/people_summary.json
Normal file
1367
outputs/attendance_hire_report_20260825/people_summary.json
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 636 KiB |
BIN
outputs/attendance_hire_report_20260825/previews/Отчет.png
Normal file
BIN
outputs/attendance_hire_report_20260825/previews/Отчет.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 892 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
BIN
outputs/attendance_hire_report_20260825/Отчет_о_датах_найма.xlsx
Normal file
BIN
outputs/attendance_hire_report_20260825/Отчет_о_датах_найма.xlsx
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user