18 Commits

Author SHA1 Message Date
gorbenko.pavel 4ad2a19a70 Реестр пакетов и ссылки на git.tnsu.kz — по https
Tests / frontend-test (pull_request) Successful in 4m25s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:02:04 +05:00
gorbenko.pavel 95b0f8f21e Реестр пакетов: организация переименована Tansu → tansu
Tests / frontend-test (pull_request) Successful in 4m25s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:35:15 +05:00
Тимур Городецкий e8b060add3 Merge branch 'main' of http://git.tnsu.kz/timur.gorodetskiy/request-portal
Tests / frontend-test (push) Successful in 4m25s
2026-10-29 08:52:53 +05:00
Тимур Городецкий ad1234b84a chore: скрипт диагностики маршрута заявки Inspect-Route.ps1
Только чтение: печатает строки маршрута одной заявки и историю версий строк
исполнения — чтобы разбираться, откуда взялся участник и кто перевёл его
в «На исполнении».

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 08:52:27 +05:00
Тимур Городецкий 353a1aa543 fix: комментарий к отпуску не выводится в печатное заявление
Комментарий адресован согласующим, а не кадрам, поэтому в печати просмотра
он скрыт (rp-no-print); обёртка с display: contents сохраняет поле ячейкой сетки.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 08:52:27 +05:00
Тимур Городецкий 7f641b36c6 feat: комментарий согласующего отдельной строкой во всю ширину маршрута
В узкой колонке «Согласующий» комментарий ломался по слову на строку, а
длинный текст растягивал таблицу. Теперь он идёт строкой под участником во
всю ширину (rowSpan «Шаг»/стрелок учитывает такие строки), полоса окрашена
по решению на шаге, длинный текст свёрнут до 3 строк с «Показать полностью»;
в печати выводится целиком.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 08:52:27 +05:00
Тимур Городецкий 9cacb6b271 feat: письмо инициатору при отклонении и возврате заявки
Поток RequestsNotification умеет ветки «Отклонено» и «Возврат на доработку»,
но портал их не вызывал: notifyActiveStep ищет только активные строки, а после
такого решения их нет — инициатор узнавал о решении, только открыв заявку.

