23 Commits

Author SHA1 Message Date
Тимур Городецкий 88ce1d498e feat: выбор БСУ и рецепта в заявке на бетон
Tests / frontend-test (push) Successful in 1m24s
Заявка заказывалась свободным текстом, без привязки к реальным рецептам
завода. Теперь БСУ выбирается из API bsu.tnsu.kz (BsuId/BsuName в шапке),
а класс смеси в позиции — из рецептов этого БСУ (formulaId + снимок
состава, т.к. на заводе рецепт могут поменять). Состав показывается
плашками. Если API не ответил, отправку не блокируем — ручной ввод.

Новые колонки BsuId/BsuName нужно провизионировать до деплоя.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 09:34:35 +05:00
Тимур Городецкий 3495e681e7 chore: ui-kit 0.15.0 — иконка кресла у раздела «Мебель»
Tests / frontend-test (push) Successful in 4m26s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 17:52:47 +05:00
Тимур Городецкий 583f400e26 feat: имя PDF при печати — название формы, ФИО и номер заявки
Окно печати ui-kit было без заголовка, и при «Сохранить как PDF» браузер
называл файл как попало. Теперь имя — «<Форма> — <ФИО> №<код>» во всех
15 просмотрах с печатью (у подбора без ФИО — это заявка на вакансию).

