From ca1d7512d6f76feabfe9fb29c87f654924857d3c Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 25 Aug 2026 12:45:17 +0500 Subject: [PATCH 01/14] =?UTF-8?q?feat:=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=20?= =?UTF-8?q?=C2=AB=D0=97=D0=B0=D1=8F=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=83=D0=B2=D0=BE=D0=BB=D1=8C=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=C2=BB=20(hr-dismissal)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новая форма отдела кадров по бумажному шаблону «Заявление на увольнение ШТАТ»: сотрудник, должность, структурное и самостоятельное подразделение, последний рабочий день, контактный телефон, комментарий. По умолчанию заявка на себя, но можно подать за другого — маршрут резолвится по выбранному сотруднику. Подразделение подтягивается из профиля (SPS-Department, read-only), должность и организации — из справочника Employee/ByEmail. Последний рабочий день не может быть в прошлом; срок предупреждения об увольнении форма не проверяет — дату согласуют с работодателем. Перекрёстные ссылки с «Обходным листом»: колонки-гиперссылки ClearanceUrl / DismissalUrl (только в списках, формы их пока не пишут). Для них в провижинере появился тип колонки Url, а сам скрипт разбит на два прохода — сначала все списки, потом колонки: иначе Lookup на список другой формы держится на алфавитном порядке обхода манифестов. Списки «Заявление на увольнение 1 — данные» / «… — согласование» созданы на /sites/requests. Тип заявки в админке approval-route ещё не заведён — до этого форма откроется, но отправить заявку не даст. Co-Authored-By: Claude Opus 5 --- docs/forms.md | 1 + provisioning/Provision-Lists.ps1 | 51 ++- provisioning/README.md | 5 +- src/webparts/requestPortal/data/requests.ts | 1 + .../requestPortal/forms/Clearance/README.md | 6 + .../forms/Clearance/provisioning.json | 4 +- .../forms/Dismissal/DismissalForm.module.scss | 20 ++ .../forms/Dismissal/DismissalForm.tsx | 319 ++++++++++++++++++ .../forms/Dismissal/DismissalView.module.scss | 14 + .../forms/Dismissal/DismissalView.tsx | 162 +++++++++ .../requestPortal/forms/Dismissal/README.md | 64 ++++ .../Dismissal/approval/provisioning.json | 16 + .../requestPortal/forms/Dismissal/load.ts | 63 ++++ .../requestPortal/forms/Dismissal/model.ts | 71 ++++ .../forms/Dismissal/provisioning.json | 18 + .../Dismissal/sections/RequestSection.tsx | 80 +++++ .../requestPortal/forms/Dismissal/submit.ts | 73 ++++ src/webparts/requestPortal/forms/registry.ts | 3 + .../requestPortal/forms/viewRegistry.ts | 3 + .../requestPortal/shared/listTitles.ts | 1 + 20 files changed, 957 insertions(+), 18 deletions(-) create mode 100644 src/webparts/requestPortal/forms/Dismissal/DismissalForm.module.scss create mode 100644 src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx create mode 100644 src/webparts/requestPortal/forms/Dismissal/DismissalView.module.scss create mode 100644 src/webparts/requestPortal/forms/Dismissal/DismissalView.tsx create mode 100644 src/webparts/requestPortal/forms/Dismissal/README.md create mode 100644 src/webparts/requestPortal/forms/Dismissal/approval/provisioning.json create mode 100644 src/webparts/requestPortal/forms/Dismissal/load.ts create mode 100644 src/webparts/requestPortal/forms/Dismissal/model.ts create mode 100644 src/webparts/requestPortal/forms/Dismissal/provisioning.json create mode 100644 src/webparts/requestPortal/forms/Dismissal/sections/RequestSection.tsx create mode 100644 src/webparts/requestPortal/forms/Dismissal/submit.ts diff --git a/docs/forms.md b/docs/forms.md index d275456..87b8cd4 100644 --- a/docs/forms.md +++ b/docs/forms.md @@ -46,6 +46,7 @@ | [`it-workplace-setup`](../src/webparts/requestPortal/forms/WorkplaceSetup/README.md) | Организация рабочего места | IT | да | да | да | префилл из «прием», поля ПО/доступы правит руководитель | ✅ | | [`it-1c-ticket`](../src/webparts/requestPortal/forms/Ticket1C/README.md) | Заявка в отдел 1С | IT | да | нет | да | контакты предзаполняются из Graph (/me); вместо фазы исполнения — карточка в Microsoft Planner (согласовано/сразу без согласования — рабочий бакет, отклонено — отдельный бакет) | ✅ | | [`clearance-form`](../src/webparts/requestPortal/forms/Clearance/README.md) | Обходной лист | Отдел кадров | да | нет | да | подаётся HR за увольняемого (People-picker); условие «IsManager» резолвится по должности из самой заявки; да/нет-поля (парковка, СКУД), сумма обучения | ✅ | +| [`hr-dismissal`](../src/webparts/requestPortal/forms/Dismissal/README.md) | Заявление на увольнение | Отдел кадров | да | нет | да | по умолчанию на себя, но можно за другого; подразделение из профиля (read-only), должность/организация из справочника; последний рабочий день не в прошлом | ⏳ тип заявки не заведён в админке approval-route | | `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) | Легенда: **Согл.** — есть фаза согласования; **Исп.** — есть фаза исполнения; diff --git a/provisioning/Provision-Lists.ps1 b/provisioning/Provision-Lists.ps1 index fc6b629..6046cb2 100644 --- a/provisioning/Provision-Lists.ps1 +++ b/provisioning/Provision-Lists.ps1 @@ -4,11 +4,14 @@ по манифестам provisioning.json (единый источник правды по колонкам). .DESCRIPTION - Обходит ВСЕ манифесты forms/**/provisioning.json (в т.ч. подпапки approval/) и для - каждого: - 1) создаёт список (если его ещё нет); - 2) при необходимости переименовывает колонку Title; - 3) обеспечивает колонки. + Обходит ВСЕ манифесты forms/**/provisioning.json (в т.ч. подпапки approval/) + ДВУМЯ проходами: + 1) создаёт ВСЕ списки (которых ещё нет); + 2) по каждому манифесту: при необходимости переименовывает колонку Title и + обеспечивает колонки. + Проходы разделены из-за Lookup'ов между формами: список-источник должен + существовать раньше колонки, а при перекрёстных ссылках (две формы ссылаются + друг на друга) одним проходом это недостижимо ни при каком порядке обхода. Имена списков НУМЕРОВАННЫЕ: «<Форма> — данные» / «<Форма> — согласование». Номер собирает сам скрипт из имени в манифесте (см. Get-NumberedListTitle) — в @@ -170,6 +173,7 @@ function Ensure-SiteColumn { } 'Choice' { Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type Choice -Choices $Col.choices -Group $g | Out-Null } 'Number' { Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type Number -Group $g | Out-Null } + 'Url' { Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type URL -Group $g | Out-Null } default { throw "Неизвестный тип столбца сайта '$($Col.type)'" } } Write-Host " [+эталон] $($Col.internalName) — «$($Col.title)» [$($Col.type)]" -ForegroundColor Green @@ -214,6 +218,7 @@ function Add-ListColumn { } } 'Choice' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Choice -Choices $Col.choices -AddToDefaultView | Out-Null } + 'Url' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type URL -AddToDefaultView | Out-Null } 'Number' { if ($null -ne $Col.decimals) { $xml = "" @@ -325,21 +330,35 @@ if ($ListNumber -eq 1 -and -not $SkipRename) { } } +# Проход 1: САМИ СПИСКИ — все до единого, ДО колонок. +# +# Раньше список и его колонки создавались одним проходом по манифесту, и Lookup на +# список ДРУГОЙ формы (WorkplaceSetup.SourceHire → «Прием на работу … — данные») +# резолвился только если та форма попадалась в обходе раньше — то есть держался на +# алфавитном везении, а для взаимных ссылок был бы невозможен в принципе. Поэтому +# сначала все списки, потом все колонки: к моменту разрешения любого Lookup'а +# список-источник уже существует. +Write-Host "`n=== Списки ===" -ForegroundColor Cyan +foreach ($manifest in $manifests) { + $def = Get-Content -Path $manifest.FullName -Raw -Encoding UTF8 | ConvertFrom-Json + $listTitle = Get-NumberedListTitle -Title $def.dataListTitle -Number $ListNumber + + if (-not (Get-PnPList -Identity $listTitle -ErrorAction SilentlyContinue)) { + New-PnPList -Title $listTitle -Template GenericList -EnableVersioning | Out-Null + Write-Host " [+] создан список «$listTitle»" -ForegroundColor Green + } + else { + Write-Host " [=] список уже существует: «$listTitle»" -ForegroundColor DarkGray + } +} + +# Проход 2: колонки (и переименование Title) — по одному манифесту за раз. foreach ($manifest in $manifests) { $def = Get-Content -Path $manifest.FullName -Raw -Encoding UTF8 | ConvertFrom-Json $listTitle = Get-NumberedListTitle -Title $def.dataListTitle -Number $ListNumber Write-Host "`n=== Форма: $($manifest.Directory.Name) → список «$listTitle» ===" -ForegroundColor Cyan - # 1) Список (идемпотентно) - if (-not (Get-PnPList -Identity $listTitle -ErrorAction SilentlyContinue)) { - New-PnPList -Title $listTitle -Template GenericList -EnableVersioning | Out-Null - Write-Host " [+] создан список" -ForegroundColor Green - } - else { - Write-Host " [=] список уже существует" -ForegroundColor DarkGray - } - - # 2) Переименование Title (если задано) + # 1) Переименование Title (если задано) if ($def.titleColumnName) { try { Set-PnPField -List $listTitle -Identity 'Title' -Values @{ Title = $def.titleColumnName } | Out-Null @@ -350,7 +369,7 @@ foreach ($manifest in $manifests) { } } - # 3) Колонки (идемпотентно): не-Lookup — через эталоны (столбцы сайта), Lookup — на список. + # 2) Колонки (идемпотентно): не-Lookup — через эталоны (столбцы сайта), Lookup — на список. foreach ($col in $def.columns) { Add-ColumnToList -List $listTitle -Col $col } diff --git a/provisioning/README.md b/provisioning/README.md index ceac1f4..52d1048 100644 --- a/provisioning/README.md +++ b/provisioning/README.md @@ -25,12 +25,15 @@ | `DateTime` | дата/время | — | | `Boolean` | да/нет | — | | `User` | пользователь | — | +| `Url` | гиперссылка (адрес + подпись) | — | | `Lookup` | подстановка | `lookupList` (заголовок источника), `lookupShowField` (по умолч. `Title`) | Плюс на уровне формы: `titleColumnName` — переименовать стандартную колонку `Title`. > **Порядок для Lookup:** список-источник должен существовать раньше, чем колонка, -> которая на него ссылается. Если источник в другой форме — сначала прогоните её манифест. +> которая на него ссылается — но следить за этим руками не нужно: скрипт сначала +> создаёт ВСЕ списки, и только потом колонки, поэтому порядок обхода манифестов +> не важен (и взаимные ссылки тоже разрешимы). ## Разовая подготовка diff --git a/src/webparts/requestPortal/data/requests.ts b/src/webparts/requestPortal/data/requests.ts index 1735966..cdfb6fb 100644 --- a/src/webparts/requestPortal/data/requests.ts +++ b/src/webparts/requestPortal/data/requests.ts @@ -17,6 +17,7 @@ export const REQUEST_TYPES: IRequestType[] = [ // Отдел кадров { id: 'hr-vacation', categoryId: 'hr', title: 'Заявка на трудовой отпуск', icon: 'calendar' }, { id: 'hr-unpaid-leave', categoryId: 'hr', title: 'Заявка на отпуск без содержания', icon: 'document' }, + { id: 'hr-dismissal', categoryId: 'hr', title: 'Заявление на увольнение', icon: 'document' }, { id: 'clearance-form', categoryId: 'hr', title: 'Обходной лист', icon: 'clipboard' }, { id: 'hr-transfer', categoryId: 'hr', title: 'Заявка на перемещение сотрудников', icon: 'people' }, diff --git a/src/webparts/requestPortal/forms/Clearance/README.md b/src/webparts/requestPortal/forms/Clearance/README.md index 56a1f96..d8b0cf3 100644 --- a/src/webparts/requestPortal/forms/Clearance/README.md +++ b/src/webparts/requestPortal/forms/Clearance/README.md @@ -39,6 +39,12 @@ RequestTypeId» в `docs/forms.md`). Тип уже зарегистрирова (в отличие от `EndDate` в «Заявке на отпуск», которая считается). - **Заголовок элемента** (`Title`): `Обходной лист — {ФИО} — дд.мм.гггг`. +## Связь с заявлением на увольнение +В списке данных есть колонка-гиперссылка `DismissalUrl` — ссылка на заявление на +увольнение (обратная ей `ClearanceUrl` — в списке заявления). Колонки существуют +только в списках: форма их не пишет и не показывает, ссылка ставится вручную в +SharePoint. См. `forms/Dismissal/README.md`. + ## Секции формы RequestSection (сотрудник · должность · подразделение · дата расторжения · сумма обучения · парковочная карта · карта СКУД) diff --git a/src/webparts/requestPortal/forms/Clearance/provisioning.json b/src/webparts/requestPortal/forms/Clearance/provisioning.json index bf10705..3d36fa6 100644 --- a/src/webparts/requestPortal/forms/Clearance/provisioning.json +++ b/src/webparts/requestPortal/forms/Clearance/provisioning.json @@ -12,6 +12,8 @@ { "internalName": "ParkingCard", "title": "Парковочная карта", "type": "Boolean" }, { "internalName": "AccessCard", "title": "Карта СКУД", "type": "Boolean" }, { "internalName": "TrainingAmount", "title": "Обучение: сумма", "type": "Number", "decimals": 2 }, - { "internalName": "Organization", "title": "Организация", "type": "Note" } + { "internalName": "Organization", "title": "Организация", "type": "Note" }, + + { "internalName": "DismissalUrl", "title": "Ссылка на заявление на увольнение", "type": "Url" } ] } diff --git a/src/webparts/requestPortal/forms/Dismissal/DismissalForm.module.scss b/src/webparts/requestPortal/forms/Dismissal/DismissalForm.module.scss new file mode 100644 index 0000000..e1ef36d --- /dev/null +++ b/src/webparts/requestPortal/forms/Dismissal/DismissalForm.module.scss @@ -0,0 +1,20 @@ +@import '../../../../styles/design-tokens'; + +.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; + } +} diff --git a/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx b/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx new file mode 100644 index 0000000..76b7e36 --- /dev/null +++ b/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx @@ -0,0 +1,319 @@ +import * as React from 'react'; +import { useState, useMemo, useCallback, useEffect } from 'react'; +import styles from './DismissalForm.module.scss'; +import { + Alert, + FormCard, + InitiatorLine, + FormModeProvider, + Icon, + RequestNumber, + SubmitActions, + useRequestSubmit +} from '../../shared/ui'; +import { + ApprovalMatrix, + StatusBadge, + useMatrixEditor, + getPlannedMatrix, + notifyActiveStep, + useRequestTypeId, + REQUEST_STATUS, + isEditableStatus, + IApproverToWrite, + unassignedStepMessage, + resolveWriteListId +} from '../../shared/approval'; +import { IRequestFormProps } from '../../models/types'; +import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary'; +import { friendlyErrorMessage } from '../../shared/diagnostics'; +import { isoDatePlusDays, formatIsoDate } from '../../shared/dateUtils'; +import { + approvalListTitle, + approvalListTitleForListId, + dataListTitle, + dataListTitleForListId +} from '../../shared/listTitles'; +import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; +import { saveDismissalRequest, dismissalApprovalService } from './submit'; +import { loadDismissalRequest } from './load'; +import { RequestSection } from './sections/RequestSection'; + +const DismissalForm: React.FC = ({ + requestType, + context, + onCancel, + onSaved, + editId, + editListId +}) => { + const [data, setData] = useState(EMPTY_DATA); + const [requestId, setRequestId] = useState(editId); + const [requestStatus, setRequestStatus] = useState(undefined); + const [requestCode, setRequestCode] = useState(undefined); + const submit = useRequestSubmit(); + + const readOnly = !isEditableStatus(requestStatus); + const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); + + // editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции + // не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь + // dismissalApprovalService можно звать не опасаясь исключения. + const approval = useMemo(() => dismissalApprovalService(editListId), [editListId]); + // pageUrl формы (requestType.id совпадает с ним по конвенции портала, см. + // data/requests.ts) → числовой RequestTypeId, нужный approval-route API. + const requestTypeIdState = useRequestTypeId(context, requestType.id); + const requestTypeId = requestTypeIdState.id; + + // Открыта СВОЯ заявка на редактирование (черновик/возврат) — подгружаем содержимое и статус. + useEffect(() => { + if (!editId) { + return; + } + let alive = true; + loadDismissalRequest(editId, editListId) + .then((loaded) => { + if (alive) { + setData(loaded.data); + setRequestStatus(loaded.status); + setRequestCode(loaded.code || undefined); + } + }) + .catch((e) => { + if (alive) { + submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); + } + }); + return () => { + alive = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editId, editListId]); + + // Новая заявка — по умолчанию на себя: подставляем текущего пользователя, его + // должность и организации (Employee/ByEmail — см. shared/dictionary/ + // employeeOrganizations.service.ts; сотрудник может состоять сразу в нескольких + // организациях, должность берём из ПЕРВОЙ записи), подразделение — из профиля. + useEffect(() => { + if (editId) { + return; + } + let alive = true; + const user = context.pageContext.user; + setData((prev) => ({ ...prev, Employee: prev.Employee || user.email })); + getEmployeeOrganizations(context, user.email) + .then((records) => { + if (!alive) return; + setData((prev) => ({ + ...prev, + Position: prev.Position || records[0]?.position || prev.Position, + Organization: formatEmployeeOrganizations(records) + })); + }) + .catch(() => undefined); + approval + .getProfileFields(user.email) + .then((profile) => { + if (!alive) return; + setData((prev) => ({ ...prev, Department: prev.Department || profile.department })); + }) + .catch(() => undefined); + return () => { + alive = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editId]); + + // Выбор другого сотрудника подтягивает его должность, подразделение и организации. + const handleEmployeeChange = (login: string): void => { + setData((prev) => ({ ...prev, Employee: login, Department: '', Organization: '' })); + if (!login) { + return; + } + approval + .getProfileFields(login) + .then((profile) => setData((prev) => ({ ...prev, Department: profile.department }))) + .catch(() => undefined); + getEmployeeOrganizations(context, login) + .then((records) => + setData((prev) => ({ + ...prev, + Position: records[0]?.position || prev.Position, + Organization: formatEmployeeOrganizations(records) + })) + ) + .catch(() => undefined); + }; + + // Планируемый маршрут резолвится по e-mail ВЫБРАННОГО сотрудника (не текущего + // пользователя) — так работает подача за другого; условие «IsManager» на бэкенде + // считается по полю Position ЭТОЙ заявки, поэтому передаём его в fieldValues. + // У заявки на редактировании маршрут читается из уже записанных строк, чтобы не + // затереть правки инициатора. + const loadMatrix = useCallback( + () => + editId + ? approval.getMatrixForRequest(editId) + : getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval', null, [ + { fieldName: 'Position', value: data.Position } + ]), + [context, approval, editId, data.Employee, data.Position, requestTypeId] + ); + const matrix = useMatrixEditor( + loadMatrix, + `${data.Employee}-${editId ?? 'new'}-${data.Position}`, + !readOnly, + !!editId || (!!data.Employee && !!requestTypeId), + (login) => approval.getJobTitle(login) + ); + + // Последний рабочий день не может быть в прошлом: заявление подаётся заранее. + // Календарь (min у input type=date, см. RequestSection) не даёт выбрать более + // раннюю дату, но проверяем и здесь на случай обхода UI. + const today = isoDatePlusDays(0); + const isLastDayValid = !data.LastWorkingDay || data.LastWorkingDay >= today; + const isValid = !!( + data.Employee && + data.Position.trim() && + data.LastWorkingDay && + isLastDayValid && + data.Phone.trim() + ); + const validate = (): string | undefined => { + if (data.LastWorkingDay && !isLastDayValid) { + return `Последний рабочий день не может быть раньше сегодняшней даты (${formatIsoDate(today)}).`; + } + if (!data.Employee || !data.Position.trim() || !data.LastWorkingDay || !data.Phone.trim()) { + return 'Заполните обязательные поля: сотрудник, должность, последний рабочий день, контактный телефон.'; + } + 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, + // Транзитом из маршрута в строку списка: наследование бэкенд применил + // при resolve, форма только переносит значения (см. IMatrixRow.slaHours). + slaHours: r.slaHours, + askOverdueReason: r.askOverdueReason + })); + + const save = (status: string): (() => Promise) => async (): Promise => { + // validate()/validateSubmit() уже гарантировали requestTypeId к моменту вызова + // (см. useRequestSubmit.run — валидирует ДО save), но TS этого через границу + // замыкания не видит — локальный const и явная проверка снимают обе проблемы. + const rtId = requestTypeId; + if (!rtId) { + throw new Error('Тип заявки не определён — обновите страницу и повторите.'); + } + // Новая заявка пишется в ТЕКУЩУЮ партицию — запрашиваем её у approval-route + // API непосредственно перед записью (не кеш из onInit): именно этот вызов + // проверяет порог элементов и запускает архивацию следующей партиции. + const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId); + const { id, code } = await saveDismissalRequest( + requestId, + rtId, + data, + toApprovers(matrix.rows), + status, + writeListId + ); + setRequestId(id); + setRequestStatus(status); + if (code) { + setRequestCode(code); + } + if (onSaved) { + onSaved(); + } + // Отправка на согласование — уведомляем первый активный шаг (поток оповещения). + // Имена списков — той же партиции, куда фактически записана заявка (writeListId), + // не текущей: поток ищет строку маршрута именно в НЕЙ. + if (status === REQUEST_STATUS.onApproval) { + 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; + if (matrix.error) { + return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`; + } + const unassignedError = unassignedStepMessage( + matrix.rows, + (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.` + ); + if (unassignedError) return unassignedError; + return undefined; + }; + + const handleSaveDraft = (): Promise => submit.run('draft', save(REQUEST_STATUS.draft), validate); + const handleSubmit = (): Promise => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit); + + const sectionProps = { data, set, context, onEmployeeChange: handleEmployeeChange }; + + return ( +
+ + + + } + headerRight={ + requestId || requestStatus ? ( + <> + {requestStatus && } + {requestCode && } + + ) : undefined + } + > + {submit.savedKind && !submit.error && ( + + {submit.savedKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'} + + )} + {submit.error && {submit.error}} + + + + + + approval.searchUsers(q)} /> + + + +
+ ); +}; + +export default DismissalForm; diff --git a/src/webparts/requestPortal/forms/Dismissal/DismissalView.module.scss b/src/webparts/requestPortal/forms/Dismissal/DismissalView.module.scss new file mode 100644 index 0000000..6a27763 --- /dev/null +++ b/src/webparts/requestPortal/forms/Dismissal/DismissalView.module.scss @@ -0,0 +1,14 @@ +@import '../../../../styles/design-tokens'; + +.state { + padding: $sp-8; + color: $gray-500; + font-size: $fs-sm; + text-align: center; +} + +.toolbar { + display: flex; + justify-content: flex-end; + margin-bottom: $sp-4; +} diff --git a/src/webparts/requestPortal/forms/Dismissal/DismissalView.tsx b/src/webparts/requestPortal/forms/Dismissal/DismissalView.tsx new file mode 100644 index 0000000..02f0302 --- /dev/null +++ b/src/webparts/requestPortal/forms/Dismissal/DismissalView.tsx @@ -0,0 +1,162 @@ +import * as React from 'react'; +import { useState, useEffect, useMemo, useCallback } from 'react'; +import type { WebPartContext } from '@microsoft/sp-webpart-base'; +import styles from './DismissalView.module.scss'; +import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton } from '../../shared/ui'; +import { + ApproverService, + DecisionPanel, + StatusBadge, + ApprovalMatrix, + useMatrixEditor, + 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 { loadDismissalRequest } from './load'; +import { dismissalApprovalService } from './submit'; +import { RequestSection } from './sections/RequestSection'; + +interface IDismissalViewProps { + context: WebPartContext; + itemId: number; + /** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */ + formKey: string; + /** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */ + listId?: string; +} + +// В режиме чтения поля не редактируются, поэтому обновление — пустышка. +const NOOP_SET: SetField = () => undefined; + +// Заявление на увольнение только для просмотра (режим согласующего). +// Переиспользует ту же секцию, что и форма подачи; редактирование выключено через +// — поля сами становятся неизменяемыми. +const DismissalView: React.FC = ({ context, itemId, formKey, listId }) => { + const [data, setData] = useState(EMPTY_DATA); + const [status, setStatus] = useState(''); + const [code, setCode] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) — + // dismissalApprovalService можно звать не опасаясь исключения. + const approval = useMemo(() => dismissalApprovalService(listId), [listId]); + const { id: requestTypeId } = useRequestTypeId(context, formKey); + // Ref списка данных для ApproverService.updateRequestStatus: listId (url) для + // архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef). + const dataListRef = listId || dataListTitle(FORM_ID); + const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]); + + // Матрица маршрута из фактических строк заявки (read-only). decisionSeq в + // reloadKey: после решения строки на сервере меняются — перечитываем матрицу. + const [decisionSeq, setDecisionSeq] = useState(0); + const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]); + const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) => + approval.getJobTitle(l) + ); + + useEffect(() => { + let alive = true; + setLoading(true); + loadDismissalRequest(itemId, listId) + .then((loaded) => { + if (alive) { + setData(loaded.data); + setStatus(loaded.status); + setCode(loaded.code); + setError(null); + setLoading(false); + } + }) + .catch((e) => { + if (alive) { + setError(`Не удалось загрузить заявку №${itemId}: ${friendlyErrorMessage(e)}`); + setLoading(false); + } + }); + return () => { + alive = false; + }; + }, [itemId, listId]); + + if (loading) { + return
Загрузка заявки…
; + } + if (error) { + return ( +
+ {error} +
+ ); + } + + const sectionProps = { data, set: NOOP_SET, context }; + + const notify = (): void => { + // requestTypeId ещё не резолвился — решение уже применилось (строка списка + // обновлена), просто нечего уведомлять на следующем шаге; не проваливаем + // весь 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); + }; + + return ( +
+
+ +
+ +
+ + } + headerRight={status ? : undefined} + > + + + + + + approval.searchUsers(q)} /> +
+ + {/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} +
+ approval.searchUsers(q)} + onDecided={(newStatus) => { + setStatus(newStatus); + setDecisionSeq((n) => n + 1); + notify(); + }} + onDelegated={() => { + setDecisionSeq((n) => n + 1); + notify(); + }} + /> +
+
+ ); +}; + +export default DismissalView; diff --git a/src/webparts/requestPortal/forms/Dismissal/README.md b/src/webparts/requestPortal/forms/Dismissal/README.md new file mode 100644 index 0000000..973e2c9 --- /dev/null +++ b/src/webparts/requestPortal/forms/Dismissal/README.md @@ -0,0 +1,64 @@ +# Заявление на увольнение (hr-dismissal) + +**Назначение:** заявление работника о расторжении трудового договора по собственному +желанию (штатный сотрудник). Бумажный первоисточник — «Шаблон Заявление на увольнение ШТАТ.docx». +**Категория:** Отдел кадров +**Списки SP:** «Заявление на увольнение 1 — данные» / «Заявление на увольнение 1 — согласование» +**RequestTypeId (внешний API маршрутов):** не хардкодится — резолвится в рантайме по +`pageUrl` («hr-dismissal») через `useRequestTypeId` (см. «Формы больше не хардкодят +RequestTypeId» в `docs/forms.md`). Тип нужно **завести в админке approval-route** +(pageUrl «hr-dismissal» + ссылка на список данных) — до этого форма откроется, но +отправить заявку не даст: маршрут не резолвится. + +## Маршрут +- Фазы: только согласование. Фазы исполнения нет — после согласования заявка + получает статус «Согласовано» (как «Обходной лист» / «Отпуск без содержания»). +- **Матрица редактируема заявителем** при новой заявке / черновике / возврате: + `useMatrixEditor(editable = !readOnly)`. +- Условие `IsManager` в маршруте (если настроено в админке) резолвится по полю + **Position ЭТОЙ заявки**, поэтому `Position` передаётся в `fieldValues` при + планировании (`getPlannedMatrix(..., [{ fieldName: 'Position', ... }])`) — как в + «Обходном листе». + +## Особое поведение +- **По умолчанию заявка на себя**, но подать можно и за другого: «Сотрудник» — + People-picker (`Employee`), маршрут резолвится по e-mail **выбранного** сотрудника, + reloadKey матрицы включает `data.Employee` и `data.Position`. +- **Автоподстановка при выборе сотрудника:** должность и организации — из справочника + `Employee/ByEmail` (`shared/dictionary`, должность из ПЕРВОЙ записи, чтобы должность + и организация были парой из одной записи); структурное подразделение — из профиля + SharePoint (`SPS-Department` через `approval.getProfileFields`). +- **Структурное подразделение** (`Department`) — read-only, только из профиля: чтобы + не расходиться с профилем (как в «Обходном листе»). **Самостоятельное + подразделение** (`StandaloneUnit`) в профиле не хранится — свободный ввод. +- **Последний рабочий день** (`LastWorkingDay`) — обязателен, не может быть в прошлом + (`min` у `input type=date` + проверка в `validate` на случай обхода UI). + Срок предупреждения об увольнении (обычно месяц) **не** проверяется — договорную + дату согласуют с работодателем, форма её только фиксирует. +- **Контактный телефон** (`Phone`) — обязателен, `PhoneInput` (маска). +- **Комментарий** (`Comment`) — необязательный свободный текст. Причины увольнения + отдельного поля нет: в шаблоне формулировка фиксированная («по собственному желанию»). +- **Заголовок элемента** (`Title`): `Заявление на увольнение — {ФИО} — дд.мм.гггг`. + +## Связь с обходным листом +В списках данных обеих форм есть **перекрёстные колонки-гиперссылки** (тип `Url`): +`ClearanceUrl` здесь — на обходной лист, `DismissalUrl` в «Обходном листе» — на это +заявление. Одно увольнение = заявление + обходной лист, и по ссылке видно парную +заявку. Не Lookup намеренно: ссылка ведёт на **страницу портала** (`…?rpmode=approve& +rptype=…&rpid=…`, см. `docs/routing.md`), а не на элемент списка, и не ломается, +когда парная заявка уезжает в другую партицию. Пока это колонки **только в списках** — +формы их не пишут и не показывают, ссылка ставится вручную в SharePoint. + +## Отличия от «Обходной лист» (clearance-form) +Обходной лист оформляет HR **после** решения об увольнении (обход подразделений, +карты, обучение). Это заявление — **вход** в процесс: его подаёт сам работник, +полей всего восемь, отметок подразделений нет. + +## Секции формы +RequestSection (сотрудник · должность · структурное подразделение · самостоятельное +подразделение · организация · последний рабочий день · телефон · комментарий) + +## Точки входа +- rptype: `hr-dismissal` +- Режим согласующего (просмотр): есть (`DismissalView`) +- «Создать на основе»: нет diff --git a/src/webparts/requestPortal/forms/Dismissal/approval/provisioning.json b/src/webparts/requestPortal/forms/Dismissal/approval/provisioning.json new file mode 100644 index 0000000..5244649 --- /dev/null +++ b/src/webparts/requestPortal/forms/Dismissal/approval/provisioning.json @@ -0,0 +1,16 @@ +{ + "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": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" } + ] +} diff --git a/src/webparts/requestPortal/forms/Dismissal/load.ts b/src/webparts/requestPortal/forms/Dismissal/load.ts new file mode 100644 index 0000000..512438a --- /dev/null +++ b/src/webparts/requestPortal/forms/Dismissal/load.ts @@ -0,0 +1,63 @@ +import { getSP } from '../../shared/pnp/pnpjs'; +import { dataListTitle } from '../../shared/listTitles'; +import { FORM_ID, IFormData, EMPTY_DATA } from './model'; + +export interface ILoadedRequest { + data: IFormData; + /** Ф.И.О. сотрудника из поля Employee (для заголовка режима чтения). */ + employeeName: string; + status: string; + /** Уникальный код заявки; '' — если запись создана до появления поля. */ + code: string; +} + +/** + * Прочитать заявление на увольнение по id: модель формы + статус (правка / просмотр). + * + * listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/ + * listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции; задан — идём напрямую + * по url списка, а не по имени: так не нужно резолвить, как называется архивный + * список, и запрос не зависит от реестра партиций. + */ +export async function loadDismissalRequest(itemId: number, listId?: string): Promise { + const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID)); + const item = (await list + .items.getById(itemId) + .select( + 'Id', + 'Position', + 'Department', + 'StandaloneUnit', + 'LastWorkingDay', + 'Phone', + 'Comment', + 'Organization', + 'RequestStatus', + 'RequestUniqueCode', + 'Employee/Title', + 'Employee/EMail' + ) + .expand('Employee')()) as Record; + + const employee = item.Employee as { Title?: string; EMail?: string } | undefined; + const lastDay = (item.LastWorkingDay as string) || ''; + + const data: IFormData = { + ...EMPTY_DATA, + Employee: employee?.EMail || '', + Position: (item.Position as string) || '', + Department: (item.Department as string) || '', + StandaloneUnit: (item.StandaloneUnit as string) || '', + LastWorkingDay: lastDay.slice(0, 10), // ISO → YYYY-MM-DD для input type=date + Phone: (item.Phone as string) || '', + Comment: (item.Comment as string) || '', + Organization: (item.Organization as string) || '' + }; + + return { + data, + employeeName: employee?.Title || '', + status: (item.RequestStatus as string) || '', + code: (item.RequestUniqueCode as string) || '' + }; +} diff --git a/src/webparts/requestPortal/forms/Dismissal/model.ts b/src/webparts/requestPortal/forms/Dismissal/model.ts new file mode 100644 index 0000000..1530ddf --- /dev/null +++ b/src/webparts/requestPortal/forms/Dismissal/model.ts @@ -0,0 +1,71 @@ +import { WebPartContext } from '@microsoft/sp-webpart-base'; +import { RequestTypeId } from '../../models/types'; + +/** + * Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков + * SharePoint выводятся из него, см. shared/listTitles.ts. + */ +export const FORM_ID: RequestTypeId = 'hr-dismissal'; + +/** + * Данные формы «Заявление на увольнение» (штатный сотрудник) по бумажному шаблону + * «Шаблон Заявление на увольнение ШТАТ.docx»: расторжение трудового договора по + * собственному желанию. Ключи по возможности = internalName колонок списка данных + * (см. shared/listTitles.ts). Employee хранит e-mail сотрудника (то, что отдаёт + * PeopleField); по нему резолвится маршрут согласования, поэтому заявку можно + * подать и за другого сотрудника (например, кадровик за работника без доступа). + * + * Причина увольнения в шаблоне фиксированная («по собственному желанию») — своего + * поля под неё нет, поэтому в модели её тоже нет; свободный текст — в Comment. + */ +export interface IFormData { + /** E-mail сотрудника, за которого заявка (People). Пусто = не выбран. */ + Employee: string; + Position: string; + /** Структурное подразделение — из профиля (SPS-Department), read-only. */ + Department: string; + /** Самостоятельное подразделение — в профиле его нет, вводится вручную. */ + StandaloneUnit: string; + /** Последний рабочий день, ISO YYYY-MM-DD (input type=date). */ + LastWorkingDay: string; + /** Контактный телефон (маска, см. PhoneInput). */ + Phone: string; + /** Необязательный комментарий заявителя. */ + Comment: string; + /** + * Организации сотрудника (`\n`-joined «Компания — должность» на строку) — + * подтягивается автоматически из Employee/ByEmail при выборе Employee, + * read-only (см. shared/ui/OrganizationField). + */ + Organization: string; +} + +/** Обновление одного поля формы с сохранением типа значения. */ +export type SetField = (key: K, value: IFormData[K]) => void; + +/** Пропсы секции формы: срез данных + колбэк обновления + контекст (people picker). */ +export interface ISectionProps { + data: IFormData; + set: SetField; + context: WebPartContext; + /** + * Выбор сотрудника подтягивает его должность, подразделение и организации — + * поэтому у поля «Сотрудник» отдельный обработчик. Если не передан (режим + * чтения) — правка через set. + */ + onEmployeeChange?: (login: string) => void; +} + +export const EMPTY_DATA: IFormData = { + Employee: '', + Position: '', + Department: '', + StandaloneUnit: '', + LastWorkingDay: '', + Phone: '', + Comment: '', + Organization: '' +}; + +/** Плейсхолдер для поля выбора сотрудника. */ +export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…'; diff --git a/src/webparts/requestPortal/forms/Dismissal/provisioning.json b/src/webparts/requestPortal/forms/Dismissal/provisioning.json new file mode 100644 index 0000000..261045a --- /dev/null +++ b/src/webparts/requestPortal/forms/Dismissal/provisioning.json @@ -0,0 +1,18 @@ +{ + "dataListTitle": "Заявление на увольнение — данные", + "columns": [ + { "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" }, + { "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] }, + + { "internalName": "Employee", "title": "Сотрудник", "type": "User", "userSelectionMode": "PeopleOnly", "required": true }, + { "internalName": "Position", "title": "Должность", "type": "Text" }, + { "internalName": "Department", "title": "Структурное подразделение", "type": "Text" }, + { "internalName": "StandaloneUnit", "title": "Самостоятельное подразделение", "type": "Text" }, + { "internalName": "LastWorkingDay", "title": "Последний рабочий день", "type": "DateTime", "dateOnly": true }, + { "internalName": "Phone", "title": "Контактный телефон", "type": "Text" }, + { "internalName": "Comment", "title": "Комментарий", "type": "Note" }, + { "internalName": "Organization", "title": "Организация", "type": "Note" }, + + { "internalName": "ClearanceUrl", "title": "Ссылка на обходной лист", "type": "Url" } + ] +} diff --git a/src/webparts/requestPortal/forms/Dismissal/sections/RequestSection.tsx b/src/webparts/requestPortal/forms/Dismissal/sections/RequestSection.tsx new file mode 100644 index 0000000..68277c7 --- /dev/null +++ b/src/webparts/requestPortal/forms/Dismissal/sections/RequestSection.tsx @@ -0,0 +1,80 @@ +import * as React from 'react'; +import { + CollapseSection, + FormField, + TextInput, + TextArea, + PhoneInput, + PeopleField, + OrganizationField +} from '../../../shared/ui'; +import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model'; +import { isoDatePlusDays } from '../../../shared/dateUtils'; + +// Структурное подразделение подтягивается из профиля при выборе сотрудника (см. +// DismissalForm.handleEmployeeChange — SharePoint User Profile), свободный ввод +// намеренно убран, чтобы не расходиться с профилем — как в «Обходном листе». +const NOOP = (): void => undefined; + +/** + * Единственная секция формы «Заявление на увольнение»: кто увольняется, где + * работает, последний рабочий день и контакты. Набор полей — с бумажного шаблона + * «Заявление на увольнение ШТАТ». + */ +export const RequestSection: React.FC = ({ data, set, context, onEmployeeChange }) => ( + + + set('Employee', v))} + placeholder={EMPLOYEE_PLACEHOLDER} + /> + + {/* Подставляется по выбранному сотруднику, но остаётся редактируемым: если + справочник организаций не заполнен, должность нужно ввести вручную — + иначе шаг маршрута с условием по должности не соберётся. */} + + set('Position', v)} + placeholder="Подставится автоматически по выбранному сотруднику" + /> + + + + + + set('StandaloneUnit', v)} + /> + + + {/* Датой увольнения нельзя назначить прошедший день: заявление подаётся + заранее, а не задним числом (min = сегодня). */} + + set('LastWorkingDay', v)} + min={isoDatePlusDays(0)} + /> + + + set('Phone', v)} /> + + +