notifyInitiatorOfReturn шлёт в тот же поток RowID строки, по которой решил
текущий пользователь; подключён в onDecided всех форм, кроме «Изменения
условий ТД» (доработаем позже).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 16:50:31 +05:00
Тимур Городецкий 72e330e659 feat: поля вида «Перевод на другую должность» в изменении условий ТД
Третий разобранный вид: новая должность из справочника с ручным вводом,
дата перевода, причина (карьерный рост / вакантная должность / новый
объект, перераспределение функций / иное) и обоснование. Должностная
инструкция по ТЗ прикладывается всегда — обязательный слот вложений.
Работника и работодателя не различаем: причины видны всем.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 16:48:09 +05:00
gorbenko.pavel 975681105b Обновлён @tnsu/ui-kit до 0.14.0, реестр @tnsu — организация Tansu
Tests / frontend-test (push) Successful in 4m46s
Все версии ui-kit скопированы в публичный реестр организации Tansu (контрольные суммы совпадают),
токен для установки больше не нужен. Сборка проекта проверена.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 16:47:31 +05:00
Тимур Городецкий ddf313c951 feat: вложения в изменении условий ТД — «Результаты аттестации»
Колонка ТЗ «Что приложить» становится подписанными слотами вложений по
виду; обязательны там, где ТЗ прямо требует документ. Первый слот —
«Результаты аттестации» при доплате за вредные условия. Обязательность
проверяется только при отправке, черновик сохраняется и без файла; файлы
скрытого слота не загружаются.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 15:41:15 +05:00
Тимур Городецкий c59b0ad208 refactor: подписанные вложения — общий модуль shared/attachments
Подпись вложения префиксом в имени файла и поле одного слота жили внутри
«Заявки на приём», а теперь нужны и другим формам. Перенесены в
shared/attachments без изменения поведения: приём реэкспортирует общее,
его форма и просмотр не тронуты. useAttachmentEditor принимает новую,
ещё не сохранённую заявку (itemId пустой) и явное «куда грузить» —
id появляется только после первого сохранения.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 15:41:15 +05:00
Тимур Городецкий 6aef9a5ba9 feat: поля вида «Надбавка / доплата» в изменении условий ТД
Второй разобранный вид: основание (а–д из шаблона ТЗ — надбавка это или
доплата, видно из пункта), срок с/по или «бессрочно», размер с
переключателем «% / ₸» и обоснование, куда пишется и объём доп. работы при
совмещении. Проценты принимают дробные значения (7,5 %), тенге — целые;
при переходе на тенге дробная часть отбрасывается.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 15:18:04 +05:00
Тимур Городецкий 4c018154d3 feat: выбор организации сотрудника в изменении условий ТД
Условия меняются в одном трудовом договоре, а у совместителя их несколько.
Как в командировке: пикер FullBase теперь выбирает человека (записи
склеены по ИИН), а под ним — список его мест работы «Компания — должность —
подразделение»; единственное место выбирается само. Маршрут строится по
человеку и не ждёт выбора организации. Строка под полем и вложенная сетка,
сжимавшая поля вида, убраны.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 14:44:16 +05:00
Тимур Городецкий 17f9dec39b feat: поля вида «Повышение должностного оклада» в изменении условий ТД
Первый разобранный с кадрами вид: текущий и новый оклад, дата, причина
(испытательный срок / расширение функционала / иное) и обоснование — оно
же текст причины «иное». Всё необязательно. Поля вида живут в своей
секции byType/, а колонки невыбранного вида при сохранении очищаются,
чтобы после смены вида в заявке не оставалось скрытых значений.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 14:43:35 +05:00
Тимур Городецкий 4eece061d1 feat: каркас заявки «Изменение условий трудового договора»
Новая кадровая форма (hr-contract-change, только тестировщикам): сотрудник
из кадровой базы снимком в текстовых колонках и вид изменения из 10 по
ст. 33 ТК РК. Вид хранится MultiChoice-массивом, хотя по ТЗ выбирается
один, — чтобы разрешить несколько без миграции. Маршрут — по сотруднику,
а без учётной записи — по заявителю. Поля по видам добавляются отдельно.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 12:10:43 +05:00
Тимур Городецкий 9f97a19cab feat: выбор сотрудника из полной кадровой базы (Employee/FullBase)
PeoplePicker находит только тех, у кого есть учётка M365, — рабочих на
объектах в нём нет. Пикер ищет по части Ф.И.О. в Employee/FullBase,
показывает только работающих, а совместителя — строкой на каждое место
работы, чтобы сразу выбрать нужную компанию.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 12:10:42 +05:00
Тимур Городецкий 82a9a68fbc style: заметный комментарий согласующего в маршруте
Tests / frontend-test (push) Successful in 1m31s
Мелкий серый текст под именем согласующего пользователи не замечали,
а там бывает причина возврата или отклонения. Теперь это плашка:
светлый фон, полоса слева, обычный размер шрифта. Действует во всех формах.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 10:05:03 +05:00
Тимур Городецкий ac633c767e feat: комментарий инициатора в заявке на отпуск
Необязательное поле «Комментарий» (VacationComment, Note) последним в
секции: инициатор может пояснить заявку для согласующих. В просмотре
пустой комментарий не показывается, в DOCX для ЭДО не идёт.
Колонка уже провизионирована.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 10:05:02 +05:00
81 changed files with 3308 additions and 372 deletions
+1 -1
View File
@@ -1 +1 @@
@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/
@tnsu:registry=https://git.tnsu.kz/api/packages/tansu/npm/
+2 -2
View File
@@ -269,7 +269,7 @@ Info "Подключаем @tnsu/ui-kit..."
# .npmrc: адрес registry для скоупа @tnsu (НЕ секрет, можно коммитить).
# Токен на чтение — у каждого разработчика свой, в ~/.npmrc (в проект не кладём).
$npmrc = @'
@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/
@tnsu:registry=https://git.tnsu.kz/api/packages/tansu/npm/
'@
$npmrc | Set-Content -Encoding UTF8 ".npmrc"
Success ".npmrc создан (адрес registry @tnsu)."
@@ -282,7 +282,7 @@ try {
} catch {
Warn "Не удалось поставить @tnsu/ui-kit. Скорее всего нет токена @tnsu в ~/.npmrc."
Warn "Создай токен (Gitea → Настройки → Приложения → package: Read) и пропиши в ~/.npmrc:"
Warn " //git.tnsu.kz/api/packages/timur.gorodetskiy/npm/:_authToken=ТВОЙ_ТОКЕН"
Warn " //git.tnsu.kz/api/packages/tansu/npm/:_authToken=ТВОЙ_ТОКЕН"
Warn "Потом выполни: npm install @tnsu/ui-kit"
}
+1
View File
@@ -51,6 +51,7 @@
| [`hr-skud-explanation`](../src/webparts/requestPortal/forms/SkudExplanation/README.md) | Объяснительная об отсутствии отметок в СКУД | Отдел кадров | да | нет | да | только «на себя»; дни выбираются кликом в календаре «Мой СКУД» (проходы Hikvision текущего пользователя), сохраняются снимком для согласующего; текст записки собирается автоматически, печать — бланк объяснительной | ⏳ код готов, списки/тип/маршрут не заведены |
| [`hr-business-trip`](../src/webparts/requestPortal/forms/BusinessTrip/README.md) | Заявка на командировку | Отдел кадров | да | да | да | можно за другого сотрудника; командирующая организация — выбор из организаций сотрудника; маршрут лентой (точки по Казахстану — город из справочника КАТО на русском и казахском + объект из справочника, за рубежом — страна/город/объект вручную; даты не задним числом, поездка ≤ 40 дней; участки с видом транспорта и временем выезда); суточные (≤ 6 МРП КЗ / ≤ 8 за рубежом) и проживание (ночи сами, ≤ 15 000 ₸ за ночь КЗ) — по точкам; комментарий после итога; задание — список задач; смета: статьи с позициями, привязанными к точкам/участкам (суммой или кол-во × цена — всё вручную), итог с долями и по объектам; обязательство по авансовому отчёту — флажок; печать без шапки адресата | ⏳ код готов, списки/тип/маршрут не заведены |
| `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
| [`hr-contract-change`](../src/webparts/requestPortal/forms/ContractChange/README.md) | Изменение условий трудового договора | Отдел кадров | да | — | да | сотрудник из `Employee/FullBase` (и без учётки M365), снимок в текстовых колонках; маршрут по сотруднику, без учётки — по заявителю; 10 видов изменения (один, данные под несколько); поля по видам — в работе | ⏳ каркас, списки/тип/маршрут не заведены |
| [`wh-service-request`](../src/webparts/requestPortal/forms/ServiceRequest/README.md) | Заявка на услуги | Склад / Снабжение | да | нет | да | маршрут по проекту (руководитель проекта/ПТО); табличные части «Приложения» и «Смета» (JSON-колонки); сумма по смете считается на лету (qty × цена), итог дублируется числовой колонкой для отчётов | ✅ |
Легенда: **Согл.** — есть фаза согласования; **Исп.** — есть фаза исполнения;
+4 -4
View File
@@ -17,7 +17,7 @@
"@microsoft/sp-webpart-base": "1.22.1",
"@pnp/sp": "^4.20.0",
"@pnp/spfx-controls-react": "^3.25.0",
"@tnsu/ui-kit": "^0.12.0",
"@tnsu/ui-kit": "^0.14.0",
"jszip": "~3.8.0",
"react": "17.0.1",
"react-dom": "17.0.1",
@@ -9886,9 +9886,9 @@
"license": "0BSD"
},
"node_modules/@tnsu/ui-kit": {
"version": "0.12.0",
"resolved": "http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/%40tnsu%2Fui-kit/-/0.12.0/ui-kit-0.12.0.tgz",
"integrity": "sha512-lJYf2DoYaydrktbSotRNY0umeJlQw8Ekhh6fu1FGB0p+/TXTozpBfAs+TxK/mvOXlS9yYenEuGBi85fd/dRF2A==",
"version": "0.14.0",
"resolved": "https://git.tnsu.kz/api/packages/tansu/npm/%40tnsu%2Fui-kit/-/0.14.0/ui-kit-0.14.0.tgz",
"integrity": "sha512-Vun9AUM9jL8rttbsbKo5Yh6TUz1srkNqxiO6mmzebrSo1JOASNJqNjJPMTS+8Jt3MTrJPklvh0TbeqGQzw7qxQ==",
"license": "ISC",
"peerDependencies": {
"@fluentui/react": "^8.106.4",
+1 -1
View File
@@ -21,7 +21,7 @@
"@microsoft/sp-webpart-base": "1.22.1",
"@pnp/sp": "^4.20.0",
"@pnp/spfx-controls-react": "^3.25.0",
"@tnsu/ui-kit": "^0.12.0",
"@tnsu/ui-kit": "^0.14.0",
"jszip": "~3.8.0",
"react": "17.0.1",
"react-dom": "17.0.1",
+68
View File
@@ -0,0 +1,68 @@
<#
.SYNOPSIS
Диагностика: печатает строки маршрута одной заявки из списка «— согласование»
(StepOrder, статус, фаза, кем переназначено, кто создал/менял) и историю версий
строк исполнения — чтобы понять, откуда в маршруте взялся участник и кто
перевёл его в «На исполнении». Только чтение, ничего не меняет.
.EXAMPLE
./Inspect-Route.ps1 -SiteUrl "https://tnsukz.sharepoint.com/sites/requests" `
-ClientId "7459c965-5ed4-4673-8140-0ab8faf9db64" -Tenant "tnsukz.onmicrosoft.com" `
-Thumbprint "224B64C8E0CAB1E09AF373E92B3AF4AC33C1233C" `
-ListTitle "Прием на работу 1 — согласование" -RequestId 68
#>
param(
[Parameter(Mandatory = $true)][string]$SiteUrl,
[Parameter(Mandatory = $true)][string]$ClientId,
[Parameter(Mandatory = $true)][string]$Tenant,
[Parameter(Mandatory = $true)][string]$Thumbprint,
[Parameter(Mandatory = $true)][string]$ListTitle,
[Parameter(Mandatory = $true)][int]$RequestId
)
$ErrorActionPreference = 'Stop'
Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Tenant $Tenant -Thumbprint $Thumbprint
$query = "<View><Query><Where><Eq><FieldRef Name='Ticket' LookupId='TRUE'/><Value Type='Lookup'>$RequestId</Value></Eq></Where>" +
"<OrderBy><FieldRef Name='StepOrder'/></OrderBy></Query></View>"
$items = Get-PnPListItem -List $ListTitle -Query $query
function UserText($value) {
if ($null -eq $value) { return '' }
return $value.Email
}
Write-Host "`nМаршрут заявки #$RequestId («$ListTitle»):`n" -ForegroundColor Cyan
$items | ForEach-Object {
[pscustomobject]@{
Id = $_.Id
StepOrder = $_['StepOrder']
Phase = $_['Phase']
Status = $_['ApprovalStatus']
Approver = UserText $_['TansuSiteApprover']
DelegatedBy = UserText $_['DelegatedBy']
Reassign = $_['AllowReassignToOtherApprovers']
Created = $_['Created']
CreatedBy = UserText $_['Author']
Modified = $_['Modified']
ModifiedBy = UserText $_['Editor']
}
} | Format-Table -AutoSize
# История версий — только строки исполнения: там видно, кто и когда менял статус.
$items | Where-Object { $_['Phase'] -eq 'Исполнение' } | ForEach-Object {
Write-Host "`nВерсии строки Id=$($_.Id) ($(UserText $_['TansuSiteApprover'])):" -ForegroundColor Yellow
Get-PnPProperty -ClientObject $_ -Property Versions | Out-Null
$_.Versions | ForEach-Object {
Get-PnPProperty -ClientObject $_ -Property CreatedBy | Out-Null
[pscustomobject]@{
Version = $_.VersionLabel
Created = $_.Created
By = $_.CreatedBy.Email
Status = $_['ApprovalStatus']
StepOrder = $_['StepOrder']
}
} | Format-Table -AutoSize
}
Disconnect-PnPOnline
@@ -42,6 +42,14 @@ export const REQUEST_TYPES: IRequestType[] = [
icon: 'people',
comingSoon: true
},
// Пока только тестировщикам: каркас без полей по видам; тип и маршрут не заведены в админке approval-route.
{
id: 'hr-contract-change',
categoryId: 'hr',
title: 'Изменение условий трудового договора',
icon: 'document',
comingSoon: true
},
// Склад / Снабжение — формы открыты для ознакомления, но приём заявок ещё не
// запущен (маршруты/списки не готовы): кнопки сохранения/отправки в самих
@@ -10,6 +10,7 @@ import {
StatusBadge,
ApprovalHistory,
notifyActiveStep,
notifyInitiatorOfReturn,
useRequestTypeId,
usePhasedRoute
} from '../../shared/approval';
@@ -108,14 +109,17 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
);
}
const notify = (): void => {
const notify = (newStatus?: string): void => {
// Решение уже применено; без requestTypeId просто некого уведомлять — UI не роняем.
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
const target = {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined);
};
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
};
const sectionProps = { data, set: NOOP_SET, context };
@@ -173,7 +177,7 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
notify();
notify(newStatus);
}}
onDelegated={() => {
setDecisionSeq((n) => n + 1);
@@ -11,6 +11,7 @@ import {
ApprovalHistory,
useMatrixEditor,
notifyActiveStep,
notifyInitiatorOfReturn,
useRequestTypeId,
APPROVAL_PHASE
} from '../../shared/approval';
@@ -255,16 +256,19 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
printOrgIndex
};
const notify = (): void => {
const notify = (newStatus?: string): void => {
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
// весь UI ради необязательного оповещения.
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
const target = {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined);
};
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
};
return (
@@ -348,7 +352,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
notify();
notify(newStatus);
}}
onDelegated={() => {
setDecisionSeq((n) => n + 1);
@@ -12,6 +12,7 @@ import {
ApprovalHistory,
useMatrixEditor,
notifyActiveStep,
notifyInitiatorOfReturn,
useRequestTypeId,
useViewExecutors,
approvalPhaseRows
@@ -118,16 +119,19 @@ const ConcreteRequestView: React.FC<IConcreteRequestViewProps> = ({ context, ite
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
const notify = (): void => {
const notify = (newStatus?: string): void => {
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
// весь UI ради необязательного оповещения.
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
const target = {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined);
};
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
};
return (
@@ -175,7 +179,7 @@ const ConcreteRequestView: React.FC<IConcreteRequestViewProps> = ({ context, ite
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
notify();
notify(newStatus);
}}
onDelegated={() => {
setDecisionSeq((n) => n + 1);
@@ -0,0 +1,21 @@
@import '../../../../styles/design-tokens';
// Ссылка «К списку заявок» — как в остальных формах (см. DismissalForm.module.scss).
.back {
display: inline-flex;
align-items: center;
gap: $sp-2;
margin-bottom: $sp-4;
padding: 0;
border: 0;
background: transparent;
color: $navy-700;
font-family: $font-sans;
font-size: $fs-sm;
cursor: pointer;
transition: color .18s;
&:hover {
color: $gold-600;
}
}
@@ -0,0 +1,321 @@
import * as React from 'react';
import { useState, useMemo, useCallback, useEffect } from 'react';
import styles from './ContractChangeForm.module.scss';
import {
Alert,
FormCard,
InitiatorLine,
FormModeProvider,
Icon,
RequestNumber,
SubmitActions,
getDraftDeleteHandler,
useRequestSubmit
} from '../../shared/ui';
import {
composeRemoveRowRule,
ApprovalMatrix,
ExecutorsMatrix,
ApprovalHistory,
StatusBadge,
useMatrixEditor,
getPlannedMatrix,
loadRoutePhase,
isOpenedRouteReady,
notifyActiveStep,
useRequestTypeId,
REQUEST_STATUS,
isEditableStatus,
IApproverToWrite,
unassignedStepMessage,
resolveWriteListId,
executorsToWrite,
initialSubmitStatus,
useFormExecutors
} from '../../shared/approval';
import { effectiveUserEmail } from '../../shared/impersonation';
import { IRequestFormProps } from '../../models/types';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField, REQUIRED_FIELDS_MESSAGE, routeEmail } from './model';
import { saveContractChangeRequest, contractChangeApprovalService } from './submit';
import { loadContractChangeRequest } from './load';
import { useContractEmployee } from './useContractEmployee';
import { RequestSection } from './sections/RequestSection';
import { ChangeTypeSections } from './byType/ChangeTypeSections';
import { missingRequiredAttachments, prepareFilesFor } from './byType/attachmentSlots';
import { useAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
import { getAttachments } from '../../shared/attachments/attachments.service';
import { useStableCallback } from '../../shared/useStableCallback';
const ContractChangeForm: React.FC<IRequestFormProps> = ({
requestType,
context,
onCancel,
onSaved,
editId,
editListId,
onCodeLoaded
}) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [requestId, setRequestId] = useState<number | undefined>(editId);
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
const submit = useRequestSubmit();
// Стабильная обёртка над колбэком родителя — иначе заявка перезапрашивалась бы
// при каждом рендере родителя (см. DismissalForm).
const notifyCode = useStableCallback(onCodeLoaded);
const { setError: setSubmitError } = submit;
const readOnly = !isEditableStatus(requestStatus);
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
const applicantEmail = effectiveUserEmail(context);
const approval = useMemo(() => contractChangeApprovalService(editListId), [editListId]);
const requestTypeIdState = useRequestTypeId(context, requestType.id);
const requestTypeId = requestTypeIdState.id;
// Вложения (подписанные слоты по ТЗ «Что приложить», см. byType/attachmentSlots).
const dataListRef = editListId || dataListTitle(FORM_ID);
const attachments = useAttachmentEditor(dataListRef, requestId);
const { setItems: setAttachmentItems } = attachments;
// Открыта СВОЯ заявка на редактирование (черновик/возврат) — подгружаем содержимое, статус и вложения.
useEffect(() => {
if (!editId) {
return;
}
let alive = true;
Promise.all([loadContractChangeRequest(editId, editListId), getAttachments(dataListRef, editId)])
.then(([loaded, files]) => {
if (!alive) return;
setData(loaded.data);
setAttachmentItems(files);
setRequestStatus(loaded.status);
setRequestCode(loaded.code || undefined);
if (loaded.code) notifyCode(loaded.code);
})
.catch((e) => {
if (alive) {
setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
}
});
return () => {
alive = false;
};
}, [editId, editListId, dataListRef, notifyCode, setSubmitError, setAttachmentItems]);
// Сотрудник + его организация (как в командировке); новая заявка — по умолчанию на себя.
const employeeControl = useContractEmployee(context, applicantEmail, !editId, data.Employee, (record) =>
set('Employee', record)
);
// Маршрут — по сотруднику, а без учётной записи — по заявителю (см. routeEmail).
// У заявки на редактировании маршрут читается из уже записанных строк.
const routeUserEmail = routeEmail(employeeControl.person, applicantEmail);
const loadMatrix = useCallback(
() =>
loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
getPlannedMatrix(context, requestTypeId as number, routeUserEmail, approval, 'Approval')
),
[context, approval, editId, requestStatus, routeUserEmail, requestTypeId]
);
const matrix = useMatrixEditor(
loadMatrix,
`${routeUserEmail}-${editId ?? 'new'}`,
!readOnly,
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!routeUserEmail && !!requestTypeId)),
(login) => approval.getJobTitle(login)
);
const executors = useFormExecutors({
readOnly,
context,
approval,
requestTypeId,
userEmail: routeUserEmail,
editId,
requestStatus
});
const isValid = !!data.Employee && data.ChangeTypes.length > 0;
const validate = (): string | undefined => {
if (!isValid) return REQUIRED_FIELDS_MESSAGE;
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
return undefined;
};
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
rows.map((r) => ({
email: r.email,
position: r.position,
allowReassign: r.allowReassign,
order: r.order,
slaHours: r.slaHours,
askOverdueReason: r.askOverdueReason,
autoApprove: r.autoApprove
}));
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
// validate() уже гарантировал requestTypeId, но TS этого через замыкание не видит.
const rtId = requestTypeId;
if (!rtId) {
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
}
// Новая заявка пишется в ТЕКУЩУЮ партицию — спрашиваем её прямо перед записью.
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
const { id, code } = await saveContractChangeRequest(
requestId,
rtId,
data,
toApprovers(matrix.rows),
executorsToWrite(executors.rows),
status,
writeListId
);
// id фиксируем ДО загрузки файлов: если она упадёт, повторное сохранение
// обновит эту же заявку, а не создаст вторую.
setRequestId(id);
setRequestStatus(status);
if (code) setRequestCode(code);
// Файлы — после записи элемента (нужен его id), с подписью-префиксом слота.
try {
await attachments.uploadPending(prepareFilesFor(data), {
listRef: writeListId || dataListTitle(FORM_ID),
itemId: id
});
} catch (e) {
throw new Error(`Заявка сохранена, но файлы не прикрепились: ${friendlyErrorMessage(e)}. Сохраните ещё раз.`);
}
if (onSaved) onSaved();
// Отправка — уведомляем первый активный шаг; списки той партиции, куда записали.
if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) {
await notifyActiveStep(context, approval, id, {
requestTypeId: rtId,
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID)
});
}
};
// На отправку маршрут должен быть укомплектован — шаг без человека молча пропал бы при записи.
const validateSubmit = (): string | undefined => {
const formError = validate();
if (formError) return formError;
// Обязательные документы — только на отправку: черновик можно сохранить и без файла.
const missingFiles = missingRequiredAttachments(data, attachments);
if (missingFiles.length > 0) return `Приложите обязательные документы: ${missingFiles.join(', ')}.`;
if (matrix.error) {
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
}
const unassignedError = unassignedStepMessage(
matrix.rows,
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
);
if (unassignedError) return unassignedError;
return unassignedStepMessage(
executors.rows,
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
);
};
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
const handleSubmit = (): Promise<void> =>
submit.run(
'submit',
save(
executors.rows.length > 0
? initialSubmitStatus(matrix.rows.length, executors.rows.length)
: REQUEST_STATUS.onApproval
),
validateSubmit
);
const handleDelete = getDraftDeleteHandler({
formKey: FORM_ID,
requestId,
requestStatus,
listId: editListId,
code: requestCode,
submit,
onDeleted: onCancel
});
return (
<div>
<button type="button" className={styles.back} onClick={onCancel}>
<Icon name="arrow-left" size="sm" />
<span>К списку заявок</span>
</button>
<FormCard
title={requestType.title}
subtitle="Категория: Отдел кадров"
belowSubtitle={
<InitiatorLine
listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
itemId={requestId}
/>
}
headerRight={
requestId || requestStatus ? (
<>
{requestStatus && <StatusBadge status={requestStatus} />}
{requestCode && <RequestNumber code={requestCode} />}
</>
) : undefined
}
>
{submit.savedKind && !submit.error && (
<Alert variant="ok">
{submit.savedKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'}
</Alert>
)}
{submit.error && <Alert variant="err">{submit.error}</Alert>}
<FormModeProvider readOnly={readOnly}>
<RequestSection data={data} set={set} context={context} employeeControl={employeeControl} />
<ChangeTypeSections data={data} set={set} context={context} attachments={attachments} />
</FormModeProvider>
<ApprovalMatrix
editor={matrix}
search={(q) => approval.searchUsers(q)}
canRemoveRow={composeRemoveRowRule(applicantEmail)}
/>
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
{/* Автору на доработке важно видеть, почему вернули, — история и в форме. */}
{editId && (
<div className="rp-no-print">
<ApprovalHistory
approval={approval}
dataListRef={editListId || dataListTitle(FORM_ID)}
requestId={editId}
reloadKey={requestStatus}
/>
</div>
)}
<SubmitActions
onDelete={handleDelete}
submit={submit}
isValid={isValid}
readOnly={readOnly}
onCancel={onCancel}
onSaveDraft={handleSaveDraft}
onSubmit={handleSubmit}
submitLabel="Отправить на согласование"
/>
</FormCard>
</div>
);
};
export default ContractChangeForm;
@@ -0,0 +1,9 @@
@import '../../../../styles/design-tokens';
// Заглушка на время загрузки/ошибки — как в остальных просмотрах.
.state {
padding: $sp-8;
color: $gray-500;
font-size: $fs-sm;
text-align: center;
}
@@ -0,0 +1,171 @@
import * as React from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './ContractChangeView.module.scss';
import { Alert, FormCard, InitiatorLine, FormModeProvider } from '../../shared/ui';
import {
ApproverService,
DecisionPanel,
StatusBadge,
ApprovalMatrix,
ApprovalHistory,
useMatrixEditor,
useViewExecutors,
ExecutorsMatrix,
approvalPhaseRows,
notifyActiveStep,
useRequestTypeId
} from '../../shared/approval';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { loadContractChangeRequest } from './load';
import { contractChangeApprovalService } from './submit';
import { RequestSection } from './sections/RequestSection';
import { ChangeTypeSections } from './byType/ChangeTypeSections';
import { useAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
import { getAttachments } from '../../shared/attachments/attachments.service';
import { useStableCallback } from '../../shared/useStableCallback';
interface IContractChangeViewProps {
context: WebPartContext;
itemId: number;
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
formKey: string;
/** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */
listId?: string;
/** Уведомление о загруженном уникальном коде заявки — см. IApprovalViewProps.onCodeLoaded. */
onCodeLoaded?: (code: string) => void;
}
const VIEW_TITLE = 'Изменение условий трудового договора';
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
const NOOP_SET: SetField = () => undefined;
/**
* Заявка только для просмотра (режим согласующего): та же секция, что в форме
* подачи, под <FormModeProvider readOnly>, + маршрут, история и панель решения.
* Печати пока нет — бланк кадров ещё не согласован.
*/
const ContractChangeView: React.FC<IContractChangeViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
const notifyCode = useStableCallback(onCodeLoaded);
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>('');
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string>('');
const approval = useMemo(() => contractChangeApprovalService(listId), [listId]);
const { id: requestTypeId } = useRequestTypeId(context, formKey);
// listId (url) для архивной партиции, Title текущей — см. ApproverService.dataListRef.
const dataListRef = listId || dataListTitle(FORM_ID);
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
// Вложения — только для показа (секции рисуют их под FormModeProvider readOnly).
const attachments = useAttachmentEditor(dataListRef, itemId);
const { setItems: setAttachmentItems } = attachments;
// decisionSeq в reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
const [decisionSeq, setDecisionSeq] = useState<number>(0);
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]);
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
approval.getJobTitle(l)
);
const executors = useViewExecutors(context, approval, itemId, decisionSeq);
useEffect(() => {
let alive = true;
setLoading(true);
Promise.all([loadContractChangeRequest(itemId, listId), getAttachments(dataListRef, itemId)])
.then(([loaded, files]) => {
if (!alive) return;
setData(loaded.data);
setAttachmentItems(files);
setStatus(loaded.status);
setCode(loaded.code);
notifyCode(loaded.code);
setError('');
setLoading(false);
})
.catch((e) => {
if (!alive) return;
setError(`Не удалось загрузить заявку №${itemId}: ${friendlyErrorMessage(e)}`);
setLoading(false);
});
return () => {
alive = false;
};
}, [itemId, listId, dataListRef, notifyCode, setAttachmentItems]);
if (loading) {
return <div className={styles.state}>Загрузка заявки…</div>;
}
if (error) {
return (
<div className={styles.state}>
<Alert variant="err">{error}</Alert>
</div>
);
}
const notify = (): void => {
// Тип ещё не резолвился — решение уже применилось, просто некого уведомлять;
// не роняем UI ради необязательного оповещения.
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined);
};
const handleRouteChanged = (): void => {
setDecisionSeq((n) => n + 1);
notify();
};
return (
<div>
<FormCard
title={VIEW_TITLE}
subtitle={`№${code || itemId}`}
belowSubtitle={
<InitiatorLine
listTitle={listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID)}
itemId={itemId}
/>
}
headerRight={status ? <StatusBadge status={status} /> : undefined}
>
<FormModeProvider readOnly>
<RequestSection data={data} set={NOOP_SET} context={context} />
<ChangeTypeSections data={data} set={NOOP_SET} context={context} attachments={attachments} />
</FormModeProvider>
</FormCard>
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
{/* История: маршрут пересохраняется патчем (см. writeRoute), прошлые решения — в версиях строк. */}
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
<DecisionPanel
service={approverService}
requestId={itemId}
search={(q) => approval.searchUsers(q)}
onDecided={(newStatus) => {
setStatus(newStatus);
handleRouteChanged();
}}
onDelegated={handleRouteChanged}
/>
</div>
);
};
export default ContractChangeView;
@@ -0,0 +1,89 @@
# Изменение условий трудового договора (hr-contract-change)
**Назначение:** заявка на изменение условий трудового договора сотрудника (ст. 33 ТК РК) —
повышение оклада, надбавка, перевод, смена режима работы и т. д. (10 видов из ТЗ кадров).
**Категория:** Отдел кадров
**Списки SP:** «Изменение условий трудового договора 1 — данные» / «… 1 — согласование»
**RequestTypeId (внешний API маршрутов):** не хардкодится — резолвится в рантайме по
`pageUrl` («hr-contract-change») через `useRequestTypeId` (см. «Формы больше не хардкодят
RequestTypeId» в `docs/forms.md`). Тип и маршрут в админке approval-route ещё не заведены.
> **Статус: каркас.** Есть сотрудник и вид изменения; поля под конкретные виды
> добавляются по мере разбора ТЗ кадров. Таблица ТЗ (пример, кто инициирует, что
> приложить, сроки, маршрут А/Б/В/Г…) — общая, в форму из неё попадает только то,
> что явно согласовано. Печатного бланка пока нет.
## Маршрут
- Фазы: согласование (+ исполнение, если его заведут в админке — форма поддерживает).
- Маршрут строится по **e-mail сотрудника**, а если его нет (рабочий без учётной
записи M365) — по **e-mail заявителя** (`routeEmail` в `model.ts`); на форме под
сотрудником тогда пометка об этом.
- Матрица редактируема заявителем при новой заявке / черновике / возврате.
- От вида изменения маршрут пока не зависит (`fieldValues` не передаются).
## Особое поведение
- **Сотрудник — из `Employee/FullBase`, а не People-picker** (`EmployeeFullBasePicker`,
`shared/dictionary`): так находятся и рабочие без учётной записи. Ищется по части
Ф.И.О. (от 3 букв), только работающие (`dismissalDate` пустая). Записи FullBase
(одна на место работы) собираются в человека по ИИН — в поиске он одной строкой.
- **Организация — отдельный список под сотрудником**, как «Командирующая организация»
в командировке: места работы человека в виде «Компания — должность — подразделение».
Одно место — выбрано само и заблокировано. Условия меняются в ОДНОМ договоре, поэтому
в заявку пишется только выбранное место работы. Логика — `useContractEmployee`.
⚠️ На 2026-10-28 FullBase отдаёт по человеку одну запись; бэкенд доработают, чтобы
возвращались все места работы — тогда у совместителей появится выбор.
- **Хранение сотрудника — снимок в текстовых колонках `Employee*`** (Ф.И.О., ИИН,
табельный, e-mail, организация, должность, подразделение, id записи), без колонки
People. Справочник со временем меняется, а заявка фиксирует данные на момент подачи.
- **По умолчанию — на себя** (`useContractEmployee`): Ф.И.О. из `Employee/ByEmail` →
поиск в `FullBase` → человек, у которого есть место работы с e-mail текущего
пользователя. Совместителю организацию не выбираем молча — выбирает сам.
- **Черновик/возврат:** список организаций подтягивается заново (поиск по Ф.И.О.,
тот же ИИН); не нашлось — в списке только сохранённая.
- **Маршрут строится по человеку** (его e-mail), а не по месту работы — поэтому
грузится сразу после выбора сотрудника, ещё до выбора организации.
- **Вид изменения** — `ChangeTypes`, MultiChoice в SP, массив в модели. По ТЗ выбор
один (`MAX_CHANGE_TYPES`, обычный `Select`); данные уже рассчитаны на несколько,
чтобы снять ограничение правкой UI, без миграции. В коде вид — ключ
(`ChangeType`), в SP — подпись (`CHANGE_TYPE_LABELS`): подпись = choice колонки,
менять только вместе с `provisioning.json`.
- **Заголовок элемента** (`Title`): `Изменение условий ТД — {ФИО} — дд.мм.гггг`.
## Поля по видам
Каждый вид — своя секция в `byType/<Вид>/`, подключается строкой в
`byType/ChangeTypeSections.tsx`; ключи модели и колонки SP — с префиксом вида.
Колонки невыбранного вида при сохранении очищаются (`null`), чтобы в заявке не
оставалось скрытых значений после смены вида.
| Вид | Секция | Поля (все необязательные, без ограничений) |
| --- | --- | --- |
| Повышение должностного оклада | `SalaryIncrease` | текущий оклад, новый оклад (`MoneyInput`, Number), дата изменения, причина (испытательный срок / расширение функционала / иное), обоснование — при «иное» это и есть текст причины |
| Установление надбавки / доплаты | `Allowance` | основание (а–д: надбавка или доплата видно из пункта), дата начала, дата окончания или «бессрочно» (флажок прячет и очищает дату), размер + переключатель «% / ₸» (`UnitSwitch`, по умолчанию %), обоснование — в т.ч. объём доп. работы при совмещении и текст «иное». Письменное согласие работника на совмещение оформляет кадровик после согласования — не в форме. Вложение **«Результаты аттестации»** — только при основании «г», обязательно |
| Перевод на другую должность (постоянный) | `PositionTransfer` | новая должность (`PositionPicker`: справочник `Dictionary/Positions` + ручной ввод), дата перевода, причина (карьерный рост / вакантная должность / новый объект, перераспределение функций / иное), обоснование. Работника и работодателя не различаем — причины видны всем, работник пишет текст заявления в обоснование. Вложение **«Должностная инструкция»** — всегда, обязательно |
| Остальные 7 | — | ещё не разобраны с кадрами |
**Вложения по колонке ТЗ «Что приложить»** (решение 2026-10-28): фиксированные
подписанные слоты по виду (подпись — что за документ); обязательны там, где ТЗ
прямо требует документ (вид 2 «г» — аттестация, вид 3 — ДИ, вид 8 — новая ДИ;
вид 9 — текст ИЛИ файл), «можно приложить» — необязательно (вид 7).
Как устроено: вложения элемента списка данных, подпись — префикс в имени файла
(`shared/attachments/attachmentCategories`, тот же механизм, что в «Приёме»), поле
слота — `AttachmentSlotField`. Какие слоты видны и обязательны — одна функция
`byType/attachmentSlots.ts` (`activeAttachmentSlots`); новый слот = строка там +
`AttachmentSlotField` в секции вида. Файлы грузятся после записи элемента; файлы
скрытого слота (сменили вид/основание) не грузятся. Обязательность проверяется
только при отправке — черновик сохраняется и без файла.
## Открытые вопросы (проверить на тесте)
- Выдаёт ли бэкенд (вебхуки) права на элемент по People-колонке `Employee`? Если да —
сотрудник, за которого подали заявку, её не увидит (такой колонки здесь нет).
- `Employee/FullBase`: ищет ли `Fio` по подстроке; что фильтрует `Status`.
## Секции формы
RequestSection (сотрудник · организация · вид изменения) · ChangeTypeSections (секция выбранного вида, см. «Поля по видам»)
## Точки входа
- rptype: `hr-contract-change`
- Режим согласующего (просмотр): есть (`ContractChangeView`), без печати
- «Создать на основе»: нет
@@ -0,0 +1,17 @@
{
"dataListTitle": "Изменение условий трудового договора — согласование",
"columns": [
{ "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
{ "internalName": "ApprovalStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] },
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Изменение условий трудового договора — данные", "lookupShowField": "Title" },
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] },
{ "internalName": "SLAhours", "title": "SLA, часов", "type": "Number" },
{ "internalName": "AskOverdueReason", "title": "Опрос причины просрочки", "type": "Boolean" },
{ "internalName": "AutoApprove", "title": "Автосогласование", "type": "Boolean" },
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
]
}
@@ -0,0 +1,13 @@
@import '../../../../../../styles/design-tokens';
// Сумма + переключатель «% / ₸» в одну строку: поле тянется, переключатель — по содержимому.
.amount {
display: flex;
align-items: stretch;
gap: $sp-2;
}
.amountInput {
flex: 1;
min-width: 0;
}
@@ -0,0 +1,104 @@
import * as React from 'react';
import { CollapseSection, FormField, MoneyInput, TextInput, TextArea, Select, Checkbox } from '../../../../shared/ui';
import { useFormReadOnly } from '@tnsu/ui-kit';
import { AttachmentSlotField } from '../../../../shared/attachments/AttachmentSlotField';
import { CHANGE_TYPE_LABELS } from '../../changeTypes';
import { ISectionProps } from '../../model';
import { ALLOWANCE_REASONS, ATTESTATION_ATTACHMENT, requiresAttestation } from './options';
import { UnitSwitch } from './UnitSwitch';
import { sanitizePercentInput, amountForUnit } from './amount';
import styles from './AllowanceSection.module.scss';
/**
* Поля вида «Установление надбавки / доплаты». Состав согласован с Тимуром по ТЗ
* кадров: основание (а–д), срок с/по или бессрочно, размер в % или ₸, обоснование
* (в т.ч. объём доп. работы при совмещении). Поля необязательные; вложение
* «Результаты аттестации» — только при основании «г» и там обязательно (по ТЗ).
*
* Поля — прямо в CollapseSection, без FormGrid (тело секции само сетка).
*/
export const AllowanceSection: React.FC<ISectionProps> = ({ data, set, attachments }) => {
const readOnly = useFormReadOnly();
return (
<CollapseSection title={CHANGE_TYPE_LABELS.allowance} icon="calculator">
<FormField label="Основание" htmlFor="AllowanceReason" full>
<Select
id="AllowanceReason"
value={data.AllowanceReason}
onChange={(v) => set('AllowanceReason', v)}
options={ALLOWANCE_REASONS}
placeholder="— выберите —"
/>
</FormField>
<FormField label="Дата начала" htmlFor="AllowanceStartDate">
<TextInput
id="AllowanceStartDate"
type="date"
value={data.AllowanceStartDate}
onChange={(v) => set('AllowanceStartDate', v)}
/>
</FormField>
<FormField label="Дата окончания" htmlFor="AllowanceEndDate">
{/* «Бессрочно» прячет дату окончания — и очищает её, чтобы не сохранить скрытое значение. */}
{!data.AllowanceIndefinite && (
<TextInput
id="AllowanceEndDate"
type="date"
value={data.AllowanceEndDate}
onChange={(v) => set('AllowanceEndDate', v)}
/>
)}
<Checkbox
checked={data.AllowanceIndefinite}
onChange={(checked) => {
set('AllowanceIndefinite', checked);
if (checked) set('AllowanceEndDate', '');
}}
label="Бессрочно"
/>
</FormField>
<FormField label="Размер" htmlFor="AllowanceAmount">
<div className={styles.amount}>
<div className={styles.amountInput}>
{/* Проценты бывают дробными (7,5 %), тенге — целые с разделителем тысяч. */}
{data.AllowanceUnit === '%' ? (
<TextInput
id="AllowanceAmount"
value={data.AllowanceAmount}
onChange={(v) => set('AllowanceAmount', sanitizePercentInput(v))}
placeholder="например, 7.5"
/>
) : (
<MoneyInput id="AllowanceAmount" value={data.AllowanceAmount} onChange={(v) => set('AllowanceAmount', v)} />
)}
</div>
<UnitSwitch
value={data.AllowanceUnit}
onChange={(unit) => {
set('AllowanceUnit', unit);
set('AllowanceAmount', amountForUnit(data.AllowanceAmount, unit));
}}
/>
</div>
</FormField>
<FormField label="Обоснование" htmlFor="AllowanceJustification" full>
<TextArea
id="AllowanceJustification"
value={data.AllowanceJustification}
onChange={(v) => set('AllowanceJustification', v)}
/>
</FormField>
{attachments && requiresAttestation(data.AllowanceReason) && (
<AttachmentSlotField
label={ATTESTATION_ATTACHMENT}
editor={attachments}
editable={!readOnly}
groupKey={ATTESTATION_ATTACHMENT}
category={ATTESTATION_ATTACHMENT}
required
/>
)}
</CollapseSection>
);
};
@@ -0,0 +1,40 @@
@import '../../../../../../styles/design-tokens';
// Две кнопки впритык, как сегментный контрол; высота — как у полей ввода рядом.
.switch {
display: inline-flex;
flex-shrink: 0;
border: 1px solid $gray-300;
border-radius: $r-sm;
overflow: hidden;
}
.option {
min-width: 44px;
padding: 0 $sp-3;
border: 0;
background: $white;
font-family: $font-sans;
font-size: 13.5px;
color: $gray-700;
cursor: pointer;
transition: .15s;
& + & {
border-left: 1px solid $gray-300;
}
&:hover:not(:disabled) {
background: $gray-100;
}
&:disabled {
cursor: default;
}
}
.active,
.active:hover:not(:disabled) {
background: $navy-700;
color: $white;
}
@@ -0,0 +1,36 @@
import * as React from 'react';
import { useFormReadOnly } from '@tnsu/ui-kit';
import { AllowanceUnit, ALLOWANCE_UNITS } from './options';
import styles from './UnitSwitch.module.scss';
interface Props {
value: AllowanceUnit;
onChange: (unit: AllowanceUnit) => void;
}
/**
* Переключатель единицы размера «% / ₸» рядом с полем суммы. Сегментного
* контрола в ui-kit нет; если понадобится ещё где-то — перенести туда.
* В режиме чтения кнопки неактивны, выбранная остаётся подсвеченной.
*/
export const UnitSwitch: React.FC<Props> = ({ value, onChange }) => {
const readOnly = useFormReadOnly();
return (
<div className={styles.switch} role="radiogroup" aria-label="Единица размера">
{ALLOWANCE_UNITS.map((unit) => (
<button
key={unit}
type="button"
role="radio"
aria-checked={unit === value}
className={unit === value ? `${styles.option} ${styles.active}` : styles.option}
disabled={readOnly}
onClick={() => onChange(unit)}
>
{unit}
</button>
))}
</div>
);
};
@@ -0,0 +1,33 @@
import { sanitizePercentInput, amountForUnit } from './amount';
describe('sanitizePercentInput', () => {
it('оставляет целое как есть', () => {
expect(sanitizePercentInput('10')).toBe('10');
});
it('запятую превращает в точку', () => {
expect(sanitizePercentInput('7,5')).toBe('7.5');
});
it('выбрасывает буквы, пробелы и знак процента', () => {
expect(sanitizePercentInput(' 1a2 %')).toBe('12');
});
it('оставляет только первый разделитель и не больше двух знаков дроби', () => {
expect(sanitizePercentInput('1.2.345')).toBe('1.23');
});
it('разрешает разделитель в конце — человек ещё вводит дробь', () => {
expect(sanitizePercentInput('7,')).toBe('7.');
});
});
describe('amountForUnit', () => {
it('при переходе в тенге отбрасывает дробную часть', () => {
expect(amountForUnit('7.5', '₸')).toBe('7');
});
it('в процентах значение не меняет', () => {
expect(amountForUnit('7.5', '%')).toBe('7.5');
});
});
@@ -0,0 +1,25 @@
import { AllowanceUnit } from './options';
/** Знаков после разделителя у процента (7,25 %). */
const PERCENT_FRACTION_DIGITS = 2;
/**
* Ввод процента → «сырое» число строкой с точкой: «7,5» → «7.5». Пропускаем
* только цифры и один разделитель (запятая или точка), дробь — до 2 знаков.
* Не type=number: у TextInput нет step, и браузер считал бы 7.5 невалидным при шаге 1.
*/
export function sanitizePercentInput(text: string): string {
const normalized = text.replace(/,/g, '.').replace(/[^\d.]/g, '');
const [whole, ...rest] = normalized.split('.');
if (rest.length === 0) return whole;
return `${whole}.${rest.join('').slice(0, PERCENT_FRACTION_DIGITS)}`;
}
/**
* Значение суммы после смены единицы: в тенге сумма целая (MoneyInput),
* поэтому дробную часть процента отбрасываем, а не показываем «75» из «7.5».
*/
export function amountForUnit(amount: string, unit: AllowanceUnit): string {
if (unit === '₸') return amount.split('.')[0];
return amount;
}
@@ -0,0 +1,41 @@
import { ISelectOption } from '../../../../shared/ui';
/**
* Основания надбавки/доплаты — по шаблону текста ТЗ кадров (а–д). Что это —
* надбавка или доплата — видно из самого пункта, отдельного поля нет. Значения
* совпадают с choices колонки AllowanceReason (provisioning.json).
*/
export const ALLOWANCE_REASON_MENTORING = 'Надбавка: наставничество и обучение новых работников';
export const ALLOWANCE_REASON_TRAVEL = 'Надбавка: разъездной характер работы';
export const ALLOWANCE_REASON_COMBINING = 'Доплата: совмещение должностей / расширение зоны обслуживания';
export const ALLOWANCE_REASON_HAZARDOUS = 'Доплата: работа в тяжёлых, вредных и (или) опасных условиях труда';
export const ALLOWANCE_REASON_OTHER = 'Доплата: иное по решению первого руководителя';
export const ALLOWANCE_REASONS: ISelectOption[] = [
ALLOWANCE_REASON_MENTORING,
ALLOWANCE_REASON_TRAVEL,
ALLOWANCE_REASON_COMBINING,
ALLOWANCE_REASON_HAZARDOUS,
ALLOWANCE_REASON_OTHER
].map((reason) => ({ value: reason, label: reason }));
/**
* Вложение «Результаты аттестации» — по ТЗ прикладывается документ при доплате
* за вредные условия (пункт «г»); там же оно обязательно.
*/
export const ATTESTATION_ATTACHMENT = 'Результаты аттестации';
export function requiresAttestation(reason: string): boolean {
return reason === ALLOWANCE_REASON_HAZARDOUS;
}
/** Единица размера; значения = choices колонки AllowanceUnit. */
export type AllowanceUnit = '%' | '₸';
export const ALLOWANCE_UNITS: AllowanceUnit[] = ['%', '₸'];
/** По умолчанию — проценты: пример в ТЗ «Надбавка 10%». */
export const DEFAULT_ALLOWANCE_UNIT: AllowanceUnit = '%';
/** Строка из SP → единица; неизвестное/пустое — единица по умолчанию. */
export function allowanceUnitFromText(text: string): AllowanceUnit {
return ALLOWANCE_UNITS.find((unit) => unit === text) ?? DEFAULT_ALLOWANCE_UNIT;
}
@@ -0,0 +1,27 @@
import * as React from 'react';
import { ChangeType } from '../changeTypes';
import { ISectionProps } from '../model';
import { SalaryIncreaseSection } from './SalaryIncrease/SalaryIncreaseSection';
import { AllowanceSection } from './Allowance/AllowanceSection';
import { PositionTransferSection } from './PositionTransfer/PositionTransferSection';
/**
* Вид изменения → секция с его полями. Partial: поля видов добавляются по мере
* согласования с кадрами; у вида без секции под выбором просто ничего не появится.
* Новый вид = папка byType/<Вид>/ + строка здесь.
*/
const SECTION_BY_TYPE: Partial<Record<ChangeType, React.FC<ISectionProps>>> = {
'salary-increase': SalaryIncreaseSection,
allowance: AllowanceSection,
'position-transfer': PositionTransferSection
};
/** Секции всех выбранных видов, в порядке выбора (сейчас вид один — см. MAX_CHANGE_TYPES). */
export const ChangeTypeSections: React.FC<ISectionProps> = (props) => (
<>
{props.data.ChangeTypes.map((type) => {
const Section = SECTION_BY_TYPE[type];
return Section ? <Section key={type} {...props} /> : undefined;
})}
</>
);
@@ -0,0 +1,68 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput, TextArea, Select } from '../../../../shared/ui';
import { useFormReadOnly } from '@tnsu/ui-kit';
import { PositionPicker } from '../../../../shared/dictionary';
import { AttachmentSlotField } from '../../../../shared/attachments/AttachmentSlotField';
import { CHANGE_TYPE_LABELS } from '../../changeTypes';
import { ISectionProps } from '../../model';
import { POSITION_TRANSFER_REASONS, JOB_DESCRIPTION_ATTACHMENT } from './options';
/**
* Поля вида «Перевод на другую должность (постоянный)». Состав согласован с
* Тимуром по ТЗ кадров: новая должность (справочник с ручным вводом), дата,
* причина (а–г), обоснование и обязательная должностная инструкция. Текущая
* должность уже видна в «Организации» — отдельного «было» нет.
*
* Поля — прямо в CollapseSection, без FormGrid (тело секции само сетка).
*/
export const PositionTransferSection: React.FC<ISectionProps> = ({ data, set, context, attachments }) => {
const readOnly = useFormReadOnly();
return (
<CollapseSection title={CHANGE_TYPE_LABELS['position-transfer']} icon="people">
<FormField label="Новая должность" htmlFor="PositionTransferNewPosition">
<PositionPicker
context={context}
id="PositionTransferNewPosition"
value={data.PositionTransferNewPosition}
onChange={(v) => set('PositionTransferNewPosition', v)}
placeholder="Начните вводить название должности…"
/>
</FormField>
<FormField label="Дата перевода" htmlFor="PositionTransferDate">
<TextInput
id="PositionTransferDate"
type="date"
value={data.PositionTransferDate}
onChange={(v) => set('PositionTransferDate', v)}
/>
</FormField>
<FormField label="Причина" htmlFor="PositionTransferReason" full>
<Select
id="PositionTransferReason"
value={data.PositionTransferReason}
onChange={(v) => set('PositionTransferReason', v)}
options={POSITION_TRANSFER_REASONS}
placeholder="— выберите —"
/>
</FormField>
<FormField label="Обоснование" htmlFor="PositionTransferJustification" full>
<TextArea
id="PositionTransferJustification"
value={data.PositionTransferJustification}
onChange={(v) => set('PositionTransferJustification', v)}
/>
</FormField>
{attachments && (
<AttachmentSlotField
label={JOB_DESCRIPTION_ATTACHMENT}
editor={attachments}
editable={!readOnly}
groupKey={JOB_DESCRIPTION_ATTACHMENT}
category={JOB_DESCRIPTION_ATTACHMENT}
required
/>
)}
</CollapseSection>
);
};
@@ -0,0 +1,17 @@
import { ISelectOption } from '../../../../shared/ui';
/**
* Причины перевода на другую должность — по шаблону текста ТЗ кадров (а–г).
* Работника и работодателя не различаем (решение 2026-10-28): причины видны
* всем, работник при необходимости выбирает «Иное» и пишет текст в обоснование.
* Значения совпадают с choices колонки PositionTransferReason (provisioning.json).
*/
export const POSITION_TRANSFER_REASONS: ISelectOption[] = [
'Карьерный рост (успешное выполнение обязанностей, высокие результаты)',
'Вакантная должность, соответствующая квалификации работника',
'Открытие нового объекта / проекта, перераспределение функций',
'Иное'
].map((reason) => ({ value: reason, label: reason }));
/** Вложение «Должностная инструкция» — по ТЗ ПРИКЛАДЫВАЕТСЯ всегда, обязательно. */
export const JOB_DESCRIPTION_ATTACHMENT = 'Должностная инструкция';
@@ -0,0 +1,56 @@
import * as React from 'react';
import { CollapseSection, FormField, MoneyInput, TextInput, TextArea, Select } from '../../../../shared/ui';
import { CHANGE_TYPE_LABELS } from '../../changeTypes';
import { ISectionProps } from '../../model';
import { SALARY_INCREASE_REASONS } from './reasons';
/**
* Поля вида «Повышение должностного оклада». Состав согласован с Тимуром по ТЗ
* кадров: было/стало, дата, причина (а/б/в) и обоснование. Все необязательные,
* без ограничений — по решению от 2026-10-28.
*
* Поля — прямо в CollapseSection, без FormGrid: тело секции само является
* двухколоночной сеткой, вложенная FormGrid сжала бы всё в одну её ячейку.
*/
export const SalaryIncreaseSection: React.FC<ISectionProps> = ({ data, set }) => (
<CollapseSection title={CHANGE_TYPE_LABELS['salary-increase']} icon="calculator">
<FormField label="Текущий оклад, ₸" htmlFor="SalaryIncreaseCurrent">
<MoneyInput
id="SalaryIncreaseCurrent"
value={data.SalaryIncreaseCurrent}
onChange={(v) => set('SalaryIncreaseCurrent', v)}
/>
</FormField>
<FormField label="Новый оклад, ₸" htmlFor="SalaryIncreaseNew">
<MoneyInput
id="SalaryIncreaseNew"
value={data.SalaryIncreaseNew}
onChange={(v) => set('SalaryIncreaseNew', v)}
/>
</FormField>
<FormField label="Дата изменения" htmlFor="SalaryIncreaseDate">
<TextInput
id="SalaryIncreaseDate"
type="date"
value={data.SalaryIncreaseDate}
onChange={(v) => set('SalaryIncreaseDate', v)}
/>
</FormField>
<FormField label="Причина" htmlFor="SalaryIncreaseReason">
<Select
id="SalaryIncreaseReason"
value={data.SalaryIncreaseReason}
onChange={(v) => set('SalaryIncreaseReason', v)}
options={SALARY_INCREASE_REASONS}
placeholder="— выберите —"
/>
</FormField>
<FormField label="Обоснование" htmlFor="SalaryIncreaseJustification" full>
<TextArea
id="SalaryIncreaseJustification"
value={data.SalaryIncreaseJustification}
onChange={(v) => set('SalaryIncreaseJustification', v)}
/>
</FormField>
</CollapseSection>
);
@@ -0,0 +1,16 @@
import { ISelectOption } from '../../../../shared/ui';
/**
* Причины повышения оклада — по шаблону текста ТЗ кадров (а/б/в). Значения
* совпадают с choices колонки SalaryIncreaseReason (provisioning.json).
* «Иное» отдельного текста не имеет — его раскрывает поле «Обоснование».
*/
export const SALARY_REASON_PROBATION = 'Успешное прохождение испытательного срока';
export const SALARY_REASON_RESPONSIBILITY = 'Расширение функционала / зоны ответственности';
export const SALARY_REASON_OTHER = 'Иное';
export const SALARY_INCREASE_REASONS: ISelectOption[] = [
{ value: SALARY_REASON_PROBATION, label: SALARY_REASON_PROBATION },
{ value: SALARY_REASON_RESPONSIBILITY, label: SALARY_REASON_RESPONSIBILITY },
{ value: SALARY_REASON_OTHER, label: SALARY_REASON_OTHER }
];
@@ -0,0 +1,48 @@
import { PrepareFiles, IAttachmentEditor } from '../../../shared/attachments/useAttachmentEditor';
import { attachmentsOfCategory, prefixFiles } from '../../../shared/attachments/attachmentCategories';
import { IFormData } from '../model';
import { ATTESTATION_ATTACHMENT, requiresAttestation } from './Allowance/options';
import { JOB_DESCRIPTION_ATTACHMENT } from './PositionTransfer/options';
/**
* Подписанный слот вложений по колонке ТЗ «Что приложить». Подпись (category)
* одновременно и префикс в имени файла, и ключ группы в editor.pending.
*/
export interface IAttachmentSlot {
category: string;
/** Обязателен там, где ТЗ прямо требует документ. */
required: boolean;
}
/** Слоты, видимые при текущих данных формы. Новый слот вида = строка здесь. */
export function activeAttachmentSlots(data: IFormData): IAttachmentSlot[] {
const slots: IAttachmentSlot[] = [];
if (data.ChangeTypes.includes('allowance') && requiresAttestation(data.AllowanceReason)) {
slots.push({ category: ATTESTATION_ATTACHMENT, required: true });
}
if (data.ChangeTypes.includes('position-transfer')) {
slots.push({ category: JOB_DESCRIPTION_ATTACHMENT, required: true });
}
return slots;
}
/**
* Подготовка выбранных файлов к загрузке: префикс-подпись в имени. Файлы слота,
* который сейчас скрыт (сменили вид или основание), не загружаем — их не видно.
*/
export function prepareFilesFor(data: IFormData): PrepareFiles {
const visible = activeAttachmentSlots(data).map((slot) => slot.category);
return (groupKey, files) => (visible.indexOf(groupKey) >= 0 ? prefixFiles(files, groupKey) : []);
}
/** Подписи обязательных слотов, где нет ни прикреплённого, ни выбранного файла. */
export function missingRequiredAttachments(data: IFormData, editor: IAttachmentEditor): string[] {
return activeAttachmentSlots(data)
.filter((slot) => slot.required)
.filter((slot) => {
const uploaded = attachmentsOfCategory(editor.items, slot.category).length;
const selected = (editor.pending[slot.category] || []).length;
return uploaded + selected === 0;
})
.map((slot) => slot.category);
}
@@ -0,0 +1,58 @@
import { ISelectOption } from '../../shared/ui';
/**
* Виды изменения условий трудового договора (ст. 33 ТК РК) — по ТЗ кадров.
*
* В коде вид — стабильный ключ (union ниже), в SharePoint — его подпись: колонка
* ChangeTypes (MultiChoice) хранит текст, чтобы кадры видели в списке и отчётах
* понятные значения. Подпись = choice колонки, поэтому менять её можно только
* вместе с choices в provisioning.json (иначе старые заявки перестанут узнаваться).
*/
export type ChangeType =
| 'salary-increase'
| 'allowance'
| 'position-transfer'
| 'department-transfer'
| 'workplace-change'
| 'contract-extension'
| 'schedule-change'
| 'function-change'
| 'employer-initiative'
| 'temporary-transfer';
/** Порядок — как в ТЗ (в нём же показываются в выпадающем списке). */
export const CHANGE_TYPE_LABELS: Record<ChangeType, string> = {
'salary-increase': 'Повышение должностного оклада',
allowance: 'Установление надбавки / доплаты',
'position-transfer': 'Перевод на другую должность (постоянный)',
'department-transfer': 'Перевод в другое подразделение',
'workplace-change': 'Изменение места работы (другая местность)',
'contract-extension': 'Продление срока трудового договора',
'schedule-change':
'Изменение режима / графика работы по соглашению сторон; неполное рабочее время по просьбе работника',
'function-change': 'Изменение трудовой функции (без смены должности)',
// Самая длинная подпись (~225 символов) — близко к лимиту choice в SP (255), не удлинять.
'employer-initiative':
'Изменение условий труда по инициативе работодателя по причинам экономического, технологического характера, ' +
'изменений в организации и (или) сокращения объёма работ (снижение оклада, сокращение рабочего времени, изменение графика)',
'temporary-transfer': 'Временный перевод при производственной необходимости / простое'
};
/**
* Сколько видов можно выбрать в одной заявке. По ТЗ — один; данные (массив
* ChangeTypes + MultiChoice-колонка) уже рассчитаны на несколько, чтобы снять
* ограничение правкой UI, а не миграцией.
*/
export const MAX_CHANGE_TYPES = 1;
export const CHANGE_TYPE_OPTIONS: ISelectOption[] = (Object.keys(CHANGE_TYPE_LABELS) as ChangeType[]).map(
(type) => ({ value: type, label: CHANGE_TYPE_LABELS[type] })
);
/** Подпись из SP → ключ вида. Неизвестная подпись (переименовали choice) — undefined. */
export function changeTypeFromLabel(label: string): ChangeType | undefined {
const normalized = label.trim();
return (Object.keys(CHANGE_TYPE_LABELS) as ChangeType[]).find(
(type) => CHANGE_TYPE_LABELS[type] === normalized
);
}
@@ -0,0 +1,137 @@
import { getSP } from '../../shared/pnp/pnpjs';
import { dataListTitle } from '../../shared/listTitles';
import { spDateOnlyToIso } from '../../shared/dateUtils';
import { IEmployeeFullBaseRecord } from '../../shared/dictionary';
import { ChangeType, changeTypeFromLabel } from './changeTypes';
import { allowanceUnitFromText } from './byType/Allowance/options';
import { FORM_ID, IFormData, EMPTY_DATA } from './model';
export interface ILoadedRequest {
data: IFormData;
status: string;
/** Уникальный код заявки; '' — если запись создана до появления поля. */
code: string;
}
function text(raw: unknown): string {
return typeof raw === 'string' ? raw : '';
}
/** Number-колонка → «сырое» число строкой (для MoneyInput / ввода процента); пусто → ''. */
function numberText(raw: unknown): string {
return typeof raw === 'number' && Number.isFinite(raw) ? String(raw) : '';
}
/**
* Колонки Employee* → запись сотрудника (см. employeeColumns в submit.ts).
* Нет ни id, ни Ф.И.О. — сотрудник не был выбран (черновик) → undefined.
* Даты приёма/увольнения не хранятся — ''; e-mail пустой — у сотрудника нет учётки.
*/
function employeeFromItem(item: Record<string, unknown>): IEmployeeFullBaseRecord | undefined {
const id = text(item.EmployeeRecordId);
const fio = text(item.EmployeeFio);
if (!id && !fio) return undefined;
return {
id,
fio,
iin: text(item.EmployeeIin),
code: text(item.EmployeeCode),
company: text(item.EmployeeCompany),
position: text(item.EmployeePosition),
department: text(item.EmployeeDepartment),
admissionDate: '',
dismissalDate: '',
email: text(item.EmployeeEmail)
};
}
/**
* MultiChoice → ключи видов. Подпись, которой нет в CHANGE_TYPE_LABELS (choice
* переименовали в SP, а код — нет), пропускаем с предупреждением в консоли:
* форма покажет «вид не выбран», и при отправке валидация попросит выбрать заново.
*/
function changeTypesFromItem(raw: unknown): ChangeType[] {
const labels = Array.isArray(raw) ? raw.filter((v): v is string => typeof v === 'string') : [];
const types: ChangeType[] = [];
labels.forEach((label) => {
const type = changeTypeFromLabel(label);
if (type) {
types.push(type);
} else {
console.warn(`[ContractChange] Неизвестный вид изменения «${label}» — нет в CHANGE_TYPE_LABELS`);
}
});
return types;
}
/**
* Прочитать заявку по id: модель формы + статус (правка / просмотр).
*
* listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/
* listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции; задан — идём напрямую
* по url списка.
*/
export async function loadContractChangeRequest(itemId: number, listId?: string): Promise<ILoadedRequest> {
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const item = (await list.items
.getById(itemId)
.select(
'Id',
'RequestStatus',
'RequestUniqueCode',
'EmployeeRecordId',
'EmployeeFio',
'EmployeeIin',
'EmployeeCode',
'EmployeeEmail',
'EmployeeCompany',
'EmployeePosition',
'EmployeeDepartment',
'ChangeTypes',
'SalaryIncreaseCurrent',
'SalaryIncreaseNew',
'SalaryIncreaseDate',
'SalaryIncreaseReason',
'SalaryIncreaseJustification',
'AllowanceReason',
'AllowanceStartDate',
'AllowanceEndDate',
'AllowanceIndefinite',
'AllowanceAmount',
'AllowanceUnit',
'AllowanceJustification',
'PositionTransferNewPosition',
'PositionTransferDate',
'PositionTransferReason',
'PositionTransferJustification'
)()) as Record<string, unknown>;
const data: IFormData = {
...EMPTY_DATA,
Employee: employeeFromItem(item),
ChangeTypes: changeTypesFromItem(item.ChangeTypes),
SalaryIncreaseCurrent: numberText(item.SalaryIncreaseCurrent),
SalaryIncreaseNew: numberText(item.SalaryIncreaseNew),
// Дату только через spDateOnlyToIso — иначе из-за часового пояса уезжает на день назад.
SalaryIncreaseDate: spDateOnlyToIso(text(item.SalaryIncreaseDate)),
SalaryIncreaseReason: text(item.SalaryIncreaseReason),
SalaryIncreaseJustification: text(item.SalaryIncreaseJustification),
AllowanceReason: text(item.AllowanceReason),
AllowanceStartDate: spDateOnlyToIso(text(item.AllowanceStartDate)),
AllowanceEndDate: spDateOnlyToIso(text(item.AllowanceEndDate)),
AllowanceIndefinite: item.AllowanceIndefinite === true,
AllowanceAmount: numberText(item.AllowanceAmount),
AllowanceUnit: allowanceUnitFromText(text(item.AllowanceUnit)),
AllowanceJustification: text(item.AllowanceJustification),
PositionTransferNewPosition: text(item.PositionTransferNewPosition),
PositionTransferDate: spDateOnlyToIso(text(item.PositionTransferDate)),
PositionTransferReason: text(item.PositionTransferReason),
PositionTransferJustification: text(item.PositionTransferJustification)
};
return {
data,
status: text(item.RequestStatus),
code: text(item.RequestUniqueCode)
};
}
@@ -0,0 +1,134 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { RequestTypeId } from '../../models/types';
import { IEmployeeFullBasePerson, IEmployeeFullBaseRecord } from '../../shared/dictionary';
import { ChangeType } from './changeTypes';
import { AllowanceUnit, DEFAULT_ALLOWANCE_UNIT } from './byType/Allowance/options';
import { IAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
/**
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
* SharePoint выводятся из него, см. shared/listTitles.ts.
*/
export const FORM_ID: RequestTypeId = 'hr-contract-change';
/**
* Данные формы «Изменение условий трудового договора» (ст. 33 ТК РК).
*
* Сотрудник — запись из Employee/FullBase, а не People: заявку подают и за
* рабочих без учётной записи M365. В списке SP запись лежит снимком в текстовых
* колонках Employee* (разборка/сборка — submit.ts/load.ts); даты приёма и
* увольнения не храним — после загрузки они ''.
*
* Поля под конкретные виды изменения добавляются по мере согласования с кадрами
* — каждый вид своим блоком с префиксом имён (см. changeTypes.ts).
*/
export interface IFormData {
/**
* Место работы сотрудника, где меняются условия ТД (человек + организация —
* см. useContractEmployee). undefined — не выбран человек или ещё не выбрана организация.
*/
Employee: IEmployeeFullBaseRecord | undefined;
/**
* Выбранные виды изменения. По ТЗ ровно один (MAX_CHANGE_TYPES), массив —
* задел под несколько, см. changeTypes.ts. [] — не выбран.
*/
ChangeTypes: ChangeType[];
// ── Вид «Повышение должностного оклада» (byType/SalaryIncrease). Все необязательные.
/** Текущий оклад — «сырые» цифры без пробелов (так отдаёт MoneyInput); '' — не заполнен. */
SalaryIncreaseCurrent: string;
/** Новый оклад — как SalaryIncreaseCurrent. */
SalaryIncreaseNew: string;
/** С какой даты, ISO YYYY-MM-DD. */
SalaryIncreaseDate: string;
/** Причина — одно из SALARY_INCREASE_REASONS (Choice-колонка); '' — не выбрана. */
SalaryIncreaseReason: string;
/** Обоснование; при причине «Иное» — её текст. */
SalaryIncreaseJustification: string;
// ── Вид «Установление надбавки / доплаты» (byType/Allowance). Все необязательные.
/** Основание — одно из ALLOWANCE_REASONS (Choice-колонка); '' — не выбрано. */
AllowanceReason: string;
/** С какой даты, ISO YYYY-MM-DD. */
AllowanceStartDate: string;
/** По какую дату, ISO; при AllowanceIndefinite всегда ''. */
AllowanceEndDate: string;
/** Бессрочно — тогда даты окончания нет. */
AllowanceIndefinite: boolean;
/**
* Размер — «сырое» число строкой: в ₸ целое (MoneyInput), в % может быть дробным
* с точкой, «7.5» (см. byType/Allowance/amount.ts); единица — в AllowanceUnit.
*/
AllowanceAmount: string;
AllowanceUnit: AllowanceUnit;
/** Обоснование; при совмещении — в т.ч. объём доп. работы, при «иное» — его текст. */
AllowanceJustification: string;
// ── Вид «Перевод на другую должность (постоянный)» (byType/PositionTransfer).
// Поля необязательные; вложение «Должностная инструкция» — обязательно (attachmentSlots).
/** Новая должность — из справочника или вручную (PositionPicker). */
PositionTransferNewPosition: string;
/** С какой даты, ISO YYYY-MM-DD. */
PositionTransferDate: string;
/** Причина — одно из POSITION_TRANSFER_REASONS (Choice-колонка); '' — не выбрана. */
PositionTransferReason: string;
/** Обоснование; при «Иное» — его текст, у работника — текст заявления. */
PositionTransferJustification: string;
}
/** Обновление одного поля формы с сохранением типа значения. */
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
/** Пропсы секции формы: срез данных + колбэк обновления + контекст (запросы к справочникам). */
export interface ISectionProps {
data: IFormData;
set: SetField;
context: WebPartContext;
/** Вложения заявки — для подписанных слотов в секциях видов (см. byType/attachmentSlots). */
attachments?: IAttachmentEditor;
}
export const EMPTY_DATA: IFormData = {
Employee: undefined,
ChangeTypes: [],
SalaryIncreaseCurrent: '',
SalaryIncreaseNew: '',
SalaryIncreaseDate: '',
SalaryIncreaseReason: '',
SalaryIncreaseJustification: '',
AllowanceReason: '',
AllowanceStartDate: '',
AllowanceEndDate: '',
AllowanceIndefinite: false,
AllowanceAmount: '',
AllowanceUnit: DEFAULT_ALLOWANCE_UNIT,
AllowanceJustification: '',
PositionTransferNewPosition: '',
PositionTransferDate: '',
PositionTransferReason: '',
PositionTransferJustification: ''
};
/** Плейсхолдер для поля выбора сотрудника. */
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
/** Плейсхолдер для выбора организации (места работы) сотрудника. */
export const WORKPLACE_PLACEHOLDER = 'Выберите организацию, в которой меняются условия';
/** Плейсхолдер для выбора вида изменения. */
export const CHANGE_TYPE_PLACEHOLDER = '— выберите вид изменения —';
/**
* Пояснение под сотрудником без учётной записи: approval-route резолвит маршрут
* по e-mail, а его нет — маршрут строится по заявителю (см. routeEmail в форме).
*/
export const ROUTE_BY_APPLICANT_NOTE =
'У сотрудника нет учётной записи — маршрут согласования построен по заявителю.';
export const REQUIRED_FIELDS_MESSAGE = 'Заполните обязательные поля: сотрудник, организация, вид изменения.';
/**
* E-mail, по которому строится маршрут: сотрудника, а если у него нет учётной
* записи (рабочие на объектах) — заявителя. Берётся от человека, а не от места
* работы: маршрут от организации не зависит и грузится, пока её ещё выбирают.
*/
export function routeEmail(person: IEmployeeFullBasePerson | undefined, applicantEmail: string): string {
if (!person) return '';
return person.email || applicantEmail;
}
@@ -0,0 +1,63 @@
{
"dataListTitle": "Изменение условий трудового договора — данные",
"columns": [
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
{ "internalName": "EmployeeRecordId", "title": "Сотрудник: id записи кадровой базы", "type": "Text" },
{ "internalName": "EmployeeFio", "title": "Сотрудник: Ф.И.О.", "type": "Text" },
{ "internalName": "EmployeeIin", "title": "Сотрудник: ИИН", "type": "Text" },
{ "internalName": "EmployeeCode", "title": "Сотрудник: табельный номер", "type": "Text" },
{ "internalName": "EmployeeEmail", "title": "Сотрудник: e-mail", "type": "Text" },
{ "internalName": "EmployeeCompany", "title": "Сотрудник: организация", "type": "Text" },
{ "internalName": "EmployeePosition", "title": "Сотрудник: должность", "type": "Text" },
{ "internalName": "EmployeeDepartment", "title": "Сотрудник: подразделение", "type": "Text" },
{ "internalName": "ChangeTypes", "title": "Вид изменения", "type": "Choice", "allowMultiple": true, "choices": [
"Повышение должностного оклада",
"Установление надбавки / доплаты",
"Перевод на другую должность (постоянный)",
"Перевод в другое подразделение",
"Изменение места работы (другая местность)",
"Продление срока трудового договора",
"Изменение режима / графика работы по соглашению сторон; неполное рабочее время по просьбе работника",
"Изменение трудовой функции (без смены должности)",
"Изменение условий труда по инициативе работодателя по причинам экономического, технологического характера, изменений в организации и (или) сокращения объёма работ (снижение оклада, сокращение рабочего времени, изменение графика)",
"Временный перевод при производственной необходимости / простое"
] },
{ "internalName": "SalaryIncreaseCurrent", "title": "Повышение оклада: текущий оклад", "type": "Number", "decimals": 0 },
{ "internalName": "SalaryIncreaseNew", "title": "Повышение оклада: новый оклад", "type": "Number", "decimals": 0 },
{ "internalName": "SalaryIncreaseDate", "title": "Повышение оклада: дата изменения", "type": "DateTime", "dateOnly": true },
{ "internalName": "SalaryIncreaseReason", "title": "Повышение оклада: причина", "type": "Choice", "choices": [
"Успешное прохождение испытательного срока",
"Расширение функционала / зоны ответственности",
"Иное"
] },
{ "internalName": "SalaryIncreaseJustification", "title": "Повышение оклада: обоснование", "type": "Note" },
{ "internalName": "AllowanceReason", "title": "Надбавка/доплата: основание", "type": "Choice", "choices": [
"Надбавка: наставничество и обучение новых работников",
"Надбавка: разъездной характер работы",
"Доплата: совмещение должностей / расширение зоны обслуживания",
"Доплата: работа в тяжёлых, вредных и (или) опасных условиях труда",
"Доплата: иное по решению первого руководителя"
] },
{ "internalName": "AllowanceStartDate", "title": "Надбавка/доплата: дата начала", "type": "DateTime", "dateOnly": true },
{ "internalName": "AllowanceEndDate", "title": "Надбавка/доплата: дата окончания", "type": "DateTime", "dateOnly": true },
{ "internalName": "AllowanceIndefinite", "title": "Надбавка/доплата: бессрочно", "type": "Boolean" },
{ "internalName": "AllowanceAmount", "title": "Надбавка/доплата: размер", "type": "Number", "decimals": 2 },
{ "internalName": "AllowanceUnit", "title": "Надбавка/доплата: единица размера", "type": "Choice", "choices": ["%", "₸"] },
{ "internalName": "AllowanceJustification", "title": "Надбавка/доплата: обоснование", "type": "Note" },
{ "internalName": "PositionTransferNewPosition", "title": "Перевод на должность: новая должность", "type": "Text" },
{ "internalName": "PositionTransferDate", "title": "Перевод на должность: дата перевода", "type": "DateTime", "dateOnly": true },
{ "internalName": "PositionTransferReason", "title": "Перевод на должность: причина", "type": "Choice", "choices": [
"Карьерный рост (успешное выполнение обязанностей, высокие результаты)",
"Вакантная должность, соответствующая квалификации работника",
"Открытие нового объекта / проекта, перераспределение функций",
"Иное"
] },
{ "internalName": "PositionTransferJustification", "title": "Перевод на должность: обоснование", "type": "Note" }
]
}
@@ -0,0 +1,8 @@
@import '../../../../../styles/design-tokens';
// Пояснение под полем «Сотрудник» (маршрут по заявителю — см. ROUTE_BY_APPLICANT_NOTE).
.note {
margin-top: $sp-1;
font-size: $fs-label;
color: $gray-500;
}
@@ -0,0 +1,87 @@
import * as React from 'react';
import { FormGrid, FormField, Select, ISelectOption } from '../../../shared/ui';
import { useFormReadOnly } from '@tnsu/ui-kit';
import {
EmployeeFullBasePicker,
formatOrganizationLine,
personFromWorkplace,
workplaceKey
} from '../../../shared/dictionary';
import { ChangeType, CHANGE_TYPE_OPTIONS } from '../changeTypes';
import {
ISectionProps,
EMPLOYEE_PLACEHOLDER,
WORKPLACE_PLACEHOLDER,
CHANGE_TYPE_PLACEHOLDER,
ROUTE_BY_APPLICANT_NOTE
} from '../model';
import { IContractEmployee } from '../useContractEmployee';
import styles from './RequestSection.module.scss';
interface IRequestSectionProps extends ISectionProps {
/** Выбор сотрудника/организации — только на форме подачи; в просмотре не передаётся. */
employeeControl?: IContractEmployee;
}
const NOOP = (): void => undefined;
/**
* Общая часть заявки, не зависящая от вида изменения: чьи условия ТД меняются,
* в какой организации (у совместителя их несколько) и что именно меняется.
* Поля конкретных видов — отдельными секциями (byType/).
*/
export const RequestSection: React.FC<IRequestSectionProps> = ({ data, set, context, employeeControl }) => {
const readOnly = useFormReadOnly();
// В просмотре человека нет в состоянии — собираем из сохранённого места работы.
const person = employeeControl?.person ?? (data.Employee ? personFromWorkplace(data.Employee) : undefined);
const workplaceOptions: ISelectOption[] = (person?.workplaces ?? []).map((w) => ({
value: workplaceKey(w),
label: formatOrganizationLine(w.company, w.position, w.department)
}));
const hasNoAccount = !!person && !person.email;
// По ТЗ вид один (MAX_CHANGE_TYPES), поэтому обычный Select: значение — первый
// элемент массива, выбор заменяет массив целиком. Под несколько видов здесь
// встанет MultiChoiceField, модель и SP уже готовы.
const selectedType = data.ChangeTypes[0] || '';
const handleTypeChange = (value: string): void => {
set('ChangeTypes', value ? [value as ChangeType] : []);
};
return (
<FormGrid>
<FormField label="Сотрудник" htmlFor="Employee" required full>
<EmployeeFullBasePicker
context={context}
id="Employee"
value={person}
onChange={employeeControl ? employeeControl.onPersonChange : NOOP}
placeholder={EMPLOYEE_PLACEHOLDER}
/>
{!readOnly && hasNoAccount && <div className={styles.note}>{ROUTE_BY_APPLICANT_NOTE}</div>}
</FormField>
{workplaceOptions.length > 0 && (
<FormField label="Организация" htmlFor="EmployeeWorkplace" required full>
<Select
id="EmployeeWorkplace"
value={data.Employee ? workplaceKey(data.Employee) : ''}
onChange={employeeControl ? employeeControl.onWorkplaceChange : NOOP}
options={workplaceOptions}
placeholder={WORKPLACE_PLACEHOLDER}
// Единственное место работы выбрано автоматически — выбирать не из чего (как в командировке).
readOnly={workplaceOptions.length === 1 && !!data.Employee}
/>
</FormField>
)}
<FormField label="Вид изменения" htmlFor="ChangeTypes" required full>
<Select
id="ChangeTypes"
value={selectedType}
onChange={handleTypeChange}
options={CHANGE_TYPE_OPTIONS}
placeholder={CHANGE_TYPE_PLACEHOLDER}
/>
</FormField>
</FormGrid>
);
};
@@ -0,0 +1,153 @@
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
import { IEmployeeFullBaseRecord } from '../../shared/dictionary';
import { CHANGE_TYPE_LABELS } from './changeTypes';
import { FORM_ID, IFormData } from './model';
/**
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
* approvalServiceFor (см. shared/approval/approvalServiceFactory.ts).
*/
export function contractChangeApprovalService(listId?: string): ApprovalListService {
return approvalServiceFor(FORM_ID, listId);
}
function pad2(n: number): string {
return n < 10 ? `0${n}` : `${n}`;
}
/** Осмысленный заголовок элемента (системная колонка Title). */
function buildTitle(employeeFio: string): string {
const now = new Date();
const date = `${pad2(now.getDate())}.${pad2(now.getMonth() + 1)}.${now.getFullYear()}`;
return `Изменение условий ТД — ${employeeFio || 'сотрудник не выбран'} — ${date}`;
}
/**
* Снимок сотрудника → текстовые колонки Employee* (см. provisioning.json).
* Не выбран (черновик) — все колонки очищаются null (так SharePoint REST очищает
* поле), а не остаются от прошлого выбора.
*/
function employeeColumns(employee: IEmployeeFullBaseRecord | undefined): Record<string, unknown> {
return {
EmployeeRecordId: employee?.id || null,
EmployeeFio: employee?.fio || null,
EmployeeIin: employee?.iin || null,
EmployeeCode: employee?.code || null,
EmployeeEmail: employee?.email || null,
EmployeeCompany: employee?.company || null,
EmployeePosition: employee?.position || null,
EmployeeDepartment: employee?.department || null
};
}
/** «Сырое» число строкой (сумма или процент) → число для Number-колонки; '' или мусор → undefined. */
function numberColumn(raw: string): number | undefined {
const amount = Number(raw);
return raw.trim() !== '' && Number.isFinite(amount) ? amount : undefined;
}
/**
* Колонки вида «Повышение оклада». Вид не выбран — колонки очищаются: иначе
* после смены вида в заявке остались бы скрытые поля прежнего, которых никто не видит.
*/
function salaryIncreaseColumns(data: IFormData): Record<string, unknown> {
if (!data.ChangeTypes.includes('salary-increase')) {
return {
SalaryIncreaseCurrent: null,
SalaryIncreaseNew: null,
SalaryIncreaseDate: null,
SalaryIncreaseReason: null,
SalaryIncreaseJustification: null
};
}
return {
SalaryIncreaseCurrent: numberColumn(data.SalaryIncreaseCurrent) ?? null,
SalaryIncreaseNew: numberColumn(data.SalaryIncreaseNew) ?? null,
SalaryIncreaseDate: data.SalaryIncreaseDate || null,
SalaryIncreaseReason: data.SalaryIncreaseReason || null,
SalaryIncreaseJustification: data.SalaryIncreaseJustification || null
};
}
/** Колонки вида «Надбавка / доплата»; вид не выбран — очищаются (см. salaryIncreaseColumns). */
function allowanceColumns(data: IFormData): Record<string, unknown> {
if (!data.ChangeTypes.includes('allowance')) {
return {
AllowanceReason: null,
AllowanceStartDate: null,
AllowanceEndDate: null,
AllowanceIndefinite: null,
AllowanceAmount: null,
AllowanceUnit: null,
AllowanceJustification: null
};
}
return {
AllowanceReason: data.AllowanceReason || null,
AllowanceStartDate: data.AllowanceStartDate || null,
// Бессрочно — даты окончания нет, даже если она осталась в состоянии формы.
AllowanceEndDate: data.AllowanceIndefinite ? null : data.AllowanceEndDate || null,
AllowanceIndefinite: data.AllowanceIndefinite,
AllowanceAmount: numberColumn(data.AllowanceAmount) ?? null,
AllowanceUnit: data.AllowanceUnit,
AllowanceJustification: data.AllowanceJustification || null
};
}
/** Колонки вида «Перевод на другую должность»; вид не выбран — очищаются (см. salaryIncreaseColumns). */
function positionTransferColumns(data: IFormData): Record<string, unknown> {
if (!data.ChangeTypes.includes('position-transfer')) {
return {
PositionTransferNewPosition: null,
PositionTransferDate: null,
PositionTransferReason: null,
PositionTransferJustification: null
};
}
return {
PositionTransferNewPosition: data.PositionTransferNewPosition.trim() || null,
PositionTransferDate: data.PositionTransferDate || null,
PositionTransferReason: data.PositionTransferReason || null,
PositionTransferJustification: data.PositionTransferJustification || null
};
}
/** Поля элемента списка данных из модели формы. */
function buildItem(data: IFormData, status: string): Record<string, unknown> {
return {
Title: buildTitle(data.Employee?.fio || ''),
RequestStatus: status,
...employeeColumns(data.Employee),
// MultiChoice пишется плоским массивом подписей (как «База 1С» в Ticket1C); [] очищает колонку.
ChangeTypes: data.ChangeTypes.map((type) => CHANGE_TYPE_LABELS[type]),
...salaryIncreaseColumns(data),
...allowanceColumns(data),
...positionTransferColumns(data)
};
}
export type ISaveResult = ISaveItemResult;
/**
* Сохранить заявку: содержимое + маршрут согласования.
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
* executors — фаза «Исполнение» маршрута из админки (пусто, если её нет).
*
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadContractChangeRequest); новая
* заявка всегда создаётся в текущей партиции.
*/
export async function saveContractChangeRequest(
existingId: number | undefined,
requestTypeId: number,
data: IFormData,
approvers: IApproverToWrite[],
executors: IApproverToWrite[],
status: string,
listId?: string
): Promise<ISaveResult> {
const item = buildItem(data, status);
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
await writeRoute(contractChangeApprovalService(listId), result.id, approvers, executors, status);
return result;
}
@@ -0,0 +1,116 @@
import { useEffect, useRef, useState } from 'react';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import {
IEmployeeFullBasePerson,
IEmployeeFullBaseRecord,
getEmployeeOrganizations,
groupEmployeesByPerson,
personFromWorkplace,
personKey,
searchActiveEmployees,
workplaceKey
} from '../../shared/dictionary';
// Совпадений по Ф.И.О. немного (тёзки × места работы) — запас с лихвой.
const MAX_NAMESAKE_RECORDS = 60;
/** Человек + выбор его места работы — то, что нужно секции «Сотрудник / Организация». */
export interface IContractEmployee {
person: IEmployeeFullBasePerson | undefined;
onPersonChange: (person: IEmployeeFullBasePerson | undefined) => void;
/** key — workplaceKey выбранного места работы. */
onWorkplaceChange: (key: string) => void;
}
/** Место работы, если выбирать не из чего (оно одно) — иначе выбирает пользователь. */
function singleWorkplace(person: IEmployeeFullBasePerson): IEmployeeFullBaseRecord | undefined {
return person.workplaces.length === 1 ? person.workplaces[0] : undefined;
}
/**
* Себя — по e-mail. Параметра Email у FullBase нет, поэтому: Ф.И.О. из
* Employee/ByEmail → поиск по Ф.И.О. → человек, у кого есть место работы с этим e-mail.
*/
async function findPersonByEmail(context: WebPartContext, email: string): Promise<IEmployeeFullBasePerson | undefined> {
const organizations = await getEmployeeOrganizations(context, email);
const fio = organizations[0]?.fio || '';
if (!fio) return undefined;
const people = groupEmployeesByPerson(await searchActiveEmployees(context, fio, MAX_NAMESAKE_RECORDS));
const target = email.toLowerCase();
return people.find((p) => p.workplaces.some((w) => w.email.toLowerCase() === target));
}
/** Все места работы человека, чьё одно место сохранено в заявке (черновик/возврат). */
async function findPersonOfWorkplace(
context: WebPartContext,
record: IEmployeeFullBaseRecord
): Promise<IEmployeeFullBasePerson | undefined> {
const people = groupEmployeesByPerson(await searchActiveEmployees(context, record.fio, MAX_NAMESAKE_RECORDS));
return people.find((p) => p.key === personKey(record));
}
/**
* Выбор сотрудника и его организации, как в «Заявке на командировку»: человек —
* пикером, место работы — списком; одно место выбирается само.
*
* В заявке хранится только выбранное место работы (setEmployee); человек со
* всеми местами — состояние формы, при открытии черновика подтягивается заново.
*/
export function useContractEmployee(
context: WebPartContext,
applicantEmail: string,
isNew: boolean,
employee: IEmployeeFullBaseRecord | undefined,
setEmployee: (record: IEmployeeFullBaseRecord | undefined) => void
): IContractEmployee {
const [person, setPerson] = useState<IEmployeeFullBasePerson | undefined>(undefined);
// Пользователь уже выбрал сам — фоновые поиски его выбор не перетирают.
const isTouchedRef = useRef(false);
const applyPerson = (next: IEmployeeFullBasePerson | undefined): void => {
setPerson(next);
setEmployee(next ? singleWorkplace(next) : undefined);
};
// Новая заявка — по умолчанию на себя.
useEffect(() => {
if (!isNew || !applicantEmail) return;
let alive = true;
findPersonByEmail(context, applicantEmail)
.then((found) => {
if (alive && found && !isTouchedRef.current) applyPerson(found);
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [context, applicantEmail, isNew]);
// Черновик/возврат: восстановить список организаций; не нашлось — хотя бы сохранённая.
const restoreKey = !isNew && employee && !person ? personKey(employee) : '';
useEffect(() => {
if (!restoreKey || !employee) return;
let alive = true;
findPersonOfWorkplace(context, employee)
.then((found) => {
if (alive && !isTouchedRef.current) setPerson(found ?? personFromWorkplace(employee));
})
.catch(() => {
if (alive && !isTouchedRef.current) setPerson(personFromWorkplace(employee));
});
return () => {
alive = false;
};
}, [context, restoreKey]);
return {
person,
onPersonChange: (next) => {
isTouchedRef.current = true;
applyPerson(next);
},
onWorkplaceChange: (key) => {
setEmployee(person?.workplaces.find((w) => workplaceKey(w) === key));
}
};
}
@@ -12,6 +12,7 @@ import {
ApprovalHistory,
useMatrixEditor,
notifyActiveStep,
notifyInitiatorOfReturn,
useRequestTypeId,
REQUEST_STATUS,
approvalPhaseRows,
@@ -218,16 +219,19 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
await updateAssetsAcceptor(itemId, data.AssetsAcceptor, listId);
};
const notify = (): void => {
const notify = (newStatus?: string): void => {
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
// весь UI ради необязательного оповещения.
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
const target = {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined);
};
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
};
// Заявление согласовано целиком → сразу заводим обходной лист по нему (данные
@@ -311,7 +315,7 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
notify();
notify(newStatus);
// «Согласовано» = маршрут заявления пройден целиком (фазы исполнения у
// него нет, см. README формы) — значит увольнение подтверждено и пора
// заводить обходной лист. Промис ВОЗВРАЩАЕМ: пока он не разрешится,
@@ -14,6 +14,7 @@ import {
ExecutorsMatrix,
approvalPhaseRows,
notifyActiveStep,
notifyInitiatorOfReturn,
useRequestTypeId
} from '../../shared/approval';
import { friendlyErrorMessage } from '../../shared/diagnostics';
@@ -117,16 +118,19 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
const notify = (): void => {
const notify = (newStatus?: string): void => {
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
// весь UI ради необязательного оповещения.
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
const target = {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined);
};
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
};
return (
@@ -172,7 +176,7 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
notify();
notify(newStatus);
}}
onDelegated={() => {
setDecisionSeq((n) => n + 1);
@@ -1,32 +0,0 @@
import * as React from 'react';
import { AttachmentList, FileDropzone, FormField } from '../../shared/ui';
import { IAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
import { attachmentsOfCategory, categoryPrefix, uncategorizedAttachments } from './attachmentCategories';
import styles from './HireAttachmentField.module.scss';
interface Props {
label: string;
editor: IAttachmentEditor;
editable: boolean;
/** Группа новых файлов в editor.pending. */
groupKey: string;
/** Категория в имени файла; не задана — «прочие» вложения без префикса. */
category?: string;
}
/** Поле одной категории вложений: прикреплённые файлы + (в правке) выбор новых. */
export const HireAttachmentField: React.FC<Props> = ({ label, editor, editable, groupKey, category }) => {
const items = category ? attachmentsOfCategory(editor.items, category) : uncategorizedAttachments(editor.items);
// В списке имена показаны без префикса категории — для удаления возвращаем его.
const removeWithPrefix = (name: string): void => editor.remove(category ? categoryPrefix(category) + name : name);
return (
<FormField label={label} full>
{items.length > 0 && <AttachmentList items={items} onRemove={editable ? removeWithPrefix : undefined} />}
{items.length === 0 && !editable && <span className={styles.empty}>—</span>}
{editable && (
<FileDropzone files={editor.pending[groupKey] || []} onChange={(files) => editor.setPending(groupKey, files)} />
)}
</FormField>
);
};
@@ -1,8 +1,8 @@
import * as React from 'react';
import { Alert, CollapseSection } from '../../shared/ui';
import { IAttachmentEditor, PrepareFiles } from '../../shared/attachments/useAttachmentEditor';
import { AttachmentSlotField } from '../../shared/attachments/AttachmentSlotField';
import { ATT_CATEGORY_ID, ATT_CATEGORY_RESUME, prefixFiles, uncategorizedAttachments } from './attachmentCategories';
import { HireAttachmentField } from './HireAttachmentField';
/**
* Группы новых файлов → категория в имени файла (см. attachmentCategories).
@@ -37,9 +37,16 @@ export const HireAttachments: React.FC<Props> = ({ editor, editable }) => {
return (
<CollapseSection title="Вложения" icon="upload">
{editor.error && <Alert variant="err">{editor.error}</Alert>}
<HireAttachmentField label="Удостоверение" groupKey={GROUP_ID} category={ATT_CATEGORY_ID} {...fieldProps} />
<HireAttachmentField label="Резюме" groupKey={GROUP_RESUME} category={ATT_CATEGORY_RESUME} {...fieldProps} />
{showOther && <HireAttachmentField label="Прочие вложения" groupKey={GROUP_OTHER} {...fieldProps} />}
<AttachmentSlotField label="Удостоверение" groupKey={GROUP_ID} category={ATT_CATEGORY_ID} {...fieldProps} />
<AttachmentSlotField label="Резюме" groupKey={GROUP_RESUME} category={ATT_CATEGORY_RESUME} {...fieldProps} />
{showOther && (
<AttachmentSlotField
label="Прочие вложения"
groupKey={GROUP_OTHER}
knownCategories={[ATT_CATEGORY_ID, ATT_CATEGORY_RESUME]}
{...fieldProps}
/>
)}
</CollapseSection>
);
};
@@ -24,6 +24,7 @@ import {
ApprovalHistory,
useMatrixEditor,
notifyActiveStep,
notifyInitiatorOfReturn,
useRequestTypeId,
APPROVAL_PHASE,
isApprovedStatus,
@@ -283,13 +284,16 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
);
// Выше ранних return (загрузка/ошибка): зовётся и из колбэков хуков ниже.
const notify = (): void => {
const notify = (newStatus?: string): void => {
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
const target = {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID),
}).catch(() => undefined);
};
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
};
// Добавить согласующего/исполнителя в поданную заявку — то же право
@@ -479,7 +483,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
// Очередь сдвинулась — уведомляем следующий активный шаг.
notify();
notify(newStatus);
}}
onDelegated={() => {
// Шаг переназначен: активной стала строка делегата — обновляем
@@ -1,50 +1,24 @@
import { IAttachmentInfo } from '../../shared/attachments/attachments.service';
import { attachmentsWithoutCategories } from '../../shared/attachments/attachmentCategories';
/**
* Категории вложений заявки на прием. Вложения SharePoint — плоская коллекция
* файлов на элементе списка, «категории» у них нет. Поэтому категорию кодируем
* ПРЕФИКСОМ в имени файла: «<Категория> — <исходное имя>». При загрузке имя
* файла переписываем, при показе — группируем по префиксу и префикс срезаем.
* Категории вложений заявки на прием. Механизм подписи префиксом в имени файла —
* общий (shared/attachments/attachmentCategories); здесь только категории этой
* формы. Общие функции реэкспортируются, чтобы импорты формы не менялись.
*/
export const ATT_CATEGORY_ID = 'Удостоверение';
export const ATT_CATEGORY_RESUME = 'Резюме';
const SEP = ' — ';
/** Префикс имени файла для категории: «Удостоверение — ». */
export function categoryPrefix(category: string): string {
return `${category}${SEP}`;
}
/**
* Копии File с префиксом категории в имени — для загрузки. File.name неизменяемо,
* поэтому создаём новый File с тем же содержимым и типом, но другим именем.
*/
export function prefixFiles(files: File[], category: string): File[] {
const prefix = categoryPrefix(category);
return files.map((f) => new File([f], prefix + f.name, { type: f.type }));
}
/**
* Вложения указанной категории, с ОБРЕЗАННЫМ префиксом в имени (для показа).
* url сохраняется как есть; чтобы удалить такой файл, префикс надо вернуть
* обратно (см. categoryPrefix в обработчике удаления формы).
*/
export function attachmentsOfCategory(items: IAttachmentInfo[], category: string): IAttachmentInfo[] {
const prefix = categoryPrefix(category);
return items
.filter((a) => a.fileName.indexOf(prefix) === 0)
.map((a) => ({ url: a.url, fileName: a.fileName.slice(prefix.length) }));
}
export {
categoryPrefix,
prefixFiles,
attachmentsOfCategory
} from '../../shared/attachments/attachmentCategories';
/**
* Вложения без известной категории (старые заявки, загруженные до разделения).
* Показываем как есть, чтобы файлы не «исчезали» с экрана.
*/
export function uncategorizedAttachments(items: IAttachmentInfo[]): IAttachmentInfo[] {
const idPrefix = categoryPrefix(ATT_CATEGORY_ID);
const resumePrefix = categoryPrefix(ATT_CATEGORY_RESUME);
return items.filter(
(a) => a.fileName.indexOf(idPrefix) !== 0 && a.fileName.indexOf(resumePrefix) !== 0
);
return attachmentsWithoutCategories(items, [ATT_CATEGORY_ID, ATT_CATEGORY_RESUME]);
}
@@ -22,6 +22,7 @@ import {
ApprovalHistory,
useMatrixEditor,
notifyActiveStep,
notifyInitiatorOfReturn,
useRequestTypeId,
isApprovedStatus,
resolveApproverOrgInfo,
@@ -204,20 +205,25 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
await saveAllFields();
};
// rowId — оповестить конкретную активную строку (см. notifyActiveStep).
const notify = (rowId?: number): void => {
if (!requestTypeId) return;
notifyActiveStep(
context,
approval,
itemId,
{
// Куда смотрит поток оповещения; undefined — тип заявки ещё не резолвился.
const notifyTarget = requestTypeId
? {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
},
rowId
).catch(() => undefined);
}
: undefined;
// rowId — оповестить конкретную активную строку (см. notifyActiveStep).
const notify = (rowId?: number): void => {
if (!notifyTarget) return;
notifyActiveStep(context, approval, itemId, notifyTarget, rowId).catch(() => undefined);
};
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
const notifyInitiator = (newStatus: string): void => {
if (!notifyTarget) return;
notifyInitiatorOfReturn(context, approval, itemId, notifyTarget, newStatus).catch(() => undefined);
};
// Удалить ещё не решённый шаг маршрута (см. ApproverService.deleteQueuedStep).
@@ -467,6 +473,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
notify();
notifyInitiator(newStatus);
}}
onDelegated={() => {
setDecisionSeq((n) => n + 1);
@@ -21,6 +21,7 @@ import {
ApprovalHistory,
useMatrixEditor,
notifyActiveStep,
notifyInitiatorOfReturn,
useRequestTypeId,
APPROVAL_PHASE,
isApprovedStatus,
@@ -169,16 +170,19 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
const notify = (): void => {
const notify = (newStatus?: string): void => {
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
// весь UI ради необязательного оповещения.
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
const target = {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined);
};
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
};
return (
@@ -253,7 +257,7 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
notify();
notify(newStatus);
}}
onDelegated={() => {
setDecisionSeq((n) => n + 1);
@@ -24,6 +24,7 @@ import {
ExecutorsMatrix,
approvalPhaseRows,
notifyActiveStep,
notifyInitiatorOfReturn,
useRequestTypeId,
isEditableStatus
} from '../../shared/approval';
@@ -141,13 +142,16 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
const sectionProps = { data, set, context, printOrgIndex, attachmentUrls };
const notify = (): void => {
const notify = (newStatus?: string): void => {
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
const target = {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined);
};
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
};
return (
@@ -205,7 +209,7 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
notify();
notify(newStatus);
}}
onDelegated={() => {
setDecisionSeq((n) => n + 1);
@@ -9,6 +9,7 @@ import {
StatusBadge,
ApprovalHistory,
notifyActiveStep,
notifyInitiatorOfReturn,
useRequestTypeId,
usePhasedRoute
} from '../../shared/approval';
@@ -110,14 +111,17 @@ const SkudExplanationView: React.FC<ISkudExplanationViewProps> = ({ context, ite
);
}
const notify = (): void => {
const notify = (newStatus?: string): void => {
// Решение уже применено; без requestTypeId просто некого уведомлять — UI не роняем.
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
const target = {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined);
};
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
};
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
@@ -175,7 +179,7 @@ const SkudExplanationView: React.FC<ISkudExplanationViewProps> = ({ context, ite
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
notify();
notify(newStatus);
}}
onDelegated={() => {
setDecisionSeq((n) => n + 1);
@@ -23,6 +23,7 @@ import {
ApprovalHistory,
useMatrixEditor,
notifyActiveStep,
notifyInitiatorOfReturn,
useRequestTypeId,
isEditableStatus,
APPROVAL_PHASE
@@ -102,20 +103,25 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
approval.getJobTitle(l)
);
// rowId — оповестить конкретную активную строку (см. notifyActiveStep).
const notify = (rowId?: number): void => {
if (!requestTypeId) return;
notifyActiveStep(
context,
approval,
itemId,
{
// Куда смотрит поток оповещения; undefined — тип заявки ещё не резолвился.
const notifyTarget = requestTypeId
? {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
},
rowId
).catch(() => undefined);
}
: undefined;
// rowId — оповестить конкретную активную строку (см. notifyActiveStep).
const notify = (rowId?: number): void => {
if (!notifyTarget) return;
notifyActiveStep(context, approval, itemId, notifyTarget, rowId).catch(() => undefined);
};
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
const notifyInitiator = (newStatus: string): void => {
if (!notifyTarget) return;
notifyInitiatorOfReturn(context, approval, itemId, notifyTarget, newStatus).catch(() => undefined);
};
const executorEditing = useExecutorEditing({
@@ -246,6 +252,7 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
setDecisionSeq((n) => n + 1);
// Очередь сдвинулась — уведомляем следующий активный шаг.
notify();
notifyInitiator(newStatus);
// Дошла до исполнения / завершилась / отклонена — карточка в Planner
// (повторно не создаётся, см. plannerHandoff.ts).
@@ -24,6 +24,7 @@ import {
ApprovalHistory,
useMatrixEditor,
notifyActiveStep,
notifyInitiatorOfReturn,
useRequestTypeId,
isEditableStatus,
APPROVAL_PHASE
@@ -144,13 +145,16 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
const sectionProps = { data, set, context, printOrgIndex };
const notify = (): void => {
const notify = (newStatus?: string): void => {
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
const target = {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined);
};
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
};
return (
@@ -222,7 +226,7 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
notify();
notify(newStatus);
}}
onDelegated={() => {
setDecisionSeq((n) => n + 1);
@@ -22,6 +22,7 @@ import {
useViewExecutors,
approvalPhaseRows,
notifyActiveStep,
notifyInitiatorOfReturn,
useRequestTypeId
} from '../../shared/approval';
import { friendlyErrorMessage } from '../../shared/diagnostics';
@@ -125,16 +126,19 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
const notify = (): void => {
const notify = (newStatus?: string): void => {
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
// весь UI ради необязательного оповещения.
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
const target = {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined);
};
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
};
return (
@@ -182,7 +186,7 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
notify();
notify(newStatus);
}}
onDelegated={() => {
setDecisionSeq((n) => n + 1);
@@ -28,6 +28,9 @@
- **Даты окончания нет**: отпуск задаётся началом и количеством дней, чтобы не считать
праздники и выходные. Колонка `EndDate` не используется: из провижининга, типа контента и представления она убрана (само системное поле SharePoint `FromBaseType` удалить нельзя).
- **Заголовок элемента** (`Title`): `Отпуск — {ФИО} — дд.мм.гггг`.
- **Комментарий** (с 10-28, `VacationComment`, Note) — необязательный, заполняет инициатор,
последним полем секции; в просмотре пустой не показывается. В DOCX для ЭДО (`vacationDocument.ts`) не идёт; в печать
тоже не попадает (обёртка `rp-no-print` с `display: contents`, чтобы на экране поле оставалось ячейкой сетки).
## Подписание в ЭДО
> **Сейчас выключено** (`VACATION_EDO_SIGNING_ENABLED = false` в `shared/config.ts`): решение последнего
@@ -71,7 +74,7 @@
SLA на бэкенде approval-route этот код не вызывает — такое заявление на подпись не уйдёт.
## Секции формы
RequestSection (сотрудник · должность · дата начала · количество дней)
RequestSection (сотрудник · должность · дата начала · количество дней · замещающий · комментарий)
## Точки входа
- rptype: `hr-vacation`
@@ -23,6 +23,7 @@ import {
ExecutorsMatrix,
approvalPhaseRows,
notifyActiveStep,
notifyInitiatorOfReturn,
useRequestTypeId
} from '../../shared/approval';
import { completesApprovalPhase } from '../../shared/approval/models/statuses';
@@ -143,16 +144,19 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
const notify = (): void => {
const notify = (newStatus?: string): void => {
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
// весь UI ради необязательного оповещения.
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
const target = {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined);
};
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
};
// Заявление уходит на подпись в ЭДО (по документу на каждую компанию сотрудника, одним запросом).
@@ -263,7 +267,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
notify();
notify(newStatus);
return VACATION_EDO_SIGNING_ENABLED && sentBeforeDecision.current ?'Документы отправлены на подписание в ЭДО.' : undefined;
}}
onDelegated={() => {
@@ -34,6 +34,7 @@ export async function loadVacationRequest(itemId: number, listId?: string): Prom
'DepartmentId',
'Project',
'ProjectId',
'VacationComment',
'RequestStatus',
'RequestUniqueCode',
'Employee/Title',
@@ -58,7 +59,8 @@ export async function loadVacationRequest(itemId: number, listId?: string): Prom
Department: (item.Department as string) || '',
DepartmentId: (item.DepartmentId as string) || '',
Project: (item.Project as string) || '',
ProjectId: (item.ProjectId as string) || ''
ProjectId: (item.ProjectId as string) || '',
VacationComment: (item.VacationComment as string) || ''
};
return {
@@ -44,6 +44,8 @@ export interface IFormData {
Project: string;
/** oid проекта ('' — если введён вручную без выбора из списка). */
ProjectId: string;
/** Комментарий инициатора к заявке — необязательный, для согласующих. */
VacationComment: string;
}
/** Обновление одного поля формы с сохранением типа значения. */
@@ -73,7 +75,8 @@ export const EMPTY_DATA: IFormData = {
Department: '',
DepartmentId: '',
Project: '',
ProjectId: ''
ProjectId: '',
VacationComment: ''
};
/** Плейсхолдер для поля выбора сотрудника. */
@@ -13,6 +13,7 @@
{ "internalName": "Department", "title": "Подразделение", "type": "Text" },
{ "internalName": "DepartmentId", "title": "Id подразделения (справочник)", "type": "Text" },
{ "internalName": "Project", "title": "Проект", "type": "Text" },
{ "internalName": "ProjectId", "title": "Id проекта (справочник)", "type": "Text" }
{ "internalName": "ProjectId", "title": "Id проекта (справочник)", "type": "Text" },
{ "internalName": "VacationComment", "title": "Комментарий", "type": "Note" }
]
}
@@ -0,0 +1,5 @@
// Обёртка поля только ради класса rp-no-print: contents убирает её из раскладки,
// поэтому поле остаётся ячейкой FormGrid и сохраняет растяжку `full`.
.noPrintField {
display: contents;
}
@@ -1,11 +1,15 @@
import * as React from 'react';
import { FormGrid, FormField, TextInput, PeopleField, OrganizationTable } from '../../../shared/ui';
import { FormGrid, FormField, TextInput, TextArea, PeopleField, OrganizationTable, useFormReadOnly } from '../../../shared/ui';
import { ProjectPicker } from '../../../shared/dictionary';
import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS, MAX_DAYS } from '../model';
import { isoDatePlusDays } from '../../../shared/dateUtils';
import styles from './RequestSection.module.scss';
/** Единственная секция формы «Заявка на отпуск»: сотрудник (должность и подразделение подтягиваются из справочника), даты. */
export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange, printOrgIndex }) => (
export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange, printOrgIndex }) => {
const readOnly = useFormReadOnly();
return (
<FormGrid>
<FormField label="Сотрудник" htmlFor="Employee" required full>
<PeopleField
@@ -56,5 +60,20 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
placeholder={SUBSTITUTE_PLACEHOLDER}
/>
</FormField>
{/* Пустой комментарий в просмотре не показываем — пустое поле только мешает читать.
В печатное заявление комментарий не входит — он для согласующих, не для кадров. */}
{(!readOnly || data.VacationComment.trim()) && (
<div className={`${styles.noPrintField} rp-no-print`}>
<FormField label="Комментарий" htmlFor="VacationComment" full>
<TextArea
id="VacationComment"
value={data.VacationComment}
onChange={(v) => set('VacationComment', v)}
rows={3}
/>
</FormField>
</div>
)}
</FormGrid>
);
};
@@ -43,7 +43,8 @@ function buildItem(
Department: data.Department || null,
DepartmentId: data.DepartmentId || null,
Project: data.Project || null,
ProjectId: data.ProjectId || null
ProjectId: data.ProjectId || null,
VacationComment: data.VacationComment.trim() || null
};
}
@@ -23,6 +23,7 @@ import {
ApprovalHistory,
useMatrixEditor,
notifyActiveStep,
notifyInitiatorOfReturn,
useRequestTypeId,
isEditableStatus,
APPROVAL_PHASE,
@@ -224,13 +225,16 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
const sectionProps = { data, set, context, printOrgIndex };
const canEditAnything = canEditAll || canEditReviewFields;
const notify = (): void => {
const notify = (newStatus?: string): void => {
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
const target = {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID),
}).catch(() => undefined);
};
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
};
return (
@@ -316,7 +320,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
// Очередь сдвинулась — уведомляем следующий активный шаг.
notify();
notify(newStatus);
}}
onDelegated={() => {
// Шаг переназначен: активной стала строка делегата — обновляем
@@ -57,6 +57,9 @@ const formRegistry: Record<string, React.LazyExoticComponent<FormComponent>> = {
),
'wh-concrete-request': React.lazy(
() => import(/* webpackChunkName: 'form-concrete-request' */ './ConcreteRequest/ConcreteRequestForm')
),
'hr-contract-change': React.lazy(
() => import(/* webpackChunkName: 'form-contract-change' */ './ContractChange/ContractChangeForm')
)
};
@@ -76,6 +76,9 @@ const viewRegistry: Record<string, React.LazyExoticComponent<ViewComponent>> = {
),
'wh-concrete-request': React.lazy(
() => import(/* webpackChunkName: 'view-concrete-request' */ './ConcreteRequest/ConcreteRequestView')
),
'hr-contract-change': React.lazy(
() => import(/* webpackChunkName: 'view-contract-change' */ './ContractChange/ContractChangeView')
)
};
@@ -59,7 +59,7 @@ export { approvalServiceFor } from './approvalServiceFactory';
export { ApproverService } from './approverDecision.service';
export type { ApproverDecision, IPendingRow } from './approverDecision.service';
export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service';
export { notifyActiveStep, postToFlow } from './notify.service';
export { notifyActiveStep, notifyInitiatorOfReturn, postToFlow } from './notify.service';
export type { INotifyTarget } from './notify.service';
export { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor';
export type { IUseMatrixEditor } from './useMatrixEditor';
@@ -1,8 +1,10 @@
import { AadHttpClient } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { FLOW_RESOURCE, FLOW_NOTIFY_URL, getNotificationsEnabled, getNotifyOnlyTo } from '../config';
import { getCreatedBy } from '../createdBy';
import { ApprovalListService } from './approvalList.service';
import { IApprovalRow } from './models/IApprovalRow';
import { REQUEST_STATUS } from './models/statuses';
import { pickRowsToNotify } from './notifyTargets';
// Данные формы для тела запроса к потоку (постоянны для формы).
@@ -42,11 +44,17 @@ export async function postToFlow(context: WebPartContext, flowUrl: string, body:
// живому человеку лучше не слать».
async function isNotifyAllowed(approval: ApprovalListService, approverId: number): Promise<boolean> {
if (!getNotificationsEnabled()) return false;
if (!getNotifyOnlyTo()) return true;
return isRecipientAllowed(await approval.resolveUserEmail(approverId));
}
// То же ограничение notifyOnlyTo, когда адрес получателя уже известен.
function isRecipientAllowed(email: string): boolean {
if (!getNotificationsEnabled()) return false;
const onlyTo = getNotifyOnlyTo();
if (!onlyTo) return true;
const email = await approval.resolveUserEmail(approverId);
if (email.toLowerCase() === onlyTo.toLowerCase()) return true;
console.warn(
@@ -109,3 +117,51 @@ export async function notifyActiveStep(
pickRowsToNotify(rows, rowId).map((row) => notifyRow(context, approval, requestId, target, row))
);
}
// Решения, после которых заявка уходит обратно к инициатору и ему нужно письмо.
const RETURNED_TO_INITIATOR_STATUSES: string[] = [REQUEST_STATUS.rejected, REQUEST_STATUS.returned];
// Сообщить инициатору, что заявку отклонили или вернули на доработку. Вызывать
// в onDecided с новым статусом заявки — при любом другом статусе ничего не делает.
//
// Поток тот же, что у notifyActiveStep: у него есть ветки «Отклонено» и «Возврат
// на доработку», в которых он пишет не согласующему строки, а автору заявки.
// notifyActiveStep до них не доходит — после такого решения активных строк нет.
//
// Строка — та, по которой решил ТЕКУЩИЙ пользователь: он только что принял
// решение. Искать «любую отклонённую» нельзя — на параллельном шаге второй
// участник может решить по уже отклонённой заявке, и в письме оказался бы чужой
// комментарий.
//
// Некритично, как и notifyActiveStep: ошибки глотаем.
export async function notifyInitiatorOfReturn(
context: WebPartContext,
approval: ApprovalListService,
requestId: number,
target: INotifyTarget,
requestStatus: string
): Promise<void> {
if (RETURNED_TO_INITIATOR_STATUSES.indexOf(requestStatus) === -1) {
return;
}
try {
const [rows, userId, initiator] = await Promise.all([
approval.getForRequest(requestId),
approval.getCurrentUserId(),
getCreatedBy(target.dataListName, requestId),
]);
const decidedRow = rows.filter((r) => r.status === requestStatus && r.approverId === userId)[0];
if (!decidedRow || !isRecipientAllowed(initiator?.email || '')) {
return;
}
await postToFlow(context, FLOW_NOTIFY_URL, {
RequestTypeId: target.requestTypeId,
RowID: decidedRow.id,
RequestID: requestId,
ListName: target.dataListName,
ApproveListName: target.approvalListName,
});
} catch {
// не критично — просто не отправили уведомление
}
}
@@ -0,0 +1,107 @@
@import '../../../../../styles/design-tokens';
// Сколько строк комментария видно до «Показать полностью». Три — короткие
// пометки («Вместо Иванова») видны целиком, а длинная причина отказа не
// растягивает таблицу маршрута на пол-экрана.
$comment-visible-lines: 3;
// Высота первой строки текста ($fs-sm × line-height 1.45 ≈ 19px): кавычка
// стоит вровень с ней, а не сползает вниз из-за своего крупного кегля.
$quote-line-height: 19px;
.comment {
display: flex;
align-items: flex-start;
gap: $sp-2;
padding: $sp-2 $sp-3;
border-left: 3px solid;
border-radius: 0 $r-sm $r-sm 0;
font-size: $fs-sm;
line-height: 1.45;
color: $gray-900;
}
// Цвет — у полосы и кавычки, фон лишь подтон: текст должен читаться так же
// спокойно, как остальная таблица, а не кричать.
.neutral {
border-left-color: $navy-600;
background: $navy-50;
.quote { color: $navy-600; }
}
.positive {
border-left-color: $green-600;
background: $green-50;
.quote { color: $green-600; }
}
.negative {
border-left-color: $red-500;
background: $red-50;
.quote { color: $red-500; }
}
.warning {
border-left-color: $gold-500;
background: $gold-50;
.quote { color: $gold-600; }
}
.quote {
flex: 0 0 auto;
font-family: $font-serif;
font-size: 22px;
font-weight: 600;
line-height: $quote-line-height;
}
.body {
flex: 1 1 auto;
min-width: 0; // иначе flex-элемент не сужается и длинный текст распирает таблицу
}
.text {
white-space: pre-wrap; // сохранить переносы строк из комментария
overflow-wrap: anywhere; // длинная ссылка без пробелов не распирает ячейку
}
.clamped {
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: $comment-visible-lines;
-webkit-box-orient: vertical;
}
.toggle {
margin-top: 2px;
padding: 0;
border: 0;
background: none;
font-family: inherit;
font-size: $fs-label;
font-weight: 500;
color: $navy-700;
cursor: pointer;
&:hover {
color: $gold-600;
text-decoration: underline;
}
}
// На бумаге кнопку не нажать — печатаем комментарий целиком.
@media print {
.clamped {
display: block;
overflow: visible;
-webkit-line-clamp: unset;
}
.toggle {
display: none;
}
}
@@ -0,0 +1,72 @@
import * as React from 'react';
import styles from './ApprovalComment.module.scss';
import { REQUEST_STATUS } from '../models/statuses';
import { useTextOverflow } from './useTextOverflow';
type CommentTone = 'positive' | 'negative' | 'warning' | 'neutral';
// Цвет полосы — по решению на шаге: причину отказа или возврата видно с первого
// взгляда, ещё до того, как глаз дойдёт до колонки «Статус».
const TONE_BY_STATUS: Record<string, CommentTone> = {
[REQUEST_STATUS.approved]: 'positive',
[REQUEST_STATUS.executed]: 'positive',
[REQUEST_STATUS.rejected]: 'negative',
[REQUEST_STATUS.returned]: 'warning',
};
const TONE_CLASS: Record<CommentTone, string> = {
positive: styles.positive,
negative: styles.negative,
warning: styles.warning,
neutral: styles.neutral,
};
const TEXT = {
expand: 'Показать полностью',
collapse: 'Свернуть',
};
export interface IApprovalCommentProps {
/** Текст комментария к решению на шаге. */
text: string;
/** Статус строки маршрута (см. REQUEST_STATUS) — от него цвет полосы. */
status?: string;
}
/**
* Комментарий участника маршрута к своему решению. Длинный текст свёрнут до
* нескольких строк (см. $comment-visible-lines), остальное — по кнопке.
*/
export const ApprovalComment: React.FC<IApprovalCommentProps> = ({ text, status }) => {
const [isExpanded, setIsExpanded] = React.useState(false);
const textRef = React.useRef<HTMLDivElement>(null);
const isClamped = useTextOverflow(textRef, text);
const tone = (status && TONE_BY_STATUS[status]) || 'neutral';
// В развёрнутом виде обрезки нет, и замер честно вернёт false, но кнопка
// «Свернуть» при этом всё равно нужна.
const showToggle = isExpanded || isClamped;
return (
<div className={`${styles.comment} ${TONE_CLASS[tone]}`}>
<span className={styles.quote} aria-hidden="true">
“
</span>
<div className={styles.body}>
<div ref={textRef} className={isExpanded ? styles.text : `${styles.text} ${styles.clamped}`}>
{text}
</div>
{showToggle && (
<button
type="button"
className={styles.toggle}
aria-expanded={isExpanded}
onClick={() => setIsExpanded((prev) => !prev)}
>
{isExpanded ? TEXT.collapse : TEXT.expand}
</button>
)}
</div>
</div>
);
};
@@ -7,9 +7,16 @@ import { IMatrixRow } from '../models/IMatrixRow';
import { StatusBadge } from './StatusBadge';
import { UserPicker } from './UserPicker';
import { ReplaceRowPanel } from './ReplaceRowPanel';
import { ApprovalComment } from './ApprovalComment';
import { useFlipRows } from './useFlipRows';
import { StepMoveDirection } from '../queuedStepMove';
/**
* Ключ <tr> с комментарием участника. Своя строка — свой ключ: анимация
* перестановки (useFlipRows) должна везти комментарий вместе с его участником.
*/
const commentRowKey = (rowKey: string): string => `${rowKey}:comment`;
export interface IApprovalMatrixProps {
editor: IUseMatrixEditor;
search: (query: string) => Promise<ISiteUser[]>;
@@ -114,10 +121,16 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
// Строка → шаг, которому она принадлежит. Нужно, чтобы отрисовать ячейку
// «Шаг» один раз на весь шаг (rowSpan) и понять, можно ли двигать его выше/ниже.
const blockByRow: Array<{ index: number; start: number; size: number }> = [];
// size — сколько участников в шаге;
// span — сколько <tr> он занимает в таблице: у участника с комментарием
// под его строкой идёт ещё одна (см. ApprovalComment), и объединённые
// ячейки «Шаг» и стрелок обязаны её накрыть, иначе таблица съедет.
const blockByRow: Array<{ index: number; start: number; size: number; span: number }> = [];
blocks.forEach((b, bi) => {
const size = b.end - b.start + 1;
const commentCount = rows.slice(b.start, b.end + 1).filter((r) => !!r.comment).length;
for (let i = b.start; i <= b.end; i += 1) {
blockByRow[i] = { index: bi, start: b.start, size: b.end - b.start + 1 };
blockByRow[i] = { index: bi, start: b.start, size, span: size + commentCount };
}
});
@@ -139,7 +152,13 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
return;
}
const b = blockByRow[rowIndex];
setActiveKeys(rowKeys.slice(b.start, b.start + b.size));
// Подсвечиваем и поднимаем шаг вместе со строками комментариев его участников.
const blockKeys: string[] = [];
for (let i = b.start; i < b.start + b.size; i += 1) {
blockKeys.push(rowKeys[i]);
if (rows[i].comment) blockKeys.push(commentRowKey(rowKeys[i]));
}
setActiveKeys(blockKeys);
if (direction === 'up') editor.moveBlockUp(rowIndex);
else editor.moveBlockDown(rowIndex);
};
@@ -190,8 +209,12 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
if (replacingRow && onReplaceRow) onReplaceRow(replacingRow, user);
};
// Число колонок (для colSpan) — на случай будущих нужд не используется: комментарий
// выводим внутри ячейки согласующего, чтобы не ломать rowSpan колонки «Шаг».
// Комментарий занимает всю ширину между объединёнными колонками «Шаг» и
// стрелок: «Согласующий» + «Должность» + те необязательные колонки, что
// сейчас показаны. Колонку действий не захватывает — в ней своя пустая ячейка.
const optionalColumns = [hasDepartment, hasOrganization, hasStatuses, hasDates, hasSla];
const commentColSpan = 2 + optionalColumns.filter(Boolean).length;
const hasActionsCol = editable || hasRemovable;
return (
<div className={styles.matrixWrap}>
@@ -223,21 +246,24 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
{hasDates && <th>Дата</th>}
{hasSla && <th>SLA</th>}
{showMoveCol && <th />}
{(editable || hasRemovable) && <th />}
{hasActionsCol && <th />}
</tr>
</thead>
<tbody>
{rows.map((r, i) => {
const block = blockByRow[i];
const isBlockStart = block.start === i;
const isParallel = block.size > 1;
const rowClassNames: string[] = [];
if (isParallel) rowClassNames.push(styles.parallelRow);
if (r.comment) rowClassNames.push(styles.rowWithComment);
const commentClassNames: string[] = [styles.commentRow];
if (isParallel) commentClassNames.push(styles.parallelRow);
return (
<tr
key={rowKeys[i]}
ref={registerRow(rowKeys[i])}
className={block.size > 1 ? styles.parallelRow : undefined}
>
<React.Fragment key={rowKeys[i]}>
<tr ref={registerRow(rowKeys[i])} className={rowClassNames.join(' ') || undefined}>
{isBlockStart && (
<td className={styles.matrixStepCol} rowSpan={block.size}>
<td className={styles.matrixStepCol} rowSpan={block.span}>
<span className={styles.stepNo}>{r.order}</span>
</td>
)}
@@ -255,22 +281,13 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
<span className={styles.warningInline}>{r.warning}</span>
)}
{/* Доп. участник шага, добавленный переназначением — помечаем,
кто его добавил. Пусто у обычных строк маршрута. Как и
комментарий — inline под именем, чтобы не ломать rowSpan
колонки «Шаг» у параллельных шагов. */}
кто его добавил. Пусто у обычных строк маршрута. */}
{r.addedBy && (
<span className={styles.addedByInline}>
<span className={styles.addedByLabel}>{addedByLabel}</span>{' '}
{r.addedBy}
</span>
)}
{/* Комментарий решения — мелким текстом под именем (не отдельной
строкой: иначе ломается rowSpan колонки «Шаг» у параллельных). */}
{r.comment && (
<span className={styles.commentInline}>
<span className={styles.commentLabel}>Комментарий:</span> {r.comment}
</span>
)}
</td>
<td data-label="Должность">{r.position}</td>
{hasDepartment && <td data-label="Отдел">{r.department || '—'}</td>}
@@ -292,7 +309,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
{/* Перемещение действует на ШАГ целиком (параллельные строки
переезжают вместе): одна пара стрелок на весь шаг. */}
{showMoveCol && isBlockStart && (
<td className={styles.matrixMoveCol} rowSpan={block.size}>
<td className={styles.matrixMoveCol} rowSpan={block.span}>
{/* В поданной заявке у шагов, которые двигать нельзя совсем
(активный, пройденный), стрелок нет — иначе вся колонка
из неактивных кнопок только шумит. */}
@@ -320,7 +337,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
)}
</td>
)}
{(editable || hasRemovable) && (
{hasActionsCol && (
<td className={styles.matrixActions}>
{editable && (
<button
@@ -360,6 +377,19 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
</td>
)}
</tr>
{/* Комментарий — отдельной строкой во всю ширину под участником:
в узкой колонке «Согласующий» текст ломался по слову на строку.
Ячейки «Шаг» и стрелок её не повторяют — их накрывает rowSpan
(см. block.span); колонка действий — пустая ячейка. */}
{r.comment && (
<tr ref={registerRow(commentRowKey(rowKeys[i]))} className={commentClassNames.join(' ')}>
<td colSpan={commentColSpan} className={styles.commentCell}>
<ApprovalComment text={r.comment} status={r.status} />
</td>
{hasActionsCol && <td />}
</tr>
)}
</React.Fragment>
);
})}
</tbody>
@@ -157,20 +157,18 @@
}
}
// Комментарий решения — мелким текстом под именем согласующего (в той же ячейке,
// а не отдельной строкой: иначе ломается rowSpan колонки «Шаг» у параллельных).
.commentInline {
display: block;
margin-top: 3px;
font-size: $fs-caption;
color: $gray-500;
line-height: 1.45;
white-space: pre-wrap; // сохранить переносы строк из комментария
// Строка участника, под которой идёт его комментарий (см. ApprovalComment):
// разделитель между ними убираем, иначе комментарий читается как отдельная
// запись маршрута. Ячейки «Шаг» и стрелок не трогаем — они объединены на весь
// шаг (rowSpan), и их нижняя граница — это граница всего шага.
.rowWithComment > td:not(.matrixStepCol):not(.matrixMoveCol) {
border-bottom-color: transparent;
}
.commentLabel {
color: $gray-400;
font-weight: 500;
// Отступ сверху убран: комментарий прилегает к строке своего участника.
// Двойной класс — чтобы перебить padding из `.matrix td`.
.matrix .commentCell {
padding: 0 8px 10px;
}
// Пометка «доп. согласующий, добавил: …» — под именем согласующего. Золотой
@@ -197,7 +195,7 @@
// Причина, по которой approval-route не назначил человека на шаг (см.
// ApprovalMatrix.tsx/r.warning) — красным, как настоящая проблема маршрута
// (в отличие от нейтрального addedByInline/commentInline): пустая строка
// (в отличие от нейтрального addedByInline): пустая строка
// «Согласующий» без этого текста выглядит как баг, а не как «маршрут просит
// вмешательства администратора».
.warningInline {
@@ -371,12 +369,30 @@
font-weight: 500;
}
// Комментарий под именем — на всю ширину ячейки (своя строка).
.commentInline,
.addedByInline {
flex-basis: 100%;
}
// Карточка участника и его комментарий — одна карточка: без линии и
// отступа между ними.
.matrix tr.rowWithComment {
border-bottom: 0;
padding-bottom: 0;
}
.matrix tr.commentRow {
padding-top: $sp-1;
}
.commentCell > * {
flex: 1 1 100%;
}
// Пустая ячейка под колонкой действий на телефоне дала бы лишний отступ.
.commentRow > td:empty {
display: none;
}
.matrixActions,
.matrixMoveCol {
justify-content: flex-start;
@@ -0,0 +1,31 @@
import * as React from 'react';
/** Расхождение меньше этого — округление субпикселей, а не обрезанный текст. */
const OVERFLOW_TOLERANCE_PX = 1;
/**
* Обрезан ли текст элемента по высоте (например, CSS line-clamp).
*
* Заранее по длине строки это не понять: сколько строк займёт текст, зависит от
* ширины колонки, а она меняется вместе с окном и набором колонок таблицы. Поэтому
* меряем реальную раскладку и перемеряем при каждом изменении размера элемента.
*/
export function useTextOverflow<T extends HTMLElement>(ref: React.RefObject<T>, text: string): boolean {
const [isOverflowing, setIsOverflowing] = React.useState(false);
// useLayoutEffect, а не useEffect: кнопка «Показать полностью» должна появиться
// в том же кадре, что и текст, иначе строка таблицы дёрнется по высоте.
React.useLayoutEffect(() => {
const el = ref.current;
if (!el) return undefined;
const check = (): void => setIsOverflowing(el.scrollHeight - el.clientHeight > OVERFLOW_TOLERANCE_PX);
check();
const observer = new ResizeObserver(check);
observer.observe(el);
return () => observer.disconnect();
}, [ref, text]);
return isOverflowing;
}
@@ -1,5 +1,6 @@
@import '../../../../styles/design-tokens';
// Прочерк вместо пустого слота в режиме чтения.
.empty {
font-family: $font-sans;
font-size: 13.5px;
@@ -0,0 +1,52 @@
import * as React from 'react';
import { AttachmentList, FileDropzone, FormField } from '../ui';
import { IAttachmentEditor } from './useAttachmentEditor';
import { attachmentsOfCategory, attachmentsWithoutCategories, categoryPrefix } from './attachmentCategories';
import styles from './AttachmentSlotField.module.scss';
interface Props {
/** Подпись поля — что за документ. */
label: string;
editor: IAttachmentEditor;
/** Можно добавлять и удалять файлы (форма подачи / согласующий с полным правом). */
editable: boolean;
/** Группа новых файлов в editor.pending. */
groupKey: string;
/** Категория в имени файла (см. attachmentCategories); не задана — «прочие» вложения. */
category?: string;
/** Для «прочих» (category не задана): категории формы, файлы которых сюда НЕ попадают. */
knownCategories?: string[];
/** Звёздочка «обязательно» у подписи; саму проверку делает форма. */
required?: boolean;
}
/**
* Поле одного подписанного слота вложений: уже прикреплённые файлы + (в правке)
* выбор новых. Новые файлы копятся в editor.pending[groupKey] и загружаются
* формой при сохранении (editor.uploadPending), с префиксом категории в имени.
*/
export const AttachmentSlotField: React.FC<Props> = ({
label,
editor,
editable,
groupKey,
category,
knownCategories = [],
required
}) => {
const items = category
? attachmentsOfCategory(editor.items, category)
: attachmentsWithoutCategories(editor.items, knownCategories);
// В списке имена показаны без префикса категории — для удаления возвращаем его.
const removeWithPrefix = (name: string): void => editor.remove(category ? categoryPrefix(category) + name : name);
return (
<FormField label={label} required={required} full>
{items.length > 0 && <AttachmentList items={items} onRemove={editable ? removeWithPrefix : undefined} />}
{items.length === 0 && !editable && <span className={styles.empty}>—</span>}
{editable && (
<FileDropzone files={editor.pending[groupKey] || []} onChange={(files) => editor.setPending(groupKey, files)} />
)}
</FormField>
);
};
@@ -0,0 +1,27 @@
import {
categoryPrefix,
attachmentsOfCategory,
attachmentsWithoutCategories
} from './attachmentCategories';
const file = (fileName: string): { fileName: string; url: string } => ({ fileName, url: `/att/${fileName}` });
describe('attachmentCategories', () => {
const items = [file('Резюме — cv.pdf'), file('Удостоверение — id.jpg'), file('старый.docx')];
it('префикс — подпись и длинное тире с пробелами', () => {
expect(categoryPrefix('Резюме')).toBe('Резюме — ');
});
it('файлы категории — с обрезанным префиксом и прежней ссылкой', () => {
expect(attachmentsOfCategory(items, 'Резюме')).toEqual([{ fileName: 'cv.pdf', url: '/att/Резюме — cv.pdf' }]);
});
it('без категорий — только файлы, не попавшие ни в одну из известных', () => {
expect(attachmentsWithoutCategories(items, ['Резюме', 'Удостоверение'])).toEqual([file('старый.docx')]);
});
it('без известных категорий «прочие» — это все файлы', () => {
expect(attachmentsWithoutCategories(items, [])).toEqual(items);
});
});
@@ -0,0 +1,46 @@
import { IAttachmentInfo } from './attachments.service';
/**
* Подписанные вложения («категории»). Вложения SharePoint — плоская коллекция
* файлов на элементе списка, подписи у них нет. Поэтому подпись (что за
* документ) кодируем ПРЕФИКСОМ в имени файла: «<Подпись> — <исходное имя>».
* При загрузке имя файла переписываем, при показе — группируем по префиксу и
* префикс срезаем. Вынесено из «Заявки на приём», где появилось первым.
*/
const SEP = ' — ';
/** Префикс имени файла для категории: «Удостоверение — ». */
export function categoryPrefix(category: string): string {
return `${category}${SEP}`;
}
/**
* Копии File с префиксом категории в имени — для загрузки. File.name неизменяемо,
* поэтому создаём новый File с тем же содержимым и типом, но другим именем.
*/
export function prefixFiles(files: File[], category: string): File[] {
const prefix = categoryPrefix(category);
return files.map((f) => new File([f], prefix + f.name, { type: f.type }));
}
/**
* Вложения указанной категории, с ОБРЕЗАННЫМ префиксом в имени (для показа).
* url сохраняется как есть; чтобы удалить такой файл, префикс надо вернуть
* обратно (categoryPrefix + показанное имя).
*/
export function attachmentsOfCategory(items: IAttachmentInfo[], category: string): IAttachmentInfo[] {
const prefix = categoryPrefix(category);
return items
.filter((a) => a.fileName.indexOf(prefix) === 0)
.map((a) => ({ url: a.url, fileName: a.fileName.slice(prefix.length) }));
}
/**
* Вложения, не относящиеся ни к одной из известных категорий формы (старые
* заявки, «прочие»). Показываем как есть, чтобы файлы не «исчезали» с экрана.
*/
export function attachmentsWithoutCategories(items: IAttachmentInfo[], categories: string[]): IAttachmentInfo[] {
const prefixes = categories.map(categoryPrefix);
return items.filter((a) => !prefixes.some((prefix) => a.fileName.indexOf(prefix) === 0));
}
@@ -4,10 +4,16 @@ import { friendlyErrorMessage } from '../diagnostics';
/**
* Подготовка новых файлов группы к загрузке — напр. префикс категории в имени
* (см. forms/RecruitingHire/attachmentCategories). По умолчанию — как есть.
* (см. attachmentCategories). По умолчанию — как есть.
*/
export type PrepareFiles = (groupKey: string, files: File[]) => File[];
/** Куда грузить: список (Title текущей партиции или url архивной) + id элемента. */
export interface IAttachmentTarget {
listRef: string;
itemId: number;
}
export interface IAttachmentEditor {
/** Вложения, уже прикреплённые к заявке. */
items: IAttachmentInfo[];
@@ -17,8 +23,12 @@ export interface IAttachmentEditor {
setPending: (groupKey: string, files: File[]) => void;
/** Удалить прикреплённый файл сразу из заявки (по полному имени файла в SP). */
remove: (fileName: string) => void;
/** Загрузить все выбранные файлы и перечитать список вложений. */
uploadPending: (prepare?: PrepareFiles) => Promise<void>;
/**
* Загрузить все выбранные файлы и перечитать список вложений. target — когда
* элемент только что создан и его id ещё не дошёл до хука (сохранение новой
* заявки); не задан — грузим в listRef/itemId хука.
*/
uploadPending: (prepare?: PrepareFiles, target?: IAttachmentTarget) => Promise<void>;
/** Ошибка последнего удаления — показать рядом со списком. */
error: string | undefined;
}
@@ -26,14 +36,16 @@ export interface IAttachmentEditor {
const keepAsIs: PrepareFiles = (_groupKey, files) => files;
/**
* Правка вложений уже поданной заявки — для согласующего с полным правом
* (см. shared/approval/fullEditRights.ts). Удаление — сразу, как в форме
* автора; новые файлы копятся и загружаются вместе с сохранением полей
* (кнопка «Сохранить» или решение), чтобы не было второй кнопки сохранения.
* Правка вложений заявки: удаление — сразу, новые файлы копятся и загружаются
* вместе с сохранением полей (чтобы не было второй кнопки сохранения).
* Используется в просмотре согласующего с полным правом (см. shared/approval/
* fullEditRights.ts) и в формах подачи.
*
* listRef — Title текущей партиции или url архивной (см. attachments.service).
* itemId — undefined, пока новая заявка не сохранена: удалять ещё нечего, а
* загрузка идёт с явным target.
*/
export function useAttachmentEditor(listRef: string, itemId: number): IAttachmentEditor {
export function useAttachmentEditor(listRef: string, itemId: number | undefined): IAttachmentEditor {
const [items, setItems] = useState<IAttachmentInfo[]>([]);
const [pending, setPendingState] = useState<Record<string, File[]>>({});
const [error, setError] = useState<string | undefined>(undefined);
@@ -42,22 +54,27 @@ export function useAttachmentEditor(listRef: string, itemId: number): IAttachmen
setPendingState((prev) => ({ ...prev, [groupKey]: files }));
const remove = (fileName: string): void => {
if (itemId === undefined) return;
setError(undefined);
deleteAttachment(listRef, itemId, fileName)
.then(() => setItems((prev) => prev.filter((a) => a.fileName !== fileName)))
.catch((e) => setError(`Не удалось удалить файл «${fileName}»: ${friendlyErrorMessage(e)}`));
};
const uploadPending = async (prepare: PrepareFiles = keepAsIs): Promise<void> => {
const uploadPending = async (prepare: PrepareFiles = keepAsIs, target?: IAttachmentTarget): Promise<void> => {
const files = Object.keys(pending).reduce<File[]>(
(acc, groupKey) => acc.concat(prepare(groupKey, pending[groupKey])),
[]
);
if (files.length === 0) return;
await uploadAttachments(listRef, itemId, files);
const to = target ?? (itemId === undefined ? undefined : { listRef, itemId });
if (!to) {
throw new Error('Не удалось прикрепить файлы: заявка ещё не сохранена.');
}
await uploadAttachments(to.listRef, to.itemId, files);
// Загруженные убираем из выбора, иначе повторное сохранение продублирует их.
setPendingState({});
setItems(await getAttachments(listRef, itemId));
setItems(await getAttachments(to.listRef, to.itemId));
};
return { items, setItems, pending, setPending, remove, uploadPending, error };
@@ -0,0 +1,94 @@
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../config';
import { IEmployeeFullBaseRecord } from './models/IEmployeeFullBaseRecord';
import { IEmployeeFullBasePerson } from './models/IEmployeeFullBasePerson';
// null из API → '' (см. IEmployeeFullBaseRecord).
function textOrEmpty(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function toRecord(r: Record<string, unknown>): IEmployeeFullBaseRecord {
return {
id: textOrEmpty(r.id),
fio: textOrEmpty(r.fio),
iin: textOrEmpty(r.iin),
code: textOrEmpty(r.code),
company: textOrEmpty(r.company),
// Должности в базе бывают с хвостовым пробелом («Монтажник ») — textOrEmpty его срезает.
position: textOrEmpty(r.position),
department: textOrEmpty(r.department),
// В API опечатка «admision» — переименовываем на входе, дальше по коду правильное имя.
admissionDate: textOrEmpty(r.admisionDate),
dismissalDate: textOrEmpty(r.dismissalDate),
email: textOrEmpty(r.email)
};
}
/**
* GET /Employee/FullBase?Fio=... — работающие сотрудники по части Ф.И.О.
*
* Без фильтра эндпоинт отдаёт всю базу (тысячи записей, с уволенными), поэтому
* ищем на сервере по мере ввода, как PositionPicker — и ничего не кешируем.
*
* Уволенных отсекаем по dismissalDate на клиенте. Параметр Status у эндпоинта
* есть, но его значения не задокументированы — ДОПУЩЕНИЕ, проверить и, если он
* фильтрует работающих, перейти на него.
*/
export async function searchActiveEmployees(
context: WebPartContext,
fio: string,
maxResults: number
): Promise<IEmployeeFullBaseRecord[]> {
const query = fio.trim();
if (!query) return [];
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
const response: HttpClientResponse = await client.get(
`${DICTIONARY_API_BASE_URL}/Employee/FullBase?Fio=${encodeURIComponent(query)}`,
AadHttpClient.configurations.v1
);
if (response.status === 404) return [];
if (!response.ok) {
throw new Error(`Employee/FullBase: ${response.status} ${response.statusText}`);
}
const body = (await response.json()) as Array<Record<string, unknown>>;
return body
.map(toRecord)
.filter((r) => !r.dismissalDate)
.slice(0, maxResults);
}
/** Чей это человек: ИИН, а без него — id записи (тогда запись считается отдельным человеком). */
export function personKey(record: IEmployeeFullBaseRecord): string {
return record.iin || record.id;
}
/**
* Уникальный ключ места работы — для value в выпадающем списке. Не только id:
* у совместителя на каждое место работы может прийти один и тот же id человека.
*/
export function workplaceKey(record: IEmployeeFullBaseRecord): string {
return `${record.id}|${record.company}|${record.position}`;
}
/** Человек из одной записи — когда остальные его места работы неизвестны (напр. просмотр заявки). */
export function personFromWorkplace(record: IEmployeeFullBaseRecord): IEmployeeFullBasePerson {
return { key: personKey(record), fio: record.fio, iin: record.iin, email: record.email, workplaces: [record] };
}
/** Записи FullBase → люди (по ИИН), порядок — по первому появлению человека в ответе. */
export function groupEmployeesByPerson(records: IEmployeeFullBaseRecord[]): IEmployeeFullBasePerson[] {
const people: IEmployeeFullBasePerson[] = [];
records.forEach((record) => {
const person = people.find((p) => p.key === personKey(record));
if (!person) {
people.push(personFromWorkplace(record));
return;
}
person.workplaces.push(record);
if (!person.email) person.email = record.email;
});
return people;
}
@@ -19,6 +19,17 @@ export {
employeeCompanies
} from './employeeOrganizations.service';
export type { IEmployeeOrganization } from './models/IEmployeeOrganization';
export {
searchActiveEmployees,
groupEmployeesByPerson,
personKey,
personFromWorkplace,
workplaceKey
} from './employeeFullBase.service';
export type { IEmployeeFullBaseRecord } from './models/IEmployeeFullBaseRecord';
export type { IEmployeeFullBasePerson } from './models/IEmployeeFullBasePerson';
export { EmployeeFullBasePicker } from './ui/EmployeeFullBasePicker';
export type { IEmployeeFullBasePickerProps } from './ui/EmployeeFullBasePicker';
export { getNomenclature } from './nomenclature.service';
export type { INomenclatureRow } from './models/INomenclatureRow';
export { searchPositions } from './positions.service';
@@ -33,5 +44,9 @@ export {
} from './employeeLookup.service';
export { CityPicker } from './ui/CityPicker';
export type { ICityPickerProps, ICityValue } from './ui/CityPicker';
export { formatEmployeeOrganizationsWithDepartment, parseOrganizationLine } from './organizationLines';
export {
formatEmployeeOrganizationsWithDepartment,
formatOrganizationLine,
parseOrganizationLine
} from './organizationLines';
export type { IOrganizationLine } from './organizationLines';
@@ -0,0 +1,17 @@
import { IEmployeeFullBaseRecord } from './IEmployeeFullBaseRecord';
/**
* Человек из Employee/FullBase — все его записи (места работы), собранные по ИИН.
* Пикер выбирает человека, а конкретное место работы форма выбирает отдельно
* (у совместителя их несколько) — как «Командирующая организация» в командировке.
*/
export interface IEmployeeFullBasePerson {
/** Ключ группировки: ИИН, а если его нет — id записи (см. personKey). */
key: string;
fio: string;
iin: string;
/** Первый непустой e-mail среди мест работы; '' — нет учётной записи. */
email: string;
/** Места работы, минимум одно. */
workplaces: IEmployeeFullBaseRecord[];
}
@@ -0,0 +1,29 @@
/**
* Одна запись из GET /Employee/FullBase — полная база сотрудников из кадровой
* системы, ВКЛЮЧАЯ уволенных и рабочих без учётной записи M365.
*
* Запись = одно место работы, а не человек: совместитель приходит несколькими
* записями с одним iin и разными company/position/department.
*
* В отличие от Employee/ByEmail (поля с большой буквы), этот эндпоинт отдаёт
* camelCase — так в ответе API. null из API приводится к '' (как в
* IEmployeeOrganization): «нет значения» везде — пустая строка.
*/
export interface IEmployeeFullBaseRecord {
id: string;
/** «Фамилия Имя Отчество». */
fio: string;
/** ИИН, 12 цифр. */
iin: string;
/** Табельный номер в 1С (напр. «ЗК-0073429»). */
code: string;
company: string;
position: string;
department: string;
/** Дата приёма, «YYYY-MM-DDT00:00:00»; '' — не указана. */
admissionDate: string;
/** Дата увольнения; '' — работает. */
dismissalDate: string;
/** Рабочий e-mail; '' — у сотрудника нет учётной записи (рабочие на объектах). */
email: string;
}
@@ -8,12 +8,17 @@ import { IEmployeeOrganization } from './models/IEmployeeOrganization';
* формат прежний, как у formatEmployeeOrganizations.
*/
export function formatEmployeeOrganizationsWithDepartment(records: IEmployeeOrganization[]): string {
return records
.map((r) => {
if (r.section) return [r.company, r.position, r.section].join(' — ');
return r.position ? `${r.company} — ${r.position}` : r.company;
})
.join('\n');
return records.map((r) => formatOrganizationLine(r.company, r.position, r.section)).join('\n');
}
/**
* Одна строка «Компания — должность[ — подразделение]» — тот же формат, что у
* formatEmployeeOrganizationsWithDepartment, для источников кроме Employee/ByEmail
* (напр. запись Employee/FullBase).
*/
export function formatOrganizationLine(company: string, position: string, department: string): string {
if (department) return [company, position, department].join(' — ');
return position ? `${company} — ${position}` : company;
}
export interface IOrganizationLine {
@@ -0,0 +1,111 @@
@import '../../../../../styles/design-tokens';
// Поле и выпадающий список — в той же стилистике, что PositionPicker.
.wrap {
position: relative;
}
.input {
box-sizing: border-box;
width: 100%;
// справа место под крестик-очистку
padding: 11px 36px 11px 13px;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
font-family: $font-sans;
font-size: 13.5px;
color: $gray-900;
transition: .18s;
@include input-focus-gold;
@include readonly-field(':read-only');
&::placeholder {
color: $gray-400;
}
}
.clear {
position: absolute;
top: 50%;
right: 8px;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: 0;
border-radius: $r-sm;
background: transparent;
color: $gray-400;
font-size: 18px;
line-height: 1;
cursor: pointer;
transition: .15s;
&:hover {
background: $gray-100;
color: $gray-700;
}
}
.list {
position: absolute;
z-index: 20;
left: 0;
right: 0;
margin-top: $sp-1;
max-height: 300px;
overflow-y: auto;
background: $white;
border: 1px solid $gray-200;
border-radius: $r-sm;
box-shadow: $sh-2;
}
.item {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 9px 12px;
border: 0;
background: transparent;
text-align: left;
font-family: $font-sans;
cursor: pointer;
&:hover {
background: $navy-50;
}
}
.itemName {
font-size: $fs-sm;
color: $gray-900;
}
.itemMeta {
font-size: 12px;
color: $gray-500;
overflow-wrap: anywhere;
}
.status,
.error {
padding: 9px 12px;
font-family: $font-sans;
font-size: $fs-sm;
}
.status {
color: $gray-500;
}
.error {
color: $red-500;
}
@@ -0,0 +1,169 @@
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './EmployeeFullBasePicker.module.scss';
import { useFormReadOnly } from '@tnsu/ui-kit';
import { searchActiveEmployees, groupEmployeesByPerson } from '../employeeFullBase.service';
import { IEmployeeFullBasePerson } from '../models/IEmployeeFullBasePerson';
export interface IEmployeeFullBasePickerProps {
context: WebPartContext;
id?: string;
/** Выбранный человек (со всеми местами работы); undefined — не выбран. */
value: IEmployeeFullBasePerson | undefined;
onChange: (value: IEmployeeFullBasePerson | undefined) => void;
placeholder?: string;
/** Только чтение (режим согласующего). */
readOnly?: boolean;
}
const DEBOUNCE_MS = 300;
// Записей у совместителя несколько на человека — берём с запасом, людей в списке потом меньше.
const MAX_RECORDS = 60;
// По 1–2 буквам совпадений тысячи — запрос тяжёлый, а выбрать из такого списка всё равно нельзя.
const MIN_QUERY_LENGTH = 3;
const TEXT_SEARCHING = 'Поиск…';
const TEXT_TYPE_MORE = `Введите не меньше ${MIN_QUERY_LENGTH} букв Ф.И.О.`;
const TEXT_NOT_FOUND = 'Работающих сотрудников с таким Ф.И.О. не найдено';
const TEXT_LOAD_ERROR = 'Не удалось загрузить список сотрудников. Попробуйте ещё раз.';
/** Компании человека через запятую — чтобы различать тёзок. */
function describePerson(person: IEmployeeFullBasePerson): string {
return person.workplaces.map((w) => w.company).filter(Boolean).join(', ');
}
/**
* Выбор сотрудника из полной кадровой базы (GET /Employee/FullBase) — в отличие
* от PeoplePicker находит и тех, у кого нет учётной записи M365.
*
* Значение — человек целиком (все места работы, собранные по ИИН), а не текст:
* ручной ввод не принимается, иначе у заявки не будет ИИН, компании и e-mail.
* Начали править текст после выбора — выбор сбрасывается (onChange(undefined)).
* Конкретное место работы (организацию) форма выбирает отдельным полем.
*/
export const EmployeeFullBasePicker: React.FC<IEmployeeFullBasePickerProps> = ({
context,
id,
value,
onChange,
placeholder,
readOnly
}) => {
const contextReadOnly = useFormReadOnly();
const effectiveReadOnly = readOnly ?? contextReadOnly;
const [query, setQuery] = React.useState<string>('');
const [suggestions, setSuggestions] = React.useState<IEmployeeFullBasePerson[]>([]);
const [open, setOpen] = React.useState<boolean>(false);
const [loading, setLoading] = React.useState<boolean>(false);
const [loadError, setLoadError] = React.useState<boolean>(false);
// Счётчик запросов — ответ на устаревший запрос не перезаписывает более свежий (как в PositionPicker).
const requestIdRef = React.useRef(0);
const trimmedQuery = query.trim();
const isQueryTooShort = trimmedQuery.length < MIN_QUERY_LENGTH;
React.useEffect(() => {
if (isQueryTooShort) {
setSuggestions([]);
setLoading(false);
setLoadError(false);
return;
}
const requestId = ++requestIdRef.current;
setLoading(true);
const timer = setTimeout(() => {
searchActiveEmployees(context, trimmedQuery, MAX_RECORDS)
.then((records) => {
if (requestIdRef.current !== requestId) return;
setSuggestions(groupEmployeesByPerson(records));
setLoadError(false);
setLoading(false);
})
.catch(() => {
if (requestIdRef.current !== requestId) return;
setSuggestions([]);
setLoadError(true);
setLoading(false);
});
}, DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [context, trimmedQuery, isQueryTooShort]);
const handleInput = (text: string): void => {
if (value) onChange(undefined);
setQuery(text);
setOpen(true);
};
const handlePick = (person: IEmployeeFullBasePerson): void => {
onChange(person);
setQuery('');
setOpen(false);
};
const handleClear = (): void => {
onChange(undefined);
setQuery('');
setOpen(false);
};
const statusText = getStatusText(loading, loadError, isQueryTooShort, suggestions.length);
const isListShown = !effectiveReadOnly && open && !value && trimmedQuery.length > 0;
return (
<div className={styles.wrap}>
<input
id={id}
className={styles.input}
value={value ? value.fio : query}
placeholder={placeholder}
readOnly={effectiveReadOnly}
autoComplete="off"
onChange={(e) => handleInput(e.target.value)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
/>
{/* preventDefault на mousedown — чтобы клик не «съел» фокус до onClick (как в PositionPicker). */}
{!effectiveReadOnly && (value || query) && (
<button
type="button"
className={styles.clear}
aria-label="Очистить"
title="Очистить"
onMouseDown={(e) => e.preventDefault()}
onClick={handleClear}
>
×
</button>
)}
{isListShown && (
<div className={styles.list}>
{statusText && <div className={loadError ? styles.error : styles.status}>{statusText}</div>}
{!statusText &&
suggestions.map((person) => (
<button
key={person.key}
type="button"
className={styles.item}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handlePick(person)}
>
<span className={styles.itemName}>{person.fio}</span>
<span className={styles.itemMeta}>{describePerson(person)}</span>
</button>
))}
</div>
)}
</div>
);
};
/** Текст служебной строки списка; '' — показать сами подсказки. */
function getStatusText(loading: boolean, loadError: boolean, isQueryTooShort: boolean, found: number): string {
if (isQueryTooShort) return TEXT_TYPE_MORE;
if (loading) return TEXT_SEARCHING;
if (loadError) return TEXT_LOAD_ERROR;
if (found === 0) return TEXT_NOT_FOUND;
return '';
}
@@ -34,6 +34,7 @@ const LIST_TITLE_BASE: Record<RequestTypeId, string> = {
'hr-unpaid-leave': 'Отпуск без содержания',
'hr-skud-explanation': 'Объяснительная по СКУД',
'hr-business-trip': 'Заявка на командировку',
'hr-contract-change': 'Изменение условий трудового договора',
'it-1c-ticket': 'Заявка в отдел 1С',
'it-workplace-setup': 'Организация рабочего места',
'recruiting-hire': 'Прием на работу',