Пока временно локальная копия printRoot/PrintButton в shared/ui/PrintButton
с параметром заголовка; параметр будет перенесён в ui-kit, после чего копия
удаляется.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 17:52:09 +05:00
Тимур Городецкий 36432c75e6 feat: добавление исполнителей при правке мебели/ПО согласующим
Маршрут планируется один раз при подаче: если мебель или ПО отметил
руководитель на своём шаге, исполнитель по ним не появлялся. Теперь при
сохранении правок (и перед решением) строятся два плана исполнения — со
старыми и новыми значениями — и разница встаёт в конец исполнения «Ждёт
очереди» с SLA из маршрута. Никого не удаляем, уже стоящих не дублируем;
ошибка добавления не теряется и блокирует решение.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 17:52:08 +05:00
Тимур Городецкий 45fc06a6ac feat: раздел «Мебель» в организации рабочего места
Мультивыбор (стол, кресло, тумбочка, канцелярия, другое) с полем своего
значения при «Другое». Необязательный; руководитель на своём шаге правит его
наравне с ПО и доступами. Хранится в WorkplaceFurnitureJson/WorkplaceFurnitureOther
и уходит в маршрут (fieldValues), чтобы шаг исполнения «по мебели» мог
назначаться условием в админке.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 17:52:08 +05:00
gorbenko.pavel 14ac640c50 Реестр пакетов: организация tansu, адреса по https
Tests / frontend-test (push) Successful in 1m52s
2026-10-29 12:49:25 +00:00
Тимур Городецкий 2434ead754 feat: поля вида «Временный перевод» в изменении условий ТД
Tests / frontend-test (push) Successful in 1m31s
Последний из разобранных видов: откуда и куда — проект из справочника и
работа текстом, период не дольше 3 месяцев включительно (календарь не
даёт выбрать лишнее, при отправке — проверка), основание (производственная
необходимость / простой), обоснование и пометка из шаблона ТЗ о сохранении
оплаты. Поля разобраны по всем видам, кроме продления срока ТД.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 15:51:16 +05:00
Тимур Городецкий bdd8da9b89 feat: поля вида «Инициатива работодателя» и короткие названия видов
Девятый вид: причина (из ТЗ + «Другое»), что меняется — график работы
и/или оклад; оклад вводится один, долю постоянной части можно задать
(по умолчанию 75 % из ТЗ), переменная — остаток до 100 %, суммы считаются
сами; даты не раньше чем через 15 дней (срок уведомления работника);
обоснование — текстом или документом. Длинные названия видов 7 и 9
сокращены, а дублирующий выбранный вид заголовок над полями убран у всех
видов.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 15:42:56 +05:00
Тимур Городецкий f61418dbe2 feat: поля вида «Изменение трудовой функции» в изменении условий ТД
Восьмой вид, ровно по шаблону ТЗ: дата изменения, «в связи с» свободным
текстом (списка причин в ТЗ нет) и обязательная новая должностная
инструкция — отдельный слот вложений. Инициатор — только руководитель.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 15:24:55 +05:00
Тимур Городецкий 55659f94cf feat: поля вида «Изменение режима / графика работы» в изменении условий ТД
Седьмой вид, по ролям строго по шаблонам ТЗ. Работник просит неполный
рабочий день / неполную рабочую неделю и пишет заявление; работодатель
переводит на вахтовый, сменный или иной режим с новым графиком и
временем работы и указывает причину из ТЗ. Текущий режим и дата — у
обоих, документ «Новый график» необязателен. Списки графика и времени
работы и поле «список + Другое» стали общими для видов формы.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 15:18:28 +05:00
Тимур Городецкий 040ab475b5 feat: инициатор (работник / работодатель) в изменении условий ТД
По ТЗ у видов, которые может начать и сам работник (3, 4, 5, 7), шаблон
разный: у работника — только текст заявления, у работодателя — причины
из списка и обоснование. Разделяем строго по ТЗ полем «Инициатор»: по
умолчанию «на себя — работник, за другого — работодатель» (с подсказкой
под полем), следует за сменой сотрудника, пока его не выбрали руками.
Причина у работника при сохранении очищается.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 15:11:34 +05:00
Тимур Городецкий c4b2cb3c3d feat: поля вида «Изменение места работы» в изменении условий ТД
Пятый вид: место работы «было → стало» парой организация + проект
(текущая организация — из шапки, новая — из справочника и обязательна),
адрес, дата, причина, условия переезда, вахта с графиком работы и
обоснование. Проверки «обязательно при отправке» по видам собраны в
одну функцию, чтобы форма не росла с каждым новым видом.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 14:33:25 +05:00
Тимур Городецкий 87c9f92d48 feat: поля вида «Перевод в другое подразделение» в изменении условий ТД
Четвёртый вид: новое подразделение из справочника, дата, причина а–г и
обоснование. По ТЗ согласие принимающего руководителя — шаг маршрута, а
approval-route руководителя нового подразделения найти не умеет, поэтому
пока форма сама ставит выбранного принимающего руководителя первым шагом
согласования (временно, до шага в админке маршрутов). Логика маршрута
вынесена в useContractRoute — форма перерастала 300 строк.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 14:20:41 +05:00
Тимур Городецкий b77261079b feat: исполнение не печатается ни в одной форме
Исполнение — внутренняя работа после согласования, к печатному документу
заявки оно не относится: на печати остаётся только маршрут согласования.
Раньше так было лишь в отпусках, через ручную обёртку. Теперь у
ApprovalMatrix есть признак printable, ExecutorsMatrix всегда выключает
печать (и для будущих форм), а блоки «Исполнение», выведенные через
ApprovalMatrix напрямую, помечены явно. На экране ничего не меняется.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 12:51:50 +05:00
Тимур Городецкий 0005bf0cbd feat: кадровик в маршруте отпуска без содержания зависит от проекта
В маршрут #15 добавлен тот же шаг кадровика, что в ежегодном отпуске:
в офисе — кадровик компании, на объекте — кадровик объекта. Форма
теперь передаёт проект в approval-route, поле «Проект» обязательно и
только из справочника. Сборка «организация + проект» вынесена в общий
organizationProjectRouteFieldValues — ей пользуются обе формы отпуска.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 12:47:00 +05:00
Тимур Городецкий 614a9d7e37 feat: кадровик в маршруте отпуска зависит от проекта
Кадры назначают кадровика по объекту, а в офисе — по компании, поэтому
маршрут #9 теперь учитывает проект: форма передаёт его в approval-route
вместе с организацией. Поле «Проект» стало обязательным и принимается
только из справочника — название, вписанное руками, условия маршрута
не узнают, и заявка ушла бы без кадровика.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 12:10:22 +05:00
Тимур Городецкий efa3fdf65e feat: примечание о сроке уведомления в заявлении на увольнение
Tests / frontend-test (push) Successful in 1m24s
По аналогии с ежегодным отпуском вверху формы и просмотра увольнения —
плашка «Примечание» с текстом от кадров про срок уведомления по ст. 56
ТК РК. На печать не идёт.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 10:59:14 +05:00
Тимур Городецкий 16699ef2e2 refactor: LeaveNotice переименован в общий FormNotice
Плашка «Примечание» с правилами нужна не только заявлениям на отпуск —
следующей её получает увольнение. Имя LeaveNotice вводило бы в
заблуждение, поэтому компонент стал нейтральным FormNotice; поведение
не меняется.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 10:59:13 +05:00
Тимур Городецкий 9fd94e0adf feat: таблица «Исполнение» не печатается в заявлениях на отпуск
Tests / frontend-test (push) Successful in 1m19s
Исполнение — внутренняя работа кадров после согласования и к заявлению
сотрудника не относится. На печати «Заявки на отпуск» и «Отпуска без
содержания» остаётся только маршрут согласования; на экране таблица видна.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 10:13:59 +05:00
Тимур Городецкий d3940ce584 feat: место для подписи сотрудника на печати заявлений на отпуск
Печатное заявление подписывают от руки, а на листе было негде. В правом
верхнем углу шапки, под статусом, теперь ФИО сотрудника, место под роспись
и линия «(подпись)» — только при печати. ФИО именно сотрудника, а не
инициатора: заявку могут оформить за него. Общий компонент
shared/ui/PrintSignature, подключён в «Заявку на отпуск» и «Отпуск без
содержания».

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 10:13:40 +05:00
Тимур Городецкий 49568e00e8 feat: итог отправки в заголовке истории согласования
Tests / frontend-test (push) Successful in 1m21s
Чтобы понять, чем закончилась отправка, приходилось читать все её события.
Теперь рядом с номером отправки — плашка итога: возврат/отказ, «В работе»,
если заявка сейчас у кого-то на шаге, иначе последнее решение. Правила —
в чистой функции roundOutcome, покрыты тестами.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 09:29:13 +05:00
Тимур Городецкий f0951cdda2 feat: история согласования — таймлайн с цветными плашками действий
Действие в ленте не выделялось среди остального текста, а комментарий шёл
серым курсивом. Теперь события на вертикальной линии с цветными кружками,
что произошло — плашкой по смыслу решения, комментарий — тем же блоком, что
в маршруте (ApprovalComment получил проп tone), у кого заявка сейчас —
пульсирующий кружок.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 09:20:15 +05:00
Тимур Городецкий 26ddfcb5ba fix: кнопки действий маршрута вровень со стрелками у строки с комментарием
Стрелки шага объединены на строку участника и его комментария и стояли по
центру пары, а кнопки действий — по центру одной строки, из-за чего
расходились по высоте. Ячейка действий теперь тоже накрывает комментарий.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 09:20:15 +05:00
135 changed files with 4219 additions and 397 deletions
+1 -1
View File
@@ -1 +1 @@
@tnsu:registry=http://git.tnsu.kz/api/packages/Tansu/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: адрес registry для скоупа @tnsu (НЕ секрет, можно коммитить).
# Токен на чтение — у каждого разработчика свой, в ~/.npmrc (в проект не кладём). # Токен на чтение — у каждого разработчика свой, в ~/.npmrc (в проект не кладём).
$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" $npmrc | Set-Content -Encoding UTF8 ".npmrc"
Success ".npmrc создан (адрес registry @tnsu)." Success ".npmrc создан (адрес registry @tnsu)."
@@ -282,7 +282,7 @@ try {
} catch { } catch {
Warn "Не удалось поставить @tnsu/ui-kit. Скорее всего нет токена @tnsu в ~/.npmrc." Warn "Не удалось поставить @tnsu/ui-kit. Скорее всего нет токена @tnsu в ~/.npmrc."
Warn "Создай токен (Gitea → Настройки → Приложения → package: Read) и пропиши в ~/.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" Warn "Потом выполни: npm install @tnsu/ui-kit"
} }
+1 -1
View File
@@ -51,7 +51,7 @@
| [`hr-skud-explanation`](../src/webparts/requestPortal/forms/SkudExplanation/README.md) | Объяснительная об отсутствии отметок в СКУД | Отдел кадров | да | нет | да | только «на себя»; дни выбираются кликом в календаре «Мой СКУД» (проходы Hikvision текущего пользователя), сохраняются снимком для согласующего; текст записки собирается автоматически, печать — бланк объяснительной | ⏳ код готов, списки/тип/маршрут не заведены | | [`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-business-trip`](../src/webparts/requestPortal/forms/BusinessTrip/README.md) | Заявка на командировку | Отдел кадров | да | да | да | можно за другого сотрудника; командирующая организация — выбор из организаций сотрудника; маршрут лентой (точки по Казахстану — город из справочника КАТО на русском и казахском + объект из справочника, за рубежом — страна/город/объект вручную; даты не задним числом, поездка ≤ 40 дней; участки с видом транспорта и временем выезда); суточные (≤ 6 МРП КЗ / ≤ 8 за рубежом) и проживание (ночи сами, ≤ 15 000 ₸ за ночь КЗ) — по точкам; комментарий после итога; задание — список задач; смета: статьи с позициями, привязанными к точкам/участкам (суммой или кол-во × цена — всё вручную), итог с долями и по объектам; обязательство по авансовому отчёту — флажок; печать без шапки адресата | ⏳ код готов, списки/тип/маршрут не заведены |
| `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) | | `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
| [`hr-contract-change`](../src/webparts/requestPortal/forms/ContractChange/README.md) | Изменение условий трудового договора | Отдел кадров | да | — | да | сотрудник из `Employee/FullBase` (и без учётки M365), снимок в текстовых колонках; маршрут по сотруднику, без учётки — по заявителю; 10 видов изменения (один, данные под несколько); поля по видам — в работе | ⏳ каркас, списки/тип/маршрут не заведены | | [`hr-contract-change`](../src/webparts/requestPortal/forms/ContractChange/README.md) | Изменение условий трудового договора | Отдел кадров | да | — | да | сотрудник из `Employee/FullBase` (и без учётки M365), снимок в текстовых колонках; маршрут по сотруднику, без учётки — по заявителю; 10 видов изменения (один, данные под несколько), поля по каждому виду по ТЗ кадров (кроме «Продления срока ТД»); инициатор работник/работодатель; подписанные вложения по «Что приложить» | ⏳ код готов (кроме вида 6), списки/тип/маршрут не заведены |
| [`wh-service-request`](../src/webparts/requestPortal/forms/ServiceRequest/README.md) | Заявка на услуги | Склад / Снабжение | да | нет | да | маршрут по проекту (руководитель проекта/ПТО); табличные части «Приложения» и «Смета» (JSON-колонки); сумма по смете считается на лету (qty × цена), итог дублируется числовой колонкой для отчётов | ✅ | | [`wh-service-request`](../src/webparts/requestPortal/forms/ServiceRequest/README.md) | Заявка на услуги | Склад / Снабжение | да | нет | да | маршрут по проекту (руководитель проекта/ПТО); табличные части «Приложения» и «Смета» (JSON-колонки); сумма по смете считается на лету (qty × цена), итог дублируется числовой колонкой для отчётов | ✅ |
Легенда: **Согл.** — есть фаза согласования; **Исп.** — есть фаза исполнения; Легенда: **Согл.** — есть фаза согласования; **Исп.** — есть фаза исполнения;
+1 -1
View File
@@ -26,7 +26,7 @@
| Править **все поля и вложения** заявки (добавлять и удалять файлы) на своём активном шаге согласования | Алтынай Бултурукова | email в коде (список по каждой форме) | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` | | Править **все поля и вложения** заявки (добавлять и удалять файлы) на своём активном шаге согласования | Алтынай Бултурукова | email в коде (список по каждой форме) | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` |
| То же: **все поля и вложения**, но **не только на своём шаге**: достаточно быть в маршруте (согласование или исполнение), пока заявка «На согласовании» или «Выполнение» | Ербол Шаймерденов | email в коде (список по каждой форме) + участие в маршруте | **Только** Организация рабочего места | `shared/approval/fullEditRights.ts` → `canEditAllFields` + `forms/WorkplaceSetup/useApproverRights.ts` | | То же: **все поля и вложения**, но **не только на своём шаге**: достаточно быть в маршруте (согласование или исполнение), пока заявка «На согласовании» или «Выполнение» | Ербол Шаймерденов | email в коде (список по каждой форме) + участие в маршруте | **Только** Организация рабочего места | `shared/approval/fullEditRights.ts` → `canEditAllFields` + `forms/WorkplaceSetup/useApproverRights.ts` |
| Заменять человека на шаге согласования (не удалять шаг) **до отправки**: новая заявка, черновик, доработка | Любой инициатор | право по статусу заявки (форма редактируема) | **Только** Организация рабочего места | `forms/WorkplaceSetup/WorkplaceSetupForm.tsx` (`canReplaceRow`) + `useMatrixEditor.replace` | | Заменять человека на шаге согласования (не удалять шаг) **до отправки**: новая заявка, черновик, доработка | Любой инициатор | право по статусу заявки (форма редактируема) | **Только** Организация рабочего места | `forms/WorkplaceSetup/WorkplaceSetupForm.tsx` (`canReplaceRow`) + `useMatrixEditor.replace` |
| Заполнять «Необходимое ПО» и «Доступы» на своём шаге согласования | Первый шаг маршрута согласования (по маршруту — непосредственный руководитель) | порядок шага в маршруте | Организация рабочего места | `forms/WorkplaceSetup/useApproverRights.ts` (`canEditReviewFields`) | | Заполнять «Необходимое ПО», «Доступы» и «Мебель» на своём шаге согласования | Первый шаг маршрута согласования (по маршруту — непосредственный руководитель) | порядок шага в маршруте | Организация рабочего места | `forms/WorkplaceSetup/useApproverRights.ts` (`canEditReviewFields`) |
| Править оклад на ИС, оклад после ИС, «Отдел/Участок» на своём шаге согласования | Начальник расчётной группы (сейчас Ким Анна) | должность шага: «начальник/руководитель» + «расчет» | Заявка на приём | `forms/RecruitingHire/approverRights.ts` → `canEditPayrollFields` | | Править оклад на ИС, оклад после ИС, «Отдел/Участок» на своём шаге согласования | Начальник расчётной группы (сейчас Ким Анна) | должность шага: «начальник/руководитель» + «расчет» | Заявка на приём | `forms/RecruitingHire/approverRights.ts` → `canEditPayrollFields` |
| Ставить дату приёма (2.2) | Активный исполнитель | роль в маршруте (шаг «Исполнение»), без проверки должности | Заявка на приём | `forms/RecruitingHire/RecruitingHireView.tsx` (`hireDateEditable`) | | Ставить дату приёма (2.2) | Активный исполнитель | роль в маршруте (шаг «Исполнение»), без проверки должности | Заявка на приём | `forms/RecruitingHire/RecruitingHireView.tsx` (`hireDateEditable`) |
| Править карту СКУД и сумму за обучение | Начальник отдела кадрового делопроизводства | должность шага: «начальник/руководитель» + «кадров» + «делопроизводств» | Обходной лист | `forms/Clearance/approverRights.ts` → `canEditHrRecordsFields` | | Править карту СКУД и сумму за обучение | Начальник отдела кадрового делопроизводства | должность шага: «начальник/руководитель» + «кадров» + «делопроизводств» | Обходной лист | `forms/Clearance/approverRights.ts` → `canEditHrRecordsFields` |
+4 -4
View File
@@ -17,7 +17,7 @@
"@microsoft/sp-webpart-base": "1.22.1", "@microsoft/sp-webpart-base": "1.22.1",
"@pnp/sp": "^4.20.0", "@pnp/sp": "^4.20.0",
"@pnp/spfx-controls-react": "^3.25.0", "@pnp/spfx-controls-react": "^3.25.0",
"@tnsu/ui-kit": "^0.14.0", "@tnsu/ui-kit": "^0.15.0",
"jszip": "~3.8.0", "jszip": "~3.8.0",
"react": "17.0.1", "react": "17.0.1",
"react-dom": "17.0.1", "react-dom": "17.0.1",
@@ -9886,9 +9886,9 @@
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/@tnsu/ui-kit": { "node_modules/@tnsu/ui-kit": {
"version": "0.14.0", "version": "0.15.0",
"resolved": "http://git.tnsu.kz/api/packages/Tansu/npm/%40tnsu%2Fui-kit/-/0.14.0/ui-kit-0.14.0.tgz", "resolved": "https://git.tnsu.kz/api/packages/tansu/npm/%40tnsu%2Fui-kit/-/0.15.0/ui-kit-0.15.0.tgz",
"integrity": "sha512-Vun9AUM9jL8rttbsbKo5Yh6TUz1srkNqxiO6mmzebrSo1JOASNJqNjJPMTS+8Jt3MTrJPklvh0TbeqGQzw7qxQ==", "integrity": "sha512-vs+7NPem7PDaYj00nwaFztkw2K/nOkw6m2bvqgMABZG5rWjLVqfyFyiopKFkPYgqPWcMPwmQuxoFnc9xi7rwjQ==",
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
"@fluentui/react": "^8.106.4", "@fluentui/react": "^8.106.4",
+1 -1
View File
@@ -21,7 +21,7 @@
"@microsoft/sp-webpart-base": "1.22.1", "@microsoft/sp-webpart-base": "1.22.1",
"@pnp/sp": "^4.20.0", "@pnp/sp": "^4.20.0",
"@pnp/spfx-controls-react": "^3.25.0", "@pnp/spfx-controls-react": "^3.25.0",
"@tnsu/ui-kit": "^0.14.0", "@tnsu/ui-kit": "^0.15.0",
"jszip": "~3.8.0", "jszip": "~3.8.0",
"react": "17.0.1", "react": "17.0.1",
"react-dom": "17.0.1", "react-dom": "17.0.1",
@@ -2,7 +2,7 @@ import * as React from 'react';
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base'; import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './BusinessTripView.module.scss'; import styles from './BusinessTripView.module.scss';
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton } from '../../shared/ui'; import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton, buildPrintTitle } from '../../shared/ui';
import { import {
ApproverService, ApproverService,
ApprovalMatrix, ApprovalMatrix,
@@ -55,6 +55,7 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
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 [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
const [employeeName, setEmployeeName] = useState<string>('');
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);
@@ -84,6 +85,7 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
setData(loaded.data); setData(loaded.data);
setStatus(loaded.status); setStatus(loaded.status);
setCode(loaded.code); setCode(loaded.code);
setEmployeeName(loaded.employeeName);
notifyCode(loaded.code); notifyCode(loaded.code);
setError(null); setError(null);
setLoading(false); setLoading(false);
@@ -128,7 +130,9 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
return ( return (
<div> <div>
<div className={`${styles.toolbar} rp-no-print`}> <div className={`${styles.toolbar} rp-no-print`}>
<PrintButton /> <PrintButton
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
/>
</div> </div>
<div className="rp-print-root"> <div className="rp-print-root">
@@ -156,6 +160,7 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
{!executors.loading && executors.rows.length > 0 && ( {!executors.loading && executors.rows.length > 0 && (
<ApprovalMatrix <ApprovalMatrix
editor={executors} editor={executors}
printable={false}
search={searchUsers} search={searchUsers}
title="Исполнение" title="Исполнение"
roleLabel="Исполнитель" roleLabel="Исполнитель"
@@ -11,6 +11,8 @@ export interface ILoadedRequest {
status: string; status: string;
/** Уникальный код заявки; '' — если запись создана до появления поля. */ /** Уникальный код заявки; '' — если запись создана до появления поля. */
code: string; code: string;
/** ФИО командируемого (Employee/Title) — для имени печатного документа. */
employeeName: string;
} }
/** /**
@@ -37,11 +39,12 @@ export async function loadBusinessTripRequest(itemId: number, listId?: string):
'TripObligationAccepted', 'TripObligationAccepted',
'RequestStatus', 'RequestStatus',
'RequestUniqueCode', 'RequestUniqueCode',
'Employee/EMail' 'Employee/EMail',
'Employee/Title'
) )
.expand('Employee')()) as Record<string, unknown>; .expand('Employee')()) as Record<string, unknown>;
const employee = item.Employee as { EMail?: string } | undefined; const employee = item.Employee as { EMail?: string; Title?: string } | undefined;
const data: IFormData = { const data: IFormData = {
...EMPTY_DATA, ...EMPTY_DATA,
@@ -61,6 +64,7 @@ export async function loadBusinessTripRequest(itemId: number, listId?: string):
return { return {
data, data,
status: (item.RequestStatus as string) || '', status: (item.RequestStatus as string) || '',
code: (item.RequestUniqueCode as string) || '' code: (item.RequestUniqueCode as string) || '',
employeeName: employee?.Title || ''
}; };
} }
@@ -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 './ClearanceView.module.scss'; import styles from './ClearanceView.module.scss';
import { Alert, Button, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui'; import {
Alert,
Button,
FormCard,
InitiatorLine,
FormModeProvider,
PrintSplitButton,
OrgLetterhead,
buildPrintTitle
} from '../../shared/ui';
import { import {
ApproverService, ApproverService,
DecisionPanel, DecisionPanel,
@@ -48,6 +57,8 @@ interface IClearanceViewProps {
type SaveState = 'idle' | 'saving' | 'saved' | 'error'; type SaveState = 'idle' | 'saving' | 'saved' | 'error';
const FORM_TITLE = 'Обходной лист';
// Обходной лист в режиме согласующего. Форма read-only, КРОМЕ отметок, которые // Обходной лист в режиме согласующего. Форма read-only, КРОМЕ отметок, которые
// заполняет профильный отдел на своём шаге: КДП — карта СКУД и сумма обучения, // заполняет профильный отдел на своём шаге: КДП — карта СКУД и сумма обучения,
// отдел подбора — парковочная карта (см. approverRights.ts). // отдел подбора — парковочная карта (см. approverRights.ts).
@@ -60,6 +71,8 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
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 [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
// ФИО увольняемого — для имени печатного документа.
const [employeeName, setEmployeeName] = useState<string>('');
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 [hrRecordsEditable, setHrRecordsEditable] = useState<boolean>(false); const [hrRecordsEditable, setHrRecordsEditable] = useState<boolean>(false);
@@ -123,6 +136,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
setData(loaded.data); setData(loaded.data);
setStatus(loaded.status); setStatus(loaded.status);
setCode(loaded.code); setCode(loaded.code);
setEmployeeName(loaded.employeeName);
notifyCode(loaded.code); notifyCode(loaded.code);
setError(null); setError(null);
setLoading(false); setLoading(false);
@@ -274,7 +288,12 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
return ( return (
<div> <div>
<div className={`${styles.toolbar} rp-no-print`}> <div className={`${styles.toolbar} rp-no-print`}>
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} /> <PrintSplitButton
value={data.Organization}
selectedIndex={printOrgIndex}
onChange={setPrintOrgIndex}
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
/>
</div> </div>
{canEditAnything && ( {canEditAnything && (
@@ -295,7 +314,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
<div className="rp-print-root"> <div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} /> <OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard <FormCard
title="Обходной лист" title={FORM_TITLE}
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={ belowSubtitle={
<InitiatorLine <InitiatorLine
@@ -315,6 +334,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
{!executors.loading && executors.rows.length > 0 && ( {!executors.loading && executors.rows.length > 0 && (
<ApprovalMatrix <ApprovalMatrix
editor={executors} editor={executors}
printable={false}
search={(q) => approval.searchUsers(q)} search={(q) => approval.searchUsers(q)}
title="Исполнение" title="Исполнение"
roleLabel="Исполнитель" roleLabel="Исполнитель"
@@ -43,7 +43,8 @@ import {
dataListTitle, dataListTitle,
dataListTitleForListId dataListTitleForListId
} from '../../shared/listTitles'; } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField, withoutFormula } from './model';
import { useBsuCatalog } from './bsu/useBsuCatalog';
import { saveConcreteRequest, concreteRequestApprovalService } from './submit'; import { saveConcreteRequest, concreteRequestApprovalService } from './submit';
import { loadConcreteRequest } from './load'; import { loadConcreteRequest } from './load';
import { HeaderSection } from './sections/HeaderSection'; import { HeaderSection } from './sections/HeaderSection';
@@ -167,6 +168,19 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
.catch(() => undefined); .catch(() => undefined);
}; };
// Справочник БСУ нужен только при заполнении — в режиме чтения показываем сохранённые названия.
const bsu = useBsuCatalog(context, data.BsuId, !readOnly);
// Рецепты позиций принадлежат конкретному заводу — при смене БСУ сбрасываем их.
const handleBsuChange = (bsuId: string, bsuName: string): void => {
setData((prev) => ({
...prev,
BsuId: bsuId,
BsuName: bsuName,
Items: bsuId === prev.BsuId ? prev.Items : prev.Items.map(withoutFormula)
}));
};
// Маршрут согласования зависит от начальника участка И проекта (в админке // Маршрут согласования зависит от начальника участка И проекта (в админке
// approval-route для этого типа заявки настроено условие ByProject) — // approval-route для этого типа заявки настроено условие ByProject) —
// planned resolve без ProjectId 404-ит, поэтому матрица не готова, пока // planned resolve без ProjectId 404-ит, поэтому матрица не готова, пока
@@ -205,10 +219,10 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
}); });
const hasItems = data.Items.some((i) => i.mixClass.trim().length > 0 && i.volume.trim().length > 0); const hasItems = data.Items.some((i) => i.mixClass.trim().length > 0 && i.volume.trim().length > 0);
const isValid = !!(data.RequestDate && data.SiteChiefEmail && data.Project.trim() && hasItems); const isValid = !!(data.RequestDate && data.SiteChiefEmail && data.Project.trim() && data.BsuName.trim() && hasItems);
const validate = (): string | undefined => { const validate = (): string | undefined => {
if (!isValid) { if (!isValid) {
return 'Заполните обязательные поля: дата поставки, начальник участка, проект и хотя бы одну позицию (класс смеси + количество).'; return 'Заполните обязательные поля: дата поставки, начальник участка, проект, БСУ и хотя бы одну позицию (класс смеси + количество).';
} }
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.'; if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
@@ -303,7 +317,9 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
set, set,
context, context,
onSiteChiefChange: handleSiteChiefChange, onSiteChiefChange: handleSiteChiefChange,
onForemanChange: handleForemanChange onForemanChange: handleForemanChange,
bsu: readOnly ? undefined : bsu,
onBsuChange: handleBsuChange
}; };
// «Удалить черновик» — только у сохранённой заявки в статусе «Черновик». // «Удалить черновик» — только у сохранённой заявки в статусе «Черновик».
@@ -2,7 +2,15 @@ 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 './ConcreteRequestView.module.scss'; import styles from './ConcreteRequestView.module.scss';
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui'; import {
Alert,
FormCard,
InitiatorLine,
FormModeProvider,
PrintSplitButton,
OrgLetterhead,
buildPrintTitle
} from '../../shared/ui';
import { import {
ApproverService, ApproverService,
DecisionPanel, DecisionPanel,
@@ -42,6 +50,8 @@ interface IConcreteRequestViewProps {
onCodeLoaded?: (code: string) => void; onCodeLoaded?: (code: string) => void;
} }
const FORM_TITLE = 'Заявка на бетон';
// В режиме чтения поля не редактируются, поэтому обновление — пустышка. // В режиме чтения поля не редактируются, поэтому обновление — пустышка.
const NOOP_SET: SetField = () => undefined; const NOOP_SET: SetField = () => undefined;
@@ -137,13 +147,18 @@ const ConcreteRequestView: React.FC<IConcreteRequestViewProps> = ({ context, ite
return ( return (
<div> <div>
<div className={`${styles.toolbar} rp-no-print`}> <div className={`${styles.toolbar} rp-no-print`}>
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} /> <PrintSplitButton
value={data.Organization}
selectedIndex={printOrgIndex}
onChange={setPrintOrgIndex}
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.SiteChiefName, code, itemId })}
/>
</div> </div>
<div className="rp-print-root"> <div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} /> <OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard <FormCard
title="Заявка на бетон" title={FORM_TITLE}
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={ belowSubtitle={
<InitiatorLine <InitiatorLine
@@ -51,11 +51,20 @@
здесь другой, поэтому у формы свой редактор. Карточки (`FormGrid`/`FormField`), здесь другой, поэтому у формы свой редактор. Карточки (`FormGrid`/`FormField`),
а не строки плоской таблицы — адаптивнее на мобильных, чем строка с а не строки плоской таблицы — адаптивнее на мобильных, чем строка с
горизонтальным скроллом. горизонтальным скроллом.
- **БСУ и рецепты** (`bsu/`, API `https://bsu.tnsu.kz/api/v1`, токен того же
AAD-приложения, что у approval-route): в шапке выбирается БСУ (`GET /bsu` →
`BsuId`/`BsuName`, обязателен), в позиции — рецепт этого БСУ
(`GET /bsu/{id}/formulas`): в позицию пишутся `formulaId`, название в
`mixClass` и снимок состава `ingredients` (на заводе рецепт могут поменять).
Смена БСУ сбрасывает выбранные рецепты (`withoutFormula`). API не ответил —
отправку **не блокируем**: предупреждение и ручной ввод (название БСУ /
класс смеси текстом, как до справочника). В просмотре API не вызывается —
показываются сохранённые названия.
- **Заголовок элемента** (`Title`): `Заявка на бетон — {ФИО начальника участка} — дд.мм.гггг`. - **Заголовок элемента** (`Title`): `Заявка на бетон — {ФИО начальника участка} — дд.мм.гггг`.
## Секции формы ## Секции формы
HeaderSection (дата поставки · начальник участка · организация (read-only) · HeaderSection (дата поставки · начальник участка · организация (read-only) ·
проект · мастер участка) + ItemsEditor (табличная часть поставок) проект · БСУ · мастер участка) + ItemsEditor (табличная часть поставок)
## Точки входа ## Точки входа
- rptype: `wh-concrete-request` - rptype: `wh-concrete-request`
@@ -0,0 +1,15 @@
/** БСУ (бетонно-смесительный узел) — ответ GET /bsu. */
export interface IBsu {
id: string;
/** Бэкенд может отдать null — подпись в списке строит bsuLabel. */
name: string | null;
}
/** Рецепт (формула) смеси конкретного БСУ — ответ GET /bsu/{id}/formulas. */
export interface IBsuFormula {
id: number;
/** Марка/класс («В25», «pp M100»). Бывает пустой строкой. */
name: string;
/** Состав одной строкой: «Бункер 1: 670; Цемент 1: 400; Вода: 146; …». */
ingredients: string;
}
@@ -0,0 +1,40 @@
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { BSU_API_BASE_URL, BSU_API_CLIENT_ID } from '../../../shared/config';
import { IBsu, IBsuFormula } from './IBsu';
async function getJson<T>(context: WebPartContext, path: string): Promise<T> {
const client = await context.aadHttpClientFactory.getClient(BSU_API_CLIENT_ID);
const response: HttpClientResponse = await client.get(`${BSU_API_BASE_URL}${path}`, AadHttpClient.configurations.v1);
if (!response.ok) {
throw new Error(`БСУ ${path}: ${response.status} ${response.statusText}`);
}
return response.json();
}
// Список БСУ в рамках сессии не меняется — грузим один раз (как projects.service).
let bsuCache: Promise<IBsu[]> | undefined;
export function getBsuList(context: WebPartContext): Promise<IBsu[]> {
if (!bsuCache) {
bsuCache = getJson<IBsu[]>(context, '/bsu').catch((e) => {
bsuCache = undefined;
throw e;
});
}
return bsuCache;
}
/** Рецепты выбранного БСУ. Не кэшируем: БСУ переключают редко, а рецепты на заводе могут правиться. */
export function getBsuFormulas(context: WebPartContext, bsuId: string): Promise<IBsuFormula[]> {
return getJson<IBsuFormula[]>(context, `/bsu/${encodeURIComponent(bsuId)}/formulas`);
}
/** Подпись БСУ в списке: имени может не быть — тогда хотя бы начало id, чтобы пункты различались. */
export function bsuLabel(bsu: IBsu): string {
return bsu.name?.trim() || `БСУ ${bsu.id.slice(0, 8)}`;
}
/** Подпись рецепта: пустое имя встречается (id 13) — показываем номер. */
export function formulaLabel(formula: IBsuFormula): string {
return formula.name.trim() || `Рецепт №${formula.id}`;
}
@@ -0,0 +1,62 @@
import { useCallback, useEffect, useState } from 'react';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { friendlyErrorMessage } from '../../../shared/diagnostics';
import { getBsuFormulas, getBsuList } from './bsu.service';
import { IBsu, IBsuFormula } from './IBsu';
/** Список из API с явными состояниями загрузки и ошибки. */
export interface IRemoteList<T> {
items: T[];
loading: boolean;
/** '' — ошибки нет. */
error: string;
}
/** Справочник БСУ для формы: список заводов и рецепты выбранного. */
export interface IBsuCatalog {
bsu: IRemoteList<IBsu>;
formulas: IRemoteList<IBsuFormula>;
}
function emptyList<T>(): IRemoteList<T> {
return { items: [], loading: false, error: '' };
}
/** load = null — грузить нечего (выключено или не выбран БСУ). load должен быть стабильным (useCallback). */
function useRemoteList<T>(load: (() => Promise<T[]>) | null): IRemoteList<T> {
const [state, setState] = useState<IRemoteList<T>>(emptyList);
useEffect(() => {
if (!load) {
setState(emptyList());
return;
}
let alive = true;
setState({ items: [], loading: true, error: '' });
load()
.then((items) => {
if (alive) setState({ items, loading: false, error: '' });
})
.catch((e) => {
if (alive) setState({ items: [], loading: false, error: friendlyErrorMessage(e) });
});
return () => {
alive = false;
};
}, [load]);
return state;
}
/**
* Заводы и рецепты выбранного завода. enabled = false (заявка только для чтения) —
* ничего не грузим: в просмотре показываются сохранённые в заявке названия.
*/
export function useBsuCatalog(context: WebPartContext, bsuId: string, enabled: boolean): IBsuCatalog {
const loadBsu = useCallback(() => getBsuList(context), [context]);
const loadFormulas = useCallback(() => getBsuFormulas(context, bsuId), [context, bsuId]);
const bsu = useRemoteList(enabled ? loadBsu : null);
const formulas = useRemoteList(enabled && bsuId ? loadFormulas : null);
return { bsu, formulas };
}
@@ -45,6 +45,8 @@ export async function loadConcreteRequest(itemId: number, listId?: string): Prom
'Organization', 'Organization',
'Project', 'Project',
'ProjectId', 'ProjectId',
'BsuId',
'BsuName',
'ItemsJson', 'ItemsJson',
'RequestStatus', 'RequestStatus',
'RequestUniqueCode' 'RequestUniqueCode'
@@ -62,6 +64,8 @@ export async function loadConcreteRequest(itemId: number, listId?: string): Prom
Organization: (item.Organization as string) || '', Organization: (item.Organization as string) || '',
Project: (item.Project as string) || '', Project: (item.Project as string) || '',
ProjectId: (item.ProjectId as string) || '', ProjectId: (item.ProjectId as string) || '',
BsuId: (item.BsuId as string) || '',
BsuName: (item.BsuName as string) || '',
Items: parseItems(item.ItemsJson) Items: parseItems(item.ItemsJson)
}; };
@@ -1,6 +1,7 @@
import { WebPartContext } from '@microsoft/sp-webpart-base'; import { WebPartContext } from '@microsoft/sp-webpart-base';
import { ISelectOption } from '../../shared/ui'; import { ISelectOption } from '../../shared/ui';
import { RequestTypeId } from '../../models/types'; import { RequestTypeId } from '../../models/types';
import { IBsuCatalog } from './bsu/useBsuCatalog';
/** /**
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков * Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
@@ -18,8 +19,18 @@ export const FORM_ID: RequestTypeId = 'wh-concrete-request';
* согласования (ByProject, см. IFormData). * согласования (ByProject, см. IFormData).
*/ */
export interface IConcreteItem { export interface IConcreteItem {
/** Класс/марка смеси и краткая характеристика (напр. «C20/25 F75 W6»). */ /**
* Класс/марка смеси. Выбран рецепт БСУ — название рецепта; API БСУ недоступен
* или заявка старая — свободный текст (напр. «C20/25 F75 W6»).
*/
mixClass: string; mixClass: string;
/**
* id рецепта БСУ (GET /bsu/{id}/formulas). Нет — рецепт введён вручную или
* заявка подана до справочника БСУ, поэтому поле необязательное.
*/
formulaId?: number;
/** Состав рецепта на момент подачи — снимок: на заводе рецепт могут поменять. */
ingredients?: string;
/** Количество, м³. */ /** Количество, м³. */
volume: string; volume: string;
/** Подвижность смеси — см. WORKABILITY_OPTIONS. */ /** Подвижность смеси — см. WORKABILITY_OPTIONS. */
@@ -38,6 +49,12 @@ export const EMPTY_CONCRETE_ITEM: IConcreteItem = {
note: '' note: ''
}; };
/** Позиция без рецепта БСУ. Рецепт, введённый вручную (без formulaId), не трогаем. */
export function withoutFormula(item: IConcreteItem): IConcreteItem {
if (item.formulaId === undefined) return item;
return { ...item, formulaId: undefined, mixClass: '', ingredients: '' };
}
/** Подвижность бетонной смеси (ГОСТ 7473) — фиксированный набор, чтобы не плодить опечатки. */ /** Подвижность бетонной смеси (ГОСТ 7473) — фиксированный набор, чтобы не плодить опечатки. */
export const WORKABILITY_OPTIONS: ISelectOption[] = [ export const WORKABILITY_OPTIONS: ISelectOption[] = [
{ value: 'П1', label: 'П1' }, { value: 'П1', label: 'П1' },
@@ -90,6 +107,10 @@ export interface IFormData {
Project: string; Project: string;
/** oid проекта из справочника; '' — введён вручную без выбора. */ /** oid проекта из справочника; '' — введён вручную без выбора. */
ProjectId: string; ProjectId: string;
/** uuid БСУ (бетонного завода) из API БСУ; '' — введён вручную (API недоступен). */
BsuId: string;
/** Название БСУ — для просмотра и печати без обращения к API. */
BsuName: string;
/** Табличная часть — позиции поставки (сериализуется в ItemsJson). */ /** Табличная часть — позиции поставки (сериализуется в ItemsJson). */
Items: IConcreteItem[]; Items: IConcreteItem[];
} }
@@ -108,6 +129,10 @@ export interface ISectionProps {
onSiteChiefChange?: (login: string) => void; onSiteChiefChange?: (login: string) => void;
/** Выбор мастера участка подтягивает его ФИО. Не передан (просмотр) — правка через set. */ /** Выбор мастера участка подтягивает его ФИО. Не передан (просмотр) — правка через set. */
onForemanChange?: (login: string) => void; onForemanChange?: (login: string) => void;
/** Справочник БСУ. Не передан (просмотр) — БСУ и рецепты показываются сохранённым текстом. */
bsu?: IBsuCatalog;
/** Смена БСУ сбрасывает выбранные в позициях рецепты (они от прежнего завода). */
onBsuChange?: (bsuId: string, bsuName: string) => void;
} }
export const SITE_CHIEF_PLACEHOLDER = 'Начните вводить Ф.И.О. начальника участка…'; export const SITE_CHIEF_PLACEHOLDER = 'Начните вводить Ф.И.О. начальника участка…';
@@ -122,5 +147,7 @@ export const EMPTY_DATA: IFormData = {
Organization: '', Organization: '',
Project: '', Project: '',
ProjectId: '', ProjectId: '',
BsuId: '',
BsuName: '',
Items: [] Items: []
}; };
@@ -12,6 +12,8 @@
{ "internalName": "Organization", "title": "Организация", "type": "Note" }, { "internalName": "Organization", "title": "Организация", "type": "Note" },
{ "internalName": "Project", "title": "Проект", "type": "Text", "required": true }, { "internalName": "Project", "title": "Проект", "type": "Text", "required": true },
{ "internalName": "ProjectId", "title": "Id проекта (справочник)", "type": "Text" }, { "internalName": "ProjectId", "title": "Id проекта (справочник)", "type": "Text" },
{ "internalName": "BsuId", "title": "Id БСУ (API БСУ)", "type": "Text" },
{ "internalName": "BsuName", "title": "БСУ", "type": "Text" },
{ "internalName": "ItemsJson", "title": "Позиции поставки (JSON)", "type": "Note" } { "internalName": "ItemsJson", "title": "Позиции поставки (JSON)", "type": "Note" }
] ]
@@ -0,0 +1,51 @@
import * as React from 'react';
import { Alert, FormField, Select, TextInput } from '../../../shared/ui';
import { IBsuCatalog } from '../bsu/useBsuCatalog';
import { bsuLabel } from '../bsu/bsu.service';
interface Props {
bsuId: string;
bsuName: string;
/** Не передан (просмотр) — показываем сохранённое название. */
catalog?: IBsuCatalog;
onChange: (bsuId: string, bsuName: string) => void;
}
const LABEL = 'БСУ (бетонный завод)';
/**
* Выбор БСУ из API. Если API не ответил — отправку не блокируем: предупреждение
* и ручной ввод названия (BsuId остаётся пустым).
*/
export const BsuField: React.FC<Props> = ({ bsuId, bsuName, catalog, onChange }) => {
if (!catalog || catalog.bsu.error) {
return (
<FormField label={LABEL} htmlFor="BsuName" required>
{catalog && (
<Alert variant="warn">
Не удалось загрузить список БСУ ({catalog.bsu.error}). Введите название вручную.
</Alert>
)}
<TextInput id="BsuName" value={bsuName} onChange={(v) => onChange('', v)} placeholder="Название БСУ" />
</FormField>
);
}
const options = catalog.bsu.items.map((b) => ({ value: b.id, label: bsuLabel(b) }));
return (
<FormField label={LABEL} htmlFor="BsuId" required>
<Select
id="BsuId"
value={bsuId}
onChange={(id) => {
const picked = catalog.bsu.items.find((b) => b.id === id);
onChange(id, picked ? bsuLabel(picked) : '');
}}
options={options}
placeholder={catalog.bsu.loading ? 'Загрузка…' : 'Выберите БСУ'}
readOnly={catalog.bsu.loading}
/>
</FormField>
);
};
@@ -2,15 +2,18 @@ import * as React from 'react';
import { FormGrid, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui'; import { FormGrid, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui';
import { ProjectPicker } from '../../../shared/dictionary'; import { ProjectPicker } from '../../../shared/dictionary';
import { ISectionProps, SITE_CHIEF_PLACEHOLDER, FOREMAN_PLACEHOLDER } from '../model'; import { ISectionProps, SITE_CHIEF_PLACEHOLDER, FOREMAN_PLACEHOLDER } from '../model';
import { BsuField } from './BsuField';
/** Шапка заявки на бетон: дата поставки, начальник участка (составил), мастер участка. */ /** Шапка заявки на бетон: дата поставки, начальник участка (составил), проект, БСУ, мастер участка. */
export const HeaderSection: React.FC<ISectionProps> = ({ export const HeaderSection: React.FC<ISectionProps> = ({
data, data,
set, set,
context, context,
printOrgIndex, printOrgIndex,
onSiteChiefChange, onSiteChiefChange,
onForemanChange onForemanChange,
bsu,
onBsuChange
}) => ( }) => (
<FormGrid> <FormGrid>
<FormField label="Дата поставки" htmlFor="RequestDate" required> <FormField label="Дата поставки" htmlFor="RequestDate" required>
@@ -37,6 +40,18 @@ export const HeaderSection: React.FC<ISectionProps> = ({
placeholder="Начните вводить название или код проекта…" placeholder="Начните вводить название или код проекта…"
/> />
</FormField> </FormField>
<BsuField
bsuId={data.BsuId}
bsuName={data.BsuName}
catalog={bsu}
onChange={
onBsuChange ??
((id, name) => {
set('BsuId', id);
set('BsuName', name);
})
}
/>
<FormField label="Мастер участка" htmlFor="ForemanEmail" full> <FormField label="Мастер участка" htmlFor="ForemanEmail" full>
<PeopleField <PeopleField
context={context} context={context}
@@ -49,6 +49,61 @@
} }
} }
// Состав рецепта БСУ под полем «Класс смеси» (RecipeIngredients).
.recipe {
margin-top: $sp-2;
padding: $sp-3;
border: 1px solid $navy-100;
border-left: 3px solid $gold-500;
border-radius: $r-sm;
background: $white;
font-family: $font-sans;
}
.recipeLabel {
display: block;
margin-bottom: $sp-2;
font-size: $fs-label;
font-weight: 600;
color: $navy-800;
}
.recipeList {
display: flex;
flex-wrap: wrap;
gap: $sp-2;
margin: 0;
padding: 0;
list-style: none;
}
.recipeChip {
display: inline-flex;
align-items: baseline;
gap: $sp-2;
padding: $sp-1 $sp-3;
border-radius: $r-full;
background: $navy-50;
border: 1px solid $navy-100;
font-size: $fs-sm;
}
.recipeName {
color: $gray-700;
}
.recipeAmount {
font-weight: 600;
color: $gray-900;
font-variant-numeric: tabular-nums;
}
.recipeRaw {
font-size: $fs-sm;
color: $gray-700;
overflow-wrap: anywhere;
}
.empty { .empty {
padding: $sp-3 0; padding: $sp-3 0;
font-family: $font-sans; font-family: $font-sans;
@@ -2,22 +2,25 @@ import * as React from 'react';
import styles from './ItemsEditor.module.scss'; import styles from './ItemsEditor.module.scss';
import { Button, Icon, TextInput, Select, FormGrid, FormField, useFormReadOnly } from '../../../shared/ui'; import { Button, Icon, TextInput, Select, FormGrid, FormField, useFormReadOnly } from '../../../shared/ui';
import { EMPTY_CONCRETE_ITEM, IConcreteItem, WORKABILITY_OPTIONS } from '../model'; import { EMPTY_CONCRETE_ITEM, IConcreteItem, WORKABILITY_OPTIONS } from '../model';
import { IRecipePicker, MixClassField } from './MixClassField';
export interface IItemsEditorProps { export interface IItemsEditorProps {
value: IConcreteItem[]; value: IConcreteItem[];
onChange: (rows: IConcreteItem[]) => void; onChange: (rows: IConcreteItem[]) => void;
/** Рецепты БСУ; не передан — класс смеси вводится текстом (см. MixClassField). */
recipePicker?: IRecipePicker;
} }
/** /**
* Табличная часть заявки на бетон: класс смеси, объём, подвижность, время и * Табличная часть заявки на бетон: класс смеси (рецепт БСУ), объём, подвижность,
* примечание — карточка на каждую поставку. Проект/объект сюда не входит: он * время и примечание — карточка на каждую поставку. Проект/объект сюда не входит: он
* один на всю заявку и выбирается в шапке (см. HeaderSection, IFormData.Project) — * один на всю заявку и выбирается в шапке (см. HeaderSection, IFormData.Project) —
* от него зависит маршрут согласования, поэтому поле не дублируется по позициям. * от него зависит маршрут согласования, поэтому поле не дублируется по позициям.
* *
* НЕ плоская таблица-простыня (как shared/ui/ItemsTable): карточки с обычным * НЕ плоская таблица-простыня (как shared/ui/ItemsTable): карточки с обычным
* FormGrid адаптивнее узкой строки с горизонтальным скроллом на мобильных. * FormGrid адаптивнее узкой строки с горизонтальным скроллом на мобильных.
*/ */
export const ItemsEditor: React.FC<IItemsEditorProps> = ({ value, onChange }) => { export const ItemsEditor: React.FC<IItemsEditorProps> = ({ value, onChange, recipePicker }) => {
const readOnly = useFormReadOnly(); const readOnly = useFormReadOnly();
const setRow = (index: number, patch: Partial<IConcreteItem>): void => { const setRow = (index: number, patch: Partial<IConcreteItem>): void => {
@@ -43,13 +46,8 @@ export const ItemsEditor: React.FC<IItemsEditorProps> = ({ value, onChange }) =>
)} )}
</div> </div>
<FormGrid> <FormGrid>
<FormField label="Класс смеси и хар-ка" htmlFor={`mixClass-${i}`} required full> <FormField label="Класс смеси (рецепт)" htmlFor={`mixClass-${i}`} required full>
<TextInput <MixClassField id={`mixClass-${i}`} row={row} picker={recipePicker} onChange={(patch) => setRow(i, patch)} />
id={`mixClass-${i}`}
value={row.mixClass}
onChange={(v) => setRow(i, { mixClass: v })}
placeholder="C20/25 F75 W6"
/>
</FormField> </FormField>
<FormField label="Количество, м³" htmlFor={`volume-${i}`} required> <FormField label="Количество, м³" htmlFor={`volume-${i}`} required>
<TextInput id={`volume-${i}`} value={row.volume} onChange={(v) => setRow(i, { volume: v })} placeholder="0" /> <TextInput id={`volume-${i}`} value={row.volume} onChange={(v) => setRow(i, { volume: v })} placeholder="0" />
@@ -1,11 +1,35 @@
import * as React from 'react'; import * as React from 'react';
import { CollapseSection } from '../../../shared/ui'; import styles from './ItemsEditor.module.scss';
import { Alert, CollapseSection } from '../../../shared/ui';
import { ItemsEditor } from './ItemsEditor'; import { ItemsEditor } from './ItemsEditor';
import { IRecipePicker } from './MixClassField';
import { ISectionProps } from '../model'; import { ISectionProps } from '../model';
import { IBsuCatalog } from '../bsu/useBsuCatalog';
/** Табличная часть заявки на бетон: класс смеси, объём, подвижность, время, примечание. */ /**
export const ItemsSection: React.FC<ISectionProps> = ({ data, set }) => ( * Рецепты из справочника — только когда API БСУ отвечает. Нет справочника
* (просмотр) или API упал — undefined: класс смеси вводится текстом, заявку
* подать можно.
*/
function toRecipePicker(bsuId: string, catalog?: IBsuCatalog): IRecipePicker | undefined {
if (!catalog || catalog.bsu.error || catalog.formulas.error) return undefined;
return { formulas: catalog.formulas.items, loading: catalog.formulas.loading, bsuChosen: !!bsuId };
}
/** Табличная часть заявки на бетон: класс смеси (рецепт БСУ), объём, подвижность, время, примечание. */
export const ItemsSection: React.FC<ISectionProps> = ({ data, set, bsu }) => (
<CollapseSection title="Позиции поставки" icon="clipboard"> <CollapseSection title="Позиции поставки" icon="clipboard">
<ItemsEditor value={data.Items} onChange={(rows) => set('Items', rows)} /> {bsu?.formulas.error && (
<div className={styles.full}>
<Alert variant="warn">
Не удалось загрузить рецепты БСУ ({bsu.formulas.error}). Укажите класс смеси вручную.
</Alert>
</div>
)}
<ItemsEditor
value={data.Items}
onChange={(rows) => set('Items', rows)}
recipePicker={toRecipePicker(data.BsuId, bsu)}
/>
</CollapseSection> </CollapseSection>
); );
@@ -0,0 +1,64 @@
import * as React from 'react';
import { Select, TextInput } from '../../../shared/ui';
import { RecipeIngredients } from './RecipeIngredients';
import { IConcreteItem } from '../model';
import { IBsuFormula } from '../bsu/IBsu';
import { formulaLabel } from '../bsu/bsu.service';
/** Рецепты выбранного БСУ для выпадающего списка. */
export interface IRecipePicker {
formulas: IBsuFormula[];
loading: boolean;
/** БСУ ещё не выбран — рецептов взять неоткуда. */
bsuChosen: boolean;
}
interface Props {
id: string;
row: IConcreteItem;
/** Не передан — ручной ввод (API БСУ недоступен) или просмотр сохранённой заявки. */
picker?: IRecipePicker;
onChange: (patch: Partial<IConcreteItem>) => void;
}
function pickerPlaceholder(picker: IRecipePicker): string {
if (!picker.bsuChosen) return 'Сначала выберите БСУ';
if (picker.loading) return 'Загрузка рецептов…';
if (picker.formulas.length === 0) return 'У этого БСУ нет рецептов';
return 'Выберите рецепт';
}
/** Класс смеси: рецепт из справочника БСУ, а без справочника — свободный текст. Под полем — состав рецепта. */
export const MixClassField: React.FC<Props> = ({ id, row, picker, onChange }) => {
const handlePick = (value: string): void => {
const formula = picker?.formulas.find((f) => String(f.id) === value);
onChange(
formula
? { formulaId: formula.id, mixClass: formulaLabel(formula), ingredients: formula.ingredients }
: { formulaId: undefined, mixClass: '', ingredients: '' }
);
};
return (
<>
{picker ? (
<Select
id={id}
value={row.formulaId === undefined ? '' : String(row.formulaId)}
onChange={handlePick}
options={picker.formulas.map((f) => ({ value: String(f.id), label: formulaLabel(f) }))}
placeholder={pickerPlaceholder(picker)}
readOnly={!picker.bsuChosen || picker.loading}
/>
) : (
<TextInput
id={id}
value={row.mixClass}
onChange={(v) => onChange({ mixClass: v, formulaId: undefined, ingredients: '' })}
placeholder="C20/25 F75 W6"
/>
)}
{row.ingredients && <RecipeIngredients value={row.ingredients} />}
</>
);
};
@@ -0,0 +1,47 @@
import * as React from 'react';
import styles from './ItemsEditor.module.scss';
interface Props {
/** Состав строкой из API БСУ: «Бункер 1: 670; Цемент 1: 400; Вода: 146». */
value: string;
}
interface IIngredient {
name: string;
amount: string;
}
/** «Бункер 1: 670; Вода: 146» → [{Бункер 1, 670}, {Вода, 146}]. Хоть одна часть не по формату — null. */
function parseIngredients(value: string): IIngredient[] | null {
const parts = value.split(';').map((p) => p.trim()).filter((p) => p.length > 0);
const result: IIngredient[] = [];
for (const part of parts) {
const colon = part.lastIndexOf(':');
if (colon <= 0) return null;
result.push({ name: part.slice(0, colon).trim(), amount: part.slice(colon + 1).trim() });
}
return result.length > 0 ? result : null;
}
/** Состав рецепта плашками «компонент — количество»; формат не распознан — исходная строка. */
export const RecipeIngredients: React.FC<Props> = ({ value }) => {
const ingredients = parseIngredients(value);
return (
<div className={styles.recipe}>
<span className={styles.recipeLabel}>Состав рецепта</span>
{ingredients ? (
<ul className={styles.recipeList}>
{ingredients.map((ing, i) => (
<li key={i} className={styles.recipeChip}>
<span className={styles.recipeName}>{ing.name}</span>
<span className={styles.recipeAmount}>{ing.amount}</span>
</li>
))}
</ul>
) : (
<span className={styles.recipeRaw}>{value}</span>
)}
</div>
);
};
@@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { useState, useMemo, useCallback, useEffect } from 'react'; import { useState, useMemo, useEffect } from 'react';
import styles from './ContractChangeForm.module.scss'; import styles from './ContractChangeForm.module.scss';
import { import {
Alert, Alert,
@@ -18,20 +18,14 @@ import {
ExecutorsMatrix, ExecutorsMatrix,
ApprovalHistory, ApprovalHistory,
StatusBadge, StatusBadge,
useMatrixEditor,
getPlannedMatrix,
loadRoutePhase,
isOpenedRouteReady,
notifyActiveStep, notifyActiveStep,
useRequestTypeId, useRequestTypeId,
REQUEST_STATUS, REQUEST_STATUS,
isEditableStatus, isEditableStatus,
IApproverToWrite,
unassignedStepMessage, unassignedStepMessage,
resolveWriteListId, resolveWriteListId,
executorsToWrite, executorsToWrite,
initialSubmitStatus, initialSubmitStatus
useFormExecutors
} from '../../shared/approval'; } from '../../shared/approval';
import { effectiveUserEmail } from '../../shared/impersonation'; import { effectiveUserEmail } from '../../shared/impersonation';
import { IRequestFormProps } from '../../models/types'; import { IRequestFormProps } from '../../models/types';
@@ -46,9 +40,12 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField, REQUIRED_FIELDS_MESSAGE, rout
import { saveContractChangeRequest, contractChangeApprovalService } from './submit'; import { saveContractChangeRequest, contractChangeApprovalService } from './submit';
import { loadContractChangeRequest } from './load'; import { loadContractChangeRequest } from './load';
import { useContractEmployee } from './useContractEmployee'; import { useContractEmployee } from './useContractEmployee';
import { useInitiatorAutofill } from './useInitiatorAutofill';
import { RequestSection } from './sections/RequestSection'; import { RequestSection } from './sections/RequestSection';
import { ChangeTypeSections } from './byType/ChangeTypeSections'; import { ChangeTypeSections } from './byType/ChangeTypeSections';
import { missingRequiredAttachments, prepareFilesFor } from './byType/attachmentSlots'; import { prepareFilesFor } from './byType/attachmentSlots';
import { changeTypeSubmitError } from './byType/submitChecks';
import { useContractRoute, matrixToApprovers } from './useContractRoute';
import { useAttachmentEditor } from '../../shared/attachments/useAttachmentEditor'; import { useAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
import { getAttachments } from '../../shared/attachments/attachments.service'; import { getAttachments } from '../../shared/attachments/attachments.service';
import { useStableCallback } from '../../shared/useStableCallback'; import { useStableCallback } from '../../shared/useStableCallback';
@@ -113,33 +110,21 @@ const ContractChangeForm: React.FC<IRequestFormProps> = ({
const employeeControl = useContractEmployee(context, applicantEmail, !editId, data.Employee, (record) => const employeeControl = useContractEmployee(context, applicantEmail, !editId, data.Employee, (record) =>
set('Employee', record) set('Employee', record)
); );
// Пропсы шапки: сотрудник + инициатор, который следует за сотрудником, пока его не выбрали руками.
const headerProps = useInitiatorAutofill(!!editId, data, set, applicantEmail, employeeControl);
// Маршрут — по сотруднику, а без учётной записи — по заявителю (см. routeEmail). // Маршрут — по сотруднику, без учётной записи — по заявителю (см. routeEmail); при
// У заявки на редактировании маршрут читается из уже записанных строк. // переводе в другое подразделение принимающий руководитель — первым шагом.
const routeUserEmail = routeEmail(employeeControl.person, applicantEmail); const { matrix, executors } = useContractRoute({
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, context,
approval, approval,
requestTypeId, requestTypeId,
userEmail: routeUserEmail, routeUserEmail: routeEmail(employeeControl.person, applicantEmail),
editId, editId,
requestStatus requestStatus,
readOnly,
receivingHeadEmail:
!readOnly && data.ChangeTypes.includes('department-transfer') ? data.DepartmentTransferReceivingHead : ''
}); });
const isValid = !!data.Employee && data.ChangeTypes.length > 0; const isValid = !!data.Employee && data.ChangeTypes.length > 0;
@@ -150,17 +135,6 @@ const ContractChangeForm: React.FC<IRequestFormProps> = ({
return undefined; 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> => { const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
// validate() уже гарантировал requestTypeId, но TS этого через замыкание не видит. // validate() уже гарантировал requestTypeId, но TS этого через замыкание не видит.
const rtId = requestTypeId; const rtId = requestTypeId;
@@ -173,7 +147,7 @@ const ContractChangeForm: React.FC<IRequestFormProps> = ({
requestId, requestId,
rtId, rtId,
data, data,
toApprovers(matrix.rows), matrixToApprovers(matrix.rows),
executorsToWrite(executors.rows), executorsToWrite(executors.rows),
status, status,
writeListId writeListId
@@ -207,9 +181,9 @@ const ContractChangeForm: React.FC<IRequestFormProps> = ({
const validateSubmit = (): string | undefined => { const validateSubmit = (): string | undefined => {
const formError = validate(); const formError = validate();
if (formError) return formError; if (formError) return formError;
// Обязательные документы — только на отправку: черновик можно сохранить и без файла. // Обязательное по виду изменения (документы, принимающий руководитель и т.п.) — только на отправку.
const missingFiles = missingRequiredAttachments(data, attachments); const typeError = changeTypeSubmitError(data, attachments);
if (missingFiles.length > 0) return `Приложите обязательные документы: ${missingFiles.join(', ')}.`; if (typeError) return typeError;
if (matrix.error) { if (matrix.error) {
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`; return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
} }
@@ -279,7 +253,7 @@ const ContractChangeForm: React.FC<IRequestFormProps> = ({
{submit.error && <Alert variant="err">{submit.error}</Alert>} {submit.error && <Alert variant="err">{submit.error}</Alert>}
<FormModeProvider readOnly={readOnly}> <FormModeProvider readOnly={readOnly}>
<RequestSection data={data} set={set} context={context} employeeControl={employeeControl} /> <RequestSection data={data} set={set} context={context} {...headerProps} />
<ChangeTypeSections data={data} set={set} context={context} attachments={attachments} /> <ChangeTypeSections data={data} set={set} context={context} attachments={attachments} />
</FormModeProvider> </FormModeProvider>
@@ -8,10 +8,12 @@
`pageUrl` («hr-contract-change») через `useRequestTypeId` (см. «Формы больше не хардкодят `pageUrl` («hr-contract-change») через `useRequestTypeId` (см. «Формы больше не хардкодят
RequestTypeId» в `docs/forms.md`). Тип и маршрут в админке approval-route ещё не заведены. RequestTypeId» в `docs/forms.md`). Тип и маршрут в админке approval-route ещё не заведены.
> **Статус: каркас.** Есть сотрудник и вид изменения; поля под конкретные виды > **Статус (2026-10-29):** поля разобраны по всем видам, кроме 6 «Продление срока ТД»
> добавляются по мере разбора ТЗ кадров. Таблица ТЗ (пример, кто инициирует, что > (это автоматический запрос, не обычная заявка — отдельно). ТЗ кадров — файл
> приложить, сроки, маршрут А/Б/В/Г…) — общая, в форму из неё попадает только то, > «ТЗ_ст33_маршруты_v2» (листы «Виды изменений ст.33» и «Маршрут»); оно общее, в форму
> что явно согласовано. Печатного бланка пока нет. > из него попадает только то, что явно согласовано. Ещё не сделано: передача вида и
> инициатора в маршрут (`fieldValues`: фин. блок — виды 1, 2, 3, 5, 9; маршрут А/Б —
> по инициатору), печатный бланк, заведение типа и маршрутов в админке.
## Маршрут ## Маршрут
- Фазы: согласование (+ исполнение, если его заведут в админке — форма поддерживает). - Фазы: согласование (+ исполнение, если его заведут в админке — форма поддерживает).
@@ -60,7 +62,43 @@ RequestTypeId» в `docs/forms.md`). Тип и маршрут в админке
| Повышение должностного оклада | `SalaryIncrease` | текущий оклад, новый оклад (`MoneyInput`, Number), дата изменения, причина (испытательный срок / расширение функционала / иное), обоснование — при «иное» это и есть текст причины | | Повышение должностного оклада | `SalaryIncrease` | текущий оклад, новый оклад (`MoneyInput`, Number), дата изменения, причина (испытательный срок / расширение функционала / иное), обоснование — при «иное» это и есть текст причины |
| Установление надбавки / доплаты | `Allowance` | основание (а–д: надбавка или доплата видно из пункта), дата начала, дата окончания или «бессрочно» (флажок прячет и очищает дату), размер + переключатель «% / ₸» (`UnitSwitch`, по умолчанию %), обоснование — в т.ч. объём доп. работы при совмещении и текст «иное». Письменное согласие работника на совмещение оформляет кадровик после согласования — не в форме. Вложение **«Результаты аттестации»** — только при основании «г», обязательно | | Установление надбавки / доплаты | `Allowance` | основание (а–д: надбавка или доплата видно из пункта), дата начала, дата окончания или «бессрочно» (флажок прячет и очищает дату), размер + переключатель «% / ₸» (`UnitSwitch`, по умолчанию %), обоснование — в т.ч. объём доп. работы при совмещении и текст «иное». Письменное согласие работника на совмещение оформляет кадровик после согласования — не в форме. Вложение **«Результаты аттестации»** — только при основании «г», обязательно |
| Перевод на другую должность (постоянный) | `PositionTransfer` | новая должность (`PositionPicker`: справочник `Dictionary/Positions` + ручной ввод), дата перевода, причина (карьерный рост / вакантная должность / новый объект, перераспределение функций / иное), обоснование. Работника и работодателя не различаем — причины видны всем, работник пишет текст заявления в обоснование. Вложение **«Должностная инструкция»** — всегда, обязательно | | Перевод на другую должность (постоянный) | `PositionTransfer` | новая должность (`PositionPicker`: справочник `Dictionary/Positions` + ручной ввод), дата перевода, причина (карьерный рост / вакантная должность / новый объект, перераспределение функций / иное), обоснование. Работника и работодателя не различаем — причины видны всем, работник пишет текст заявления в обоснование. Вложение **«Должностная инструкция»** — всегда, обязательно |
| Остальные 7 | — | ещё не разобраны с кадрами | | Перевод в другое подразделение | `DepartmentTransfer` | новое подразделение (`DepartmentPicker`, имя + id), дата перевода, причина (усиление подразделения / перераспределение функций / вакансия / иное), обоснование, **принимающий руководитель** (People, обязателен при отправке) — см. ниже |
| Изменение места работы (другая местность) | `WorkplaceChange` | место работы «было → стало» парой **организация + проект**: текущая организация — из «Организации» сотрудника (только чтение, отдельно не хранится — это `EmployeeCompany`), текущий проект (`ProjectPicker`); новая организация (`Dictionary/Companies`, **обязательна при отправке**, не подставляется) и новый проект; адрес нового места, дата, причина (новый объект / переезд подразделения или работодателя / иное), условия переезда и компенсации; переключатель **«Вахта»** → «График работы» (тот же набор, что в подборе и приёме, «Другое» → ручной ввод; без вахты график очищается); обоснование |
| Продление срока ТД | — | **пропущен** (2026-10-29): по ТЗ это автоматический запрос ОК за месяц до окончания срочного ТД (маршрут В), а не обычная заявка — решать отдельно |
| Изменение режима / графика работы | `ScheduleChange` | по ролям строго по ТЗ. Общее: текущий график + текущее время работы (списки как в подборе/приёме, «Другое» → текст; `OptionWithOtherField`, наборы — `byType/workSchedule.ts`), дата начала, вложение «Новый график» (необязательно). **Работник:** неполный рабочий день (часов) / неполная рабочая неделя (дней) — одно или оба, текст заявления. **Работодатель:** новый режим (вахтовый / сменный / иной + описание), новые график и время работы, причина (специфика объекта / смещение сроков проекта — только из ТЗ), обоснование. Поля другой роли при сохранении очищаются |
| Изменение трудовой функции (без смены должности) | `FunctionChange` | ровно по шаблону ТЗ: дата изменения, «В связи с» (свободный текст — списка причин в ТЗ нет), вложение **«Новая должностная инструкция»** — обязательно. Инициатор — только руководитель |
| Изменение условий труда по инициативе работодателя | `EmployerInitiative` | причина (из названия вида в ТЗ: экономические / технологические / изменения в организации / сокращение объёма работ + «Другое» со своим текстом); «Что меняется» — флажки а) график работы, б) должностной оклад (можно оба, при отправке — хотя бы один); а) текущий → новый график + дата; б) **один новый оклад + доля постоянной части в %** (по умолчанию 75 из ТЗ, 0–100, можно дробную), переменная доля = остаток до 100 %, суммы обеих частей в ₸ считаются сами (`salaryParts.ts`, превысить 100 % нельзя; в SP пишутся при сохранении) + дата; даты **не раньше +15 дней** (уведомление по ТЗ: блок в календаре + проверка при отправке); обоснование — **текстом ИЛИ документом**, одно из двух обязательно при отправке |
| Временный перевод при производственной необходимости / простое | `TemporaryTransfer` | откуда и куда — проект из справочника + работа текстом; дата начала / окончания — **не дольше 3 месяцев** (по ТЗ «до 3 мес. в календарном году»; включительно: с 01.01 по 31.03, `period.ts`; сумму за год проверяет HR-директор); основание (производственная необходимость / простой), обоснование; пометка из шаблона ТЗ «Оплата — не ниже средней заработной платы по прежней работе». Инициатор — только руководитель |
Обязательное по виду при отправке (инициатор, документы, принимающий руководитель,
новая организация) — одна функция `byType/submitChecks.ts`; черновик сохраняется без них.
**Инициатор (работник / работодатель)** — решение 2026-10-29: разделяем строго по
ТЗ, чтобы у кадров не было вопросов. Поле `ChangeInitiator` в шапке — только у
видов, которые по ТЗ может начать и работник (3, 4, 5, 7; `hasInitiatorChoice`).
По умолчанию при выборе вида: подаёт на себя — «Работник», за другого —
«Работодатель» (`defaultInitiatorRole`), под полем подсказка об этом; можно
поменять. Пока инициатора не выбирали руками, он следует за сотрудником: сменили
сотрудника — пересчитался (`useInitiatorAutofill`); после ручного выбора и у
открытого черновика — не трогается. От роли (`byType/InitiatorReasonFields`): работник — только «Текст
заявления» (по ТЗ «От работника: заявление (текст)»), работодатель — причина из
списка + «Обоснование». Текст пишется в ту же колонку `*Justification`; причина у
работника при сохранении очищается. Остальные поля вида — у обеих ролей.
**Принимающий руководитель — временное решение (2026-10-29).** По ТЗ (лист
«Маршрут»: А шаг 2, Б шаг 3 параллельно с непосредственным руководителем) его
согласие — шаг маршрута. Approval-route строит маршрут по сотруднику и найти
руководителя НОВОГО подразделения не умеет, поэтому шаг вставляет форма:
`byType/DepartmentTransfer/receivingHead.ts` (`withReceivingHead`, с тестами) —
первым шагом, с должностью-пометкой «Принимающий руководитель» (по ней шаг
узнаётся у черновика); вызывается из `useContractRoute`. Смена человека/вида
пересобирает маршрут. Ограничения: человек выбирается вручную; шаг всегда первым и
последовательным (для маршрута Б параллельность инициатор может включить в матрице);
SLA и настроек из админки у шага нет. **Правильное решение** — тип шага в
approval-route «руководитель подразделения из заявки» по `departmentId` (его
форма уже хранит в `DepartmentTransferNewDepartmentId`). Когда появится — удалить
поле, `receivingHead.ts` и `receivingHeadEmail` в `useContractRoute`, передать
`departmentId` в резолв.
**Вложения по колонке ТЗ «Что приложить»** (решение 2026-10-28): фиксированные **Вложения по колонке ТЗ «Что приложить»** (решение 2026-10-28): фиксированные
подписанные слоты по виду (подпись — что за документ); обязательны там, где ТЗ подписанные слоты по виду (подпись — что за документ); обязательны там, где ТЗ
@@ -1,8 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import { CollapseSection, FormField, MoneyInput, TextInput, TextArea, Select, Checkbox } from '../../../../shared/ui'; import { FormGrid, FormField, MoneyInput, TextInput, TextArea, Select, Checkbox } from '../../../../shared/ui';
import { useFormReadOnly } from '@tnsu/ui-kit'; import { useFormReadOnly } from '@tnsu/ui-kit';
import { AttachmentSlotField } from '../../../../shared/attachments/AttachmentSlotField'; import { AttachmentSlotField } from '../../../../shared/attachments/AttachmentSlotField';
import { CHANGE_TYPE_LABELS } from '../../changeTypes';
import { ISectionProps } from '../../model'; import { ISectionProps } from '../../model';
import { ALLOWANCE_REASONS, ATTESTATION_ATTACHMENT, requiresAttestation } from './options'; import { ALLOWANCE_REASONS, ATTESTATION_ATTACHMENT, requiresAttestation } from './options';
import { UnitSwitch } from './UnitSwitch'; import { UnitSwitch } from './UnitSwitch';
@@ -15,13 +14,13 @@ import styles from './AllowanceSection.module.scss';
* (в т.ч. объём доп. работы при совмещении). Поля необязательные; вложение * (в т.ч. объём доп. работы при совмещении). Поля необязательные; вложение
* «Результаты аттестации» — только при основании «г» и там обязательно (по ТЗ). * «Результаты аттестации» — только при основании «г» и там обязательно (по ТЗ).
* *
* Поля — прямо в CollapseSection, без FormGrid (тело секции само сетка). * Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал.
*/ */
export const AllowanceSection: React.FC<ISectionProps> = ({ data, set, attachments }) => { export const AllowanceSection: React.FC<ISectionProps> = ({ data, set, attachments }) => {
const readOnly = useFormReadOnly(); const readOnly = useFormReadOnly();
return ( return (
<CollapseSection title={CHANGE_TYPE_LABELS.allowance} icon="calculator"> <FormGrid>
<FormField label="Основание" htmlFor="AllowanceReason" full> <FormField label="Основание" htmlFor="AllowanceReason" full>
<Select <Select
id="AllowanceReason" id="AllowanceReason"
@@ -99,6 +98,6 @@ export const AllowanceSection: React.FC<ISectionProps> = ({ data, set, attachmen
required required
/> />
)} )}
</CollapseSection> </FormGrid>
); );
}; };
@@ -0,0 +1,6 @@
// Поля вида идут сразу под шапкой (сотрудник · вид · инициатор) отдельной сеткой
// FormGrid. У сетки ui-kit нет внешнего отступа, а расстояние между строками полей —
// 18px (row-gap FormGrid): тот же отступ, чтобы стык двух сеток был не виден.
.typeFields {
margin-top: 18px;
}
@@ -4,6 +4,13 @@ import { ISectionProps } from '../model';
import { SalaryIncreaseSection } from './SalaryIncrease/SalaryIncreaseSection'; import { SalaryIncreaseSection } from './SalaryIncrease/SalaryIncreaseSection';
import { AllowanceSection } from './Allowance/AllowanceSection'; import { AllowanceSection } from './Allowance/AllowanceSection';
import { PositionTransferSection } from './PositionTransfer/PositionTransferSection'; import { PositionTransferSection } from './PositionTransfer/PositionTransferSection';
import { DepartmentTransferSection } from './DepartmentTransfer/DepartmentTransferSection';
import { WorkplaceChangeSection } from './WorkplaceChange/WorkplaceChangeSection';
import { ScheduleChangeSection } from './ScheduleChange/ScheduleChangeSection';
import { FunctionChangeSection } from './FunctionChange/FunctionChangeSection';
import { EmployerInitiativeSection } from './EmployerInitiative/EmployerInitiativeSection';
import { TemporaryTransferSection } from './TemporaryTransfer/TemporaryTransferSection';
import styles from './ChangeTypeSections.module.scss';
/** /**
* Вид изменения → секция с его полями. Partial: поля видов добавляются по мере * Вид изменения → секция с его полями. Partial: поля видов добавляются по мере
@@ -13,15 +20,28 @@ import { PositionTransferSection } from './PositionTransfer/PositionTransferSect
const SECTION_BY_TYPE: Partial<Record<ChangeType, React.FC<ISectionProps>>> = { const SECTION_BY_TYPE: Partial<Record<ChangeType, React.FC<ISectionProps>>> = {
'salary-increase': SalaryIncreaseSection, 'salary-increase': SalaryIncreaseSection,
allowance: AllowanceSection, allowance: AllowanceSection,
'position-transfer': PositionTransferSection 'position-transfer': PositionTransferSection,
'department-transfer': DepartmentTransferSection,
'workplace-change': WorkplaceChangeSection,
'schedule-change': ScheduleChangeSection,
'function-change': FunctionChangeSection,
'employer-initiative': EmployerInitiativeSection,
'temporary-transfer': TemporaryTransferSection
}; };
/** Секции всех выбранных видов, в порядке выбора (сейчас вид один — см. MAX_CHANGE_TYPES). */ /**
* Секции всех выбранных видов, в порядке выбора (сейчас вид один — см. MAX_CHANGE_TYPES).
* Заголовка у секции нет — вид выбран прямо над ней; обёртка даёт только отступ от шапки.
*/
export const ChangeTypeSections: React.FC<ISectionProps> = (props) => ( export const ChangeTypeSections: React.FC<ISectionProps> = (props) => (
<> <>
{props.data.ChangeTypes.map((type) => { {props.data.ChangeTypes.map((type) => {
const Section = SECTION_BY_TYPE[type]; const Section = SECTION_BY_TYPE[type];
return Section ? <Section key={type} {...props} /> : undefined; return Section ? (
<div key={type} className={styles.typeFields}>
<Section {...props} />
</div>
) : undefined;
})} })}
</> </>
); );
@@ -0,0 +1,62 @@
import * as React from 'react';
import { FormGrid, FormField, TextInput, PeopleField } from '../../../../shared/ui';
import { InitiatorReasonFields } from '../InitiatorReasonFields';
import { DepartmentPicker } from '../../../../shared/dictionary';
import { ISectionProps } from '../../model';
import { DEPARTMENT_TRANSFER_REASONS } from './options';
/**
* Поля вида «Перевод в другое подразделение». Состав согласован с Тимуром по ТЗ
* кадров: новое подразделение (справочник), дата, причина (а–г), обоснование и
* принимающий руководитель. Его согласие по ТЗ — шаг маршрута: форма сама ставит
* его первым шагом согласования (см. receivingHead.ts). Обязателен при отправке.
*
* Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал.
*/
export const DepartmentTransferSection: React.FC<ISectionProps> = ({ data, set, context }) => (
<FormGrid>
<FormField label="Новое подразделение" htmlFor="DepartmentTransferNewDepartment">
<DepartmentPicker
context={context}
id="DepartmentTransferNewDepartment"
value={data.DepartmentTransferNewDepartment}
valueId={data.DepartmentTransferNewDepartmentId}
onChange={(name, id) => {
set('DepartmentTransferNewDepartment', name);
set('DepartmentTransferNewDepartmentId', id);
}}
placeholder="Начните вводить название подразделения…"
/>
</FormField>
<FormField label="Дата перевода" htmlFor="DepartmentTransferDate">
<TextInput
id="DepartmentTransferDate"
type="date"
value={data.DepartmentTransferDate}
onChange={(v) => set('DepartmentTransferDate', v)}
/>
</FormField>
<FormField label="Принимающий руководитель" htmlFor="DepartmentTransferReceivingHead" required full>
<PeopleField
context={context}
value={data.DepartmentTransferReceivingHead}
onChange={(v) => set('DepartmentTransferReceivingHead', v)}
placeholder="Начните вводить Ф.И.О. руководителя, который принимает работника…"
/>
</FormField>
<InitiatorReasonFields
initiator={data.ChangeInitiator}
reason={{
id: 'DepartmentTransferReason',
value: data.DepartmentTransferReason,
options: DEPARTMENT_TRANSFER_REASONS,
onChange: (v) => set('DepartmentTransferReason', v)
}}
justification={{
id: 'DepartmentTransferJustification',
value: data.DepartmentTransferJustification,
onChange: (v) => set('DepartmentTransferJustification', v)
}}
/>
</FormGrid>
);
@@ -0,0 +1,13 @@
import { ISelectOption } from '../../../../shared/ui';
/**
* Причины перевода в другое подразделение — по шаблону текста ТЗ кадров (а–г).
* Работника и работодателя не различаем (как в переводе на должность).
* Значения совпадают с choices колонки DepartmentTransferReason (provisioning.json).
*/
export const DEPARTMENT_TRANSFER_REASONS: ISelectOption[] = [
'Усиление подразделения (рост объёма работ / запуск нового объекта)',
'Перераспределение функций между подразделениями',
'Вакансия, соответствующая квалификации работника',
'Иное'
].map((reason) => ({ value: reason, label: reason }));
@@ -0,0 +1,55 @@
import { IMatrixRow } from '../../../../shared/approval';
import { withReceivingHead, RECEIVING_HEAD_POSITION } from './receivingHead';
const row = (order: number, email: string, position = 'Директор'): IMatrixRow => ({
order,
who: email,
email,
position,
status: '—',
date: '',
comment: '',
allowReassign: false
});
const head = { email: 'head@tnsu.kz', name: 'Принимающий Иван' };
describe('withReceivingHead', () => {
it('вставляет принимающего первым шагом и сдвигает остальные', () => {
const result = withReceivingHead([row(1, 'hrd@tnsu.kz'), row(2, 'dir@tnsu.kz')], head);
expect(result.map((r) => [r.order, r.email, r.position])).toEqual([
[1, 'head@tnsu.kz', RECEIVING_HEAD_POSITION],
[2, 'hrd@tnsu.kz', 'Директор'],
[3, 'dir@tnsu.kz', 'Директор']
]);
});
it('сохраняет параллельные шаги параллельными', () => {
const result = withReceivingHead([row(1, 'a@tnsu.kz'), row(1, 'b@tnsu.kz'), row(2, 'c@tnsu.kz')], head);
expect(result.map((r) => r.order)).toEqual([1, 2, 2, 3]);
});
it('без принимающего убирает его шаг и перенумеровывает', () => {
const withHead = withReceivingHead([row(1, 'hrd@tnsu.kz')], head);
expect(withReceivingHead(withHead, undefined).map((r) => [r.order, r.email])).toEqual([[1, 'hrd@tnsu.kz']]);
});
it('при смене человека заменяет шаг, а не добавляет второй', () => {
const withHead = withReceivingHead([row(1, 'hrd@tnsu.kz')], head);
const result = withReceivingHead(withHead, { email: 'new@tnsu.kz', name: 'Новый' });
expect(result.filter((r) => r.position === RECEIVING_HEAD_POSITION).map((r) => r.email)).toEqual(['new@tnsu.kz']);
expect(result).toHaveLength(2);
});
it('тот же человек — строку не трогает (статус и комментарий доработки сохраняются)', () => {
const decided = { ...row(1, 'head@tnsu.kz', RECEIVING_HEAD_POSITION), status: 'Согласовано', comment: 'ок' };
const rows = [decided, row(2, 'hrd@tnsu.kz')];
expect(withReceivingHead(rows, head)).toBe(rows);
});
it('не дублирует человека, который уже есть в маршруте другой ролью', () => {
const result = withReceivingHead([row(1, 'HEAD@tnsu.kz', 'Руководитель')], head);
expect(result).toHaveLength(1);
expect(result[0].position).toBe('Руководитель');
});
});
@@ -0,0 +1,68 @@
import { IMatrixRow } from '../../../../shared/approval';
/**
* Принимающий руководитель в маршруте согласования (вид «Перевод в другое
* подразделение»). По ТЗ (лист «Маршрут», А шаг 2 / Б шаг 3) это отдельный шаг
* согласия. Approval-route строит маршрут по сотруднику и найти руководителя
* НОВОГО подразделения не может — поэтому шаг вставляет сама форма.
*
* Шаг узнаётся по должности-пометке: она сохраняется в строке маршрута (Title
* списка согласования) и читается обратно, поэтому у черновика шаг тоже находится.
*/
export const RECEIVING_HEAD_POSITION = 'Принимающий руководитель';
export interface IReceivingHead {
email: string;
name: string;
}
function isReceivingHeadRow(row: IMatrixRow): boolean {
return row.position === RECEIVING_HEAD_POSITION;
}
function sameEmail(a: string, b: string): boolean {
return !!a && a.toLowerCase() === b.toLowerCase();
}
/**
* Перенумеровать шаги подряд с 1, сохранив параллельность: строки с одинаковым
* order (параллельный шаг) снова получают одинаковый номер.
*/
function renumber(rows: IMatrixRow[]): IMatrixRow[] {
let order = 0;
let prevOrder: number | undefined;
return rows.map((row) => {
if (row.order !== prevOrder) order += 1;
prevOrder = row.order;
return { ...row, order };
});
}
/**
* Маршрут с принимающим руководителем ПЕРВЫМ шагом (сразу после инициатора, как
* в маршруте А). head не задан — шаг убирается (сменили вид или очистили поле).
*
* - Тот же человек уже стоит этим шагом — строку не трогаем: у возвращённой на
* доработку заявки в ней может быть статус и комментарий.
* - Человек уже есть в маршруте другой ролью (напр. он же непосредственный
* руководитель) — второй строкой не добавляем.
*/
export function withReceivingHead(rows: IMatrixRow[], head: IReceivingHead | undefined): IMatrixRow[] {
const existing = rows.find(isReceivingHeadRow);
if (head && existing && sameEmail(existing.email, head.email)) return rows;
const others = rows.filter((row) => !isReceivingHeadRow(row));
if (!head || others.some((row) => sameEmail(row.email, head.email))) return renumber(others);
const headRow: IMatrixRow = {
order: 0, // перенумерует renumber
who: head.name,
email: head.email,
position: RECEIVING_HEAD_POSITION,
status: others[0]?.status || '—',
date: '',
comment: '',
allowReassign: false
};
return renumber([headRow, ...others]);
}
@@ -0,0 +1,15 @@
@import '../../../../../../styles/design-tokens';
// Флажки «что меняется» — в строку, на узком экране переносятся.
.flags {
display: flex;
flex-wrap: wrap;
gap: $sp-2 $sp-6;
}
// Пояснение под датой (срок уведомления работника по ТЗ).
.hint {
margin-top: $sp-1;
font-size: $fs-label;
color: $gray-500;
}
@@ -0,0 +1,179 @@
import * as React from 'react';
import { FormGrid, FormField, TextInput, TextArea, Select, Checkbox, MoneyInput } from '../../../../shared/ui';
import { useFormReadOnly } from '@tnsu/ui-kit';
import { AttachmentSlotField } from '../../../../shared/attachments/AttachmentSlotField';
import { ISectionProps } from '../../model';
import { OptionWithOtherField } from '../OptionWithOtherField';
import { WORK_SCHEDULE_OPTIONS } from '../workSchedule';
import {
EMPLOYER_CHANGE_REASONS,
EMPLOYER_REASON_OTHER,
EMPLOYER_CHANGE_NOTICE_HINT,
EMPLOYER_JUSTIFICATION_ATTACHMENT,
minEmployerChangeDate
} from './options';
import { splitSalary, sanitizeFixedPercent } from './salaryParts';
import styles from './EmployerInitiativeSection.module.scss';
/**
* Поля вида «Изменение условий труда по инициативе работодателя» — по шаблону ТЗ:
* «в связи с ___ (причина) прошу: а) изменить график работы с ___ на ___ с …;
* б) изменить должностной оклад: постоянная часть 75 % — ___, переменная 25 % — ___, с …».
* Что меняется — флажки а/б (можно оба); оклад вводится один, доля постоянной части
* задаётся (по умолчанию 75 %), переменная — остаток до 100 %, суммы считаются сами. Даты — не раньше чем через 15 дней (уведомление работника по ТЗ).
* Обоснование — текстом или документом (одно из двух обязательно при отправке).
*
* Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал.
*/
export const EmployerInitiativeSection: React.FC<ISectionProps> = ({ data, set, attachments }) => {
const readOnly = useFormReadOnly();
const minDate = minEmployerChangeDate();
const parts = splitSalary(data.EmployerChangeNewSalary, data.EmployerChangeFixedPercent);
return (
<FormGrid>
<FormField label="Причина" htmlFor="EmployerChangeReason" full>
<Select
id="EmployerChangeReason"
value={data.EmployerChangeReason}
onChange={(v) => {
set('EmployerChangeReason', v);
if (v !== EMPLOYER_REASON_OTHER) set('EmployerChangeReasonOther', '');
}}
options={EMPLOYER_CHANGE_REASONS}
placeholder="— выберите —"
/>
</FormField>
{data.EmployerChangeReason === EMPLOYER_REASON_OTHER && (
<FormField label="Причина (другое)" htmlFor="EmployerChangeReasonOther" full>
<TextInput
id="EmployerChangeReasonOther"
value={data.EmployerChangeReasonOther}
onChange={(v) => set('EmployerChangeReasonOther', v)}
/>
</FormField>
)}
<FormField label="Что меняется" htmlFor="EmployerChangeSchedule" full>
<div className={styles.flags}>
<Checkbox
checked={data.EmployerChangeSchedule}
onChange={(checked) => set('EmployerChangeSchedule', checked)}
label="График работы"
/>
<Checkbox
checked={data.EmployerChangeSalary}
onChange={(checked) => set('EmployerChangeSalary', checked)}
label="Должностной оклад"
/>
</div>
</FormField>
{data.EmployerChangeSchedule && (
<>
<OptionWithOtherField
label="Текущий график работы"
id="EmployerChangeCurrentSchedule"
value={data.EmployerChangeCurrentSchedule}
otherValue={data.EmployerChangeCurrentScheduleOther}
options={WORK_SCHEDULE_OPTIONS}
onChange={(v) => set('EmployerChangeCurrentSchedule', v)}
onOtherChange={(v) => set('EmployerChangeCurrentScheduleOther', v)}
/>
<OptionWithOtherField
label="Новый график работы"
id="EmployerChangeNewSchedule"
value={data.EmployerChangeNewSchedule}
otherValue={data.EmployerChangeNewScheduleOther}
options={WORK_SCHEDULE_OPTIONS}
onChange={(v) => set('EmployerChangeNewSchedule', v)}
onOtherChange={(v) => set('EmployerChangeNewScheduleOther', v)}
/>
<FormField label="Дата изменения графика" htmlFor="EmployerChangeScheduleDate" full>
<TextInput
id="EmployerChangeScheduleDate"
type="date"
value={data.EmployerChangeScheduleDate}
onChange={(v) => set('EmployerChangeScheduleDate', v)}
min={minDate}
/>
{!readOnly && <div className={styles.hint}>{EMPLOYER_CHANGE_NOTICE_HINT}</div>}
</FormField>
</>
)}
{data.EmployerChangeSalary && (
<>
<FormField label="Новый должностной оклад, ₸" htmlFor="EmployerChangeNewSalary" full>
<MoneyInput
id="EmployerChangeNewSalary"
value={data.EmployerChangeNewSalary}
onChange={(v) => set('EmployerChangeNewSalary', v)}
/>
</FormField>
{/* Задаётся только доля постоянной части; переменная — остаток до 100 %,
суммы считаются из оклада (см. salaryParts.ts) — превысить 100 % нельзя. */}
<FormField label="Постоянная (повременная) часть, %" htmlFor="EmployerChangeFixedPercent">
<TextInput
id="EmployerChangeFixedPercent"
value={data.EmployerChangeFixedPercent}
onChange={(v) => set('EmployerChangeFixedPercent', sanitizeFixedPercent(v))}
placeholder="например, 75"
/>
</FormField>
<FormField label="Переменная часть, %" htmlFor="EmployerChangeVariablePercent">
<TextInput
id="EmployerChangeVariablePercent"
value={parts ? String(parts.variablePercent) : ''}
onChange={() => undefined}
readOnly
/>
</FormField>
<FormField label="Постоянная часть, ₸" htmlFor="EmployerChangeFixedPart">
<MoneyInput
id="EmployerChangeFixedPart"
value={parts ? String(parts.fixed) : ''}
onChange={() => undefined}
readOnly
/>
</FormField>
<FormField label="Переменная часть, ₸" htmlFor="EmployerChangeVariablePart">
<MoneyInput
id="EmployerChangeVariablePart"
value={parts ? String(parts.variable) : ''}
onChange={() => undefined}
readOnly
/>
</FormField>
<FormField label="Дата изменения оклада" htmlFor="EmployerChangeSalaryDate" full>
<TextInput
id="EmployerChangeSalaryDate"
type="date"
value={data.EmployerChangeSalaryDate}
onChange={(v) => set('EmployerChangeSalaryDate', v)}
min={minDate}
/>
{!readOnly && <div className={styles.hint}>{EMPLOYER_CHANGE_NOTICE_HINT}</div>}
</FormField>
</>
)}
<FormField label="Обоснование" htmlFor="EmployerChangeJustification" full>
<TextArea
id="EmployerChangeJustification"
value={data.EmployerChangeJustification}
onChange={(v) => set('EmployerChangeJustification', v)}
/>
</FormField>
{attachments && (
<AttachmentSlotField
label={`${EMPLOYER_JUSTIFICATION_ATTACHMENT} (приказ о реорганизации, сокращении объёма и т. п.)`}
editor={attachments}
editable={!readOnly}
groupKey={EMPLOYER_JUSTIFICATION_ATTACHMENT}
category={EMPLOYER_JUSTIFICATION_ATTACHMENT}
/>
)}
</FormGrid>
);
};
@@ -0,0 +1,36 @@
import { ISelectOption } from '../../../../shared/ui';
import { isoDatePlusDays } from '../../../../shared/dateUtils';
/**
* Вид «Изменение условий труда по инициативе работодателя». Причины — из названия
* вида в ТЗ (экономические, технологические, изменения в организации, сокращение
* объёма работ) + «Другое» со своим текстом (решение Тимура 2026-10-29).
* Значения = choices колонки EmployerChangeReason.
*/
export const EMPLOYER_REASON_OTHER = 'Другое';
export const EMPLOYER_CHANGE_REASONS: ISelectOption[] = [
'Экономические причины',
'Технологические причины',
'Изменения в организации',
'Сокращение объёма работ',
EMPLOYER_REASON_OTHER
].map((reason) => ({ value: reason, label: reason }));
/**
* По ТЗ работника уведомляют не позднее чем за столько календарных дней до даты
* изменений — поэтому раньше дату не даём выбрать (как срок подачи в отпуске).
*/
export const EMPLOYER_CHANGE_NOTICE_DAYS = 15;
export const EMPLOYER_CHANGE_NOTICE_HINT = `Не раньше чем через ${EMPLOYER_CHANGE_NOTICE_DAYS} календарных дней: работника уведомляют за ${EMPLOYER_CHANGE_NOTICE_DAYS} дней до изменений.`;
/** Самая ранняя допустимая дата изменений, YYYY-MM-DD. */
export function minEmployerChangeDate(): string {
return isoDatePlusDays(EMPLOYER_CHANGE_NOTICE_DAYS);
}
/**
* Вложение-обоснование (приказ о реорганизации, сокращении объёма и т.п.). По ТЗ
* «ТЕКСТ ИЛИ ПРИЛОЖИТЬ ДОКУМЕНТ» — сам по себе необязателен, но при отправке
* нужен текст обоснования или этот файл (см. submitChecks).
*/
export const EMPLOYER_JUSTIFICATION_ATTACHMENT = 'Документ-обоснование';
@@ -0,0 +1,43 @@
import { splitSalary, sanitizeFixedPercent } from './salaryParts';
describe('splitSalary', () => {
it('по умолчанию из ТЗ — 75 / 25', () => {
expect(splitSalary('400000', '75')).toEqual({ fixedPercent: 75, variablePercent: 25, fixed: 300000, variable: 100000 });
});
it('своя доля: переменная часть — остаток до 100 %', () => {
expect(splitSalary('400000', '70')).toEqual({ fixedPercent: 70, variablePercent: 30, fixed: 280000, variable: 120000 });
});
it('при округлении сумма частей всё равно равна окладу', () => {
const parts = splitSalary('333333', '75');
expect(parts?.fixed).toBe(250000);
expect((parts?.fixed ?? 0) + (parts?.variable ?? 0)).toBe(333333);
});
it('дробная доля', () => {
expect(splitSalary('200000', '62.5')).toEqual({
fixedPercent: 62.5,
variablePercent: 37.5,
fixed: 125000,
variable: 75000
});
});
it('нет оклада или доли — частей нет', () => {
expect(splitSalary('', '75')).toBeUndefined();
expect(splitSalary('0', '75')).toBeUndefined();
expect(splitSalary('400000', '')).toBeUndefined();
});
});
describe('sanitizeFixedPercent', () => {
it('больше 100 не даёт', () => {
expect(sanitizeFixedPercent('150')).toBe('100');
});
it('пропускает обычные и дробные доли', () => {
expect(sanitizeFixedPercent('75')).toBe('75');
expect(sanitizeFixedPercent('62,5')).toBe('62.5');
});
});
@@ -0,0 +1,50 @@
import { sanitizePercentInput } from '../Allowance/amount';
/**
* Новый оклад делится на постоянную (повременную) и переменную части. В шаблоне ТЗ
* — 75 % / 25 %; по решению Тимура (2026-10-29) долю постоянной части можно задать
* самому, а переменная — всегда остаток до 100 %. Так сумма частей ровно 100 % и
* превысить её нельзя в принципе.
*/
export const DEFAULT_FIXED_PERCENT = '75';
const MAX_PERCENT = 100;
export interface ISalaryParts {
/** Доли в процентах; variablePercent = 100 − fixedPercent. */
fixedPercent: number;
variablePercent: number;
/** Суммы в тенге; variable — остаток, чтобы fixed + variable = оклад. */
fixed: number;
variable: number;
}
/**
* Ввод доли постоянной части → «сырое» число строкой: только цифры и один
* разделитель (как ввод процента надбавки), не больше 100.
*/
export function sanitizeFixedPercent(text: string): string {
const percent = sanitizePercentInput(text);
return Number(percent) > MAX_PERCENT ? String(MAX_PERCENT) : percent;
}
/** Доля постоянной части числом; пусто/некорректно/вне 0–100 — undefined. */
function parsePercent(raw: string): number | undefined {
const percent = Number(raw);
if (raw.trim() === '' || !Number.isFinite(percent) || percent < 0 || percent > MAX_PERCENT) return undefined;
return percent;
}
/**
* Оклад и доля постоянной части → проценты и суммы обеих частей. Постоянная сумма
* округляется до тенге, переменная — остаток (без расхождений от округления).
* Нет оклада или доли — undefined.
*/
export function splitSalary(salaryRaw: string, fixedPercentRaw: string): ISalaryParts | undefined {
const salary = Number(salaryRaw);
const fixedPercent = parsePercent(fixedPercentRaw);
if (salaryRaw.trim() === '' || !Number.isFinite(salary) || salary <= 0 || fixedPercent === undefined) {
return undefined;
}
const fixed = Math.round((salary * fixedPercent) / MAX_PERCENT);
return { fixedPercent, variablePercent: MAX_PERCENT - fixedPercent, fixed, variable: salary - fixed };
}
@@ -0,0 +1,49 @@
import * as React from 'react';
import { FormGrid, FormField, TextInput, TextArea } from '../../../../shared/ui';
import { useFormReadOnly } from '@tnsu/ui-kit';
import { AttachmentSlotField } from '../../../../shared/attachments/AttachmentSlotField';
import { ISectionProps } from '../../model';
import { NEW_JOB_DESCRIPTION_ATTACHMENT } from './options';
/**
* Поля вида «Изменение трудовой функции (без смены должности)». Ровно по шаблону
* ТЗ: «прошу внести изменения в трудовую функцию работника согласно ДИ во
* вложении с __.__.20__ в связи с ___ (текст)» — дата, «в связи с» (свободный
* текст, списка причин в ТЗ нет) и обязательная новая ДИ. Инициатор — только
* руководитель.
*
* Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал.
*/
export const FunctionChangeSection: React.FC<ISectionProps> = ({ data, set, attachments }) => {
const readOnly = useFormReadOnly();
return (
<FormGrid>
<FormField label="Дата изменения" htmlFor="FunctionChangeDate">
<TextInput
id="FunctionChangeDate"
type="date"
value={data.FunctionChangeDate}
onChange={(v) => set('FunctionChangeDate', v)}
/>
</FormField>
<FormField label="В связи с" htmlFor="FunctionChangeReason" full>
<TextArea
id="FunctionChangeReason"
value={data.FunctionChangeReason}
onChange={(v) => set('FunctionChangeReason', v)}
/>
</FormField>
{attachments && (
<AttachmentSlotField
label={NEW_JOB_DESCRIPTION_ATTACHMENT}
editor={attachments}
editable={!readOnly}
groupKey={NEW_JOB_DESCRIPTION_ATTACHMENT}
category={NEW_JOB_DESCRIPTION_ATTACHMENT}
required
/>
)}
</FormGrid>
);
};
@@ -0,0 +1,5 @@
/**
* Вложение «Новая должностная инструкция» — по ТЗ «приложить документ», обязательно.
* Отдельная подпись от «Должностной инструкции» перевода на должность: это разные слоты.
*/
export const NEW_JOB_DESCRIPTION_ATTACHMENT = 'Новая должностная инструкция';
@@ -0,0 +1,41 @@
import * as React from 'react';
import { FormField, Select, TextArea, ISelectOption } from '../../../shared/ui';
import { InitiatorRole, INITIATOR_EMPLOYEE } from '../changeTypes';
import { EMPLOYEE_STATEMENT_LABEL } from '../model';
interface Props {
initiator: InitiatorRole | '';
/** Причина из списка — только у работодателя. */
reason: { id: string; value: string; options: ISelectOption[]; onChange: (value: string) => void };
/** Обоснование работодателя / текст заявления работника — одна и та же колонка вида. */
justification: { id: string; value: string; onChange: (value: string) => void };
}
/**
* Окончание секции вида, где по ТЗ шаблон зависит от инициатора:
* - работник — «заявление (текст)»: только «Текст заявления», без списка причин;
* - работодатель (и пока инициатор не выбран) — «в связи с: а) б) в)»: причина + обоснование.
* Поля кладутся прямо в сетку FormGrid секции (без обёртки) — как и остальные поля вида.
*/
export const InitiatorReasonFields: React.FC<Props> = ({ initiator, reason, justification }) => {
const isEmployee = initiator === INITIATOR_EMPLOYEE;
return (
<>
{!isEmployee && (
<FormField label="Причина" htmlFor={reason.id} full>
<Select
id={reason.id}
value={reason.value}
onChange={reason.onChange}
options={reason.options}
placeholder="— выберите —"
/>
</FormField>
)}
<FormField label={isEmployee ? EMPLOYEE_STATEMENT_LABEL : 'Обоснование'} htmlFor={justification.id} full>
<TextArea id={justification.id} value={justification.value} onChange={justification.onChange} />
</FormField>
</>
);
};
@@ -0,0 +1,49 @@
import * as React from 'react';
import { FormField, Select, TextInput, ISelectOption } from '../../../shared/ui';
import { OPTION_OTHER } from './workSchedule';
interface Props {
label: string;
id: string;
value: string;
/** Текст при «Другое». */
otherValue: string;
options: ISelectOption[];
onChange: (value: string) => void;
onOtherChange: (text: string) => void;
}
/**
* Выбор из списка + ручной ввод при «Другое» (как график/время работы в подборе
* и приёме). Выбрали не «Другое» — ручной текст очищается, чтобы не сохранить
* скрытое значение. Поля кладутся прямо в сетку FormGrid секции.
*/
export const OptionWithOtherField: React.FC<Props> = ({
label,
id,
value,
otherValue,
options,
onChange,
onOtherChange
}) => (
<>
<FormField label={label} htmlFor={id}>
<Select
id={id}
value={value}
onChange={(v) => {
onChange(v);
if (v !== OPTION_OTHER) onOtherChange('');
}}
options={options}
placeholder="— выберите —"
/>
</FormField>
{value === OPTION_OTHER && (
<FormField label={`${label} (другое)`} htmlFor={`${id}Other`}>
<TextInput id={`${id}Other`} value={otherValue} onChange={onOtherChange} />
</FormField>
)}
</>
);
@@ -1,9 +1,9 @@
import * as React from 'react'; import * as React from 'react';
import { CollapseSection, FormField, TextInput, TextArea, Select } from '../../../../shared/ui'; import { FormGrid, FormField, TextInput } from '../../../../shared/ui';
import { InitiatorReasonFields } from '../InitiatorReasonFields';
import { useFormReadOnly } from '@tnsu/ui-kit'; import { useFormReadOnly } from '@tnsu/ui-kit';
import { PositionPicker } from '../../../../shared/dictionary'; import { PositionPicker } from '../../../../shared/dictionary';
import { AttachmentSlotField } from '../../../../shared/attachments/AttachmentSlotField'; import { AttachmentSlotField } from '../../../../shared/attachments/AttachmentSlotField';
import { CHANGE_TYPE_LABELS } from '../../changeTypes';
import { ISectionProps } from '../../model'; import { ISectionProps } from '../../model';
import { POSITION_TRANSFER_REASONS, JOB_DESCRIPTION_ATTACHMENT } from './options'; import { POSITION_TRANSFER_REASONS, JOB_DESCRIPTION_ATTACHMENT } from './options';
@@ -13,13 +13,13 @@ import { POSITION_TRANSFER_REASONS, JOB_DESCRIPTION_ATTACHMENT } from './options
* причина (а–г), обоснование и обязательная должностная инструкция. Текущая * причина (а–г), обоснование и обязательная должностная инструкция. Текущая
* должность уже видна в «Организации» — отдельного «было» нет. * должность уже видна в «Организации» — отдельного «было» нет.
* *
* Поля — прямо в CollapseSection, без FormGrid (тело секции само сетка). * Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал.
*/ */
export const PositionTransferSection: React.FC<ISectionProps> = ({ data, set, context, attachments }) => { export const PositionTransferSection: React.FC<ISectionProps> = ({ data, set, context, attachments }) => {
const readOnly = useFormReadOnly(); const readOnly = useFormReadOnly();
return ( return (
<CollapseSection title={CHANGE_TYPE_LABELS['position-transfer']} icon="people"> <FormGrid>
<FormField label="Новая должность" htmlFor="PositionTransferNewPosition"> <FormField label="Новая должность" htmlFor="PositionTransferNewPosition">
<PositionPicker <PositionPicker
context={context} context={context}
@@ -37,22 +37,20 @@ export const PositionTransferSection: React.FC<ISectionProps> = ({ data, set, co
onChange={(v) => set('PositionTransferDate', v)} onChange={(v) => set('PositionTransferDate', v)}
/> />
</FormField> </FormField>
<FormField label="Причина" htmlFor="PositionTransferReason" full> <InitiatorReasonFields
<Select initiator={data.ChangeInitiator}
id="PositionTransferReason" reason={{
value={data.PositionTransferReason} id: 'PositionTransferReason',
onChange={(v) => set('PositionTransferReason', v)} value: data.PositionTransferReason,
options={POSITION_TRANSFER_REASONS} options: POSITION_TRANSFER_REASONS,
placeholder="— выберите —" onChange: (v) => set('PositionTransferReason', v)
}}
justification={{
id: 'PositionTransferJustification',
value: data.PositionTransferJustification,
onChange: (v) => set('PositionTransferJustification', v)
}}
/> />
</FormField>
<FormField label="Обоснование" htmlFor="PositionTransferJustification" full>
<TextArea
id="PositionTransferJustification"
value={data.PositionTransferJustification}
onChange={(v) => set('PositionTransferJustification', v)}
/>
</FormField>
{attachments && ( {attachments && (
<AttachmentSlotField <AttachmentSlotField
label={JOB_DESCRIPTION_ATTACHMENT} label={JOB_DESCRIPTION_ATTACHMENT}
@@ -63,6 +61,6 @@ export const PositionTransferSection: React.FC<ISectionProps> = ({ data, set, co
required required
/> />
)} )}
</CollapseSection> </FormGrid>
); );
}; };
@@ -1,6 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { CollapseSection, FormField, MoneyInput, TextInput, TextArea, Select } from '../../../../shared/ui'; import { FormGrid, FormField, MoneyInput, TextInput, TextArea, Select } from '../../../../shared/ui';
import { CHANGE_TYPE_LABELS } from '../../changeTypes';
import { ISectionProps } from '../../model'; import { ISectionProps } from '../../model';
import { SALARY_INCREASE_REASONS } from './reasons'; import { SALARY_INCREASE_REASONS } from './reasons';
@@ -9,11 +8,10 @@ import { SALARY_INCREASE_REASONS } from './reasons';
* кадров: было/стало, дата, причина (а/б/в) и обоснование. Все необязательные, * кадров: было/стало, дата, причина (а/б/в) и обоснование. Все необязательные,
* без ограничений — по решению от 2026-10-28. * без ограничений — по решению от 2026-10-28.
* *
* Поля — прямо в CollapseSection, без FormGrid: тело секции само является * Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал.
* двухколоночной сеткой, вложенная FormGrid сжала бы всё в одну её ячейку.
*/ */
export const SalaryIncreaseSection: React.FC<ISectionProps> = ({ data, set }) => ( export const SalaryIncreaseSection: React.FC<ISectionProps> = ({ data, set }) => (
<CollapseSection title={CHANGE_TYPE_LABELS['salary-increase']} icon="calculator"> <FormGrid>
<FormField label="Текущий оклад, ₸" htmlFor="SalaryIncreaseCurrent"> <FormField label="Текущий оклад, ₸" htmlFor="SalaryIncreaseCurrent">
<MoneyInput <MoneyInput
id="SalaryIncreaseCurrent" id="SalaryIncreaseCurrent"
@@ -52,5 +50,5 @@ export const SalaryIncreaseSection: React.FC<ISectionProps> = ({ data, set }) =>
onChange={(v) => set('SalaryIncreaseJustification', v)} onChange={(v) => set('SalaryIncreaseJustification', v)}
/> />
</FormField> </FormField>
</CollapseSection> </FormGrid>
); );
@@ -0,0 +1,153 @@
import * as React from 'react';
import { FormGrid, FormField, TextInput, Select } from '../../../../shared/ui';
import { useFormReadOnly } from '@tnsu/ui-kit';
import { AttachmentSlotField } from '../../../../shared/attachments/AttachmentSlotField';
import { INITIATOR_EMPLOYEE } from '../../changeTypes';
import { ISectionProps } from '../../model';
import { InitiatorReasonFields } from '../InitiatorReasonFields';
import { OptionWithOtherField } from '../OptionWithOtherField';
import { WORK_SCHEDULE_OPTIONS, WORK_HOURS_OPTIONS } from '../workSchedule';
import {
MODE_OTHER,
NEW_SCHEDULE_ATTACHMENT,
SCHEDULE_CHANGE_NEW_MODES,
SCHEDULE_CHANGE_REASONS
} from './options';
/**
* Поля вида «Изменение режима / графика работы» — по ролям, строго по шаблонам ТЗ
* (см. options.ts). Общее: текущий режим (график + время работы), дата начала,
* необязательный документ «Новый график». Работник — неполное рабочее время
* (часов в день / дней в неделю) и текст заявления; работодатель — новый режим,
* новые график и время работы, причина и обоснование. Все поля необязательные.
*
* Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал.
*/
export const ScheduleChangeSection: React.FC<ISectionProps> = ({ data, set, attachments }) => {
const readOnly = useFormReadOnly();
const isEmployee = data.ChangeInitiator === INITIATOR_EMPLOYEE;
return (
<FormGrid>
<OptionWithOtherField
label="Текущий график работы"
id="ScheduleChangeCurrentSchedule"
value={data.ScheduleChangeCurrentSchedule}
otherValue={data.ScheduleChangeCurrentScheduleOther}
options={WORK_SCHEDULE_OPTIONS}
onChange={(v) => set('ScheduleChangeCurrentSchedule', v)}
onOtherChange={(v) => set('ScheduleChangeCurrentScheduleOther', v)}
/>
<OptionWithOtherField
label="Текущее время работы"
id="ScheduleChangeCurrentHours"
value={data.ScheduleChangeCurrentHours}
otherValue={data.ScheduleChangeCurrentHoursOther}
options={WORK_HOURS_OPTIONS}
onChange={(v) => set('ScheduleChangeCurrentHours', v)}
onOtherChange={(v) => set('ScheduleChangeCurrentHoursOther', v)}
/>
<FormField label="Дата начала" htmlFor="ScheduleChangeDate" full>
<TextInput
id="ScheduleChangeDate"
type="date"
value={data.ScheduleChangeDate}
onChange={(v) => set('ScheduleChangeDate', v)}
/>
</FormField>
{isEmployee && (
<>
{/* Термины ТК: неполный рабочий день / неполная рабочая неделя — без них
«часов в день» читалось непонятно к чему. Заполнить можно одно или оба. */}
<FormField label="Неполный рабочий день, часов" htmlFor="ScheduleChangeHoursPerDay">
<TextInput
id="ScheduleChangeHoursPerDay"
type="number"
value={data.ScheduleChangeHoursPerDay}
onChange={(v) => set('ScheduleChangeHoursPerDay', v)}
placeholder="например, 6"
/>
</FormField>
<FormField label="Неполная рабочая неделя, дней" htmlFor="ScheduleChangeDaysPerWeek">
<TextInput
id="ScheduleChangeDaysPerWeek"
type="number"
value={data.ScheduleChangeDaysPerWeek}
onChange={(v) => set('ScheduleChangeDaysPerWeek', v)}
placeholder="например, 4"
/>
</FormField>
</>
)}
{!isEmployee && (
<>
<FormField label="Новый режим" htmlFor="ScheduleChangeNewMode" full>
<Select
id="ScheduleChangeNewMode"
value={data.ScheduleChangeNewMode}
onChange={(v) => {
set('ScheduleChangeNewMode', v);
if (v !== MODE_OTHER) set('ScheduleChangeNewModeOther', '');
}}
options={SCHEDULE_CHANGE_NEW_MODES}
placeholder="— выберите —"
/>
</FormField>
{data.ScheduleChangeNewMode === MODE_OTHER && (
<FormField label="Иной режим (описание)" htmlFor="ScheduleChangeNewModeOther" full>
<TextInput
id="ScheduleChangeNewModeOther"
value={data.ScheduleChangeNewModeOther}
onChange={(v) => set('ScheduleChangeNewModeOther', v)}
/>
</FormField>
)}
<OptionWithOtherField
label="Новый график работы"
id="ScheduleChangeNewSchedule"
value={data.ScheduleChangeNewSchedule}
otherValue={data.ScheduleChangeNewScheduleOther}
options={WORK_SCHEDULE_OPTIONS}
onChange={(v) => set('ScheduleChangeNewSchedule', v)}
onOtherChange={(v) => set('ScheduleChangeNewScheduleOther', v)}
/>
<OptionWithOtherField
label="Новое время работы"
id="ScheduleChangeNewHours"
value={data.ScheduleChangeNewHours}
otherValue={data.ScheduleChangeNewHoursOther}
options={WORK_HOURS_OPTIONS}
onChange={(v) => set('ScheduleChangeNewHours', v)}
onOtherChange={(v) => set('ScheduleChangeNewHoursOther', v)}
/>
</>
)}
<InitiatorReasonFields
initiator={data.ChangeInitiator}
reason={{
id: 'ScheduleChangeReason',
value: data.ScheduleChangeReason,
options: SCHEDULE_CHANGE_REASONS,
onChange: (v) => set('ScheduleChangeReason', v)
}}
justification={{
id: 'ScheduleChangeJustification',
value: data.ScheduleChangeJustification,
onChange: (v) => set('ScheduleChangeJustification', v)
}}
/>
{attachments && (
<AttachmentSlotField
label={NEW_SCHEDULE_ATTACHMENT}
editor={attachments}
editable={!readOnly}
groupKey={NEW_SCHEDULE_ATTACHMENT}
category={NEW_SCHEDULE_ATTACHMENT}
/>
)}
</FormGrid>
);
};
@@ -0,0 +1,23 @@
import { ISelectOption } from '../../../../shared/ui';
/**
* Вид «Изменение режима / графика работы». По ТЗ шаблон зависит от инициатора:
* - работник: «прошу установить неполное рабочее время (___ ч в день / ___ дней
* в неделю) с __.__.20__ (текст)»;
* - работодатель: «прошу перевести работника на а) вахтовый метод; б) сменный
* график; в) иной режим (текст) с __.__.20__ в связи со спецификой объекта /
* смещением сроков проекта».
* Значения = choices колонок ScheduleChangeNewMode / ScheduleChangeReason.
*/
export const MODE_OTHER = 'Иной режим';
export const SCHEDULE_CHANGE_NEW_MODES: ISelectOption[] = ['Вахтовый метод', 'Сменный график', MODE_OTHER].map(
(mode) => ({ value: mode, label: mode })
);
/** Причины работодателя — ровно те, что в шаблоне ТЗ («Иное» в ТЗ нет — не добавляем). */
export const SCHEDULE_CHANGE_REASONS: ISelectOption[] = ['Специфика объекта', 'Смещение сроков проекта'].map(
(reason) => ({ value: reason, label: reason })
);
/** Вложение «Новый график» — по ТЗ «можно приложить документ», необязательно. */
export const NEW_SCHEDULE_ATTACHMENT = 'Новый график';
@@ -0,0 +1,15 @@
@import '../../../../../../styles/design-tokens';
// Пояснение под полем (ограничение периода).
.hint {
margin-top: $sp-1;
font-size: $fs-label;
color: $gray-500;
}
// Гарантия по оплате из шаблона ТЗ — на всю ширину сетки, приглушённо, как пометка.
.payNote {
grid-column: 1 / -1;
font-size: $fs-sm;
color: $gray-500;
}
@@ -0,0 +1,99 @@
import * as React from 'react';
import { FormGrid, FormField, TextInput, TextArea, Select } from '../../../../shared/ui';
import { ProjectPicker } from '../../../../shared/dictionary';
import { ISectionProps } from '../../model';
import { TEMPORARY_TRANSFER_REASONS, TEMPORARY_TRANSFER_PAY_NOTE, TEMPORARY_TRANSFER_PERIOD_HINT } from './options';
import { maxTransferEndDate } from './period';
import styles from './TemporaryTransferSection.module.scss';
const PROJECT_PLACEHOLDER = 'Начните вводить название или код проекта…';
/**
* Поля вида «Временный перевод при производственной необходимости / простое» — по
* шаблону ТЗ: «прошу временно перевести работника с ___ (объект / работа) на ___
* с … по … в связи с ___ (производственная необходимость / простой). Оплата — не
* ниже средней заработной платы по прежней работе». Откуда/куда — проект из
* справочника + работа текстом; период не длиннее 3 месяцев (period.ts).
* Инициатор — только руководитель.
*
* Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал.
*/
export const TemporaryTransferSection: React.FC<ISectionProps> = ({ data, set, context }) => (
<FormGrid>
<FormField label="Откуда: проект / объект" htmlFor="TemporaryTransferFromProject">
<ProjectPicker
context={context}
id="TemporaryTransferFromProject"
value={data.TemporaryTransferFromProject}
onChange={(name, id) => {
set('TemporaryTransferFromProject', name);
set('TemporaryTransferFromProjectId', id);
}}
placeholder={PROJECT_PLACEHOLDER}
/>
</FormField>
<FormField label="Откуда: работа" htmlFor="TemporaryTransferFromWork">
<TextInput
id="TemporaryTransferFromWork"
value={data.TemporaryTransferFromWork}
onChange={(v) => set('TemporaryTransferFromWork', v)}
/>
</FormField>
<FormField label="Куда: проект / объект" htmlFor="TemporaryTransferToProject">
<ProjectPicker
context={context}
id="TemporaryTransferToProject"
value={data.TemporaryTransferToProject}
onChange={(name, id) => {
set('TemporaryTransferToProject', name);
set('TemporaryTransferToProjectId', id);
}}
placeholder={PROJECT_PLACEHOLDER}
/>
</FormField>
<FormField label="Куда: работа" htmlFor="TemporaryTransferToWork">
<TextInput
id="TemporaryTransferToWork"
value={data.TemporaryTransferToWork}
onChange={(v) => set('TemporaryTransferToWork', v)}
/>
</FormField>
<FormField label="Дата начала" htmlFor="TemporaryTransferStartDate">
<TextInput
id="TemporaryTransferStartDate"
type="date"
value={data.TemporaryTransferStartDate}
onChange={(v) => set('TemporaryTransferStartDate', v)}
/>
</FormField>
<FormField label="Дата окончания" htmlFor="TemporaryTransferEndDate">
{/* Не раньше начала и не дольше 3 месяцев — календарь не даст выбрать лишнее. */}
<TextInput
id="TemporaryTransferEndDate"
type="date"
value={data.TemporaryTransferEndDate}
onChange={(v) => set('TemporaryTransferEndDate', v)}
min={data.TemporaryTransferStartDate || undefined}
max={maxTransferEndDate(data.TemporaryTransferStartDate)}
/>
<div className={styles.hint}>{TEMPORARY_TRANSFER_PERIOD_HINT}</div>
</FormField>
<FormField label="Основание" htmlFor="TemporaryTransferReason" full>
<Select
id="TemporaryTransferReason"
value={data.TemporaryTransferReason}
onChange={(v) => set('TemporaryTransferReason', v)}
options={TEMPORARY_TRANSFER_REASONS}
placeholder="— выберите —"
/>
</FormField>
<FormField label="Обоснование" htmlFor="TemporaryTransferJustification" full>
<TextArea
id="TemporaryTransferJustification"
value={data.TemporaryTransferJustification}
onChange={(v) => set('TemporaryTransferJustification', v)}
/>
</FormField>
<div className={styles.payNote}>{TEMPORARY_TRANSFER_PAY_NOTE}</div>
</FormGrid>
);
@@ -0,0 +1,17 @@
import { ISelectOption } from '../../../../shared/ui';
import { MAX_TRANSFER_MONTHS } from './period';
/**
* Основания временного перевода — из шаблона ТЗ («в связи с ___ (производственная
* необходимость / простой)»). Значения = choices колонки TemporaryTransferReason.
*/
export const TEMPORARY_TRANSFER_REASONS: ISelectOption[] = ['Производственная необходимость', 'Простой'].map(
(reason) => ({ value: reason, label: reason })
);
/** Гарантия работнику из шаблона ТЗ — показывается на форме пометкой. */
export const TEMPORARY_TRANSFER_PAY_NOTE = 'Оплата — не ниже средней заработной платы по прежней работе.';
/** Пояснение под датой окончания. */
export const TEMPORARY_TRANSFER_PERIOD_HINT = `Не дольше ${MAX_TRANSFER_MONTHS} месяцев.`;
export const TEMPORARY_TRANSFER_PERIOD_ERROR = `Временный перевод — не дольше ${MAX_TRANSFER_MONTHS} месяцев, и дата окончания не раньше даты начала.`;
@@ -0,0 +1,23 @@
import { maxTransferEndDate } from './period';
describe('maxTransferEndDate', () => {
it('3 месяца включительно: с 01.01 — по 31.03', () => {
expect(maxTransferEndDate('2026-01-01')).toBe('2026-03-31');
});
it('с середины месяца', () => {
expect(maxTransferEndDate('2026-02-15')).toBe('2026-05-14');
});
it('через новый год', () => {
expect(maxTransferEndDate('2026-11-01')).toBe('2027-01-31');
});
it('конец месяца не уезжает в следующий (30.11 + 3 мес. = 28.02)', () => {
expect(maxTransferEndDate('2026-11-30')).toBe('2027-02-27');
});
it('пустая дата — нет ограничения', () => {
expect(maxTransferEndDate('')).toBeUndefined();
});
});
@@ -0,0 +1,25 @@
/**
* Временный перевод — не дольше 3 месяцев (ТЗ: «До 3 месяцев в течение
* календарного года»; сумму за год форма не видит — её проверяет HR-директор).
*/
export const MAX_TRANSFER_MONTHS = 3;
function pad2(n: number): string {
return n < 10 ? `0${n}` : `${n}`;
}
/**
* Последний допустимый день периода, начатого start (YYYY-MM-DD): start + 3 месяца
* − 1 день, оба дня включительно (с 01.01 — по 31.03). К концу месяца прижимаем:
* 30.11 + 3 мес. — это 28.02, а не 02.03. Некорректная дата — undefined.
*/
export function maxTransferEndDate(startIso: string): string | undefined {
const [year, month, day] = startIso.split('-').map(Number);
if (!year || !month || !day) return undefined;
const targetMonthIndex = month - 1 + MAX_TRANSFER_MONTHS;
const lastDayOfTargetMonth = new Date(year, targetMonthIndex + 1, 0).getDate();
const end = new Date(year, targetMonthIndex, Math.min(day, lastDayOfTargetMonth));
end.setDate(end.getDate() - 1);
return `${end.getFullYear()}-${pad2(end.getMonth() + 1)}-${pad2(end.getDate())}`;
}
@@ -0,0 +1,8 @@
@import '../../../../../../styles/design-tokens';
// Ошибка загрузки справочника под полем (не прячем за пустым списком).
.fieldError {
margin-top: $sp-1;
font-size: $fs-label;
color: $red-500;
}
@@ -0,0 +1,132 @@
import * as React from 'react';
import { FormGrid, FormField, TextInput, TextArea, Select, Toggle } from '../../../../shared/ui';
import { ProjectPicker } from '../../../../shared/dictionary';
import { ISectionProps } from '../../model';
import { WORKPLACE_CHANGE_REASONS } from './options';
import { WORK_SCHEDULE_OPTIONS } from '../workSchedule';
import { OptionWithOtherField } from '../OptionWithOtherField';
import { useCompanyOptions } from './useCompanyOptions';
import { InitiatorReasonFields } from '../InitiatorReasonFields';
import styles from './WorkplaceChangeSection.module.scss';
const PROJECT_PLACEHOLDER = 'Начните вводить название или код проекта…';
/**
* Поля вида «Изменение места работы (другая местность)». Состав согласован с
* Тимуром по ТЗ кадров: место работы «было → стало» парой «организация + проект»
* (текущая организация — из выбранной в шапке, новая обязательна), адрес, дата,
* причина (а–в), условия переезда, вахта с графиком работы, обоснование.
*
* Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал.
*/
export const WorkplaceChangeSection: React.FC<ISectionProps> = ({ data, set, context }) => {
const companies = useCompanyOptions(context);
return (
<FormGrid>
<FormField label="Текущая организация" htmlFor="WorkplaceChangeCurrentCompany">
{/* Та же, что выбрана в «Организации» сотрудника, — второй раз не вводим. */}
<TextInput id="WorkplaceChangeCurrentCompany" value={data.Employee?.company || ''} onChange={() => undefined} readOnly />
</FormField>
<FormField label="Текущий проект" htmlFor="WorkplaceChangeCurrentProject">
<ProjectPicker
context={context}
id="WorkplaceChangeCurrentProject"
value={data.WorkplaceChangeCurrentProject}
onChange={(name, id) => {
set('WorkplaceChangeCurrentProject', name);
set('WorkplaceChangeCurrentProjectId', id);
}}
placeholder={PROJECT_PLACEHOLDER}
/>
</FormField>
<FormField label="Новая организация" htmlFor="WorkplaceChangeNewCompanyId" required>
<Select
id="WorkplaceChangeNewCompanyId"
value={data.WorkplaceChangeNewCompanyId}
onChange={(id) => {
set('WorkplaceChangeNewCompanyId', id);
set('WorkplaceChangeNewCompany', companies.byId.get(id)?.Name || '');
}}
options={companies.options}
placeholder={companies.loading ? 'Загрузка…' : '— выберите —'}
/>
{companies.error && <div className={styles.fieldError}>{companies.error}</div>}
</FormField>
<FormField label="Новый проект" htmlFor="WorkplaceChangeNewProject">
<ProjectPicker
context={context}
id="WorkplaceChangeNewProject"
value={data.WorkplaceChangeNewProject}
onChange={(name, id) => {
set('WorkplaceChangeNewProject', name);
set('WorkplaceChangeNewProjectId', id);
}}
placeholder={PROJECT_PLACEHOLDER}
/>
</FormField>
<FormField label="Адрес нового места работы" htmlFor="WorkplaceChangeAddress" full>
<TextInput
id="WorkplaceChangeAddress"
value={data.WorkplaceChangeAddress}
onChange={(v) => set('WorkplaceChangeAddress', v)}
/>
</FormField>
<FormField label="Дата изменения" htmlFor="WorkplaceChangeDate">
<TextInput
id="WorkplaceChangeDate"
type="date"
value={data.WorkplaceChangeDate}
onChange={(v) => set('WorkplaceChangeDate', v)}
/>
</FormField>
<FormField label="Условия переезда и компенсации" htmlFor="WorkplaceChangeRelocation" full>
<TextArea
id="WorkplaceChangeRelocation"
value={data.WorkplaceChangeRelocation}
onChange={(v) => set('WorkplaceChangeRelocation', v)}
/>
</FormField>
<FormField label="Вахта" htmlFor="WorkplaceChangeRotation" full>
<Toggle
id="WorkplaceChangeRotation"
checked={data.WorkplaceChangeRotation}
onChange={(checked) => {
set('WorkplaceChangeRotation', checked);
// Без вахты график не нужен — очищаем, чтобы не сохранить скрытое значение.
if (!checked) {
set('WorkplaceChangeSchedule', '');
set('WorkplaceChangeScheduleOther', '');
}
}}
onLabel="Да"
/>
</FormField>
{data.WorkplaceChangeRotation && (
<OptionWithOtherField
label="График работы"
id="WorkplaceChangeSchedule"
value={data.WorkplaceChangeSchedule}
otherValue={data.WorkplaceChangeScheduleOther}
options={WORK_SCHEDULE_OPTIONS}
onChange={(v) => set('WorkplaceChangeSchedule', v)}
onOtherChange={(v) => set('WorkplaceChangeScheduleOther', v)}
/>
)}
<InitiatorReasonFields
initiator={data.ChangeInitiator}
reason={{
id: 'WorkplaceChangeReason',
value: data.WorkplaceChangeReason,
options: WORKPLACE_CHANGE_REASONS,
onChange: (v) => set('WorkplaceChangeReason', v)
}}
justification={{
id: 'WorkplaceChangeJustification',
value: data.WorkplaceChangeJustification,
onChange: (v) => set('WorkplaceChangeJustification', v)
}}
/>
</FormGrid>
);
};
@@ -0,0 +1,12 @@
import { ISelectOption } from '../../../../shared/ui';
/**
* Причины изменения места работы — по шаблону текста ТЗ кадров (а–в).
* Работника и работодателя не различаем (как в видах 3–4).
* Значения совпадают с choices колонки WorkplaceChangeReason (provisioning.json).
*/
export const WORKPLACE_CHANGE_REASONS: ISelectOption[] = [
'Открытие нового объекта',
'Переезд подразделения / работодателя в другую местность',
'Иное'
].map((reason) => ({ value: reason, label: reason }));
@@ -0,0 +1,48 @@
import { useEffect, useState } from 'react';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { ISelectOption } from '../../../../shared/ui';
import { ICompany, getCompanies } from '../../../../shared/dictionary';
export interface ICompanyOptions {
options: ISelectOption[];
/** Компании по Id — чтобы по выбранному значению Select получить название. */
byId: Map<string, ICompany>;
loading: boolean;
error: string;
}
/**
* Справочник компаний (GET /Dictionary/Companies) как варианты Select: value — Id,
* label — название. Список кешируется в самом сервисе (localStorage, 4 часа).
* Не загрузился — ошибка видна под полем, не прячем её за пустым списком.
*/
export function useCompanyOptions(context: WebPartContext): ICompanyOptions {
const [companies, setCompanies] = useState<ICompany[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string>('');
useEffect(() => {
let alive = true;
getCompanies(context)
.then((list) => {
if (!alive) return;
setCompanies(list);
setLoading(false);
})
.catch(() => {
if (!alive) return;
setError('Не удалось загрузить список организаций. Обновите страницу.');
setLoading(false);
});
return () => {
alive = false;
};
}, [context]);
return {
options: companies.map((c) => ({ value: c.Id, label: c.Name })),
byId: new Map(companies.map((c) => [c.Id, c])),
loading,
error
};
}
@@ -3,6 +3,9 @@ import { attachmentsOfCategory, prefixFiles } from '../../../shared/attachments/
import { IFormData } from '../model'; import { IFormData } from '../model';
import { ATTESTATION_ATTACHMENT, requiresAttestation } from './Allowance/options'; import { ATTESTATION_ATTACHMENT, requiresAttestation } from './Allowance/options';
import { JOB_DESCRIPTION_ATTACHMENT } from './PositionTransfer/options'; import { JOB_DESCRIPTION_ATTACHMENT } from './PositionTransfer/options';
import { NEW_SCHEDULE_ATTACHMENT } from './ScheduleChange/options';
import { NEW_JOB_DESCRIPTION_ATTACHMENT } from './FunctionChange/options';
import { EMPLOYER_JUSTIFICATION_ATTACHMENT } from './EmployerInitiative/options';
/** /**
* Подписанный слот вложений по колонке ТЗ «Что приложить». Подпись (category) * Подписанный слот вложений по колонке ТЗ «Что приложить». Подпись (category)
@@ -23,6 +26,17 @@ export function activeAttachmentSlots(data: IFormData): IAttachmentSlot[] {
if (data.ChangeTypes.includes('position-transfer')) { if (data.ChangeTypes.includes('position-transfer')) {
slots.push({ category: JOB_DESCRIPTION_ATTACHMENT, required: true }); slots.push({ category: JOB_DESCRIPTION_ATTACHMENT, required: true });
} }
if (data.ChangeTypes.includes('function-change')) {
slots.push({ category: NEW_JOB_DESCRIPTION_ATTACHMENT, required: true });
}
if (data.ChangeTypes.includes('employer-initiative')) {
// «Текст ИЛИ документ» — сам слот необязателен, правило «одно из двух» — в submitChecks.
slots.push({ category: EMPLOYER_JUSTIFICATION_ATTACHMENT, required: false });
}
if (data.ChangeTypes.includes('schedule-change')) {
// «Можно приложить документ» — необязательно.
slots.push({ category: NEW_SCHEDULE_ATTACHMENT, required: false });
}
return slots; return slots;
} }
@@ -35,14 +49,16 @@ export function prepareFilesFor(data: IFormData): PrepareFiles {
return (groupKey, files) => (visible.indexOf(groupKey) >= 0 ? prefixFiles(files, groupKey) : []); return (groupKey, files) => (visible.indexOf(groupKey) >= 0 ? prefixFiles(files, groupKey) : []);
} }
/** Есть ли в слоте файл — уже прикреплённый или выбранный к загрузке. */
export function hasAttachment(editor: IAttachmentEditor, category: string): boolean {
const uploaded = attachmentsOfCategory(editor.items, category).length;
const selected = (editor.pending[category] || []).length;
return uploaded + selected > 0;
}
/** Подписи обязательных слотов, где нет ни прикреплённого, ни выбранного файла. */ /** Подписи обязательных слотов, где нет ни прикреплённого, ни выбранного файла. */
export function missingRequiredAttachments(data: IFormData, editor: IAttachmentEditor): string[] { export function missingRequiredAttachments(data: IFormData, editor: IAttachmentEditor): string[] {
return activeAttachmentSlots(data) return activeAttachmentSlots(data)
.filter((slot) => slot.required) .filter((slot) => slot.required && !hasAttachment(editor, slot.category))
.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); .map((slot) => slot.category);
} }
@@ -0,0 +1,83 @@
import { IAttachmentEditor } from '../../../shared/attachments/useAttachmentEditor';
import { formatIsoDate } from '../../../shared/dateUtils';
import {
IFormData,
INITIATOR_REQUIRED_MESSAGE,
NEW_COMPANY_REQUIRED_MESSAGE,
RECEIVING_HEAD_REQUIRED_MESSAGE,
EMPLOYER_CHANGE_NOTHING_SELECTED_MESSAGE,
EMPLOYER_JUSTIFICATION_REQUIRED_MESSAGE
} from '../model';
import { hasInitiatorChoice } from '../changeTypes';
import { hasAttachment, missingRequiredAttachments } from './attachmentSlots';
import {
EMPLOYER_CHANGE_NOTICE_DAYS,
EMPLOYER_JUSTIFICATION_ATTACHMENT,
minEmployerChangeDate
} from './EmployerInitiative/options';
import { TEMPORARY_TRANSFER_PERIOD_ERROR } from './TemporaryTransfer/options';
import { maxTransferEndDate } from './TemporaryTransfer/period';
/**
* Что обязательно по выбранному виду изменения — проверяется только при ОТПРАВКЕ:
* черновик сохраняется и без этого. Первая найденная проблема — текстом; ничего — undefined.
* Новое обязательное поле вида = строка здесь.
*/
export function changeTypeSubmitError(data: IFormData, attachments: IAttachmentEditor): string | undefined {
if (hasInitiatorChoice(data.ChangeTypes) && !data.ChangeInitiator) return INITIATOR_REQUIRED_MESSAGE;
const missingFiles = missingRequiredAttachments(data, attachments);
if (missingFiles.length > 0) return `Приложите обязательные документы: ${missingFiles.join(', ')}.`;
if (data.ChangeTypes.includes('department-transfer') && !data.DepartmentTransferReceivingHead) {
return RECEIVING_HEAD_REQUIRED_MESSAGE;
}
if (data.ChangeTypes.includes('workplace-change') && !data.WorkplaceChangeNewCompanyId) {
return NEW_COMPANY_REQUIRED_MESSAGE;
}
if (data.ChangeTypes.includes('employer-initiative')) {
return employerInitiativeError(data, attachments);
}
if (data.ChangeTypes.includes('temporary-transfer')) {
return temporaryTransferError(data);
}
return undefined;
}
/**
* Временный перевод: если обе даты заданы — окончание не раньше начала и не дольше
* 3 месяцев. Календарь это уже ограничивает; здесь — на случай, когда дату начала
* сдвинули после выбора окончания.
*/
function temporaryTransferError(data: IFormData): string | undefined {
const start = data.TemporaryTransferStartDate;
const end = data.TemporaryTransferEndDate;
if (!start || !end) return undefined;
const maxEnd = maxTransferEndDate(start);
if (end < start || (maxEnd && end > maxEnd)) return TEMPORARY_TRANSFER_PERIOD_ERROR;
return undefined;
}
/**
* Инициатива работодателя: отмечено, что меняется; обоснование текстом ИЛИ
* документом (ТЗ: «ТЕКСТ ИЛИ ПРИЛОЖИТЬ ДОКУМЕНТ»); даты — не раньше срока
* уведомления. Даты проверяем и здесь, а не только min в календаре: черновик мог
* пролежать, и прежняя дата стала слишком близкой.
*/
function employerInitiativeError(data: IFormData, attachments: IAttachmentEditor): string | undefined {
if (!data.EmployerChangeSchedule && !data.EmployerChangeSalary) return EMPLOYER_CHANGE_NOTHING_SELECTED_MESSAGE;
const hasJustification =
!!data.EmployerChangeJustification.trim() || hasAttachment(attachments, EMPLOYER_JUSTIFICATION_ATTACHMENT);
if (!hasJustification) return EMPLOYER_JUSTIFICATION_REQUIRED_MESSAGE;
const minDate = minEmployerChangeDate();
const dates = [
data.EmployerChangeSchedule ? data.EmployerChangeScheduleDate : '',
data.EmployerChangeSalary ? data.EmployerChangeSalaryDate : ''
];
if (dates.some((date) => date && date < minDate)) {
return `Дата изменений — не раньше ${formatIsoDate(minDate)}: работника уведомляют за ${EMPLOYER_CHANGE_NOTICE_DAYS} календарных дней.`;
}
return undefined;
}
@@ -0,0 +1,38 @@
import { ISelectOption } from '../../../shared/ui';
/**
* Режим работы: «График работы» и «Время работы» — те же наборы, что в «Заявке
* на подбор» и «Заявке на приём» (WORK_SCHEDULE_OPTIONS / WORK_HOURS_OPTIONS там):
* формы независимы, значения продублированы намеренно, как и между ними.
* Общие для видов этой формы (место работы — вахта, режим/график работы).
*/
/** «Другое» в списке — открывает ручной ввод (см. OptionWithOtherField). */
export const OPTION_OTHER = 'Другое';
function toOptions(values: string[]): ISelectOption[] {
return values.map((value) => ({ value, label: value }));
}
export const WORK_SCHEDULE_OPTIONS: ISelectOption[] = toOptions([
'5/2',
'Вахтовый 30/30',
'Вахтовый 20/10',
'Вахтовый 30/15',
'Вахтовый 22/8',
'Вахтовый 15/15',
'6/1',
'1/2',
'2/2',
OPTION_OTHER
]);
/** 24-часовой формат, как в подборе и приёме. */
export const WORK_HOURS_OPTIONS: ISelectOption[] = toOptions([
'08:00-17:00',
'09:00-18:00',
'07:00-19:00',
'08:00-20:00',
'09:00-21:00',
OPTION_OTHER
]);
@@ -28,13 +28,12 @@ export const CHANGE_TYPE_LABELS: Record<ChangeType, string> = {
'department-transfer': 'Перевод в другое подразделение', 'department-transfer': 'Перевод в другое подразделение',
'workplace-change': 'Изменение места работы (другая местность)', 'workplace-change': 'Изменение места работы (другая местность)',
'contract-extension': 'Продление срока трудового договора', 'contract-extension': 'Продление срока трудового договора',
'schedule-change': // В ТЗ ещё «; неполное рабочее время по просьбе работника» — сокращено по просьбе Тимура
'Изменение режима / графика работы по соглашению сторон; неполное рабочее время по просьбе работника', // (неполное время — поля работника внутри вида).
'schedule-change': 'Изменение режима / графика работы по соглашению сторон',
'function-change': 'Изменение трудовой функции (без смены должности)', 'function-change': 'Изменение трудовой функции (без смены должности)',
// Самая длинная подпись (~225 символов) — близко к лимиту choice в SP (255), не удлинять. // В ТЗ — длинная формулировка с перечнем причин; сокращено по просьбе Тимура (причины — в поле «Причина» вида).
'employer-initiative': 'employer-initiative': 'Изменение условий труда по инициативе работодателя',
'Изменение условий труда по инициативе работодателя по причинам экономического, технологического характера, ' +
'изменений в организации и (или) сокращения объёма работ (снижение оклада, сокращение рабочего времени, изменение графика)',
'temporary-transfer': 'Временный перевод при производственной необходимости / простое' 'temporary-transfer': 'Временный перевод при производственной необходимости / простое'
}; };
@@ -49,6 +48,39 @@ export const CHANGE_TYPE_OPTIONS: ISelectOption[] = (Object.keys(CHANGE_TYPE_LAB
(type) => ({ value: type, label: CHANGE_TYPE_LABELS[type] }) (type) => ({ value: type, label: CHANGE_TYPE_LABELS[type] })
); );
/**
* Кто инициирует изменение. Значения = choices колонки ChangeInitiator. По ТЗ у
* части видов шаблон разный: «От работника: заявление (текст). От работодателя:
* прошу… в связи с: а) б) в)» — форма показывает поля той роли, что выбрана.
*/
export type InitiatorRole = 'Работник' | 'Работодатель';
export const INITIATOR_EMPLOYEE: InitiatorRole = 'Работник';
export const INITIATOR_EMPLOYER: InitiatorRole = 'Работодатель';
export const INITIATOR_OPTIONS: ISelectOption[] = [INITIATOR_EMPLOYEE, INITIATOR_EMPLOYER].map((role) => ({
value: role,
label: role
}));
/**
* Виды, которые по ТЗ («Кто инициирует») может начать и руководитель, и сам
* работник. У остальных инициатор — только руководитель, выбора нет.
*/
const TYPES_WITH_INITIATOR_CHOICE: ChangeType[] = [
'position-transfer',
'department-transfer',
'workplace-change',
'schedule-change'
];
export function hasInitiatorChoice(types: ChangeType[]): boolean {
return types.some((type) => TYPES_WITH_INITIATOR_CHOICE.indexOf(type) >= 0);
}
/** Строка из SP → роль; пусто/неизвестное — '' (не выбрана). */
export function initiatorRoleFromText(text: string): InitiatorRole | '' {
return text === INITIATOR_EMPLOYEE || text === INITIATOR_EMPLOYER ? text : '';
}
/** Подпись из SP → ключ вида. Неизвестная подпись (переименовали choice) — undefined. */ /** Подпись из SP → ключ вида. Неизвестная подпись (переименовали choice) — undefined. */
export function changeTypeFromLabel(label: string): ChangeType | undefined { export function changeTypeFromLabel(label: string): ChangeType | undefined {
const normalized = label.trim(); const normalized = label.trim();
@@ -2,8 +2,9 @@ import { getSP } from '../../shared/pnp/pnpjs';
import { dataListTitle } from '../../shared/listTitles'; import { dataListTitle } from '../../shared/listTitles';
import { spDateOnlyToIso } from '../../shared/dateUtils'; import { spDateOnlyToIso } from '../../shared/dateUtils';
import { IEmployeeFullBaseRecord } from '../../shared/dictionary'; import { IEmployeeFullBaseRecord } from '../../shared/dictionary';
import { ChangeType, changeTypeFromLabel } from './changeTypes'; import { ChangeType, changeTypeFromLabel, initiatorRoleFromText } from './changeTypes';
import { allowanceUnitFromText } from './byType/Allowance/options'; import { allowanceUnitFromText } from './byType/Allowance/options';
import { DEFAULT_FIXED_PERCENT } from './byType/EmployerInitiative/salaryParts';
import { FORM_ID, IFormData, EMPTY_DATA } from './model'; import { FORM_ID, IFormData, EMPTY_DATA } from './model';
export interface ILoadedRequest { export interface ILoadedRequest {
@@ -88,6 +89,7 @@ export async function loadContractChangeRequest(itemId: number, listId?: string)
'EmployeePosition', 'EmployeePosition',
'EmployeeDepartment', 'EmployeeDepartment',
'ChangeTypes', 'ChangeTypes',
'ChangeInitiator',
'SalaryIncreaseCurrent', 'SalaryIncreaseCurrent',
'SalaryIncreaseNew', 'SalaryIncreaseNew',
'SalaryIncreaseDate', 'SalaryIncreaseDate',
@@ -103,13 +105,76 @@ export async function loadContractChangeRequest(itemId: number, listId?: string)
'PositionTransferNewPosition', 'PositionTransferNewPosition',
'PositionTransferDate', 'PositionTransferDate',
'PositionTransferReason', 'PositionTransferReason',
'PositionTransferJustification' 'PositionTransferJustification',
)()) as Record<string, unknown>; 'DepartmentTransferNewDepartment',
'DepartmentTransferNewDepartmentId',
'DepartmentTransferDate',
'DepartmentTransferReason',
'DepartmentTransferJustification',
'DepartmentTransferReceivingHead/EMail',
'WorkplaceChangeCurrentProject',
'WorkplaceChangeCurrentProjectId',
'WorkplaceChangeNewCompany',
'WorkplaceChangeNewCompanyId',
'WorkplaceChangeNewProject',
'WorkplaceChangeNewProjectId',
'WorkplaceChangeAddress',
'WorkplaceChangeDate',
'WorkplaceChangeReason',
'WorkplaceChangeRelocation',
'WorkplaceChangeRotation',
'WorkplaceChangeSchedule',
'WorkplaceChangeScheduleOther',
'WorkplaceChangeJustification',
'ScheduleChangeCurrentSchedule',
'ScheduleChangeCurrentScheduleOther',
'ScheduleChangeCurrentHours',
'ScheduleChangeCurrentHoursOther',
'ScheduleChangeDate',
'ScheduleChangeHoursPerDay',
'ScheduleChangeDaysPerWeek',
'ScheduleChangeNewMode',
'ScheduleChangeNewModeOther',
'ScheduleChangeNewSchedule',
'ScheduleChangeNewScheduleOther',
'ScheduleChangeNewHours',
'ScheduleChangeNewHoursOther',
'ScheduleChangeReason',
'ScheduleChangeJustification',
'FunctionChangeDate',
'FunctionChangeReason',
'EmployerChangeReason',
'EmployerChangeReasonOther',
'EmployerChangeSchedule',
'EmployerChangeCurrentSchedule',
'EmployerChangeCurrentScheduleOther',
'EmployerChangeNewSchedule',
'EmployerChangeNewScheduleOther',
'EmployerChangeScheduleDate',
'EmployerChangeSalary',
'EmployerChangeNewSalary',
'EmployerChangeFixedPercent',
'EmployerChangeSalaryDate',
'EmployerChangeJustification',
'TemporaryTransferFromProject',
'TemporaryTransferFromProjectId',
'TemporaryTransferFromWork',
'TemporaryTransferToProject',
'TemporaryTransferToProjectId',
'TemporaryTransferToWork',
'TemporaryTransferStartDate',
'TemporaryTransferEndDate',
'TemporaryTransferReason',
'TemporaryTransferJustification'
)
.expand('DepartmentTransferReceivingHead')()) as Record<string, unknown>;
const receivingHead = item.DepartmentTransferReceivingHead as { EMail?: string } | undefined;
const data: IFormData = { const data: IFormData = {
...EMPTY_DATA, ...EMPTY_DATA,
Employee: employeeFromItem(item), Employee: employeeFromItem(item),
ChangeTypes: changeTypesFromItem(item.ChangeTypes), ChangeTypes: changeTypesFromItem(item.ChangeTypes),
ChangeInitiator: initiatorRoleFromText(text(item.ChangeInitiator)),
SalaryIncreaseCurrent: numberText(item.SalaryIncreaseCurrent), SalaryIncreaseCurrent: numberText(item.SalaryIncreaseCurrent),
SalaryIncreaseNew: numberText(item.SalaryIncreaseNew), SalaryIncreaseNew: numberText(item.SalaryIncreaseNew),
// Дату только через spDateOnlyToIso — иначе из-за часового пояса уезжает на день назад. // Дату только через spDateOnlyToIso — иначе из-за часового пояса уезжает на день назад.
@@ -126,7 +191,69 @@ export async function loadContractChangeRequest(itemId: number, listId?: string)
PositionTransferNewPosition: text(item.PositionTransferNewPosition), PositionTransferNewPosition: text(item.PositionTransferNewPosition),
PositionTransferDate: spDateOnlyToIso(text(item.PositionTransferDate)), PositionTransferDate: spDateOnlyToIso(text(item.PositionTransferDate)),
PositionTransferReason: text(item.PositionTransferReason), PositionTransferReason: text(item.PositionTransferReason),
PositionTransferJustification: text(item.PositionTransferJustification) PositionTransferJustification: text(item.PositionTransferJustification),
DepartmentTransferNewDepartment: text(item.DepartmentTransferNewDepartment),
DepartmentTransferNewDepartmentId: text(item.DepartmentTransferNewDepartmentId),
DepartmentTransferDate: spDateOnlyToIso(text(item.DepartmentTransferDate)),
DepartmentTransferReason: text(item.DepartmentTransferReason),
DepartmentTransferJustification: text(item.DepartmentTransferJustification),
DepartmentTransferReceivingHead: receivingHead?.EMail || '',
WorkplaceChangeCurrentProject: text(item.WorkplaceChangeCurrentProject),
WorkplaceChangeCurrentProjectId: text(item.WorkplaceChangeCurrentProjectId),
WorkplaceChangeNewCompany: text(item.WorkplaceChangeNewCompany),
WorkplaceChangeNewCompanyId: text(item.WorkplaceChangeNewCompanyId),
WorkplaceChangeNewProject: text(item.WorkplaceChangeNewProject),
WorkplaceChangeNewProjectId: text(item.WorkplaceChangeNewProjectId),
WorkplaceChangeAddress: text(item.WorkplaceChangeAddress),
WorkplaceChangeDate: spDateOnlyToIso(text(item.WorkplaceChangeDate)),
WorkplaceChangeReason: text(item.WorkplaceChangeReason),
WorkplaceChangeRelocation: text(item.WorkplaceChangeRelocation),
WorkplaceChangeRotation: item.WorkplaceChangeRotation === true,
WorkplaceChangeSchedule: text(item.WorkplaceChangeSchedule),
WorkplaceChangeScheduleOther: text(item.WorkplaceChangeScheduleOther),
WorkplaceChangeJustification: text(item.WorkplaceChangeJustification),
ScheduleChangeCurrentSchedule: text(item.ScheduleChangeCurrentSchedule),
ScheduleChangeCurrentScheduleOther: text(item.ScheduleChangeCurrentScheduleOther),
ScheduleChangeCurrentHours: text(item.ScheduleChangeCurrentHours),
ScheduleChangeCurrentHoursOther: text(item.ScheduleChangeCurrentHoursOther),
ScheduleChangeDate: spDateOnlyToIso(text(item.ScheduleChangeDate)),
ScheduleChangeHoursPerDay: numberText(item.ScheduleChangeHoursPerDay),
ScheduleChangeDaysPerWeek: numberText(item.ScheduleChangeDaysPerWeek),
ScheduleChangeNewMode: text(item.ScheduleChangeNewMode),
ScheduleChangeNewModeOther: text(item.ScheduleChangeNewModeOther),
ScheduleChangeNewSchedule: text(item.ScheduleChangeNewSchedule),
ScheduleChangeNewScheduleOther: text(item.ScheduleChangeNewScheduleOther),
ScheduleChangeNewHours: text(item.ScheduleChangeNewHours),
ScheduleChangeNewHoursOther: text(item.ScheduleChangeNewHoursOther),
ScheduleChangeReason: text(item.ScheduleChangeReason),
ScheduleChangeJustification: text(item.ScheduleChangeJustification),
FunctionChangeDate: spDateOnlyToIso(text(item.FunctionChangeDate)),
FunctionChangeReason: text(item.FunctionChangeReason),
EmployerChangeReason: text(item.EmployerChangeReason),
EmployerChangeReasonOther: text(item.EmployerChangeReasonOther),
EmployerChangeSchedule: item.EmployerChangeSchedule === true,
EmployerChangeCurrentSchedule: text(item.EmployerChangeCurrentSchedule),
EmployerChangeCurrentScheduleOther: text(item.EmployerChangeCurrentScheduleOther),
EmployerChangeNewSchedule: text(item.EmployerChangeNewSchedule),
EmployerChangeNewScheduleOther: text(item.EmployerChangeNewScheduleOther),
EmployerChangeScheduleDate: spDateOnlyToIso(text(item.EmployerChangeScheduleDate)),
EmployerChangeSalary: item.EmployerChangeSalary === true,
// Суммы частей и переменную долю не читаем — они считаются (splitSalary).
EmployerChangeNewSalary: numberText(item.EmployerChangeNewSalary),
// Оклад не меняли (доли не сохранялись) — доля по умолчанию из ТЗ, 75 %.
EmployerChangeFixedPercent: numberText(item.EmployerChangeFixedPercent) || DEFAULT_FIXED_PERCENT,
EmployerChangeSalaryDate: spDateOnlyToIso(text(item.EmployerChangeSalaryDate)),
EmployerChangeJustification: text(item.EmployerChangeJustification),
TemporaryTransferFromProject: text(item.TemporaryTransferFromProject),
TemporaryTransferFromProjectId: text(item.TemporaryTransferFromProjectId),
TemporaryTransferFromWork: text(item.TemporaryTransferFromWork),
TemporaryTransferToProject: text(item.TemporaryTransferToProject),
TemporaryTransferToProjectId: text(item.TemporaryTransferToProjectId),
TemporaryTransferToWork: text(item.TemporaryTransferToWork),
TemporaryTransferStartDate: spDateOnlyToIso(text(item.TemporaryTransferStartDate)),
TemporaryTransferEndDate: spDateOnlyToIso(text(item.TemporaryTransferEndDate)),
TemporaryTransferReason: text(item.TemporaryTransferReason),
TemporaryTransferJustification: text(item.TemporaryTransferJustification)
}; };
return { return {
@@ -1,8 +1,9 @@
import { WebPartContext } from '@microsoft/sp-webpart-base'; import { WebPartContext } from '@microsoft/sp-webpart-base';
import { RequestTypeId } from '../../models/types'; import { RequestTypeId } from '../../models/types';
import { IEmployeeFullBasePerson, IEmployeeFullBaseRecord } from '../../shared/dictionary'; import { IEmployeeFullBasePerson, IEmployeeFullBaseRecord } from '../../shared/dictionary';
import { ChangeType } from './changeTypes'; import { ChangeType, InitiatorRole, INITIATOR_EMPLOYEE, INITIATOR_EMPLOYER } from './changeTypes';
import { AllowanceUnit, DEFAULT_ALLOWANCE_UNIT } from './byType/Allowance/options'; import { AllowanceUnit, DEFAULT_ALLOWANCE_UNIT } from './byType/Allowance/options';
import { DEFAULT_FIXED_PERCENT } from './byType/EmployerInitiative/salaryParts';
import { IAttachmentEditor } from '../../shared/attachments/useAttachmentEditor'; import { IAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
/** /**
@@ -33,6 +34,12 @@ export interface IFormData {
* задел под несколько, см. changeTypes.ts. [] — не выбран. * задел под несколько, см. changeTypes.ts. [] — не выбран.
*/ */
ChangeTypes: ChangeType[]; ChangeTypes: ChangeType[];
/**
* Инициатор — только у видов, где по ТЗ им может быть и работник
* (hasInitiatorChoice). От роли зависят поля: у работника — текст заявления,
* у работодателя — причины + обоснование. '' — не выбран / у вида выбора нет.
*/
ChangeInitiator: InitiatorRole | '';
// ── Вид «Повышение должностного оклада» (byType/SalaryIncrease). Все необязательные. // ── Вид «Повышение должностного оклада» (byType/SalaryIncrease). Все необязательные.
/** Текущий оклад — «сырые» цифры без пробелов (так отдаёт MoneyInput); '' — не заполнен. */ /** Текущий оклад — «сырые» цифры без пробелов (так отдаёт MoneyInput); '' — не заполнен. */
@@ -74,6 +81,126 @@ export interface IFormData {
PositionTransferReason: string; PositionTransferReason: string;
/** Обоснование; при «Иное» — его текст, у работника — текст заявления. */ /** Обоснование; при «Иное» — его текст, у работника — текст заявления. */
PositionTransferJustification: string; PositionTransferJustification: string;
// ── Вид «Перевод в другое подразделение» (byType/DepartmentTransfer).
// Поля необязательные, кроме принимающего руководителя (при отправке).
/** Новое подразделение (справочник Dictionary/Departments, DepartmentPicker). */
DepartmentTransferNewDepartment: string;
/** Id подразделения из справочника; '' — введено вручную. */
DepartmentTransferNewDepartmentId: string;
/** С какой даты, ISO YYYY-MM-DD. */
DepartmentTransferDate: string;
/** Причина — одно из DEPARTMENT_TRANSFER_REASONS (Choice-колонка); '' — не выбрана. */
DepartmentTransferReason: string;
/** Обоснование; при «Иное» — его текст, у работника — текст заявления. */
DepartmentTransferJustification: string;
/**
* E-mail принимающего руководителя (People). Форма ставит его первым шагом
* маршрута согласования (byType/DepartmentTransfer/receivingHead.ts). '' — не выбран.
*/
DepartmentTransferReceivingHead: string;
// ── Вид «Изменение места работы (другая местность)» (byType/WorkplaceChange).
// Место работы = организация + проект. Текущая организация не хранится отдельно —
// это EmployeeCompany (выбранная в шапке). Обязательна при отправке только новая организация.
/** Текущий проект (справочник /Project, ProjectPicker) и его oid; '' — не выбран / введён вручную. */
WorkplaceChangeCurrentProject: string;
WorkplaceChangeCurrentProjectId: string;
/** Новая организация (справочник Dictionary/Companies): название и Id. */
WorkplaceChangeNewCompany: string;
WorkplaceChangeNewCompanyId: string;
/** Новый проект и его oid. */
WorkplaceChangeNewProject: string;
WorkplaceChangeNewProjectId: string;
/** Адрес нового места работы. */
WorkplaceChangeAddress: string;
/** С какой даты, ISO YYYY-MM-DD. */
WorkplaceChangeDate: string;
/** Причина — одно из WORKPLACE_CHANGE_REASONS (Choice-колонка); '' — не выбрана. */
WorkplaceChangeReason: string;
/** Условия переезда и компенсации (если переезд). */
WorkplaceChangeRelocation: string;
/** Вахта — тогда показывается график работы. */
WorkplaceChangeRotation: boolean;
/** График работы — одно из WORK_SCHEDULE_OPTIONS; при «Другое» текст — в WorkplaceChangeScheduleOther. */
WorkplaceChangeSchedule: string;
WorkplaceChangeScheduleOther: string;
/** Обоснование; при «Иное» — его текст, у работника — текст заявления. */
WorkplaceChangeJustification: string;
// ── Вид «Изменение режима / графика работы» (byType/ScheduleChange). Поля по ролям
// строго по ТЗ (см. ScheduleChange/options.ts). Все необязательные; «Другое» → *Other.
/** Общее: текущий график и время работы (WORK_SCHEDULE_OPTIONS / WORK_HOURS_OPTIONS). */
ScheduleChangeCurrentSchedule: string;
ScheduleChangeCurrentScheduleOther: string;
ScheduleChangeCurrentHours: string;
ScheduleChangeCurrentHoursOther: string;
/** Общее: с какой даты, ISO YYYY-MM-DD. */
ScheduleChangeDate: string;
/** Работник: неполное рабочее время — часов в день и дней в неделю (строка из input type=number). */
ScheduleChangeHoursPerDay: string;
ScheduleChangeDaysPerWeek: string;
/** Работодатель: новый режим (SCHEDULE_CHANGE_NEW_MODES); при «Иной режим» — описание. */
ScheduleChangeNewMode: string;
ScheduleChangeNewModeOther: string;
/** Работодатель: новые график и время работы. */
ScheduleChangeNewSchedule: string;
ScheduleChangeNewScheduleOther: string;
ScheduleChangeNewHours: string;
ScheduleChangeNewHoursOther: string;
/** Работодатель: причина (SCHEDULE_CHANGE_REASONS). */
ScheduleChangeReason: string;
/** Работодатель — обоснование, работник — текст заявления. */
ScheduleChangeJustification: string;
// ── Вид «Изменение трудовой функции» (byType/FunctionChange). Только работодатель.
// Поля необязательные; вложение «Новая должностная инструкция» — обязательно (attachmentSlots).
/** С какой даты, ISO YYYY-MM-DD. */
FunctionChangeDate: string;
/** «В связи с ___ (текст)» из шаблона ТЗ — списка причин нет. */
FunctionChangeReason: string;
// ── Вид «Изменение условий труда по инициативе работодателя» (byType/EmployerInitiative).
// Только работодатель. Что меняется — флажки а) график / б) оклад (можно оба).
/** Причина — EMPLOYER_CHANGE_REASONS; при «Другое» — свой текст. */
EmployerChangeReason: string;
EmployerChangeReasonOther: string;
/** а) меняется график работы. */
EmployerChangeSchedule: boolean;
EmployerChangeCurrentSchedule: string;
EmployerChangeCurrentScheduleOther: string;
EmployerChangeNewSchedule: string;
EmployerChangeNewScheduleOther: string;
/** Не раньше чем через 15 дней (minEmployerChangeDate). ISO YYYY-MM-DD. */
EmployerChangeScheduleDate: string;
/** б) меняется должностной оклад. */
EmployerChangeSalary: boolean;
/**
* Новый оклад — «сырые» цифры (MoneyInput). Суммы частей и доля переменной в
* модели не храним — считаются (splitSalary); в SP пишутся при сохранении.
*/
EmployerChangeNewSalary: string;
/** Доля постоянной части, % (0–100, по умолчанию 75 из ТЗ); переменная — остаток до 100. */
EmployerChangeFixedPercent: string;
EmployerChangeSalaryDate: string;
/** Обоснование текстом — или документом (одно из двух обязательно при отправке). */
EmployerChangeJustification: string;
// ── Вид «Временный перевод» (byType/TemporaryTransfer). Только работодатель, поля необязательные.
/** Откуда: проект (справочник /Project) + его oid, работа — текстом. */
TemporaryTransferFromProject: string;
TemporaryTransferFromProjectId: string;
TemporaryTransferFromWork: string;
/** Куда: так же. */
TemporaryTransferToProject: string;
TemporaryTransferToProjectId: string;
TemporaryTransferToWork: string;
/** Период, ISO YYYY-MM-DD; не дольше 3 месяцев (maxTransferEndDate). */
TemporaryTransferStartDate: string;
TemporaryTransferEndDate: string;
/** Основание — TEMPORARY_TRANSFER_REASONS. */
TemporaryTransferReason: string;
TemporaryTransferJustification: string;
} }
/** Обновление одного поля формы с сохранением типа значения. */ /** Обновление одного поля формы с сохранением типа значения. */
@@ -91,6 +218,7 @@ export interface ISectionProps {
export const EMPTY_DATA: IFormData = { export const EMPTY_DATA: IFormData = {
Employee: undefined, Employee: undefined,
ChangeTypes: [], ChangeTypes: [],
ChangeInitiator: '',
SalaryIncreaseCurrent: '', SalaryIncreaseCurrent: '',
SalaryIncreaseNew: '', SalaryIncreaseNew: '',
SalaryIncreaseDate: '', SalaryIncreaseDate: '',
@@ -106,7 +234,67 @@ export const EMPTY_DATA: IFormData = {
PositionTransferNewPosition: '', PositionTransferNewPosition: '',
PositionTransferDate: '', PositionTransferDate: '',
PositionTransferReason: '', PositionTransferReason: '',
PositionTransferJustification: '' PositionTransferJustification: '',
DepartmentTransferNewDepartment: '',
DepartmentTransferNewDepartmentId: '',
DepartmentTransferDate: '',
DepartmentTransferReason: '',
DepartmentTransferJustification: '',
DepartmentTransferReceivingHead: '',
WorkplaceChangeCurrentProject: '',
WorkplaceChangeCurrentProjectId: '',
WorkplaceChangeNewCompany: '',
WorkplaceChangeNewCompanyId: '',
WorkplaceChangeNewProject: '',
WorkplaceChangeNewProjectId: '',
WorkplaceChangeAddress: '',
WorkplaceChangeDate: '',
WorkplaceChangeReason: '',
WorkplaceChangeRelocation: '',
WorkplaceChangeRotation: false,
WorkplaceChangeSchedule: '',
WorkplaceChangeScheduleOther: '',
WorkplaceChangeJustification: '',
ScheduleChangeCurrentSchedule: '',
ScheduleChangeCurrentScheduleOther: '',
ScheduleChangeCurrentHours: '',
ScheduleChangeCurrentHoursOther: '',
ScheduleChangeDate: '',
ScheduleChangeHoursPerDay: '',
ScheduleChangeDaysPerWeek: '',
ScheduleChangeNewMode: '',
ScheduleChangeNewModeOther: '',
ScheduleChangeNewSchedule: '',
ScheduleChangeNewScheduleOther: '',
ScheduleChangeNewHours: '',
ScheduleChangeNewHoursOther: '',
ScheduleChangeReason: '',
ScheduleChangeJustification: '',
FunctionChangeDate: '',
FunctionChangeReason: '',
EmployerChangeReason: '',
EmployerChangeReasonOther: '',
EmployerChangeSchedule: false,
EmployerChangeCurrentSchedule: '',
EmployerChangeCurrentScheduleOther: '',
EmployerChangeNewSchedule: '',
EmployerChangeNewScheduleOther: '',
EmployerChangeScheduleDate: '',
EmployerChangeSalary: false,
EmployerChangeNewSalary: '',
EmployerChangeFixedPercent: DEFAULT_FIXED_PERCENT,
EmployerChangeSalaryDate: '',
EmployerChangeJustification: '',
TemporaryTransferFromProject: '',
TemporaryTransferFromProjectId: '',
TemporaryTransferFromWork: '',
TemporaryTransferToProject: '',
TemporaryTransferToProjectId: '',
TemporaryTransferToWork: '',
TemporaryTransferStartDate: '',
TemporaryTransferEndDate: '',
TemporaryTransferReason: '',
TemporaryTransferJustification: ''
}; };
/** Плейсхолдер для поля выбора сотрудника. */ /** Плейсхолдер для поля выбора сотрудника. */
@@ -122,12 +310,40 @@ export const CHANGE_TYPE_PLACEHOLDER = '— выберите вид измене
export const ROUTE_BY_APPLICANT_NOTE = export const ROUTE_BY_APPLICANT_NOTE =
'У сотрудника нет учётной записи — маршрут согласования построен по заявителю.'; 'У сотрудника нет учётной записи — маршрут согласования построен по заявителю.';
export const REQUIRED_FIELDS_MESSAGE = 'Заполните обязательные поля: сотрудник, организация, вид изменения.'; export const REQUIRED_FIELDS_MESSAGE = 'Заполните обязательные поля: сотрудник, организация, вид изменения.';
/** Подсказка под полем «Инициатор» — откуда взялось значение по умолчанию. */
export const INITIATOR_HINT =
'Подставляется автоматически: подаёте на себя — «Работник», за другого сотрудника — «Работодатель». Можно изменить.';
export const INITIATOR_REQUIRED_MESSAGE = 'Укажите инициатора изменения: работник или работодатель.';
/** Подпись поля текста у работника — вместо причин и обоснования (шаблон ТЗ «От работника: заявление»). */
export const EMPLOYEE_STATEMENT_LABEL = 'Текст заявления';
export const EMPLOYER_CHANGE_NOTHING_SELECTED_MESSAGE =
'Отметьте, что меняется: график работы и (или) должностной оклад.';
export const EMPLOYER_JUSTIFICATION_REQUIRED_MESSAGE =
'Укажите обоснование текстом или приложите документ-обоснование (приказ о реорганизации и т. п.).';
export const NEW_COMPANY_REQUIRED_MESSAGE = 'Выберите новую организацию — в какой организации будет новое место работы.';
export const RECEIVING_HEAD_REQUIRED_MESSAGE =
'Укажите принимающего руководителя — его согласие входит в маршрут перевода в другое подразделение.';
/** /**
* E-mail, по которому строится маршрут: сотрудника, а если у него нет учётной * E-mail, по которому строится маршрут: сотрудника, а если у него нет учётной
* записи (рабочие на объектах) — заявителя. Берётся от человека, а не от места * записи (рабочие на объектах) — заявителя. Берётся от человека, а не от места
* работы: маршрут от организации не зависит и грузится, пока её ещё выбирают. * работы: маршрут от организации не зависит и грузится, пока её ещё выбирают.
*/ */
/**
* Инициатор по умолчанию (см. INITIATOR_HINT): подаёт на себя — работник, за
* другого — работодатель. Пользователь может поменять.
*/
export function defaultInitiatorRole(
person: IEmployeeFullBasePerson | undefined,
applicantEmail: string
): InitiatorRole {
// Сотрудник ещё не выбран/не подгрузился — новая заявка по умолчанию на себя.
if (!person) return INITIATOR_EMPLOYEE;
// У сотрудника нет учётной записи — это точно не сам заявитель.
if (!person.email) return INITIATOR_EMPLOYER;
return person.email.toLowerCase() === applicantEmail.toLowerCase() ? INITIATOR_EMPLOYEE : INITIATOR_EMPLOYER;
}
export function routeEmail(person: IEmployeeFullBasePerson | undefined, applicantEmail: string): string { export function routeEmail(person: IEmployeeFullBasePerson | undefined, applicantEmail: string): string {
if (!person) return ''; if (!person) return '';
return person.email || applicantEmail; return person.email || applicantEmail;
@@ -20,11 +20,12 @@
"Перевод в другое подразделение", "Перевод в другое подразделение",
"Изменение места работы (другая местность)", "Изменение места работы (другая местность)",
"Продление срока трудового договора", "Продление срока трудового договора",
"Изменение режима / графика работы по соглашению сторон; неполное рабочее время по просьбе работника", "Изменение режима / графика работы по соглашению сторон",
"Изменение трудовой функции (без смены должности)", "Изменение трудовой функции (без смены должности)",
"Изменение условий труда по инициативе работодателя по причинам экономического, технологического характера, изменений в организации и (или) сокращения объёма работ (снижение оклада, сокращение рабочего времени, изменение графика)", "Изменение условий труда по инициативе работодателя",
"Временный перевод при производственной необходимости / простое" "Временный перевод при производственной необходимости / простое"
] }, ] },
{ "internalName": "ChangeInitiator", "title": "Инициатор изменения", "type": "Choice", "choices": ["Работник", "Работодатель"] },
{ "internalName": "SalaryIncreaseCurrent", "title": "Повышение оклада: текущий оклад", "type": "Number", "decimals": 0 }, { "internalName": "SalaryIncreaseCurrent", "title": "Повышение оклада: текущий оклад", "type": "Number", "decimals": 0 },
{ "internalName": "SalaryIncreaseNew", "title": "Повышение оклада: новый оклад", "type": "Number", "decimals": 0 }, { "internalName": "SalaryIncreaseNew", "title": "Повышение оклада: новый оклад", "type": "Number", "decimals": 0 },
@@ -58,6 +59,90 @@
"Открытие нового объекта / проекта, перераспределение функций", "Открытие нового объекта / проекта, перераспределение функций",
"Иное" "Иное"
] }, ] },
{ "internalName": "PositionTransferJustification", "title": "Перевод на должность: обоснование", "type": "Note" } { "internalName": "PositionTransferJustification", "title": "Перевод на должность: обоснование", "type": "Note" },
{ "internalName": "DepartmentTransferNewDepartment", "title": "Перевод в подразделение: новое подразделение", "type": "Text" },
{ "internalName": "DepartmentTransferNewDepartmentId", "title": "Перевод в подразделение: id подразделения", "type": "Text" },
{ "internalName": "DepartmentTransferDate", "title": "Перевод в подразделение: дата перевода", "type": "DateTime", "dateOnly": true },
{ "internalName": "DepartmentTransferReason", "title": "Перевод в подразделение: причина", "type": "Choice", "choices": [
"Усиление подразделения (рост объёма работ / запуск нового объекта)",
"Перераспределение функций между подразделениями",
"Вакансия, соответствующая квалификации работника",
"Иное"
] },
{ "internalName": "DepartmentTransferJustification", "title": "Перевод в подразделение: обоснование", "type": "Note" },
{ "internalName": "DepartmentTransferReceivingHead", "title": "Перевод в подразделение: принимающий руководитель", "type": "User", "userSelectionMode": "PeopleOnly" },
{ "internalName": "WorkplaceChangeCurrentProject", "title": "Место работы: текущий проект", "type": "Text" },
{ "internalName": "WorkplaceChangeCurrentProjectId", "title": "Место работы: id текущего проекта", "type": "Text" },
{ "internalName": "WorkplaceChangeNewCompany", "title": "Место работы: новая организация", "type": "Text" },
{ "internalName": "WorkplaceChangeNewCompanyId", "title": "Место работы: id новой организации", "type": "Text" },
{ "internalName": "WorkplaceChangeNewProject", "title": "Место работы: новый проект", "type": "Text" },
{ "internalName": "WorkplaceChangeNewProjectId", "title": "Место работы: id нового проекта", "type": "Text" },
{ "internalName": "WorkplaceChangeAddress", "title": "Место работы: адрес нового места", "type": "Note" },
{ "internalName": "WorkplaceChangeDate", "title": "Место работы: дата изменения", "type": "DateTime", "dateOnly": true },
{ "internalName": "WorkplaceChangeReason", "title": "Место работы: причина", "type": "Choice", "choices": [
"Открытие нового объекта",
"Переезд подразделения / работодателя в другую местность",
"Иное"
] },
{ "internalName": "WorkplaceChangeRelocation", "title": "Место работы: условия переезда и компенсации", "type": "Note" },
{ "internalName": "WorkplaceChangeRotation", "title": "Место работы: вахта", "type": "Boolean" },
{ "internalName": "WorkplaceChangeSchedule", "title": "Место работы: график работы", "type": "Text" },
{ "internalName": "WorkplaceChangeScheduleOther", "title": "Место работы: график работы (другое)", "type": "Text" },
{ "internalName": "WorkplaceChangeJustification", "title": "Место работы: обоснование", "type": "Note" },
{ "internalName": "ScheduleChangeCurrentSchedule", "title": "Режим работы: текущий график", "type": "Text" },
{ "internalName": "ScheduleChangeCurrentScheduleOther", "title": "Режим работы: текущий график (другое)", "type": "Text" },
{ "internalName": "ScheduleChangeCurrentHours", "title": "Режим работы: текущее время работы", "type": "Text" },
{ "internalName": "ScheduleChangeCurrentHoursOther", "title": "Режим работы: текущее время работы (другое)", "type": "Text" },
{ "internalName": "ScheduleChangeDate", "title": "Режим работы: дата начала", "type": "DateTime", "dateOnly": true },
{ "internalName": "ScheduleChangeHoursPerDay", "title": "Режим работы: неполный рабочий день, часов", "type": "Number", "decimals": 1 },
{ "internalName": "ScheduleChangeDaysPerWeek", "title": "Режим работы: неполная рабочая неделя, дней", "type": "Number", "decimals": 0 },
{ "internalName": "ScheduleChangeNewMode", "title": "Режим работы: новый режим", "type": "Choice", "choices": ["Вахтовый метод", "Сменный график", "Иной режим"] },
{ "internalName": "ScheduleChangeNewModeOther", "title": "Режим работы: иной режим (описание)", "type": "Text" },
{ "internalName": "ScheduleChangeNewSchedule", "title": "Режим работы: новый график", "type": "Text" },
{ "internalName": "ScheduleChangeNewScheduleOther", "title": "Режим работы: новый график (другое)", "type": "Text" },
{ "internalName": "ScheduleChangeNewHours", "title": "Режим работы: новое время работы", "type": "Text" },
{ "internalName": "ScheduleChangeNewHoursOther", "title": "Режим работы: новое время работы (другое)", "type": "Text" },
{ "internalName": "ScheduleChangeReason", "title": "Режим работы: причина", "type": "Choice", "choices": ["Специфика объекта", "Смещение сроков проекта"] },
{ "internalName": "ScheduleChangeJustification", "title": "Режим работы: обоснование / текст заявления", "type": "Note" },
{ "internalName": "FunctionChangeDate", "title": "Трудовая функция: дата изменения", "type": "DateTime", "dateOnly": true },
{ "internalName": "FunctionChangeReason", "title": "Трудовая функция: в связи с", "type": "Note" },
{ "internalName": "EmployerChangeReason", "title": "Инициатива работодателя: причина", "type": "Choice", "choices": [
"Экономические причины",
"Технологические причины",
"Изменения в организации",
"Сокращение объёма работ",
"Другое"
] },
{ "internalName": "EmployerChangeReasonOther", "title": "Инициатива работодателя: причина (другое)", "type": "Text" },
{ "internalName": "EmployerChangeSchedule", "title": "Инициатива работодателя: меняется график", "type": "Boolean" },
{ "internalName": "EmployerChangeCurrentSchedule", "title": "Инициатива работодателя: текущий график", "type": "Text" },
{ "internalName": "EmployerChangeCurrentScheduleOther", "title": "Инициатива работодателя: текущий график (другое)", "type": "Text" },
{ "internalName": "EmployerChangeNewSchedule", "title": "Инициатива работодателя: новый график", "type": "Text" },
{ "internalName": "EmployerChangeNewScheduleOther", "title": "Инициатива работодателя: новый график (другое)", "type": "Text" },
{ "internalName": "EmployerChangeScheduleDate", "title": "Инициатива работодателя: дата изменения графика", "type": "DateTime", "dateOnly": true },
{ "internalName": "EmployerChangeSalary", "title": "Инициатива работодателя: меняется оклад", "type": "Boolean" },
{ "internalName": "EmployerChangeNewSalary", "title": "Инициатива работодателя: новый оклад", "type": "Number", "decimals": 0 },
{ "internalName": "EmployerChangeFixedPercent", "title": "Инициатива работодателя: постоянная часть, %", "type": "Number", "decimals": 2 },
{ "internalName": "EmployerChangeVariablePercent", "title": "Инициатива работодателя: переменная часть, %", "type": "Number", "decimals": 2 },
{ "internalName": "EmployerChangeFixedPart", "title": "Инициатива работодателя: постоянная часть, ₸", "type": "Number", "decimals": 0 },
{ "internalName": "EmployerChangeVariablePart", "title": "Инициатива работодателя: переменная часть, ₸", "type": "Number", "decimals": 0 },
{ "internalName": "EmployerChangeSalaryDate", "title": "Инициатива работодателя: дата изменения оклада", "type": "DateTime", "dateOnly": true },
{ "internalName": "EmployerChangeJustification", "title": "Инициатива работодателя: обоснование", "type": "Note" },
{ "internalName": "TemporaryTransferFromProject", "title": "Временный перевод: откуда — проект", "type": "Text" },
{ "internalName": "TemporaryTransferFromProjectId", "title": "Временный перевод: откуда — id проекта", "type": "Text" },
{ "internalName": "TemporaryTransferFromWork", "title": "Временный перевод: откуда — работа", "type": "Text" },
{ "internalName": "TemporaryTransferToProject", "title": "Временный перевод: куда — проект", "type": "Text" },
{ "internalName": "TemporaryTransferToProjectId", "title": "Временный перевод: куда — id проекта", "type": "Text" },
{ "internalName": "TemporaryTransferToWork", "title": "Временный перевод: куда — работа", "type": "Text" },
{ "internalName": "TemporaryTransferStartDate", "title": "Временный перевод: дата начала", "type": "DateTime", "dateOnly": true },
{ "internalName": "TemporaryTransferEndDate", "title": "Временный перевод: дата окончания", "type": "DateTime", "dateOnly": true },
{ "internalName": "TemporaryTransferReason", "title": "Временный перевод: основание", "type": "Choice", "choices": ["Производственная необходимость", "Простой"] },
{ "internalName": "TemporaryTransferJustification", "title": "Временный перевод: обоснование", "type": "Note" }
] ]
} }
@@ -7,13 +7,21 @@ import {
personFromWorkplace, personFromWorkplace,
workplaceKey workplaceKey
} from '../../../shared/dictionary'; } from '../../../shared/dictionary';
import { ChangeType, CHANGE_TYPE_OPTIONS } from '../changeTypes'; import {
ChangeType,
CHANGE_TYPE_OPTIONS,
INITIATOR_OPTIONS,
InitiatorRole,
hasInitiatorChoice,
initiatorRoleFromText
} from '../changeTypes';
import { import {
ISectionProps, ISectionProps,
EMPLOYEE_PLACEHOLDER, EMPLOYEE_PLACEHOLDER,
WORKPLACE_PLACEHOLDER, WORKPLACE_PLACEHOLDER,
CHANGE_TYPE_PLACEHOLDER, CHANGE_TYPE_PLACEHOLDER,
ROUTE_BY_APPLICANT_NOTE ROUTE_BY_APPLICANT_NOTE,
INITIATOR_HINT
} from '../model'; } from '../model';
import { IContractEmployee } from '../useContractEmployee'; import { IContractEmployee } from '../useContractEmployee';
import styles from './RequestSection.module.scss'; import styles from './RequestSection.module.scss';
@@ -21,6 +29,13 @@ import styles from './RequestSection.module.scss';
interface IRequestSectionProps extends ISectionProps { interface IRequestSectionProps extends ISectionProps {
/** Выбор сотрудника/организации — только на форме подачи; в просмотре не передаётся. */ /** Выбор сотрудника/организации — только на форме подачи; в просмотре не передаётся. */
employeeControl?: IContractEmployee; employeeControl?: IContractEmployee;
/**
* Инициатор по умолчанию при выборе вида с выбором инициатора: подаёт на себя —
* работник, за другого — работодатель (см. ContractChangeForm). В просмотре не нужен.
*/
defaultInitiator?: InitiatorRole;
/** Ручной выбор инициатора (см. useInitiatorAutofill); не задан — просто set. */
onInitiatorChange?: (role: InitiatorRole | '') => void;
} }
const NOOP = (): void => undefined; const NOOP = (): void => undefined;
@@ -30,7 +45,14 @@ const NOOP = (): void => undefined;
* в какой организации (у совместителя их несколько) и что именно меняется. * в какой организации (у совместителя их несколько) и что именно меняется.
* Поля конкретных видов — отдельными секциями (byType/). * Поля конкретных видов — отдельными секциями (byType/).
*/ */
export const RequestSection: React.FC<IRequestSectionProps> = ({ data, set, context, employeeControl }) => { export const RequestSection: React.FC<IRequestSectionProps> = ({
data,
set,
context,
employeeControl,
defaultInitiator,
onInitiatorChange
}) => {
const readOnly = useFormReadOnly(); const readOnly = useFormReadOnly();
// В просмотре человека нет в состоянии — собираем из сохранённого места работы. // В просмотре человека нет в состоянии — собираем из сохранённого места работы.
const person = employeeControl?.person ?? (data.Employee ? personFromWorkplace(data.Employee) : undefined); const person = employeeControl?.person ?? (data.Employee ? personFromWorkplace(data.Employee) : undefined);
@@ -45,7 +67,12 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({ data, set, cont
// встанет MultiChoiceField, модель и SP уже готовы. // встанет MultiChoiceField, модель и SP уже готовы.
const selectedType = data.ChangeTypes[0] || ''; const selectedType = data.ChangeTypes[0] || '';
const handleTypeChange = (value: string): void => { const handleTypeChange = (value: string): void => {
set('ChangeTypes', value ? [value as ChangeType] : []); const types: ChangeType[] = value ? [value as ChangeType] : [];
set('ChangeTypes', types);
// Инициатор есть только у видов с выбором: у остальных очищаем, у этих —
// подставляем по умолчанию, если ещё не выбран.
if (!hasInitiatorChoice(types)) set('ChangeInitiator', '');
else if (!data.ChangeInitiator && defaultInitiator) set('ChangeInitiator', defaultInitiator);
}; };
return ( return (
@@ -82,6 +109,22 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({ data, set, cont
placeholder={CHANGE_TYPE_PLACEHOLDER} placeholder={CHANGE_TYPE_PLACEHOLDER}
/> />
</FormField> </FormField>
{hasInitiatorChoice(data.ChangeTypes) && (
<FormField label="Инициатор" htmlFor="ChangeInitiator" required>
<Select
id="ChangeInitiator"
value={data.ChangeInitiator}
onChange={(v) => {
const role = initiatorRoleFromText(v);
if (onInitiatorChange) onInitiatorChange(role);
else set('ChangeInitiator', role);
}}
options={INITIATOR_OPTIONS}
placeholder="— выберите —"
/>
{!readOnly && <div className={styles.note}>{INITIATOR_HINT}</div>}
</FormField>
)}
</FormGrid> </FormGrid>
); );
}; };
@@ -1,8 +1,10 @@
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval'; import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList'; import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
import { IEmployeeFullBaseRecord } from '../../shared/dictionary'; import { IEmployeeFullBaseRecord } from '../../shared/dictionary';
import { CHANGE_TYPE_LABELS } from './changeTypes'; import { ensureEmployee } from '../../shared/employee';
import { CHANGE_TYPE_LABELS, INITIATOR_EMPLOYEE, hasInitiatorChoice } from './changeTypes';
import { FORM_ID, IFormData } from './model'; import { FORM_ID, IFormData } from './model';
import { splitSalary } from './byType/EmployerInitiative/salaryParts';
/** /**
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над * Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
@@ -41,6 +43,16 @@ function employeeColumns(employee: IEmployeeFullBaseRecord | undefined): Record<
}; };
} }
/**
* Причина из списка — только у работодателя: у работника по ТЗ вместо причин
* текст заявления, и списка причин на форме нет. Иначе в заявке осталась бы
* причина, выбранная до смены инициатора, которую никто не видит.
*/
function employerReason(data: IFormData, reason: string): string | undefined {
if (data.ChangeInitiator === INITIATOR_EMPLOYEE) return undefined;
return reason || undefined;
}
/** «Сырое» число строкой (сумма или процент) → число для Number-колонки; '' или мусор → undefined. */ /** «Сырое» число строкой (сумма или процент) → число для Number-колонки; '' или мусор → undefined. */
function numberColumn(raw: string): number | undefined { function numberColumn(raw: string): number | undefined {
const amount = Number(raw); const amount = Number(raw);
@@ -108,25 +120,185 @@ function positionTransferColumns(data: IFormData): Record<string, unknown> {
return { return {
PositionTransferNewPosition: data.PositionTransferNewPosition.trim() || null, PositionTransferNewPosition: data.PositionTransferNewPosition.trim() || null,
PositionTransferDate: data.PositionTransferDate || null, PositionTransferDate: data.PositionTransferDate || null,
PositionTransferReason: data.PositionTransferReason || null, PositionTransferReason: employerReason(data, data.PositionTransferReason) ?? null,
PositionTransferJustification: data.PositionTransferJustification || null PositionTransferJustification: data.PositionTransferJustification || null
}; };
} }
/**
* Колонки вида «Перевод в другое подразделение»; вид не выбран — очищаются.
* receivingHeadId — Id пользователя сайта для People-колонки (см. saveContractChangeRequest).
*/
function departmentTransferColumns(data: IFormData, receivingHeadId: number | undefined): Record<string, unknown> {
if (!data.ChangeTypes.includes('department-transfer')) {
return {
DepartmentTransferNewDepartment: null,
DepartmentTransferNewDepartmentId: null,
DepartmentTransferDate: null,
DepartmentTransferReason: null,
DepartmentTransferJustification: null,
DepartmentTransferReceivingHeadId: null
};
}
return {
DepartmentTransferNewDepartment: data.DepartmentTransferNewDepartment.trim() || null,
DepartmentTransferNewDepartmentId: data.DepartmentTransferNewDepartmentId || null,
DepartmentTransferDate: data.DepartmentTransferDate || null,
DepartmentTransferReason: employerReason(data, data.DepartmentTransferReason) ?? null,
DepartmentTransferJustification: data.DepartmentTransferJustification || null,
DepartmentTransferReceivingHeadId: receivingHeadId ?? null
};
}
/** Колонки вида «Изменение места работы»; вид не выбран — очищаются, график — только при вахте. */
function workplaceChangeColumns(data: IFormData): Record<string, unknown> {
const isSelected = data.ChangeTypes.includes('workplace-change');
const fields = keepOrClear(isSelected, {
WorkplaceChangeCurrentProject: data.WorkplaceChangeCurrentProject.trim(),
WorkplaceChangeCurrentProjectId: data.WorkplaceChangeCurrentProjectId,
WorkplaceChangeNewCompany: data.WorkplaceChangeNewCompany,
WorkplaceChangeNewCompanyId: data.WorkplaceChangeNewCompanyId,
WorkplaceChangeNewProject: data.WorkplaceChangeNewProject.trim(),
WorkplaceChangeNewProjectId: data.WorkplaceChangeNewProjectId,
WorkplaceChangeAddress: data.WorkplaceChangeAddress.trim(),
WorkplaceChangeDate: data.WorkplaceChangeDate,
WorkplaceChangeReason: employerReason(data, data.WorkplaceChangeReason),
WorkplaceChangeRelocation: data.WorkplaceChangeRelocation,
WorkplaceChangeRotation: data.WorkplaceChangeRotation,
WorkplaceChangeJustification: data.WorkplaceChangeJustification
});
// Без вахты графика нет, даже если он остался в состоянии формы.
const rotationFields = keepOrClear(isSelected && data.WorkplaceChangeRotation, {
WorkplaceChangeSchedule: data.WorkplaceChangeSchedule,
WorkplaceChangeScheduleOther: data.WorkplaceChangeScheduleOther.trim()
});
return { ...fields, ...rotationFields };
}
/**
* Колонки вида «Изменение режима / графика работы». Поля другой роли очищаются:
* у работника нет нового режима и причин, у работодателя — часов/дней неполного времени.
*/
function scheduleChangeColumns(data: IFormData): Record<string, unknown> {
const isSelected = data.ChangeTypes.includes('schedule-change');
const isEmployee = data.ChangeInitiator === INITIATOR_EMPLOYEE;
const common = keepOrClear(isSelected, {
ScheduleChangeCurrentSchedule: data.ScheduleChangeCurrentSchedule,
ScheduleChangeCurrentScheduleOther: data.ScheduleChangeCurrentScheduleOther.trim(),
ScheduleChangeCurrentHours: data.ScheduleChangeCurrentHours,
ScheduleChangeCurrentHoursOther: data.ScheduleChangeCurrentHoursOther.trim(),
ScheduleChangeDate: data.ScheduleChangeDate,
ScheduleChangeJustification: data.ScheduleChangeJustification
});
const employeeFields = keepOrClear(isSelected && isEmployee, {
ScheduleChangeHoursPerDay: numberColumn(data.ScheduleChangeHoursPerDay),
ScheduleChangeDaysPerWeek: numberColumn(data.ScheduleChangeDaysPerWeek)
});
const employerFields = keepOrClear(isSelected && !isEmployee, {
ScheduleChangeNewMode: data.ScheduleChangeNewMode,
ScheduleChangeNewModeOther: data.ScheduleChangeNewModeOther.trim(),
ScheduleChangeNewSchedule: data.ScheduleChangeNewSchedule,
ScheduleChangeNewScheduleOther: data.ScheduleChangeNewScheduleOther.trim(),
ScheduleChangeNewHours: data.ScheduleChangeNewHours,
ScheduleChangeNewHoursOther: data.ScheduleChangeNewHoursOther.trim(),
ScheduleChangeReason: data.ScheduleChangeReason
});
return { ...common, ...employeeFields, ...employerFields };
}
/**
* Группа колонок: keep — значения как есть (пустые '' / undefined → null; false
* остаётся false), иначе все → null, чтобы SharePoint очистил поля, которых при
* текущем виде/роли на форме нет.
*/
function keepOrClear(
keep: boolean,
columns: Record<string, string | number | boolean | undefined>
): Record<string, unknown> {
const result: Record<string, unknown> = {};
Object.keys(columns).forEach((key) => {
const value = columns[key];
const isEmpty = value === undefined || value === '';
result[key] = keep && !isEmpty ? value : null;
});
return result;
}
/**
* Колонки вида «Инициатива работодателя»: общие + группа графика (флажок а) +
* группа оклада (флажок б). Переменная доля и суммы частей считаются здесь же (splitSalary).
*/
function employerInitiativeColumns(data: IFormData): Record<string, unknown> {
const isSelected = data.ChangeTypes.includes('employer-initiative');
const parts = splitSalary(data.EmployerChangeNewSalary, data.EmployerChangeFixedPercent);
const common = keepOrClear(isSelected, {
EmployerChangeReason: data.EmployerChangeReason,
EmployerChangeReasonOther: data.EmployerChangeReasonOther.trim(),
EmployerChangeSchedule: data.EmployerChangeSchedule,
EmployerChangeSalary: data.EmployerChangeSalary,
EmployerChangeJustification: data.EmployerChangeJustification
});
const scheduleFields = keepOrClear(isSelected && data.EmployerChangeSchedule, {
EmployerChangeCurrentSchedule: data.EmployerChangeCurrentSchedule,
EmployerChangeCurrentScheduleOther: data.EmployerChangeCurrentScheduleOther.trim(),
EmployerChangeNewSchedule: data.EmployerChangeNewSchedule,
EmployerChangeNewScheduleOther: data.EmployerChangeNewScheduleOther.trim(),
EmployerChangeScheduleDate: data.EmployerChangeScheduleDate
});
const salaryFields = keepOrClear(isSelected && data.EmployerChangeSalary, {
EmployerChangeNewSalary: numberColumn(data.EmployerChangeNewSalary),
EmployerChangeFixedPercent: numberColumn(data.EmployerChangeFixedPercent),
EmployerChangeVariablePercent: parts?.variablePercent,
EmployerChangeFixedPart: parts?.fixed,
EmployerChangeVariablePart: parts?.variable,
EmployerChangeSalaryDate: data.EmployerChangeSalaryDate
});
return { ...common, ...scheduleFields, ...salaryFields };
}
/** Поля элемента списка данных из модели формы. */ /** Поля элемента списка данных из модели формы. */
function buildItem(data: IFormData, status: string): Record<string, unknown> { function buildItem(data: IFormData, status: string, receivingHeadId: number | undefined): Record<string, unknown> {
return { return {
Title: buildTitle(data.Employee?.fio || ''), Title: buildTitle(data.Employee?.fio || ''),
RequestStatus: status, RequestStatus: status,
...employeeColumns(data.Employee), ...employeeColumns(data.Employee),
// MultiChoice пишется плоским массивом подписей (как «База 1С» в Ticket1C); [] очищает колонку. // MultiChoice пишется плоским массивом подписей (как «База 1С» в Ticket1C); [] очищает колонку.
ChangeTypes: data.ChangeTypes.map((type) => CHANGE_TYPE_LABELS[type]), ChangeTypes: data.ChangeTypes.map((type) => CHANGE_TYPE_LABELS[type]),
ChangeInitiator: hasInitiatorChoice(data.ChangeTypes) ? data.ChangeInitiator || null : null,
...salaryIncreaseColumns(data), ...salaryIncreaseColumns(data),
...allowanceColumns(data), ...allowanceColumns(data),
...positionTransferColumns(data) ...positionTransferColumns(data),
...departmentTransferColumns(data, receivingHeadId),
...workplaceChangeColumns(data),
...scheduleChangeColumns(data),
...keepOrClear(data.ChangeTypes.includes('function-change'), {
FunctionChangeDate: data.FunctionChangeDate,
FunctionChangeReason: data.FunctionChangeReason
}),
...employerInitiativeColumns(data),
...keepOrClear(data.ChangeTypes.includes('temporary-transfer'), {
TemporaryTransferFromProject: data.TemporaryTransferFromProject.trim(),
TemporaryTransferFromProjectId: data.TemporaryTransferFromProjectId,
TemporaryTransferFromWork: data.TemporaryTransferFromWork.trim(),
TemporaryTransferToProject: data.TemporaryTransferToProject.trim(),
TemporaryTransferToProjectId: data.TemporaryTransferToProjectId,
TemporaryTransferToWork: data.TemporaryTransferToWork.trim(),
TemporaryTransferStartDate: data.TemporaryTransferStartDate,
TemporaryTransferEndDate: data.TemporaryTransferEndDate,
TemporaryTransferReason: data.TemporaryTransferReason,
TemporaryTransferJustification: data.TemporaryTransferJustification
})
}; };
} }
/** People-колонка пишется Id пользователя сайта: e-mail → ensureUser. Не выбран — undefined. */
async function receivingHeadUserId(data: IFormData): Promise<number | undefined> {
if (!data.ChangeTypes.includes('department-transfer') || !data.DepartmentTransferReceivingHead) return undefined;
const user = await ensureEmployee(data.DepartmentTransferReceivingHead);
return user.Id;
}
export type ISaveResult = ISaveItemResult; export type ISaveResult = ISaveItemResult;
/** /**
@@ -146,7 +318,7 @@ export async function saveContractChangeRequest(
status: string, status: string,
listId?: string listId?: string
): Promise<ISaveResult> { ): Promise<ISaveResult> {
const item = buildItem(data, status); const item = buildItem(data, status, await receivingHeadUserId(data));
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item); const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
await writeRoute(contractChangeApprovalService(listId), result.id, approvers, executors, status); await writeRoute(contractChangeApprovalService(listId), result.id, approvers, executors, status);
return result; return result;
@@ -0,0 +1,91 @@
import { useCallback } from 'react';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import {
ApprovalListService,
IApproverToWrite,
IMatrixRow,
IUseMatrixEditor,
getPlannedMatrix,
isOpenedRouteReady,
loadRoutePhase,
useFormExecutors,
useMatrixEditor
} from '../../shared/approval';
import { withReceivingHead } from './byType/DepartmentTransfer/receivingHead';
export interface IContractRouteSource {
context: WebPartContext;
approval: ApprovalListService;
requestTypeId: number | undefined;
/** По кому строится маршрут: сотрудник, без учётки — заявитель (см. routeEmail). */
routeUserEmail: string;
editId: number | undefined;
requestStatus: string | undefined;
readOnly: boolean;
/**
* Принимающий руководитель (перевод в другое подразделение) — ставится первым
* шагом согласования, см. receivingHead.ts. '' — шага нет (другой вид / не выбран).
*/
receivingHeadEmail: string;
}
export interface IContractRoute {
matrix: IUseMatrixEditor;
executors: IUseMatrixEditor;
}
/**
* Маршрут формы: согласование + исполнение. Новая заявка — планируемый маршрут
* из админки, открытая — уже записанные строки (loadRoutePhase). Пока маршрут
* правится, в него вставляется принимающий руководитель — approval-route строит
* маршрут по сотруднику и найти руководителя нового подразделения не может.
*/
export function useContractRoute(source: IContractRouteSource): IContractRoute {
const { context, approval, requestTypeId, routeUserEmail, editId, requestStatus, readOnly, receivingHeadEmail } =
source;
const loadMatrix = useCallback(async (): Promise<IMatrixRow[]> => {
const rows = await loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
getPlannedMatrix(context, requestTypeId as number, routeUserEmail, approval, 'Approval')
);
if (readOnly) return rows;
const head = receivingHeadEmail
? { email: receivingHeadEmail, name: await approval.resolveUserName(receivingHeadEmail) }
: undefined;
return withReceivingHead(rows, head);
}, [context, approval, editId, requestStatus, routeUserEmail, requestTypeId, readOnly, receivingHeadEmail]);
const matrix = useMatrixEditor(
loadMatrix,
// Смена принимающего руководителя пересобирает маршрут, как и смена сотрудника.
`${routeUserEmail}-${editId ?? 'new'}-${receivingHeadEmail}`,
!readOnly,
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!routeUserEmail && !!requestTypeId)),
(login) => approval.getJobTitle(login)
);
const executors = useFormExecutors({
readOnly,
context,
approval,
requestTypeId,
userEmail: routeUserEmail,
editId,
requestStatus
});
return { matrix, executors };
}
/** Строки матрицы → согласующие для записи в список (SLA и прочее — транзитом из админки). */
export function matrixToApprovers(rows: IMatrixRow[]): IApproverToWrite[] {
return rows.map((r) => ({
email: r.email,
position: r.position,
allowReassign: r.allowReassign,
order: r.order,
slaHours: r.slaHours,
askOverdueReason: r.askOverdueReason,
autoApprove: r.autoApprove
}));
}
@@ -0,0 +1,52 @@
import { useRef } from 'react';
import { IEmployeeFullBasePerson } from '../../shared/dictionary';
import { InitiatorRole, hasInitiatorChoice } from './changeTypes';
import { IFormData, SetField, defaultInitiatorRole } from './model';
import { IContractEmployee } from './useContractEmployee';
/** Пропсы шапки (RequestSection), связанные с сотрудником и инициатором. */
export interface IInitiatorAutofill {
/** Тот же выбор сотрудника, но со сменой инициатора вслед за сотрудником. */
employeeControl: IContractEmployee;
/** Инициатор по умолчанию при выборе вида — для текущего сотрудника. */
defaultInitiator: InitiatorRole;
/** Ручной выбор инициатора — после него автоподстановка больше не вмешивается. */
onInitiatorChange: (role: InitiatorRole | '') => void;
}
/**
* Инициатор следует за сотрудником, пока его не выбрали руками: сменили
* сотрудника на себя — «Работник», на другого — «Работодатель» (см.
* defaultInitiatorRole). Без этого инициатор, подставленный при выборе вида,
* оставался бы прежним после смены сотрудника.
*
* У открытой заявки (isEdit) инициатор уже сохранён — считаем его выбранным
* руками и не перебиваем.
*/
export function useInitiatorAutofill(
isEdit: boolean,
data: IFormData,
set: SetField,
applicantEmail: string,
employeeControl: IContractEmployee
): IInitiatorAutofill {
const isChosenByHandRef = useRef(isEdit);
const onPersonChange = (person: IEmployeeFullBasePerson | undefined): void => {
employeeControl.onPersonChange(person);
if (!isChosenByHandRef.current && hasInitiatorChoice(data.ChangeTypes)) {
set('ChangeInitiator', defaultInitiatorRole(person, applicantEmail));
}
};
const onInitiatorChange = (role: InitiatorRole | ''): void => {
isChosenByHandRef.current = true;
set('ChangeInitiator', role);
};
return {
employeeControl: { ...employeeControl, onPersonChange },
defaultInitiator: defaultInitiatorRole(employeeControl.person, applicantEmail),
onInitiatorChange
};
}
@@ -10,7 +10,8 @@ import {
RequestNumber, RequestNumber,
SubmitActions, SubmitActions,
getDraftDeleteHandler, getDraftDeleteHandler,
useRequestSubmit useRequestSubmit,
FormNotice
} from '../../shared/ui'; } from '../../shared/ui';
import { import {
composeRemoveRowRule, composeRemoveRowRule,
@@ -44,7 +45,16 @@ import {
dataListTitle, dataListTitle,
dataListTitleForListId dataListTitleForListId
} from '../../shared/listTitles'; } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField, IOrganizationRecord, primaryPosition } from './model'; import {
FORM_ID,
IFormData,
EMPTY_DATA,
SetField,
IOrganizationRecord,
primaryPosition,
NOTICE_TITLE,
NOTICE_ITEMS
} from './model';
import { saveDismissalRequest, dismissalApprovalService } from './submit'; import { saveDismissalRequest, dismissalApprovalService } from './submit';
import { loadDismissalRequest } from './load'; import { loadDismissalRequest } from './load';
import { RequestSection } from './sections/RequestSection'; import { RequestSection } from './sections/RequestSection';
@@ -374,6 +384,8 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
)} )}
{submit.error && <Alert variant="err">{submit.error}</Alert>} {submit.error && <Alert variant="err">{submit.error}</Alert>}
<FormNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
<FormModeProvider readOnly={readOnly}> <FormModeProvider readOnly={readOnly}>
<RequestSection {...sectionProps} /> <RequestSection {...sectionProps} />
</FormModeProvider> </FormModeProvider>
@@ -2,7 +2,7 @@ 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 './DismissalView.module.scss'; import styles from './DismissalView.module.scss';
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton } from '../../shared/ui'; import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton, FormNotice, buildPrintTitle } from '../../shared/ui';
import { import {
ApproverService, ApproverService,
DecisionPanel, DecisionPanel,
@@ -28,7 +28,7 @@ import {
dataListTitleForListId dataListTitleForListId
} from '../../shared/listTitles'; } from '../../shared/listTitles';
import { parseRoute } from '../../shared/routing'; import { parseRoute } from '../../shared/routing';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField, NOTICE_TITLE, NOTICE_ITEMS } from './model';
import { loadDismissalRequest } from './load'; import { loadDismissalRequest } from './load';
import { dismissalApprovalService, updateAssetsAcceptor } from './submit'; import { dismissalApprovalService, updateAssetsAcceptor } from './submit';
import { loadClearanceRequest } from '../Clearance/load'; import { loadClearanceRequest } from '../Clearance/load';
@@ -58,6 +58,8 @@ interface IDismissalViewProps {
onCodeLoaded?: (code: string) => void; onCodeLoaded?: (code: string) => void;
} }
const FORM_TITLE = 'Заявление на увольнение';
// Заявление на увольнение только для просмотра (режим согласующего). // Заявление на увольнение только для просмотра (режим согласующего).
// Переиспользует ту же секцию, что и форма подачи; редактирование выключено через // Переиспользует ту же секцию, что и форма подачи; редактирование выключено через
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми. // <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
@@ -70,6 +72,8 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
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 [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
// ФИО увольняющегося — для имени печатного документа.
const [employeeName, setEmployeeName] = useState<string>('');
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);
// Итог автосоздания обходного листа после согласования (см. onDecided ниже). // Итог автосоздания обходного листа после согласования (см. onDecided ниже).
@@ -107,6 +111,7 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
setData(loaded.data); setData(loaded.data);
setStatus(loaded.status); setStatus(loaded.status);
setCode(loaded.code); setCode(loaded.code);
setEmployeeName(loaded.employeeName);
notifyCode(loaded.code); notifyCode(loaded.code);
setError(null); setError(null);
setLoading(false); setLoading(false);
@@ -265,7 +270,9 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
<div> <div>
<div className={`${styles.toolbar} rp-no-print`}> <div className={`${styles.toolbar} rp-no-print`}>
{printGate === 'allowed' ? ( {printGate === 'allowed' ? (
<PrintButton /> <PrintButton
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
/>
) : ( ) : (
<span className={styles.printBlocked} title="Печать откроется после согласования обходного листа по этому увольнению"> <span className={styles.printBlocked} title="Печать откроется после согласования обходного листа по этому увольнению">
Печать станет доступна после согласования обходного листа Печать станет доступна после согласования обходного листа
@@ -275,7 +282,7 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
<div className="rp-print-root"> <div className="rp-print-root">
<FormCard <FormCard
title="Заявление на увольнение" title={FORM_TITLE}
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={ belowSubtitle={
<InitiatorLine <InitiatorLine
@@ -285,6 +292,7 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
} }
headerRight={status ? <StatusBadge status={status} /> : undefined} headerRight={status ? <StatusBadge status={status} /> : undefined}
> >
<FormNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
<FormModeProvider readOnly> <FormModeProvider readOnly>
<RequestSection {...sectionProps} /> <RequestSection {...sectionProps} />
</FormModeProvider> </FormModeProvider>
@@ -61,6 +61,8 @@ RequestTypeId» в `docs/forms.md`). Тип заведён в админке app
- **Комментарий** (`Comment`) — необязательный свободный текст. Причины увольнения - **Комментарий** (`Comment`) — необязательный свободный текст. Причины увольнения
отдельного поля нет: в шаблоне формулировка фиксированная («по собственному желанию»). отдельного поля нет: в шаблоне формулировка фиксированная («по собственному желанию»).
- **Заголовок элемента** (`Title`): `Заявление на увольнение — {ФИО} — дд.мм.гггг`. - **Заголовок элемента** (`Title`): `Заявление на увольнение — {ФИО} — дд.мм.гггг`.
- **Примечание** (с 10-29, `FormNotice`, тексты `NOTICE_TITLE`/`NOTICE_ITEMS` в `model.ts`) — плашка
вверху формы и просмотра про срок уведомления по ст. 56 ТК РК. Текст от HR, дословно; на печать не идёт.
## Совместители: организации таблицей, удаление синхронизировано с маршрутом ## Совместители: организации таблицей, удаление синхронизировано с маршрутом
Сотрудник может числиться сразу в НЕСКОЛЬКИХ компаниях (совместительство). Сотрудник может числиться сразу в НЕСКОЛЬКИХ компаниях (совместительство).
@@ -149,3 +149,18 @@ export const EMPTY_DATA: IFormData = {
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…'; export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
/** Заявление нужно подавать не позднее чем за столько месяцев до последнего рабочего дня. */ /** Заявление нужно подавать не позднее чем за столько месяцев до последнего рабочего дня. */
export const MIN_NOTICE_MONTHS = 1; export const MIN_NOTICE_MONTHS = 1;
/**
* Примечание вверху формы и просмотра (см. shared/ui/FormNotice). Текст — от HR,
* дословно. «Не менее чем за один месяц» — это MIN_NOTICE_MONTHS: поменяли срок —
* поправьте и текст.
*/
export const NOTICE_TITLE = 'Примечание';
export const NOTICE_ITEMS: string[] = [
'Срок уведомления. Согласно п. 1 ст. 56 Трудового кодекса РК, работник вправе по своей инициативе ' +
'расторгнуть трудовой договор, уведомив работодателя письменно не менее чем за один месяц, за исключением ' +
'случаев, предусмотренных п. 3 этой статьи. Если трудовым договором установлен более длительный срок ' +
'уведомления, применяется срок из трудового договора.',
'Увольнение до истечения срока уведомления. Дата увольнения раньше чем через месяц с даты подачи заявления ' +
'возможна только по согласованию с работодателем, либо в случаях, предусмотренных п. 3 ст. 56 ТК РК.'
];
@@ -2,7 +2,15 @@ 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 './EmployeeTransferView.module.scss'; import styles from './EmployeeTransferView.module.scss';
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui'; import {
Alert,
FormCard,
InitiatorLine,
FormModeProvider,
PrintSplitButton,
OrgLetterhead,
buildPrintTitle
} from '../../shared/ui';
import { import {
ApproverService, ApproverService,
DecisionPanel, DecisionPanel,
@@ -41,6 +49,8 @@ interface IEmployeeTransferViewProps {
onCodeLoaded?: (code: string) => void; onCodeLoaded?: (code: string) => void;
} }
const FORM_TITLE = 'Заявка на перемещение сотрудника';
// В режиме чтения поля не редактируются, поэтому обновление — пустышка. // В режиме чтения поля не редактируются, поэтому обновление — пустышка.
const NOOP_SET: SetField = () => undefined; const NOOP_SET: SetField = () => undefined;
@@ -56,6 +66,8 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
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 [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
// ФИО перемещаемого — для имени печатного документа.
const [employeeName, setEmployeeName] = useState<string>('');
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);
// Какая из организаций сотрудника (см. OrganizationField) идёт в печать — // Какая из организаций сотрудника (см. OrganizationField) идёт в печать —
@@ -89,6 +101,7 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
setData(loaded.data); setData(loaded.data);
setStatus(loaded.status); setStatus(loaded.status);
setCode(loaded.code); setCode(loaded.code);
setEmployeeName(loaded.employeeName);
notifyCode(loaded.code); notifyCode(loaded.code);
setError(null); setError(null);
setLoading(false); setLoading(false);
@@ -136,13 +149,18 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
return ( return (
<div> <div>
<div className={`${styles.toolbar} rp-no-print`}> <div className={`${styles.toolbar} rp-no-print`}>
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} /> <PrintSplitButton
value={data.Organization}
selectedIndex={printOrgIndex}
onChange={setPrintOrgIndex}
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
/>
</div> </div>
<div className="rp-print-root"> <div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} /> <OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard <FormCard
title="Заявка на перемещение сотрудника" title={FORM_TITLE}
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={ belowSubtitle={
<InitiatorLine <InitiatorLine
@@ -12,7 +12,8 @@ import {
PrintButton, PrintButton,
FieldChangesProvider, FieldChangesProvider,
FieldChangesBanner, FieldChangesBanner,
issueLabels issueLabels,
buildPrintTitle
} from '../../shared/ui'; } from '../../shared/ui';
import { IFieldChange, mergeFieldChanges, toChangeMap } from '../../shared/fieldChanges/fieldChanges'; import { IFieldChange, mergeFieldChanges, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
import { HIRE_CHANGE_TRACKING } from './changeTracking'; import { HIRE_CHANGE_TRACKING } from './changeTracking';
@@ -87,6 +88,7 @@ const HIRE_DATE_REQUIRED_MESSAGE = 'укажите дату приёма (п. 2.
const PAYROLL_HINT = 'Как согласующий вы можете указать оклады (п. 4.2, 4.3) и отдел/участок (п. 2.4).'; const PAYROLL_HINT = 'Как согласующий вы можете указать оклады (п. 4.2, 4.3) и отдел/участок (п. 2.4).';
const HIRE_DATE_HINT = 'Как исполнитель укажите дату приёма (п. 2.2) — она обязательна для отметки «Выполнено».'; const HIRE_DATE_HINT = 'Как исполнитель укажите дату приёма (п. 2.2) — она обязательна для отметки «Выполнено».';
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.'; const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.';
const FORM_TITLE = 'Заявка на прием';
// Заявка «на прием» только для просмотра (режим согласующего). Переиспользует // Заявка «на прием» только для просмотра (режим согласующего). Переиспользует
// те же секции, что и форма подачи; редактирование выключено через // те же секции, что и форма подачи; редактирование выключено через
@@ -382,7 +384,11 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
<Icon name="copy" size="sm" /> <Icon name="copy" size="sm" />
Организация рабочего места на основе этой Организация рабочего места на основе этой
</Button> </Button>
{isApprovedStatus(status) && <PrintButton />} {isApprovedStatus(status) && (
<PrintButton
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.HireCandidateName, code, itemId })}
/>
)}
</div> </div>
{hasEditableFields && ( {hasEditableFields && (
@@ -400,7 +406,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
<div className="rp-print-root"> <div className="rp-print-root">
<FormCard <FormCard
title="Заявка на прием" title={FORM_TITLE}
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={ belowSubtitle={
<InitiatorLine <InitiatorLine
@@ -452,6 +458,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
{!executors.loading && (executors.rows.length > 0 || canAddExecutor) && ( {!executors.loading && (executors.rows.length > 0 || canAddExecutor) && (
<ApprovalMatrix <ApprovalMatrix
editor={executors} editor={executors}
printable={false}
search={(q) => approval.searchUsers(q)} search={(q) => approval.searchUsers(q)}
title="Исполнение" title="Исполнение"
roleLabel="Исполнитель" roleLabel="Исполнитель"
@@ -10,7 +10,8 @@ import {
FormModeProvider, FormModeProvider,
PrintButton, PrintButton,
FieldChangesProvider, FieldChangesProvider,
FieldChangesBanner FieldChangesBanner,
buildPrintTitle
} from '../../shared/ui'; } from '../../shared/ui';
import { IFieldChange, mergeFieldChanges, toChangeMap } from '../../shared/fieldChanges/fieldChanges'; import { IFieldChange, mergeFieldChanges, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
import { SELECTION_CHANGE_TRACKING } from './changeTracking'; import { SELECTION_CHANGE_TRACKING } from './changeTracking';
@@ -80,6 +81,7 @@ interface IRecruitingSelectionViewProps {
type SaveState = 'idle' | 'saving' | 'saved' | 'error'; type SaveState = 'idle' | 'saving' | 'saved' | 'error';
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.'; const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.';
const FORM_TITLE = 'Заявка на подбор';
// Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же // Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же
// секции, что и форма подачи; редактирование выключено через <FormModeProvider readOnly>, // секции, что и форма подачи; редактирование выключено через <FormModeProvider readOnly>,
@@ -366,7 +368,10 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
return ( return (
<div> <div>
<div className={`${styles.toolbar} rp-no-print`}> <div className={`${styles.toolbar} rp-no-print`}>
{isApprovedStatus(status) && <PrintButton />} {/* Подбор — на вакансию, конкретного человека ещё нет: имя файла без ФИО. */}
{isApprovedStatus(status) && (
<PrintButton documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: '', code, itemId })} />
)}
</div> </div>
{fullEditable && ( {fullEditable && (
@@ -384,7 +389,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
<div className="rp-print-root"> <div className="rp-print-root">
<FormCard <FormCard
title="Заявка на подбор" title={FORM_TITLE}
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={ belowSubtitle={
<InitiatorLine <InitiatorLine
@@ -438,6 +443,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
{!executors.loading && (executors.rows.length > 0 || canAddExecutor) && ( {!executors.loading && (executors.rows.length > 0 || canAddExecutor) && (
<ApprovalMatrix <ApprovalMatrix
editor={executors} editor={executors}
printable={false}
search={(q) => approval.searchUsers(q)} search={(q) => approval.searchUsers(q)}
title="Исполнение" title="Исполнение"
roleLabel="Исполнитель" roleLabel="Исполнитель"
@@ -11,7 +11,8 @@ import {
FormField, FormField,
AttachmentList, AttachmentList,
PrintSplitButton, PrintSplitButton,
OrgLetterhead OrgLetterhead,
buildPrintTitle
} from '../../shared/ui'; } from '../../shared/ui';
import { import {
ApproverService, ApproverService,
@@ -60,6 +61,8 @@ interface IReferralViewProps {
onCodeLoaded?: (code: string) => void; onCodeLoaded?: (code: string) => void;
} }
const FORM_TITLE = 'Реферальная рекомендация';
// В режиме чтения поля не редактируются, поэтому обновление — пустышка. // В режиме чтения поля не редактируются, поэтому обновление — пустышка.
const NOOP_SET: SetField = () => undefined; const NOOP_SET: SetField = () => undefined;
@@ -189,14 +192,19 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
<div> <div>
<div className={`${styles.toolbar} rp-no-print`}> <div className={`${styles.toolbar} rp-no-print`}>
{isApprovedStatus(status) && ( {isApprovedStatus(status) && (
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} /> <PrintSplitButton
value={data.Organization}
selectedIndex={printOrgIndex}
onChange={setPrintOrgIndex}
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.CandidateName, code, itemId })}
/>
)} )}
</div> </div>
<div className="rp-print-root"> <div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} /> <OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard <FormCard
title="Реферальная рекомендация" title={FORM_TITLE}
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={ belowSubtitle={
<InitiatorLine <InitiatorLine
@@ -234,6 +242,7 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
{!executors.loading && executors.rows.length > 0 && ( {!executors.loading && executors.rows.length > 0 && (
<ApprovalMatrix <ApprovalMatrix
editor={executors} editor={executors}
printable={false}
search={(q) => approval.searchUsers(q)} search={(q) => approval.searchUsers(q)}
title="Исполнение" title="Исполнение"
roleLabel="Исполнитель" roleLabel="Исполнитель"
@@ -10,7 +10,8 @@ import {
FormModeProvider, FormModeProvider,
PrintSplitButton, PrintSplitButton,
OrgLetterhead, OrgLetterhead,
Icon Icon,
buildPrintTitle
} from '../../shared/ui'; } from '../../shared/ui';
import { navigate } from '../../shared/routing'; import { navigate } from '../../shared/routing';
import { import {
@@ -57,6 +58,8 @@ interface IServiceRequestViewProps {
onCodeLoaded?: (code: string) => void; onCodeLoaded?: (code: string) => void;
} }
const FORM_TITLE = 'Заявка на услуги';
// Заявка «Заявка на услуги» только для просмотра (режим согласующего). // Заявка «Заявка на услуги» только для просмотра (режим согласующего).
const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => { const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
// onCodeLoaded — колбэк родителя, его ссылка меняется на каждом рендере; в зависимостях эффекта // onCodeLoaded — колбэк родителя, его ссылка меняется на каждом рендере; в зависимостях эффекта
@@ -166,13 +169,18 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
Редактировать Редактировать
</Button> </Button>
)} )}
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} /> <PrintSplitButton
value={data.Organization}
selectedIndex={printOrgIndex}
onChange={setPrintOrgIndex}
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.RequesterName, code, itemId })}
/>
</div> </div>
<div className="rp-print-root"> <div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} /> <OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard <FormCard
title="Заявка на услуги" title={FORM_TITLE}
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={ belowSubtitle={
<InitiatorLine <InitiatorLine
@@ -2,7 +2,15 @@ import * as React from 'react';
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base'; import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './SkudExplanationView.module.scss'; import styles from './SkudExplanationView.module.scss';
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui'; import {
Alert,
FormCard,
InitiatorLine,
FormModeProvider,
PrintSplitButton,
OrgLetterhead,
buildPrintTitle
} from '../../shared/ui';
import { import {
ApproverService, ApproverService,
DecisionPanel, DecisionPanel,
@@ -130,7 +138,12 @@ const SkudExplanationView: React.FC<ISkudExplanationViewProps> = ({ context, ite
return ( return (
<div> <div>
<div className={`${styles.toolbar} rp-no-print`}> <div className={`${styles.toolbar} rp-no-print`}>
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} /> <PrintSplitButton
value={data.Organization}
selectedIndex={printOrgIndex}
onChange={setPrintOrgIndex}
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.EmployeeFio, code, itemId })}
/>
</div> </div>
<FormCard <FormCard
@@ -11,7 +11,8 @@ interface ISkudRouteTablesProps {
/** /**
* Маршрут согласования + исполнение (если исполнители есть). Рисуется дважды: * Маршрут согласования + исполнение (если исполнители есть). Рисуется дважды:
* на экране под карточкой и внутри печатного блока под запиской — на печати * на экране под карточкой и внутри печатного блока под запиской — на печати
* таблица маршрута заменяет подпись сотрудника. * таблица маршрута заменяет подпись сотрудника. Исполнение на печать не идёт
* (printable={false}), как во всех формах, — только согласование.
*/ */
export const SkudRouteTables: React.FC<ISkudRouteTablesProps> = ({ approvers, executors, search }) => { export const SkudRouteTables: React.FC<ISkudRouteTablesProps> = ({ approvers, executors, search }) => {
const hasExecutors = !executors.loading && executors.rows.length > 0; const hasExecutors = !executors.loading && executors.rows.length > 0;
@@ -26,6 +27,7 @@ export const SkudRouteTables: React.FC<ISkudRouteTablesProps> = ({ approvers, ex
title="Исполнение" title="Исполнение"
roleLabel="Исполнитель" roleLabel="Исполнитель"
emptyText="Исполнители не назначены." emptyText="Исполнители не назначены."
printable={false}
/> />
)} )}
</> </>
@@ -12,7 +12,8 @@ import {
FormField, FormField,
AttachmentList, AttachmentList,
PrintButton, PrintButton,
Icon Icon,
buildPrintTitle
} from '../../shared/ui'; } from '../../shared/ui';
import { navigate } from '../../shared/routing'; import { navigate } from '../../shared/routing';
import { import {
@@ -56,6 +57,8 @@ interface ITicket1CViewProps {
onCodeLoaded?: (code: string) => void; onCodeLoaded?: (code: string) => void;
} }
const FORM_TITLE = 'Заявка в отдел 1С';
// Заявка в отдел 1С только для просмотра (режим согласующего). // Заявка в отдел 1С только для просмотра (режим согласующего).
const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => { const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка // onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
@@ -185,12 +188,12 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
Редактировать Редактировать
</Button> </Button>
)} )}
<PrintButton /> <PrintButton documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.FIO, code, itemId })} />
</div> </div>
<div className="rp-print-root"> <div className="rp-print-root">
<FormCard <FormCard
title="Заявка в отдел 1С" title={FORM_TITLE}
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={ belowSubtitle={
<InitiatorLine <InitiatorLine
@@ -224,6 +227,7 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
{!executors.loading && executors.rows.length > 0 && ( {!executors.loading && executors.rows.length > 0 && (
<ApprovalMatrix <ApprovalMatrix
editor={executors} editor={executors}
printable={false}
search={(q) => approval.searchUsers(q)} search={(q) => approval.searchUsers(q)}
title="Исполнение" title="Исполнение"
roleLabel="Исполнитель" roleLabel="Исполнитель"
@@ -13,7 +13,8 @@ import {
AttachmentList, AttachmentList,
PrintSplitButton, PrintSplitButton,
OrgLetterhead, OrgLetterhead,
Icon Icon,
buildPrintTitle
} from '../../shared/ui'; } from '../../shared/ui';
import { navigate } from '../../shared/routing'; import { navigate } from '../../shared/routing';
import { import {
@@ -55,6 +56,8 @@ interface ITmcTransferViewProps {
onCodeLoaded?: (code: string) => void; onCodeLoaded?: (code: string) => void;
} }
const FORM_TITLE = 'Служебная записка на перемещение ТМЦ';
// Заявка «СЗ на перемещение ТМЦ» только для просмотра (режим согласующего). // Заявка «СЗ на перемещение ТМЦ» только для просмотра (режим согласующего).
const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => { const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка // onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
@@ -169,13 +172,18 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
Редактировать Редактировать
</Button> </Button>
)} )}
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} /> <PrintSplitButton
value={data.Organization}
selectedIndex={printOrgIndex}
onChange={setPrintOrgIndex}
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.AuthorName, code, itemId })}
/>
</div> </div>
<div className="rp-print-root"> <div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} /> <OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard <FormCard
title="Служебная записка на перемещение ТМЦ" title={FORM_TITLE}
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={ belowSubtitle={
<InitiatorLine <InitiatorLine
@@ -204,6 +212,7 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
{!executors.loading && executors.rows.length > 0 && ( {!executors.loading && executors.rows.length > 0 && (
<ApprovalMatrix <ApprovalMatrix
editor={executors} editor={executors}
printable={false}
search={(q) => approval.searchUsers(q)} search={(q) => approval.searchUsers(q)}
title="Исполнение" title="Исполнение"
roleLabel="Исполнитель" roleLabel="Исполнитель"
@@ -33,6 +33,11 @@
(черновик проверяется так же строго, как отправка). (черновик проверяется так же строго, как отправка).
- **Замещающий** (`Substitute`, People) — необязателен. - **Замещающий** (`Substitute`, People) — необязателен.
- **Заголовок элемента** (`Title`): `Отпуск без содержания — {ФИО} — дд.мм.гггг`. - **Заголовок элемента** (`Title`): `Отпуск без содержания — {ФИО} — дд.мм.гггг`.
- **Место для подписи на печати** (с 10-29, `PrintSignature`) — как в «Заявке на отпуск»:
столбиком «ФИО / линия / (подпись)» в правом верхнем углу шапки под статусом; ФИО сотрудника,
а не инициатора.
- **Таблица «Исполнение» на печать не идёт** (с 10-29, `rp-no-print`) — как в «Заявке на отпуск»;
на печати только маршрут согласования.
## Отличия от «Заявка на отпуск» (hr-vacation) ## Отличия от «Заявка на отпуск» (hr-vacation)
- Те же «дата начала + количество дней»: даты окончания нет, праздники и выходные не считаются. - Те же «дата начала + количество дней»: даты окончания нет, праздники и выходные не считаются.
@@ -13,7 +13,7 @@ import {
SubmitActions, SubmitActions,
getDraftDeleteHandler, getDraftDeleteHandler,
useRequestSubmit, useRequestSubmit,
LeaveNotice FormNotice
} from '../../shared/ui'; } from '../../shared/ui';
import { import {
composeRemoveRowRule, composeRemoveRowRule,
@@ -47,7 +47,7 @@ import {
dataListTitleForListId dataListTitleForListId
} from '../../shared/listTitles'; } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField, NOTICE_TITLE, NOTICE_ITEMS } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField, NOTICE_TITLE, NOTICE_ITEMS } from './model';
import { organizationRouteFieldValues } from '../../shared/dictionary/organizationRouteFields'; import { organizationProjectRouteFieldValues } from '../../shared/dictionary/organizationRouteFields';
import { formIssues } from './validation'; import { formIssues } from './validation';
import { saveUnpaidLeaveRequest, unpaidLeaveApprovalService } from './submit'; import { saveUnpaidLeaveRequest, unpaidLeaveApprovalService } from './submit';
import { loadUnpaidLeaveRequest } from './load'; import { loadUnpaidLeaveRequest } from './load';
@@ -164,10 +164,14 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
// пользователя) — так работает подача за другого. У заявки на редактировании // пользователя) — так работает подача за другого. У заявки на редактировании
// маршрут читается из уже записанных строк, чтобы не затереть правки инициатора. // маршрут читается из уже записанных строк, чтобы не затереть правки инициатора.
// //
// Кадровый сотрудник и исполнитель зависят от организации сотрудника (условия // Кадровый сотрудник и исполнитель зависят от организации сотрудника и проекта
// «Organization содержит …» в админке) — см. shared/dictionary/organizationRouteFields.ts. // (условия «Organization содержит …», «Project содержит …» в админке) — см.
// Организация приходит позже выбора сотрудника, поэтому она в ключе перезагрузки. // shared/dictionary/organizationRouteFields.ts. Организация приходит позже
const routeFieldValues = useMemo(() => organizationRouteFieldValues(data.Organization), [data.Organization]); // выбора сотрудника, поэтому она в ключе перезагрузки.
const routeFieldValues = useMemo(
() => organizationProjectRouteFieldValues(data.Organization, data.Project),
[data.Organization, data.Project]
);
const routeOrgKey = routeFieldValues.map((f) => f.value).join('|'); const routeOrgKey = routeFieldValues.map((f) => f.value).join('|');
const loadMatrix = useCallback( const loadMatrix = useCallback(
() => () =>
@@ -335,7 +339,7 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
)} )}
{submit.error && <Alert variant="err">{submit.error}</Alert>} {submit.error && <Alert variant="err">{submit.error}</Alert>}
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} /> <FormNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
<FormModeProvider readOnly={readOnly}> <FormModeProvider readOnly={readOnly}>
<RequestSection {...sectionProps} /> <RequestSection {...sectionProps} />
@@ -15,3 +15,12 @@
gap: $sp-3; gap: $sp-3;
margin-bottom: $sp-4; margin-bottom: $sp-4;
} }
// Правый угол шапки: статус, под ним (только в печати) подпись сотрудника.
// headerRight у FormCard раскладывает содержимое в строку — здесь нужен столбик.
.headerAside {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: $sp-3;
}
@@ -9,7 +9,9 @@ import {
FormModeProvider, FormModeProvider,
PrintSplitButton, PrintSplitButton,
OrgLetterhead, OrgLetterhead,
LeaveNotice FormNotice,
PrintSignature,
buildPrintTitle
} from '../../shared/ui'; } from '../../shared/ui';
import { import {
ApproverService, ApproverService,
@@ -49,6 +51,8 @@ interface IUnpaidLeaveViewProps {
onCodeLoaded?: (code: string) => void; onCodeLoaded?: (code: string) => void;
} }
const FORM_TITLE = 'Заявление на отпуск без сохранения заработной платы';
// В режиме чтения поля не редактируются, поэтому обновление — пустышка. // В режиме чтения поля не редактируются, поэтому обновление — пустышка.
const NOOP_SET: SetField = () => undefined; const NOOP_SET: SetField = () => undefined;
@@ -64,6 +68,8 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
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 [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
// Ф.И.О. сотрудника (Employee/Title) — для строки подписи на печатном заявлении.
const [employeeName, setEmployeeName] = useState<string>('');
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);
// Какая из организаций сотрудника (см. OrganizationField) идёт в печать — // Какая из организаций сотрудника (см. OrganizationField) идёт в печать —
@@ -97,6 +103,7 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
setData(loaded.data); setData(loaded.data);
setStatus(loaded.status); setStatus(loaded.status);
setCode(loaded.code); setCode(loaded.code);
setEmployeeName(loaded.employeeName);
notifyCode(loaded.code); notifyCode(loaded.code);
setError(null); setError(null);
setLoading(false); setLoading(false);
@@ -144,13 +151,18 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
return ( return (
<div> <div>
<div className={`${styles.toolbar} rp-no-print`}> <div className={`${styles.toolbar} rp-no-print`}>
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} /> <PrintSplitButton
value={data.Organization}
selectedIndex={printOrgIndex}
onChange={setPrintOrgIndex}
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
/>
</div> </div>
<div className="rp-print-root"> <div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} /> <OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard <FormCard
title="Заявление на отпуск без сохранения заработной платы" title={FORM_TITLE}
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={ belowSubtitle={
<InitiatorLine <InitiatorLine
@@ -158,9 +170,15 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
itemId={itemId} itemId={itemId}
/> />
} }
headerRight={status ? <StatusBadge status={status} /> : undefined} headerRight={
<div className={styles.headerAside}>
{status && <StatusBadge status={status} />}
{/* Подписывает сотрудник, а не инициатор: заявку могут оформить за него. */}
<PrintSignature name={employeeName} />
</div>
}
> >
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} /> <FormNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
<FormModeProvider readOnly> <FormModeProvider readOnly>
<RequestSection {...sectionProps} /> <RequestSection {...sectionProps} />
</FormModeProvider> </FormModeProvider>
@@ -168,6 +186,7 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} /> <ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
{/* Исполнение на печать не идёт — это делает сам ExecutorsMatrix (printable={false}). */}
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} /> <ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
</div> </div>
@@ -47,7 +47,7 @@ export interface IFormData {
Department: string; Department: string;
/** Id отдела из справочника; '' — если введён вручную без выбора из списка. */ /** Id отдела из справочника; '' — если введён вручную без выбора из списка. */
DepartmentId: string; DepartmentId: string;
/** Проект (справочник /Project, см. shared/dictionary/ProjectPicker) — необязательное, справочное. */ /** Проект (справочник /Project, см. shared/dictionary/ProjectPicker) — обязательный, от него зависит кадровик в маршруте. */
Project: string; Project: string;
/** oid проекта ('' — если введён вручную без выбора из списка). */ /** oid проекта ('' — если введён вручную без выбора из списка). */
ProjectId: string; ProjectId: string;
@@ -95,7 +95,7 @@ export const SUBSTITUTE_PLACEHOLDER = 'Начните вводить Ф.И.О.
*/ */
export const MIN_NOTICE_DAYS = 14; export const MIN_NOTICE_DAYS = 14;
/** Памятка вверху формы и просмотра (см. shared/ui/LeaveNotice). Текст — по просьбе HR. */ /** Памятка вверху формы и просмотра (см. shared/ui/FormNotice). Текст — по просьбе HR. */
export const NOTICE_TITLE = 'Памятка'; export const NOTICE_TITLE = 'Памятка';
export const NOTICE_ITEMS: string[] = [ export const NOTICE_ITEMS: string[] = [
'После согласования заявления его необходимо распечатать с портала (кнопка «Печать» на странице заявки), ' + 'После согласования заявления его необходимо распечатать с портала (кнопка «Печать» на странице заявки), ' +
@@ -39,7 +39,7 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
}} }}
/> />
</FormField> </FormField>
<FormField label="Проект" htmlFor="Project"> <FormField label="Проект" htmlFor="Project" required>
<ProjectPicker <ProjectPicker
id="Project" id="Project"
context={context} context={context}
@@ -16,6 +16,17 @@ function startDateIssue(data: IFormData): IFieldIssue | undefined {
}; };
} }
/**
* Проект: обязателен и выбран из справочника. От него зависит кадровик в
* маршруте (условия «Project содержит …»), а название, вписанное руками,
* условия могут не узнать — поэтому без ProjectId заявку не пропускаем.
*/
function projectIssue(data: IFormData): IFieldIssue | undefined {
if (!data.Project.trim()) return { field: 'Project', label: 'Проект' };
if (data.ProjectId) return undefined;
return { field: 'Project', label: 'Проект', message: 'выберите проект из списка' };
}
/** /**
* Количество дней: обязательно, целое и больше нуля. Верхнего предела нет: * Количество дней: обязательно, целое и больше нуля. Верхнего предела нет:
* сверх 5 дней (обязательных при браке/рождении/смерти) — по договорённости с работодателем. * сверх 5 дней (обязательных при браке/рождении/смерти) — по договорённости с работодателем.
@@ -37,6 +48,7 @@ export function formIssues(data: IFormData): IFieldIssue[] {
const issues: Array<IFieldIssue | undefined> = [ const issues: Array<IFieldIssue | undefined> = [
data.Employee ? undefined : { field: 'Employee', label: 'Сотрудник' }, data.Employee ? undefined : { field: 'Employee', label: 'Сотрудник' },
data.Position.trim() ? undefined : { field: 'Position', label: 'Должность' }, data.Position.trim() ? undefined : { field: 'Position', label: 'Должность' },
projectIssue(data),
startDateIssue(data), startDateIssue(data),
daysCountIssue(data), daysCountIssue(data),
data.ReasonType data.ReasonType
@@ -31,6 +31,11 @@
- **Комментарий** (с 10-28, `VacationComment`, Note) — необязательный, заполняет инициатор, - **Комментарий** (с 10-28, `VacationComment`, Note) — необязательный, заполняет инициатор,
последним полем секции; в просмотре пустой не показывается. В DOCX для ЭДО (`vacationDocument.ts`) не идёт; в печать последним полем секции; в просмотре пустой не показывается. В DOCX для ЭДО (`vacationDocument.ts`) не идёт; в печать
тоже не попадает (обёртка `rp-no-print` с `display: contents`, чтобы на экране поле оставалось ячейкой сетки). тоже не попадает (обёртка `rp-no-print` с `display: contents`, чтобы на экране поле оставалось ячейкой сетки).
- **Место для подписи на печати** (с 10-29, `PrintSignature` из `shared/ui`) — столбиком «ФИО /
линия / (подпись)» в правом верхнем углу шапки под статусом (`headerAside`), только при печати. ФИО — **сотрудника** (`Employee/Title`), а не
инициатора: заявку могут оформить за сотрудника, а заявление подписывает он сам.
- **Таблица «Исполнение» на печать не идёт** (с 10-29, обёртка `rp-no-print`) — это внутренняя
работа кадров после согласования; на печати только маршрут согласования. На экране видна.
## Подписание в ЭДО ## Подписание в ЭДО
> **Сейчас выключено** (`VACATION_EDO_SIGNING_ENABLED = false` в `shared/config.ts`): решение последнего > **Сейчас выключено** (`VACATION_EDO_SIGNING_ENABLED = false` в `shared/config.ts`): решение последнего
@@ -13,7 +13,7 @@ import {
SubmitActions, SubmitActions,
getDraftDeleteHandler, getDraftDeleteHandler,
useRequestSubmit, useRequestSubmit,
LeaveNotice FormNotice
} from '../../shared/ui'; } from '../../shared/ui';
import { import {
composeRemoveRowRule, composeRemoveRowRule,
@@ -48,7 +48,7 @@ import {
} from '../../shared/listTitles'; } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField, NOTICE_TITLE, NOTICE_ITEMS } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField, NOTICE_TITLE, NOTICE_ITEMS } from './model';
import { formIssues } from './validation'; import { formIssues } from './validation';
import { organizationRouteFieldValues } from '../../shared/dictionary/organizationRouteFields'; import { organizationProjectRouteFieldValues } from '../../shared/dictionary/organizationRouteFields';
import { saveVacationRequest, vacationApprovalService } from './submit'; import { saveVacationRequest, vacationApprovalService } from './submit';
import { loadVacationRequest } from './load'; import { loadVacationRequest } from './load';
import { RequestSection } from './sections/RequestSection'; import { RequestSection } from './sections/RequestSection';
@@ -175,11 +175,15 @@ const VacationForm: React.FC<IRequestFormProps> = ({
// маршрут читается из уже записанных строк, чтобы не затереть правки инициатора. // маршрут читается из уже записанных строк, чтобы не затереть правки инициатора.
// //
// Кадровый сотрудник и исполнитель в маршруте зависят от организации // Кадровый сотрудник и исполнитель в маршруте зависят от организации
// сотрудника (условия «Organization содержит …» в админке) — см. // сотрудника и проекта (условия «Organization содержит …», «Project
// shared/dictionary/organizationRouteFields.ts. Организация приходит позже // содержит …» в админке) — см. shared/dictionary/organizationRouteFields.ts.
// выбора сотрудника (Employee/ByEmail), поэтому она в ключе перезагрузки: // Организация приходит позже выбора сотрудника (Employee/ByEmail), поэтому
// маршрут пересчитается, когда придёт, а не останется рассчитанным без условий. // она в ключе перезагрузки: маршрут пересчитается, когда придёт, а не
const routeFieldValues = useMemo(() => organizationRouteFieldValues(data.Organization), [data.Organization]); // останется рассчитанным без условий.
const routeFieldValues = useMemo(
() => organizationProjectRouteFieldValues(data.Organization, data.Project),
[data.Organization, data.Project]
);
const routeOrgKey = routeFieldValues.map((f) => f.value).join('|'); const routeOrgKey = routeFieldValues.map((f) => f.value).join('|');
const loadMatrix = useCallback( const loadMatrix = useCallback(
() => () =>
@@ -354,7 +358,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
)} )}
{submit.error && <Alert variant="err">{submit.error}</Alert>} {submit.error && <Alert variant="err">{submit.error}</Alert>}
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} /> <FormNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
<FormModeProvider readOnly={readOnly}> <FormModeProvider readOnly={readOnly}>
<RequestSection {...sectionProps} /> <RequestSection {...sectionProps} />
@@ -15,3 +15,12 @@
gap: $sp-3; gap: $sp-3;
margin-bottom: $sp-4; margin-bottom: $sp-4;
} }
// Правый угол шапки: статус, под ним (только в печати) подпись сотрудника.
// headerRight у FormCard раскладывает содержимое в строку — здесь нужен столбик.
.headerAside {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: $sp-3;
}
@@ -10,7 +10,9 @@ import {
FormModeProvider, FormModeProvider,
PrintSplitButton, PrintSplitButton,
OrgLetterhead, OrgLetterhead,
LeaveNotice FormNotice,
PrintSignature,
buildPrintTitle
} from '../../shared/ui'; } from '../../shared/ui';
import { import {
ApproverService, ApproverService,
@@ -56,6 +58,8 @@ interface IVacationViewProps {
onCodeLoaded?: (code: string) => void; onCodeLoaded?: (code: string) => void;
} }
const FORM_TITLE = 'Заявление на оплачиваемый ежегодный трудовой отпуск';
// В режиме чтения поля не редактируются, поэтому обновление — пустышка. // В режиме чтения поля не редактируются, поэтому обновление — пустышка.
const NOOP_SET: SetField = () => undefined; const NOOP_SET: SetField = () => undefined;
@@ -206,13 +210,18 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
return ( return (
<div> <div>
<div className={`${styles.toolbar} rp-no-print`}> <div className={`${styles.toolbar} rp-no-print`}>
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} /> <PrintSplitButton
value={data.Organization}
selectedIndex={printOrgIndex}
onChange={setPrintOrgIndex}
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
/>
</div> </div>
<div className="rp-print-root"> <div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} /> <OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard <FormCard
title="Заявление на оплачиваемый ежегодный трудовой отпуск" title={FORM_TITLE}
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={ belowSubtitle={
<InitiatorLine <InitiatorLine
@@ -220,15 +229,22 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
itemId={itemId} itemId={itemId}
/> />
} }
headerRight={status ? <StatusBadge status={status} /> : undefined} headerRight={
<div className={styles.headerAside}>
{status && <StatusBadge status={status} />}
{/* Подписывает сотрудник, а не инициатор: заявку могут оформить за него. */}
<PrintSignature name={employeeName} />
</div>
}
> >
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} /> <FormNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
<FormModeProvider readOnly> <FormModeProvider readOnly>
<RequestSection {...sectionProps} /> <RequestSection {...sectionProps} />
</FormModeProvider> </FormModeProvider>
</FormCard> </FormCard>
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} /> <ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
{/* Исполнение на печать не идёт — это делает сам ExecutorsMatrix (printable={false}). */}
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} /> <ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
</div> </div>
@@ -40,7 +40,7 @@ export interface IFormData {
Department: string; Department: string;
/** Id отдела из справочника; '' — если введён вручную без выбора из списка. */ /** Id отдела из справочника; '' — если введён вручную без выбора из списка. */
DepartmentId: string; DepartmentId: string;
/** Проект (справочник /Project, см. shared/dictionary/ProjectPicker) — необязательное, справочное. */ /** Проект (справочник /Project, см. shared/dictionary/ProjectPicker) — обязательный, от него зависит кадровик в маршруте. */
Project: string; Project: string;
/** oid проекта ('' — если введён вручную без выбора из списка). */ /** oid проекта ('' — если введён вручную без выбора из списка). */
ProjectId: string; ProjectId: string;
@@ -87,7 +87,7 @@ export const MIN_NOTICE_DAYS = 7;
export const MAX_DAYS = 24; export const MAX_DAYS = 24;
/** /**
* Примечание вверху формы и просмотра (см. shared/ui/LeaveNotice). Текст — от HR, * Примечание вверху формы и просмотра (см. shared/ui/FormNotice). Текст — от HR,
* дословно. «Одну календарную неделю» — это MIN_NOTICE_DAYS: поменяли срок — поправьте и текст. * дословно. «Одну календарную неделю» — это MIN_NOTICE_DAYS: поменяли срок — поправьте и текст.
* Правило про часть не менее 14 дней только показываем, а не проверяем: форма не знает, * Правило про часть не менее 14 дней только показываем, а не проверяем: форма не знает,
* брал ли сотрудник уже часть отпуска. * брал ли сотрудник уже часть отпуска.
@@ -20,7 +20,7 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
/> />
</FormField> </FormField>
<OrganizationTable value={data.Organization} printIndex={printOrgIndex} /> <OrganizationTable value={data.Organization} printIndex={printOrgIndex} />
<FormField label="Проект" htmlFor="Project"> <FormField label="Проект" htmlFor="Project" required>
<ProjectPicker <ProjectPicker
id="Project" id="Project"
context={context} context={context}
@@ -19,6 +19,17 @@ function startDateIssue(data: IFormData): IFieldIssue | undefined {
}; };
} }
/**
* Проект: обязателен и выбран из справочника. От него зависит кадровик в
* маршруте (условия «Project содержит …»), а название, вписанное руками,
* условия могут не узнать — поэтому без ProjectId заявку не пропускаем.
*/
function projectIssue(data: IFormData): IFieldIssue | undefined {
if (!data.Project.trim()) return { field: 'Project', label: 'Проект' };
if (data.ProjectId) return undefined;
return { field: 'Project', label: 'Проект', message: 'выберите проект из списка' };
}
/** Количество дней: обязательно, целое, от 1 до MAX_DAYS. */ /** Количество дней: обязательно, целое, от 1 до MAX_DAYS. */
function daysCountIssue(data: IFormData): IFieldIssue | undefined { function daysCountIssue(data: IFormData): IFieldIssue | undefined {
if (!data.DaysCount.trim()) return { field: 'DaysCount', label: 'Количество дней' }; if (!data.DaysCount.trim()) return { field: 'DaysCount', label: 'Количество дней' };
@@ -36,6 +47,7 @@ function daysCountIssue(data: IFormData): IFieldIssue | undefined {
export function formIssues(data: IFormData): IFieldIssue[] { export function formIssues(data: IFormData): IFieldIssue[] {
const issues: Array<IFieldIssue | undefined> = [ const issues: Array<IFieldIssue | undefined> = [
data.Employee ? undefined : { field: 'Employee', label: 'Сотрудник' }, data.Employee ? undefined : { field: 'Employee', label: 'Сотрудник' },
projectIssue(data),
startDateIssue(data), startDateIssue(data),
daysCountIssue(data) daysCountIssue(data)
]; ];
@@ -23,16 +23,28 @@
- **Полное право правки** (`shared/approval/fullEditRights.ts`, сейчас Ербол - **Полное право правки** (`shared/approval/fullEditRights.ts`, сейчас Ербол
Шаймерденов): все поля и вложения, если он есть в маршруте и заявка «На Шаймерденов): все поля и вложения, если он есть в маршруте и заявка «На
согласовании»/«Выполнение» (`useApproverRights.ts`). согласовании»/«Выполнение» (`useApproverRights.ts`).
- **Поля «Необходимое ПО» и «Доступы» заполняет согласующий-руководитель** на своём - **Поля «Необходимое ПО», «Доступы» и «Мебель» заполняет согласующий-руководитель**
этапе (инициатор их обычно не знает). Доступны для правки, когда очередь дошла до на своём этапе (инициатор их обычно не знает). Доступны для правки, когда очередь дошла до
**первого** шага согласования и это текущий пользователь; сохраняются кнопкой **первого** шага согласования и это текущий пользователь; сохраняются кнопкой
«Сохранить» и автоматически при «Согласовать» (`updateReviewFields`, «Сохранить» и автоматически при «Согласовать» (`updateReviewFields`,
`DecisionPanel.onBeforeDecide`). Остальная форма при этом read-only. `DecisionPanel.onBeforeDecide`). Остальная форма при этом read-only.
- **Мебель** — мультивыбор (`FURNITURE_OPTIONS`); при «Другое» появляется поле
своего значения. В SP — `WorkplaceFurnitureJson` (JSON-массив) и
`WorkplaceFurnitureOther`. Необязательная. Уходит в маршрут как
`WorkplaceFurnitureJson` (пункты через запятую, `routeFields.ts`) — на условие
шага исполнения «исполнитель по мебели».
- **Добавление исполнителей по правке согласующего** (`routeExecutorsSync.ts`):
маршрут планируется при подаче, поэтому если руководитель/Ербол поменял мебель
или ПО, при сохранении (и перед решением) строятся два плана исполнения — со
старыми и новыми значениями — и разница добавляется в конец исполнения
(«Ждёт очереди», `ApproverService.addMissingExecutors`). Никого не удаляем;
уже стоящих в исполнении (в любом статусе) не дублируем
(`shared/approval/missingExecutors.ts`).
- **Маска телефона** (PhoneInput). - **Маска телефона** (PhoneInput).
- Печать — общий механизм (без «Вложений»). - Печать — общий механизм (без «Вложений»).
## Секции формы ## Секции формы
HireRequestPicker (только в редактируемой форме) · EmployeeSection · EquipmentSection HireRequestPicker (только в редактируемой форме) · EmployeeSection · EquipmentSection · FurnitureSection
## Точки входа ## Точки входа
- rptype: `it-workplace-setup` - rptype: `it-workplace-setup`
@@ -63,6 +63,7 @@ import { loadWorkplaceRequest } from './load';
import { hasRequiredFields, REQUIRED_FIELDS_MESSAGE } from './validation'; import { hasRequiredFields, REQUIRED_FIELDS_MESSAGE } from './validation';
import { EmployeeSection } from './sections/EmployeeSection'; import { EmployeeSection } from './sections/EmployeeSection';
import { EquipmentSection } from './sections/EquipmentSection'; import { EquipmentSection } from './sections/EquipmentSection';
import { FurnitureSection } from './sections/FurnitureSection';
import { useStableCallback } from '../../shared/useStableCallback'; import { useStableCallback } from '../../shared/useStableCallback';
const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
@@ -392,6 +393,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
<FormModeProvider readOnly={readOnly}> <FormModeProvider readOnly={readOnly}>
<EmployeeSection {...sectionProps} /> <EmployeeSection {...sectionProps} />
<EquipmentSection {...sectionProps} /> <EquipmentSection {...sectionProps} />
<FurnitureSection {...sectionProps} />
</FormModeProvider> </FormModeProvider>
</FieldChangesProvider> </FieldChangesProvider>
@@ -12,7 +12,8 @@ import {
OrgLetterhead, OrgLetterhead,
Icon, Icon,
FieldChangesProvider, FieldChangesProvider,
FieldChangesBanner FieldChangesBanner,
buildPrintTitle
} from '../../shared/ui'; } from '../../shared/ui';
import { navigate } from '../../shared/routing'; import { navigate } from '../../shared/routing';
import { import {
@@ -47,8 +48,10 @@ import { workplaceApprovalService, updateReviewFields, updateAllWorkplaceFields
import { WORKPLACE_CHANGE_TRACKING } from './changeTracking'; import { WORKPLACE_CHANGE_TRACKING } from './changeTracking';
import { hasRequiredFields, REQUIRED_FIELDS_MESSAGE } from './validation'; import { hasRequiredFields, REQUIRED_FIELDS_MESSAGE } from './validation';
import { useApproverRights } from './useApproverRights'; import { useApproverRights } from './useApproverRights';
import { addExecutorsForEditedFields, IExecutorsSyncResult } from './routeExecutorsSync';
import { EmployeeSection } from './sections/EmployeeSection'; import { EmployeeSection } from './sections/EmployeeSection';
import { EquipmentSection } from './sections/EquipmentSection'; import { EquipmentSection } from './sections/EquipmentSection';
import { FurnitureSection } from './sections/FurnitureSection';
import { useStableCallback } from '../../shared/useStableCallback'; import { useStableCallback } from '../../shared/useStableCallback';
interface IWorkplaceSetupViewProps { interface IWorkplaceSetupViewProps {
@@ -64,13 +67,14 @@ interface IWorkplaceSetupViewProps {
type SaveState = 'idle' | 'saving' | 'saved' | 'error'; type SaveState = 'idle' | 'saving' | 'saved' | 'error';
const REVIEW_FIELDS_HINT = 'Как согласующий вы можете заполнить «Необходимое ПО» и «Доступы».'; const FORM_TITLE = 'Организация рабочего места';
const REVIEW_FIELDS_HINT = 'Как согласующий вы можете заполнить «Необходимое ПО», «Доступы» и «Мебель».';
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.'; const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.';
// Заявка «орг. рабочего места» только для просмотра (режим согласующего). // Заявка «орг. рабочего места» только для просмотра (режим согласующего).
// Исключения (оба — только на своём активном шаге, см. useApproverRights): // Исключения (оба — только на своём активном шаге, см. useApproverRights):
// - первый согласующий шаг (по маршруту — непосредственный руководитель) может // - первый согласующий шаг (по маршруту — непосредственный руководитель) может
// заполнить «Необходимое ПО» и «Доступы» (инициатор их не знает); // заполнить «Необходимое ПО», «Доступы» и «Мебель» (инициатор их не знает);
// - согласующий из shared/approval/fullEditRights.ts правит все поля и вложения. // - согласующий из shared/approval/fullEditRights.ts правит все поля и вложения.
const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => { const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка // onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
@@ -102,6 +106,8 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
// Ref списка данных для ApproverService.updateRequestStatus: listId (url) для // Ref списка данных для ApproverService.updateRequestStatus: listId (url) для
// архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef). // архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
const dataListRef = listId || dataListTitle(FORM_ID); const dataListRef = listId || dataListTitle(FORM_ID);
// Title списка данных (а не url) — для оповещений, шапки «Инициатор» и чтения инициатора.
const dataListName = listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID);
const approval = useMemo(() => workplaceApprovalService(listId), [listId]); const approval = useMemo(() => workplaceApprovalService(listId), [listId]);
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]); const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
const attachments = useAttachmentEditor(dataListRef, itemId); const attachments = useAttachmentEditor(dataListRef, itemId);
@@ -174,6 +180,18 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
}; };
}, [itemId, listId, dataListRef, notifyCode, setAttachmentItems]); }, [itemId, listId, dataListRef, notifyCode, setAttachmentItems]);
const notify = (newStatus?: string): void => {
if (!requestTypeId) return;
const target = {
requestTypeId,
dataListName,
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID),
};
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
};
const saveAllFields = async (): Promise<void> => { const saveAllFields = async (): Promise<void> => {
const editor = { name: context.pageContext.user.displayName, email: effectiveUserEmail(context) }; const editor = { name: context.pageContext.user.displayName, email: effectiveUserEmail(context) };
const nextChanges = mergeFieldChanges(approverChanges, savedData, data, WORKPLACE_CHANGE_TRACKING, editor, new Date()); const nextChanges = mergeFieldChanges(approverChanges, savedData, data, WORKPLACE_CHANGE_TRACKING, editor, new Date());
@@ -183,18 +201,36 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
await attachments.uploadPending(); await attachments.uploadPending();
}; };
const saveReviewFields = (): Promise<void> => // updateReviewFields пишет только поля руководителя (ReviewFields), остальное из data игнорирует.
updateReviewFields(itemId, { Software: data.Software, AccessRights: data.AccessRights }, listId); const saveReviewFields = (): Promise<void> => updateReviewFields(itemId, data, listId);
// Полное право шире: оно включает и «ПО»/«Доступы». // Полное право шире: оно включает и поля руководителя. После записи полей —
const saveFields = (): Promise<void> => (canEditAll ? saveAllFields() : saveReviewFields()); // исполнители, которых привела правка мебели/ПО (маршрут планировался при
// подаче и сам их не добавит, см. routeExecutorsSync.ts).
const saveFields = async (): Promise<IExecutorsSyncResult> => {
const before = savedData;
if (canEditAll) {
await saveAllFields();
} else {
await saveReviewFields();
setSavedData(data);
}
return addExecutorsForEditedFields(
{ context, approval, approverService, requestTypeId, itemId, dataListTitle: dataListName },
before,
data
);
};
const handleSave = async (): Promise<void> => { const handleSave = async (): Promise<void> => {
setSaveState('saving'); setSaveState('saving');
setSaveErrorMessage(''); setSaveErrorMessage('');
try { try {
await saveFields(); const executorsSync = await saveFields();
setSaveState('saved'); setSaveState('saved');
// Новые исполнители должны появиться в таблице «Исполнение» — перечитываем маршрут.
if (executorsSync.addedCount > 0) setDecisionSeq((n) => n + 1);
if (executorsSync.activatedRowIds.length > 0) notify();
} catch (e) { } catch (e) {
setSaveErrorMessage(friendlyErrorMessage(e)); setSaveErrorMessage(friendlyErrorMessage(e));
setSaveState('error'); setSaveState('error');
@@ -225,18 +261,6 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
const sectionProps = { data, set, context, printOrgIndex }; const sectionProps = { data, set, context, printOrgIndex };
const canEditAnything = canEditAll || canEditReviewFields; const canEditAnything = canEditAll || canEditReviewFields;
const notify = (newStatus?: string): void => {
if (!requestTypeId) return;
const target = {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID),
};
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
};
return ( return (
<div> <div>
<div className={`${styles.toolbar} rp-no-print`}> <div className={`${styles.toolbar} rp-no-print`}>
@@ -249,7 +273,12 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
Редактировать Редактировать
</Button> </Button>
)} )}
<PrintSplitButton value={organizationText(data)} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} /> <PrintSplitButton
value={organizationText(data)}
selectedIndex={printOrgIndex}
onChange={setPrintOrgIndex}
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.FullName, code, itemId })}
/>
</div> </div>
{canEditAnything && ( {canEditAnything && (
@@ -268,13 +297,10 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
<div className="rp-print-root"> <div className="rp-print-root">
<OrgLetterhead value={organizationText(data)} selectedIndex={printOrgIndex} /> <OrgLetterhead value={organizationText(data)} selectedIndex={printOrgIndex} />
<FormCard <FormCard
title="Организация рабочего места" title={FORM_TITLE}
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={ belowSubtitle={
<InitiatorLine <InitiatorLine listTitle={dataListName} itemId={itemId} />
listTitle={listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID)}
itemId={itemId}
/>
} }
headerRight={status ? <StatusBadge status={status} /> : undefined} headerRight={status ? <StatusBadge status={status} /> : undefined}
> >
@@ -283,6 +309,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
<FormModeProvider readOnly={!canEditAll}> <FormModeProvider readOnly={!canEditAll}>
<EmployeeSection {...sectionProps} /> <EmployeeSection {...sectionProps} />
<EquipmentSection {...sectionProps} approverEditable={canEditReviewFields} /> <EquipmentSection {...sectionProps} approverEditable={canEditReviewFields} />
<FurnitureSection {...sectionProps} approverEditable={canEditReviewFields} />
</FormModeProvider> </FormModeProvider>
</FieldChangesProvider> </FieldChangesProvider>
@@ -296,6 +323,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
{!executors.loading && executors.rows.length > 0 && ( {!executors.loading && executors.rows.length > 0 && (
<ApprovalMatrix <ApprovalMatrix
editor={executors} editor={executors}
printable={false}
search={(q) => approval.searchUsers(q)} search={(q) => approval.searchUsers(q)}
title="Исполнение" title="Исполнение"
roleLabel="Исполнитель" roleLabel="Исполнитель"
@@ -3,18 +3,25 @@ import { dataListTitle } from '../../shared/listTitles';
import { spDateOnlyToIso } from '../../shared/dateUtils'; import { spDateOnlyToIso } from '../../shared/dateUtils';
import { ICompany } from '../../shared/dictionary'; import { ICompany } from '../../shared/dictionary';
import { IFieldChange, parseFieldChanges } from '../../shared/fieldChanges/fieldChanges'; import { IFieldChange, parseFieldChanges } from '../../shared/fieldChanges/fieldChanges';
import { COMPANIES_JSON_FIELD, FORM_ID, IFormData, EMPTY_DATA } from './model'; import {
COMPANIES_JSON_FIELD,
FURNITURE_JSON_FIELD,
FURNITURE_OTHER_FIELD,
FORM_ID,
IFormData,
EMPTY_DATA
} from './model';
import { APPROVER_CHANGES_FIELD } from './changeTracking'; import { APPROVER_CHANGES_FIELD } from './changeTracking';
/** /**
* JSON выбранных компаний → массив. Пусто — заявка до мультивыбора (тогда * JSON-колонка с массивом → массив. Пусто — колонка не заполнена (заявка создана
* организации есть только текстом в Organization) или битый JSON. * до появления поля: компании до мультивыбора, мебель до 2026-10-29) или битый JSON.
*/ */
function parseCompanies(raw: unknown): ICompany[] { function parseJsonArray<T>(raw: unknown): T[] {
if (typeof raw !== 'string' || raw.trim() === '') return []; if (typeof raw !== 'string' || raw.trim() === '') return [];
try { try {
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as ICompany[]) : []; return Array.isArray(parsed) ? (parsed as T[]) : [];
} catch { } catch {
return []; return [];
} }
@@ -50,6 +57,8 @@ export async function loadWorkplaceRequest(itemId: number, listId?: string): Pro
...SCALAR_FIELDS, ...SCALAR_FIELDS,
'NeedsEquipment', 'NeedsEquipment',
COMPANIES_JSON_FIELD, COMPANIES_JSON_FIELD,
FURNITURE_JSON_FIELD,
FURNITURE_OTHER_FIELD,
APPROVER_CHANGES_FIELD, APPROVER_CHANGES_FIELD,
'RequestStatus', 'RequestStatus',
'RequestUniqueCode' 'RequestUniqueCode'
@@ -71,7 +80,9 @@ export async function loadWorkplaceRequest(itemId: number, listId?: string): Pro
}); });
data.NeedsEquipment = item.NeedsEquipment === true; data.NeedsEquipment = item.NeedsEquipment === true;
data.Companies = parseCompanies(item[COMPANIES_JSON_FIELD]); data.Companies = parseJsonArray<ICompany>(item[COMPANIES_JSON_FIELD]);
data.Furniture = parseJsonArray<string>(item[FURNITURE_JSON_FIELD]);
data.FurnitureOther = (item[FURNITURE_OTHER_FIELD] as string) || '';
return { return {
data, data,
@@ -1,5 +1,6 @@
import { WebPartContext } from '@microsoft/sp-webpart-base'; import { WebPartContext } from '@microsoft/sp-webpart-base';
import { RequestTypeId } from '../../models/types'; import { RequestTypeId } from '../../models/types';
import { ISelectOption } from '@tnsu/ui-kit';
import { ICompany } from '../../shared/dictionary'; import { ICompany } from '../../shared/dictionary';
/** /**
@@ -14,6 +15,22 @@ export const FORM_ID: RequestTypeId = 'it-workplace-setup';
*/ */
export const COMPANIES_JSON_FIELD = 'WorkplaceCompaniesJson'; export const COMPANIES_JSON_FIELD = 'WorkplaceCompaniesJson';
/** Note-колонка с IFormData.Furniture в JSON. Префикс Workplace — по той же причине, что у компаний. */
export const FURNITURE_JSON_FIELD = 'WorkplaceFurnitureJson';
/** Text-колонка с IFormData.FurnitureOther. */
export const FURNITURE_OTHER_FIELD = 'WorkplaceFurnitureOther';
/** Значение-триггер «Другое» в «Мебели»: при нём показываем поле ручного ввода. */
export const FURNITURE_OTHER_VALUE = 'Другое';
export const FURNITURE_OPTIONS: ISelectOption[] = [
{ value: 'Стол', label: 'Стол' },
{ value: 'Кресло', label: 'Кресло' },
{ value: 'Тумбочка', label: 'Тумбочка' },
{ value: 'Канцелярские принадлежности', label: 'Канцелярские принадлежности' },
{ value: FURNITURE_OTHER_VALUE, label: FURNITURE_OTHER_VALUE }
];
/** /**
* Данные формы «Организация рабочего места». Ключи = internalName колонок SP-списка * Данные формы «Организация рабочего места». Ключи = internalName колонок SP-списка
* «Организация рабочего места — данные». * «Организация рабочего места — данные».
@@ -57,6 +74,11 @@ export interface IFormData {
NeedsEquipment: boolean; NeedsEquipment: boolean;
Software: string; Software: string;
AccessRights: string; AccessRights: string;
// Мебель (необязательно; заполняется вручную)
/** Выбранные пункты из FURNITURE_OPTIONS. В SP — JSON в колонке WorkplaceFurnitureJson. */
Furniture: string[];
/** Своё значение — только если выбрано «Другое», иначе ''. В SP — колонка WorkplaceFurnitureOther. */
FurnitureOther: string;
} }
/** /**
@@ -96,5 +118,7 @@ export const EMPTY_DATA: IFormData = {
Organization: '', Organization: '',
NeedsEquipment: false, NeedsEquipment: false,
Software: '', Software: '',
AccessRights: '' AccessRights: '',
Furniture: [],
FurnitureOther: ''
}; };
@@ -21,6 +21,8 @@
{ "internalName": "NeedsEquipment", "title": "Нужна ли техника", "type": "Boolean" }, { "internalName": "NeedsEquipment", "title": "Нужна ли техника", "type": "Boolean" },
{ "internalName": "Software", "title": "Необходимое ПО", "type": "Note" }, { "internalName": "Software", "title": "Необходимое ПО", "type": "Note" },
{ "internalName": "AccessRights", "title": "Доступы", "type": "Note" }, { "internalName": "AccessRights", "title": "Доступы", "type": "Note" },
{ "internalName": "WorkplaceFurnitureJson", "title": "Мебель (JSON)", "type": "Note" },
{ "internalName": "WorkplaceFurnitureOther", "title": "Мебель (другое)", "type": "Text" },
{ "internalName": "WorkplaceApproverChangesJson", "title": "Изменения согласующего — рабочее место (JSON)", "type": "Note" }, { "internalName": "WorkplaceApproverChangesJson", "title": "Изменения согласующего — рабочее место (JSON)", "type": "Note" },
{ "internalName": "SourceHire", "title": "Источник (заявка на прием)", "type": "Lookup", "lookupList": "Прием на работу — данные", "lookupShowField": "Title" } { "internalName": "SourceHire", "title": "Источник (заявка на прием)", "type": "Lookup", "lookupList": "Прием на работу — данные", "lookupShowField": "Title" }

Some files were not shown because too many files have changed in this diff Show More