fix: changed script to crete cells from list to site
This commit is contained in:
@@ -0,0 +1,27 @@
|
|||||||
|
# <Название формы> (<ключ-rptype>)
|
||||||
|
|
||||||
|
**Назначение:** <одна строка — что это за заявка>
|
||||||
|
**Категория:** <Рекрутинг / IT / Бухгалтерия / Отдел кадров>
|
||||||
|
**Списки SP:** «<...> — данные»<, «<...> — согласование» если есть>
|
||||||
|
**RequestTypeId (внешний API маршрутов):** <число или — если согласования нет>
|
||||||
|
|
||||||
|
## Маршрут
|
||||||
|
- Фазы: <нет / согласование / согласование + исполнение>
|
||||||
|
- Особенности: <напр. шаг 1 согласования — непосредственный руководитель; или «—»>
|
||||||
|
|
||||||
|
## Особое поведение
|
||||||
|
<Только то, чем ЭТА форма отличается. Примеры пунктов:>
|
||||||
|
- Префилл «на основе» <какой заявки>: <какие поля копируются>.
|
||||||
|
- Поля, заполняемые согласующим: <какие, кем, когда>.
|
||||||
|
- Условные поля: <поле появляется при условии>.
|
||||||
|
- Маски/форматирование: <PhoneInput / MoneyInput на каких полях>.
|
||||||
|
- Нюансы печати: <если отличается от общего>.
|
||||||
|
- Является источником для «<другая форма>» (кнопка «создать на основе»).
|
||||||
|
|
||||||
|
## Секции формы
|
||||||
|
<ApplicationSection, CandidateSection, … — список секций формы>
|
||||||
|
|
||||||
|
## Точки входа
|
||||||
|
- rptype: `<ключ>`
|
||||||
|
- Режим согласующего (просмотр): <есть / нет>
|
||||||
|
- «Создать на основе»: <откуда → сюда, или —>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Формы портала заявок — индекс
|
||||||
|
|
||||||
|
Каждая форма живёт в своей папке `src/webparts/requestPortal/forms/<Форма>/` и имеет
|
||||||
|
рядом **`README.md`** — карточку с её **бизнес-поведением** (то, что не видно из кода
|
||||||
|
с первого взгляда). Здесь — общий индекс.
|
||||||
|
|
||||||
|
## Правило (при добавлении формы)
|
||||||
|
1. Папка `forms/<Форма>/` + `README.md` по [шаблону](./form-readme-template.md).
|
||||||
|
2. Строка в индексе ниже.
|
||||||
|
3. Строка в `forms/registry.ts` (ленивая форма) и, если есть режим согласующего,
|
||||||
|
в `forms/viewRegistry.ts`.
|
||||||
|
4. Плитка в `data/requests.ts`.
|
||||||
|
|
||||||
|
## Что НЕ дублируем в карточке (лежит в других местах)
|
||||||
|
- Колонки списков → `forms/<Форма>/provisioning.json`.
|
||||||
|
- Параметры ссылок (rptype/rpmode/rpid/rpsource) → [`routing.md`](./routing.md).
|
||||||
|
- Как обращаться к спискам SP из кода → [`sp-lists-frontend.md`](./sp-lists-frontend.md).
|
||||||
|
- Общий жизненный цикл (черновик/upsert/блокировка/статусы), согласование, исполнение,
|
||||||
|
печать — **одинаковы для всех форм** (общие примитивы `shared/ui` и `shared/approval`).
|
||||||
|
|
||||||
|
Карточка — только про то, чем **эта** форма отличается.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Индекс форм
|
||||||
|
|
||||||
|
| Ключ (rptype) | Форма | Категория | Согл. | Исп. | Просмотр | Фишки | Готово |
|
||||||
|
| --- | --- | --- | :---: | :---: | :---: | --- | :---: |
|
||||||
|
| [`recruiting-selection`](../src/webparts/requestPortal/forms/RecruitingSelection/README.md) | Заявка на подбор | Рекрутинг | нет | нет | нет | много полей (~42), поля-люди (в т.ч. мульти) | ✅ |
|
||||||
|
| [`recruiting-hire`](../src/webparts/requestPortal/forms/RecruitingHire/README.md) | Заявка на прием | Рекрутинг | да | да | да | маска телефона, деньги, условное «взамен кого», источник для орг. места | ✅ |
|
||||||
|
| [`it-workplace-setup`](../src/webparts/requestPortal/forms/WorkplaceSetup/README.md) | Организация рабочего места | IT | да | да | да | префилл из «прием», поля ПО/доступы правит руководитель | ✅ |
|
||||||
|
| `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
|
||||||
|
|
||||||
|
Легенда: **Согл.** — есть фаза согласования; **Исп.** — есть фаза исполнения;
|
||||||
|
**Просмотр** — есть режим согласующего (`<Форма>View`).
|
||||||
@@ -4,18 +4,27 @@
|
|||||||
по манифестам provisioning.json (единый источник правды по колонкам).
|
по манифестам provisioning.json (единый источник правды по колонкам).
|
||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
Для каждого файла forms/<Форма>/provisioning.json скрипт:
|
Обходит ВСЕ манифесты forms/**/provisioning.json (в т.ч. подпапки approval/) и для
|
||||||
1) создаёт список данных (если его ещё нет);
|
каждого:
|
||||||
|
1) создаёт список (если его ещё нет);
|
||||||
2) при необходимости переименовывает колонку Title;
|
2) при необходимости переименовывает колонку Title;
|
||||||
3) добавляет недостающие колонки нужных типов.
|
3) обеспечивает колонки.
|
||||||
Повторный запуск безопасен: существующие список и колонки не трогаются
|
|
||||||
(перед созданием колонки проверяется, что её ещё нет).
|
Колонки — через СТОЛБЦЫ САЙТА (эталоны):
|
||||||
|
- не-Lookup колонка сначала создаётся как столбец сайта (группа «Портал заявок»),
|
||||||
|
один раз на весь сайт, затем ПОДКЛЮЧАЕТСЯ к списку по ссылке. Так одинаковые
|
||||||
|
колонки (Status, Phase, StepOrder …) определены в одном месте и единообразны;
|
||||||
|
менять их (напр. варианты Choice) можно в эталоне. Choice при повторе объединяет
|
||||||
|
варианты (union) — наборы разных форм не затирают друг друга.
|
||||||
|
- Lookup остаётся столбцом СПИСКА: эталон-lookup привязан к одному списку, а у нас
|
||||||
|
Ticket у каждого списка согласования ссылается на свой список данных.
|
||||||
|
|
||||||
|
Повторный запуск безопасен (идемпотентно): существующие эталоны/колонки не
|
||||||
|
пересоздаются. Имена колонок (internalName) — как в манифестах, к ним обращается код.
|
||||||
|
|
||||||
Вход — по сертификату «от имени приложения» (app-only, право Sites.Selected).
|
Вход — по сертификату «от имени приложения» (app-only, право Sites.Selected).
|
||||||
Сертификат берётся из личного хранилища по отпечатку (Thumbprint).
|
Сертификат берётся из личного хранилища по отпечатку (Thumbprint).
|
||||||
|
|
||||||
Списки согласования пока НЕ создаются — только данные (по договорённости).
|
|
||||||
|
|
||||||
.PARAMETER SiteUrl
|
.PARAMETER SiteUrl
|
||||||
URL сайта, например https://tnsukz.sharepoint.com/sites/requests
|
URL сайта, например https://tnsukz.sharepoint.com/sites/requests
|
||||||
|
|
||||||
@@ -52,90 +61,124 @@ function ConvertTo-XmlSafe {
|
|||||||
return $Value.Replace('&', '&').Replace('<', '<').Replace('>', '>').Replace("'", ''').Replace('"', '"')
|
return $Value.Replace('&', '&').Replace('<', '<').Replace('>', '>').Replace("'", ''').Replace('"', '"')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Есть ли колонка НА СПИСКЕ (list-scope).
|
||||||
function Test-FieldExists {
|
function Test-FieldExists {
|
||||||
param([string]$List, [string]$InternalName)
|
param([string]$List, [string]$InternalName)
|
||||||
return $null -ne (Get-PnPField -List $List -Identity $InternalName -ErrorAction SilentlyContinue)
|
return $null -ne (Get-PnPField -List $List -Identity $InternalName -ErrorAction SilentlyContinue)
|
||||||
}
|
}
|
||||||
|
|
||||||
# Создаёт одну колонку по описанию из манифеста (идемпотентно).
|
# Есть ли СТОЛБЕЦ САЙТА (site-scope, галерея столбцов сайта).
|
||||||
function Add-Column {
|
function Test-SiteFieldExists {
|
||||||
param([string]$List, $Col)
|
param([string]$InternalName)
|
||||||
|
return $null -ne (Get-PnPField -Identity $InternalName -ErrorAction SilentlyContinue)
|
||||||
|
}
|
||||||
|
|
||||||
if (Test-FieldExists -List $List -InternalName $Col.internalName) {
|
$SITE_COLUMN_GROUP = 'Портал заявок'
|
||||||
# Колонка уже есть. Для Choice — досетапливаем варианты (могли добавиться новые
|
|
||||||
# статусы): существующие значения сохраняются, добавляются недостающие.
|
# Создаёт/обновляет СТОЛБЕЦ САЙТА (эталон) — один раз на весь сайт. Для Choice при
|
||||||
|
# повторе объединяет варианты (union), чтобы наборы разных форм не затирали друг друга.
|
||||||
|
# Lookup сюда НЕ попадает (эталоном быть не может — привязка к конкретному списку).
|
||||||
|
function Ensure-SiteColumn {
|
||||||
|
param($Col)
|
||||||
|
|
||||||
|
if (Test-SiteFieldExists -InternalName $Col.internalName) {
|
||||||
if ($Col.type -eq 'Choice' -and $Col.choices) {
|
if ($Col.type -eq 'Choice' -and $Col.choices) {
|
||||||
try {
|
$existing = (Get-PnPField -Identity $Col.internalName).Choices
|
||||||
Set-PnPField -List $List -Identity $Col.internalName -Values @{ Choices = [string[]]$Col.choices } | Out-Null
|
$merged = @($existing) + @($Col.choices) | Select-Object -Unique
|
||||||
Write-Host " [~] варианты обновлены: $($Col.internalName)" -ForegroundColor DarkGray
|
Set-PnPField -Identity $Col.internalName -Values @{ Choices = [string[]]$merged } | Out-Null
|
||||||
}
|
|
||||||
catch {
|
|
||||||
Write-Warning " [!] $($Col.internalName): не удалось обновить варианты — $($_.Exception.Message)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Host " [=] колонка есть: $($Col.internalName)" -ForegroundColor DarkGray
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$g = $SITE_COLUMN_GROUP
|
||||||
|
switch ($Col.type) {
|
||||||
|
'Text' { Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type Text -Group $g | Out-Null }
|
||||||
|
'Note' { Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type Note -Group $g | Out-Null }
|
||||||
|
'DateTime' {
|
||||||
|
Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type DateTime -Group $g | Out-Null
|
||||||
|
if ($Col.dateOnly) {
|
||||||
|
Set-PnPField -Identity $Col.internalName -Values @{ DisplayFormat = 0; FriendlyDisplayFormat = 1 } | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'Boolean' { Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type Boolean -Group $g | Out-Null }
|
||||||
|
'User' {
|
||||||
|
if ($Col.allowMultiple) {
|
||||||
|
$xml = "<Field Type='UserMulti' DisplayName='$(ConvertTo-XmlSafe $Col.title)' Name='$($Col.internalName)' Mult='TRUE' Group='$(ConvertTo-XmlSafe $g)' />"
|
||||||
|
Add-PnPFieldFromXml -FieldXml $xml | Out-Null
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type User -Group $g | Out-Null
|
||||||
|
}
|
||||||
|
if ($Col.userSelectionMode) {
|
||||||
|
$mode = if ($Col.userSelectionMode -eq 'PeopleAndGroups') { 1 } else { 0 }
|
||||||
|
Set-PnPField -Identity $Col.internalName -Values @{ SelectionMode = $mode } | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'Choice' { Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type Choice -Choices $Col.choices -Group $g | Out-Null }
|
||||||
|
'Number' {
|
||||||
|
if ($null -ne $Col.decimals) {
|
||||||
|
$xml = "<Field Type='Number' DisplayName='$(ConvertTo-XmlSafe $Col.title)' Name='$($Col.internalName)' Decimals='$($Col.decimals)' Group='$(ConvertTo-XmlSafe $g)' />"
|
||||||
|
Add-PnPFieldFromXml -FieldXml $xml | Out-Null
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type Number -Group $g | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default { throw "Неизвестный тип столбца сайта '$($Col.type)'" }
|
||||||
|
}
|
||||||
|
Write-Host " [+эталон] $($Col.internalName) — «$($Col.title)» [$($Col.type)]" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
# Обеспечивает колонку НА СПИСКЕ. Не-Lookup — из эталона (столбец сайта → подключить).
|
||||||
|
# Lookup — обычным столбцом списка (эталоном быть не может).
|
||||||
|
function Add-ColumnToList {
|
||||||
|
param([string]$List, $Col)
|
||||||
|
|
||||||
|
if ($Col.type -eq 'Lookup') {
|
||||||
|
if (Test-FieldExists -List $List -InternalName $Col.internalName) {
|
||||||
|
Write-Host " [=] lookup есть: $($Col.internalName)" -ForegroundColor DarkGray
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$src = Get-PnPList -Identity $Col.lookupList -ErrorAction Stop
|
||||||
|
$guid = $src.Id.ToString()
|
||||||
|
$show = if ($Col.lookupShowField) { $Col.lookupShowField } else { 'Title' }
|
||||||
|
$xml = "<Field Type='Lookup' DisplayName='$(ConvertTo-XmlSafe $Col.title)' Name='$($Col.internalName)' ShowField='$show' List='{$guid}' />"
|
||||||
|
Add-PnPFieldFromXml -List $List -FieldXml $xml | Out-Null
|
||||||
|
Write-Host " [+] lookup (столбец списка): $($Col.internalName)" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning " [!] $($Col.internalName): $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1) эталон на сайте (создать/обновить)
|
||||||
try {
|
try {
|
||||||
switch ($Col.type) {
|
Ensure-SiteColumn -Col $Col
|
||||||
'Text' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Text -AddToDefaultView | Out-Null }
|
|
||||||
'Note' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Note -AddToDefaultView | Out-Null }
|
|
||||||
'DateTime' {
|
|
||||||
Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type DateTime -AddToDefaultView | Out-Null
|
|
||||||
if ($Col.dateOnly) {
|
|
||||||
# 0 = только дата; 1 = отключить относительный формат («Завтра», «вчера»)
|
|
||||||
Set-PnPField -List $List -Identity $Col.internalName -Values @{ DisplayFormat = 0; FriendlyDisplayFormat = 1 } | Out-Null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
'Boolean' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Boolean -AddToDefaultView | Out-Null }
|
|
||||||
'User' {
|
|
||||||
if ($Col.allowMultiple) {
|
|
||||||
# Мульти-пользователь: только через XML (UserMulti нет в enum FieldType).
|
|
||||||
$xml = "<Field Type='UserMulti' DisplayName='$(ConvertTo-XmlSafe $Col.title)' Name='$($Col.internalName)' Mult='TRUE' />"
|
|
||||||
Add-PnPFieldFromXml -List $List -FieldXml $xml | Out-Null
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type User -AddToDefaultView | Out-Null
|
|
||||||
}
|
|
||||||
if ($Col.userSelectionMode) {
|
|
||||||
# 0 = только люди, 1 = люди и группы
|
|
||||||
$mode = if ($Col.userSelectionMode -eq 'PeopleAndGroups') { 1 } else { 0 }
|
|
||||||
Set-PnPField -List $List -Identity $Col.internalName -Values @{ SelectionMode = $mode } | Out-Null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
'Choice' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Choice -Choices $Col.choices -AddToDefaultView | Out-Null }
|
|
||||||
'Number' {
|
|
||||||
if ($null -ne $Col.decimals) {
|
|
||||||
# Точность задаётся только через XML.
|
|
||||||
$xml = "<Field Type='Number' DisplayName='$(ConvertTo-XmlSafe $Col.title)' Name='$($Col.internalName)' Decimals='$($Col.decimals)' />"
|
|
||||||
Add-PnPFieldFromXml -List $List -FieldXml $xml | Out-Null
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Number -AddToDefaultView | Out-Null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
'Lookup' {
|
|
||||||
# Список-источник должен уже существовать (см. про порядок в README).
|
|
||||||
$src = Get-PnPList -Identity $Col.lookupList -ErrorAction Stop
|
|
||||||
$guid = $src.Id.ToString()
|
|
||||||
$show = if ($Col.lookupShowField) { $Col.lookupShowField } else { 'Title' }
|
|
||||||
$xml = "<Field Type='Lookup' DisplayName='$(ConvertTo-XmlSafe $Col.title)' Name='$($Col.internalName)' ShowField='$show' List='{$guid}' />"
|
|
||||||
Add-PnPFieldFromXml -List $List -FieldXml $xml | Out-Null
|
|
||||||
}
|
|
||||||
default { throw "Неизвестный тип колонки '$($Col.type)'" }
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($Col.required) {
|
|
||||||
Set-PnPField -List $List -Identity $Col.internalName -Values @{ Required = $true } | Out-Null
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host " [+] $($Col.internalName) — «$($Col.title)» [$($Col.type)]" -ForegroundColor Green
|
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
Write-Warning " [!] $($Col.internalName): $($_.Exception.Message)"
|
Write-Warning " [!] эталон $($Col.internalName): $($_.Exception.Message)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2) подключить эталон к списку (если ещё не подключён)
|
||||||
|
if (Test-FieldExists -List $List -InternalName $Col.internalName) {
|
||||||
|
Write-Host " [=] уже на списке: $($Col.internalName)" -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
try {
|
||||||
|
Add-PnPField -List $List -Field $Col.internalName -AddToDefaultView | Out-Null
|
||||||
|
Write-Host " [+] подключён эталон: $($Col.internalName)" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning " [!] подключение $($Col.internalName): $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3) обязательность — настройка уровня списка (у поля может отличаться по спискам)
|
||||||
|
if ($Col.required) {
|
||||||
|
try { Set-PnPField -List $List -Identity $Col.internalName -Values @{ Required = $true } | Out-Null } catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,9 +217,9 @@ foreach ($manifest in $manifests) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# 3) Колонки (идемпотентно)
|
# 3) Колонки (идемпотентно): не-Lookup — через эталоны (столбцы сайта), Lookup — на список.
|
||||||
foreach ($col in $def.columns) {
|
foreach ($col in $def.columns) {
|
||||||
Add-Column -List $listTitle -Col $col
|
Add-ColumnToList -List $listTitle -Col $col
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Заявка на прием (recruiting-hire)
|
||||||
|
|
||||||
|
**Назначение:** приём сотрудника на работу.
|
||||||
|
**Категория:** Отдел рекрутинга
|
||||||
|
**Списки SP:** «Прием на работу — данные» / «Прием на работу — согласование»
|
||||||
|
**RequestTypeId (внешний API маршрутов):** 5
|
||||||
|
|
||||||
|
## Маршрут
|
||||||
|
- Фазы: согласование + исполнение.
|
||||||
|
- Исполнители подтягиваются из Execution-маршрута; если их нет — заявка финиширует
|
||||||
|
на «Согласовано» (блок «Исполнение» не показывается).
|
||||||
|
|
||||||
|
## Особое поведение
|
||||||
|
- **Маска телефона** (PhoneInput) на поле «Телефон».
|
||||||
|
- **Разделитель тысяч** (MoneyInput) на окладах (испытательный срок / после).
|
||||||
|
- **Условное поле «Взамен кого» (ReplacedEmployee)** — показывается только когда
|
||||||
|
тип приёма = замена уволенного (см. `HIRE_TYPE_REPLACEMENT` в модели).
|
||||||
|
- Секция **СИЗ** (рост, размеры одежды/обуви).
|
||||||
|
- **Является источником для «Организация рабочего места»** — из просмотра этой
|
||||||
|
заявки кнопка «Организация рабочего места на основе этой» открывает форму
|
||||||
|
it-workplace-setup с префиллом (rpsource = id этой заявки).
|
||||||
|
- Печать — общий механизм (без «Вложений»).
|
||||||
|
|
||||||
|
## Секции формы
|
||||||
|
ApplicationSection · CandidateSection · PpeSection · ConditionsSection
|
||||||
|
|
||||||
|
## Точки входа
|
||||||
|
- rptype: `recruiting-hire`
|
||||||
|
- Режим согласующего (просмотр): есть (`RecruitingHireView`)
|
||||||
|
- «Создать на основе»: эта заявка → источник для `it-workplace-setup`
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Заявка на подбор (recruiting-selection)
|
||||||
|
|
||||||
|
**Назначение:** заявка на подбор персонала / открытие вакансии.
|
||||||
|
**Категория:** Отдел рекрутинга
|
||||||
|
**Списки SP:** «Заявка на подбор — данные» (только данные — согласования нет)
|
||||||
|
**RequestTypeId (внешний API маршрутов):** — (маршрут не используется)
|
||||||
|
|
||||||
|
## Маршрут
|
||||||
|
- Фаз нет: отправка = запись в список данных, без согласования и исполнения.
|
||||||
|
|
||||||
|
## Особое поведение
|
||||||
|
- **Самая большая форма (~42 поля).**
|
||||||
|
- **Поля-люди:** `Responsible`, `ProposedMentor`, `DirectManager` — один человек;
|
||||||
|
`HiringDecisionMaker` — несколько (мульти). Пишутся как `<Field>Id`
|
||||||
|
(см. `submit.ts` → `resolveUserId`).
|
||||||
|
- **Нет режима согласующего** (в `viewRegistry` не зарегистрирована) и **нет общего
|
||||||
|
жизненного цикла** (черновик/статус/upsert) — это ранняя форма, простая отправка
|
||||||
|
через `submitRecruitingSelection`.
|
||||||
|
|
||||||
|
## Секции формы
|
||||||
|
См. `sections/` формы.
|
||||||
|
|
||||||
|
## Точки входа
|
||||||
|
- rptype: `recruiting-selection`
|
||||||
|
- Режим согласующего (просмотр): нет
|
||||||
|
- «Создать на основе»: —
|
||||||
|
|
||||||
|
> Замечание: сделана до введения общего жизненного цикла и согласования. При
|
||||||
|
> необходимости приводится к общему шаблону (`useRequestSubmit` + `SubmitActions`
|
||||||
|
> + маршрут согласования), как в остальных формах.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Организация рабочего места (it-workplace-setup)
|
||||||
|
|
||||||
|
**Назначение:** подготовка рабочего места сотрудника (техника, ПО, доступы).
|
||||||
|
**Категория:** IT
|
||||||
|
**Списки SP:** «Организация рабочего места — данные» (+ lookup `SourceHire` →
|
||||||
|
«Прием на работу — данные») / «Организация рабочего места — согласование»
|
||||||
|
**RequestTypeId (внешний API маршрутов):** 7
|
||||||
|
|
||||||
|
## Маршрут
|
||||||
|
- Фазы: согласование + исполнение.
|
||||||
|
- **Шаг 1 согласования — непосредственный руководитель** (роль в маршруте).
|
||||||
|
|
||||||
|
## Особое поведение
|
||||||
|
- **Префилл «на основе» заявки на прием** (открывается по `rpsource`): копируются
|
||||||
|
ФИО, телефон, должность, отдел/участок, вид работ, местоположение, дата приёма
|
||||||
|
(`loadPrefillFromHire`). Это снимок на момент создания; ссылка на источник
|
||||||
|
сохраняется в колонке-lookup `SourceHire`.
|
||||||
|
- **Поля «Необходимое ПО» и «Доступы» заполняет согласующий-руководитель** на своём
|
||||||
|
этапе (инициатор их обычно не знает). Доступны для правки, когда очередь дошла до
|
||||||
|
**первого** шага согласования и это текущий пользователь; сохраняются кнопкой
|
||||||
|
«Сохранить» и автоматически при «Согласовать» (`updateReviewFields`,
|
||||||
|
`DecisionPanel.onBeforeDecide`). Остальная форма при этом read-only.
|
||||||
|
- **Маска телефона** (PhoneInput).
|
||||||
|
- Печать — общий механизм (без «Вложений»).
|
||||||
|
|
||||||
|
## Секции формы
|
||||||
|
EmployeeSection · EquipmentSection
|
||||||
|
|
||||||
|
## Точки входа
|
||||||
|
- rptype: `it-workplace-setup`
|
||||||
|
- Режим согласующего (просмотр): есть (`WorkplaceSetupView`)
|
||||||
|
- «Создать на основе»: создаётся из просмотра заявки `recruiting-hire`
|
||||||
@@ -12,3 +12,33 @@
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-bottom: $sp-4;
|
margin-bottom: $sp-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reviewBar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-3;
|
||||||
|
margin-bottom: $sp-4;
|
||||||
|
padding: $sp-3 $sp-4;
|
||||||
|
border: 1px solid $gold-300;
|
||||||
|
border-radius: $r-md;
|
||||||
|
background: $gold-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reviewHint {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $navy-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.savedNote {
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $green-600;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveError {
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $red-500;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,16 @@ import * as React from 'react';
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import styles from './WorkplaceSetupView.module.scss';
|
import styles from './WorkplaceSetupView.module.scss';
|
||||||
import { Alert, FormCard, FormModeProvider, CollapseSection, FormField, AttachmentList, PrintButton } from '../../shared/ui';
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
FormCard,
|
||||||
|
FormModeProvider,
|
||||||
|
CollapseSection,
|
||||||
|
FormField,
|
||||||
|
AttachmentList,
|
||||||
|
PrintButton
|
||||||
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApproverService,
|
ApproverService,
|
||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
@@ -14,7 +23,7 @@ import {
|
|||||||
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
||||||
import { IFormData, EMPTY_DATA, SetField } from './model';
|
import { IFormData, EMPTY_DATA, SetField } from './model';
|
||||||
import { loadWorkplaceRequest } from './load';
|
import { loadWorkplaceRequest } from './load';
|
||||||
import { workplaceApprovalService, DATA_LIST_TITLE } from './submit';
|
import { workplaceApprovalService, updateReviewFields, DATA_LIST_TITLE } from './submit';
|
||||||
import { EmployeeSection } from './sections/EmployeeSection';
|
import { EmployeeSection } from './sections/EmployeeSection';
|
||||||
import { EquipmentSection } from './sections/EquipmentSection';
|
import { EquipmentSection } from './sections/EquipmentSection';
|
||||||
|
|
||||||
@@ -23,15 +32,21 @@ interface IWorkplaceSetupViewProps {
|
|||||||
itemId: number;
|
itemId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NOOP_SET: SetField = () => undefined;
|
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||||
|
|
||||||
// Заявка «орг. рабочего места» только для просмотра (режим согласующего).
|
// Заявка «орг. рабочего места» только для просмотра (режим согласующего).
|
||||||
|
// Исключение: первый согласующий шаг (по маршруту — непосредственный руководитель)
|
||||||
|
// на своём этапе может заполнить «Необходимое ПО» и «Доступы» (инициатор их не знает).
|
||||||
const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemId }) => {
|
const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemId }) => {
|
||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [attachments, setAttachments] = useState<IAttachmentInfo[]>([]);
|
const [attachments, setAttachments] = useState<IAttachmentInfo[]>([]);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [canEditFields, setCanEditFields] = useState<boolean>(false);
|
||||||
|
const [saveState, setSaveState] = useState<SaveState>('idle');
|
||||||
|
|
||||||
|
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||||
|
|
||||||
const approverService = useMemo(
|
const approverService = useMemo(
|
||||||
() => new ApproverService(workplaceApprovalService(), DATA_LIST_TITLE),
|
() => new ApproverService(workplaceApprovalService(), DATA_LIST_TITLE),
|
||||||
@@ -85,6 +100,45 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
};
|
};
|
||||||
}, [itemId]);
|
}, [itemId]);
|
||||||
|
|
||||||
|
// Правка «ПО»/«Доступы» доступна, когда сейчас очередь ПЕРВОГО шага согласования
|
||||||
|
// (по маршруту — непосредственного руководителя) и это текущий пользователь.
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
Promise.all([
|
||||||
|
approval.getForRequest(itemId),
|
||||||
|
approverService.getPendingRowForCurrentUser(itemId)
|
||||||
|
])
|
||||||
|
.then(([rows, pending]) => {
|
||||||
|
if (!alive) return;
|
||||||
|
const orders = rows.filter((r) => r.phase !== APPROVAL_PHASE.execution).map((r) => r.order);
|
||||||
|
const firstOrder = orders.length > 0 ? Math.min(...orders) : undefined;
|
||||||
|
setCanEditFields(
|
||||||
|
!!pending && pending.phase !== APPROVAL_PHASE.execution && pending.order === firstOrder
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (alive) setCanEditFields(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [itemId, approval, approverService]);
|
||||||
|
|
||||||
|
const saveReviewFields = useCallback(
|
||||||
|
() => updateReviewFields(itemId, { Software: data.Software, AccessRights: data.AccessRights }),
|
||||||
|
[itemId, data.Software, data.AccessRights]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSave = async (): Promise<void> => {
|
||||||
|
setSaveState('saving');
|
||||||
|
try {
|
||||||
|
await saveReviewFields();
|
||||||
|
setSaveState('saved');
|
||||||
|
} catch {
|
||||||
|
setSaveState('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className={styles.state}>Загрузка заявки…</div>;
|
return <div className={styles.state}>Загрузка заявки…</div>;
|
||||||
}
|
}
|
||||||
@@ -96,7 +150,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sectionProps = { data, set: NOOP_SET, context };
|
const sectionProps = { data, set, context };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -104,6 +158,19 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
<PrintButton />
|
<PrintButton />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{canEditFields && (
|
||||||
|
<div className={`${styles.reviewBar} rp-no-print`}>
|
||||||
|
<span className={styles.reviewHint}>
|
||||||
|
Как согласующий вы можете заполнить «Необходимое ПО» и «Доступы».
|
||||||
|
</span>
|
||||||
|
<Button variant="secondary" onClick={handleSave} disabled={saveState === 'saving'}>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
{saveState === 'saved' && <span className={styles.savedNote}>Сохранено</span>}
|
||||||
|
{saveState === 'error' && <span className={styles.saveError}>Ошибка сохранения</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Организация рабочего места"
|
title="Организация рабочего места"
|
||||||
@@ -112,7 +179,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
>
|
>
|
||||||
<FormModeProvider readOnly>
|
<FormModeProvider readOnly>
|
||||||
<EmployeeSection {...sectionProps} />
|
<EmployeeSection {...sectionProps} />
|
||||||
<EquipmentSection {...sectionProps} />
|
<EquipmentSection {...sectionProps} approverEditable={canEditFields} />
|
||||||
</FormModeProvider>
|
</FormModeProvider>
|
||||||
|
|
||||||
<div className="rp-no-print">
|
<div className="rp-no-print">
|
||||||
@@ -138,7 +205,11 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rp-no-print">
|
<div className="rp-no-print">
|
||||||
<DecisionPanel service={approverService} requestId={itemId} />
|
<DecisionPanel
|
||||||
|
service={approverService}
|
||||||
|
requestId={itemId}
|
||||||
|
onBeforeDecide={canEditFields ? saveReviewFields : undefined}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,16 +2,28 @@ import * as React from 'react';
|
|||||||
import { CollapseSection, FormField, TextArea, Toggle } from '../../../shared/ui';
|
import { CollapseSection, FormField, TextArea, Toggle } from '../../../shared/ui';
|
||||||
import { ISectionProps } from '../model';
|
import { ISectionProps } from '../model';
|
||||||
|
|
||||||
export const EquipmentSection: React.FC<ISectionProps> = ({ data, set }) => (
|
interface IEquipmentSectionProps extends ISectionProps {
|
||||||
<CollapseSection title="Оборудование и доступы" icon="monitor">
|
/** Согласующий-руководитель на этапе согласования может править ПО и доступы,
|
||||||
<FormField label="Нужна ли техника">
|
* даже когда остальная форма read-only. undefined — обычное поведение (контекст). */
|
||||||
<Toggle checked={data.NeedsEquipment} onChange={(v) => set('NeedsEquipment', v)} onLabel="Да" />
|
approverEditable?: boolean;
|
||||||
</FormField>
|
}
|
||||||
<FormField label="Необходимое ПО" htmlFor="Software" full>
|
|
||||||
<TextArea id="Software" value={data.Software} onChange={(v) => set('Software', v)} />
|
export const EquipmentSection: React.FC<IEquipmentSectionProps> = ({ data, set, approverEditable }) => {
|
||||||
</FormField>
|
// Явный readOnly=false перебивает read-only контекст просмотра; undefined —
|
||||||
<FormField label="Доступы" htmlFor="AccessRights" full>
|
// берёт значение из FormMode (в форме подачи редактируемо, в просмотре — нет).
|
||||||
<TextArea id="AccessRights" value={data.AccessRights} onChange={(v) => set('AccessRights', v)} />
|
const fieldReadOnly = approverEditable ? false : undefined;
|
||||||
</FormField>
|
|
||||||
</CollapseSection>
|
return (
|
||||||
);
|
<CollapseSection title="Оборудование и доступы" icon="monitor">
|
||||||
|
<FormField label="Нужна ли техника">
|
||||||
|
<Toggle checked={data.NeedsEquipment} onChange={(v) => set('NeedsEquipment', v)} onLabel="Да" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Необходимое ПО" htmlFor="Software" full>
|
||||||
|
<TextArea id="Software" value={data.Software} onChange={(v) => set('Software', v)} readOnly={fieldReadOnly} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Доступы" htmlFor="AccessRights" full>
|
||||||
|
<TextArea id="AccessRights" value={data.AccessRights} onChange={(v) => set('AccessRights', v)} readOnly={fieldReadOnly} />
|
||||||
|
</FormField>
|
||||||
|
</CollapseSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -12,6 +12,20 @@ export function workplaceApprovalService(): ApprovalListService {
|
|||||||
return new ApprovalListService(APPROVAL_LIST_TITLE);
|
return new ApprovalListService(APPROVAL_LIST_TITLE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновить поля, которые заполняет согласующий-руководитель на этапе согласования
|
||||||
|
* (инициатор часто не знает нужное ПО и доступы). Пишет только эти два поля.
|
||||||
|
*/
|
||||||
|
export async function updateReviewFields(
|
||||||
|
id: number,
|
||||||
|
fields: { Software: string; AccessRights: string }
|
||||||
|
): Promise<void> {
|
||||||
|
await getSP().web.lists.getByTitle(DATA_LIST_TITLE).items.getById(id).update({
|
||||||
|
Software: fields.Software === '' ? null : fields.Software,
|
||||||
|
AccessRights: fields.AccessRights === '' ? null : fields.AccessRights,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Осмысленный заголовок элемента (системная колонка Title). */
|
/** Осмысленный заголовок элемента (системная колонка Title). */
|
||||||
function buildTitle(data: IFormData): string {
|
function buildTitle(data: IFormData): string {
|
||||||
return ['Раб. место', data.FullName, data.Position].filter(Boolean).join(' — ');
|
return ['Раб. место', data.FullName, data.Position].filter(Boolean).join(' — ');
|
||||||
|
|||||||
@@ -11,11 +11,19 @@ interface IDecisionPanelProps {
|
|||||||
requestId: number;
|
requestId: number;
|
||||||
/** Уведомление после сохранения решения (например, обновить статус на экране). */
|
/** Уведомление после сохранения решения (например, обновить статус на экране). */
|
||||||
onDecided?: (status: string) => void;
|
onDecided?: (status: string) => void;
|
||||||
|
/** Выполнить ПЕРЕД применением решения (напр. сохранить поля, заполненные
|
||||||
|
* согласующим). Если бросит — решение не применяется, показывается ошибка. */
|
||||||
|
onBeforeDecide?: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Панель решения согласующего: проверяет, его ли сейчас очередь, и если да —
|
// Панель решения согласующего: проверяет, его ли сейчас очередь, и если да —
|
||||||
// показывает комментарий и кнопки Согласовать / Вернуть / Отклонить.
|
// показывает комментарий и кнопки Согласовать / Вернуть / Отклонить.
|
||||||
export const DecisionPanel: React.FC<IDecisionPanelProps> = ({ service, requestId, onDecided }) => {
|
export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
|
||||||
|
service,
|
||||||
|
requestId,
|
||||||
|
onDecided,
|
||||||
|
onBeforeDecide,
|
||||||
|
}) => {
|
||||||
const [checking, setChecking] = useState<boolean>(true);
|
const [checking, setChecking] = useState<boolean>(true);
|
||||||
const [rowId, setRowId] = useState<number | undefined>(undefined);
|
const [rowId, setRowId] = useState<number | undefined>(undefined);
|
||||||
const [phase, setPhase] = useState<string>(APPROVAL_PHASE.approval);
|
const [phase, setPhase] = useState<string>(APPROVAL_PHASE.approval);
|
||||||
@@ -60,6 +68,10 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({ service, requestI
|
|||||||
setError(null);
|
setError(null);
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
|
// Сохраняем правки согласующего (если есть) ДО применения решения.
|
||||||
|
if (onBeforeDecide) {
|
||||||
|
await onBeforeDecide();
|
||||||
|
}
|
||||||
const status = await service.decide(requestId, rowId, decision, comment.trim());
|
const status = await service.decide(requestId, rowId, decision, comment.trim());
|
||||||
setResultStatus(status);
|
setResultStatus(status);
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
|
|||||||
Reference in New Issue
Block a user