42 Commits

Author SHA1 Message Date
gorbenko.pavel e34d290865 Мониторинг: логи, ошибки и запись экрана портала заявок (@tnsu/monitoring-web) (#29)
Tests / frontend-test (push) Successful in 1m24s
Логи, ошибки и запись экрана портала заявок в центральный мониторинг (monitoring.tnsu.kz).

**Что поменялось**
- `@tnsu/monitoring-web` 0.3.1 из реестра tansu.
- `RequestPortalWebPart.onInit`: `initMonitoring({ service: 'request-portal', getAccessToken: spfxTokenProvider(this.context.aadTokenProviderFactory) })` — до `installErrorLogging`. Один на страницу (экземпляров веб-части бывает несколько) — это делает сама библиотека.
- `config/package-solution.json`: запрос `Logs.Write` к регистрации мониторинга `9232d9e2-…` (рядом с уже запрошенным `Reports.Read` той же регистрации).
- Хэш коммита сборки (`git.commit` в логах, «Сборка» в плеере): `config/webpack-patch.json` ссылается на патч из пакета (`node_modules/@tnsu/monitoring-web/spfx/webpack-patch.cjs`), он подставляет `__TNSU_GIT_COMMIT__` из `GIT_COMMIT`, `GITHUB_SHA` или `git rev-parse`. Пайплайн менять не нужно.

**Что пишется**
- Необработанные ошибки и отказы промисов, `console.error` (в том числе вывод `installErrorLogging`), `log.*`.
- Запись экрана (rrweb) — **ничего не маскируется**, записывается вся страница SharePoint, не только веб-часть. Неудачные запросы — вкладка «Сеть» в плеере.
- В workbench / при `gulp serve` окружение `development` — в админке мониторинга такие записи по умолчанию скрыты.
- Нет связи с мониторингом час — тост «Ошибка №… Обратитесь к администратору» (не чаще 3 раз в сутки).

**Проверено:** продакшен-сборка (`heft build --production`) проходит, патч применяется, хэш коммита попадает в бандл веб-части.

**После выкатки .sppkg** администратор один раз одобряет запрос: SharePoint admin center → Advanced → API access → `Logs.Write` → Approve. Пока не одобрено, токена нет и данные копятся в браузере (до 50 МБ), портал работает как обычно.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: #29
2026-10-30 14:24:58 +00:00
Тимур Городецкий b1060433d0 feat: «Необходимое ПО» в рабочем месте — мультивыбор из списка
Tests / frontend-test (push) Successful in 1m28s
Вместо свободного текста — выбор из общего списка ПО (как в подборе) со
своим значением. В SP и в маршрут по-прежнему уходит текст через запятую в
колонке Software: условия «Software содержит …» и старые заявки работают
без изменений.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 09:58:32 +05:00
Тимур Городецкий cf039f4d08 refactor: общий список ПО в shared/dictionary
Список программ жил в модели «Заявки на подбор»; теперь он нужен и
«Организации рабочего места». Вынесен в shared/dictionary/software.ts, чтобы
обе формы брали его из одного места.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 09:58:31 +05:00
Тимур Городецкий 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
Тимур Городецкий e8b060add3 Merge branch 'main' of http://git.tnsu.kz/timur.gorodetskiy/request-portal
Tests / frontend-test (push) Successful in 4m25s
2026-10-29 08:52:53 +05:00
Тимур Городецкий ad1234b84a chore: скрипт диагностики маршрута заявки Inspect-Route.ps1
Только чтение: печатает строки маршрута одной заявки и историю версий строк
исполнения — чтобы разбираться, откуда взялся участник и кто перевёл его
в «На исполнении».

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 10:05:02 +05:00
185 changed files with 7713 additions and 670 deletions
+1 -1
View File
@@ -1 +1 @@
@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/
@tnsu:registry=https://git.tnsu.kz/api/packages/tansu/npm/
+4
View File
@@ -30,6 +30,10 @@
"resource": "9232d9e2-9807-4753-808f-07250e5474b0",
"scope": "Reports.Read"
},
{
"resource": "9232d9e2-9807-4753-808f-07250e5474b0",
"scope": "Logs.Write"
},
{
"resource": "Microsoft Flow Service",
"scope": "Flows.Read.All"
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/webpack-patch.schema.json",
"patchFiles": ["./node_modules/@tnsu/monitoring-web/spfx/webpack-patch.cjs"]
}
+2 -2
View File
@@ -269,7 +269,7 @@ Info "Подключаем @tnsu/ui-kit..."
# .npmrc: адрес registry для скоупа @tnsu (НЕ секрет, можно коммитить).
# Токен на чтение — у каждого разработчика свой, в ~/.npmrc (в проект не кладём).
$npmrc = @'
@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/
@tnsu:registry=https://git.tnsu.kz/api/packages/tansu/npm/
'@
$npmrc | Set-Content -Encoding UTF8 ".npmrc"
Success ".npmrc создан (адрес registry @tnsu)."
@@ -282,7 +282,7 @@ try {
} catch {
Warn "Не удалось поставить @tnsu/ui-kit. Скорее всего нет токена @tnsu в ~/.npmrc."
Warn "Создай токен (Gitea → Настройки → Приложения → package: Read) и пропиши в ~/.npmrc:"
Warn " //git.tnsu.kz/api/packages/timur.gorodetskiy/npm/:_authToken=ТВОЙ_ТОКЕН"
Warn " //git.tnsu.kz/api/packages/tansu/npm/:_authToken=ТВОЙ_ТОКЕН"
Warn "Потом выполни: npm install @tnsu/ui-kit"
}
+1
View File
@@ -51,6 +51,7 @@
| [`hr-skud-explanation`](../src/webparts/requestPortal/forms/SkudExplanation/README.md) | Объяснительная об отсутствии отметок в СКУД | Отдел кадров | да | нет | да | только «на себя»; дни выбираются кликом в календаре «Мой СКУД» (проходы Hikvision текущего пользователя), сохраняются снимком для согласующего; текст записки собирается автоматически, печать — бланк объяснительной | ⏳ код готов, списки/тип/маршрут не заведены |
| [`hr-business-trip`](../src/webparts/requestPortal/forms/BusinessTrip/README.md) | Заявка на командировку | Отдел кадров | да | да | да | можно за другого сотрудника; командирующая организация — выбор из организаций сотрудника; маршрут лентой (точки по Казахстану — город из справочника КАТО на русском и казахском + объект из справочника, за рубежом — страна/город/объект вручную; даты не задним числом, поездка ≤ 40 дней; участки с видом транспорта и временем выезда); суточные (≤ 6 МРП КЗ / ≤ 8 за рубежом) и проживание (ночи сами, ≤ 15 000 ₸ за ночь КЗ) — по точкам; комментарий после итога; задание — список задач; смета: статьи с позициями, привязанными к точкам/участкам (суммой или кол-во × цена — всё вручную), итог с долями и по объектам; обязательство по авансовому отчёту — флажок; печать без шапки адресата | ⏳ код готов, списки/тип/маршрут не заведены |
| `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
| [`hr-contract-change`](../src/webparts/requestPortal/forms/ContractChange/README.md) | Изменение условий трудового договора | Отдел кадров | да | — | да | сотрудник из `Employee/FullBase` (и без учётки M365), снимок в текстовых колонках; маршрут по сотруднику, без учётки — по заявителю; 10 видов изменения (один, данные под несколько), поля по каждому виду по ТЗ кадров (кроме «Продления срока ТД»); инициатор работник/работодатель; подписанные вложения по «Что приложить» | ⏳ код готов (кроме вида 6), списки/тип/маршрут не заведены |
| [`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` + `forms/WorkplaceSetup/useApproverRights.ts` |
| Заменять человека на шаге согласования (не удалять шаг) **до отправки**: новая заявка, черновик, доработка | Любой инициатор | право по статусу заявки (форма редактируема) | **Только** Организация рабочего места | `forms/WorkplaceSetup/WorkplaceSetupForm.tsx` (`canReplaceRow`) + `useMatrixEditor.replace` |
| Заполнять «Необходимое ПО» и «Доступы» на своём шаге согласования | Первый шаг маршрута согласования (по маршруту — непосредственный руководитель) | порядок шага в маршруте | Организация рабочего места | `forms/WorkplaceSetup/useApproverRights.ts` (`canEditReviewFields`) |
| Заполнять «Необходимое ПО», «Доступы» и «Мебель» на своём шаге согласования | Первый шаг маршрута согласования (по маршруту — непосредственный руководитель) | порядок шага в маршруте | Организация рабочего места | `forms/WorkplaceSetup/useApproverRights.ts` (`canEditReviewFields`) |
| Править оклад на ИС, оклад после ИС, «Отдел/Участок» на своём шаге согласования | Начальник расчётной группы (сейчас Ким Анна) | должность шага: «начальник/руководитель» + «расчет» | Заявка на приём | `forms/RecruitingHire/approverRights.ts` → `canEditPayrollFields` |
| Ставить дату приёма (2.2) | Активный исполнитель | роль в маршруте (шаг «Исполнение»), без проверки должности | Заявка на приём | `forms/RecruitingHire/RecruitingHireView.tsx` (`hireDateEditable`) |
| Править карту СКУД и сумму за обучение | Начальник отдела кадрового делопроизводства | должность шага: «начальник/руководитель» + «кадров» + «делопроизводств» | Обходной лист | `forms/Clearance/approverRights.ts` → `canEditHrRecordsFields` |
+136 -28
View File
@@ -17,7 +17,9 @@
"@microsoft/sp-webpart-base": "1.22.1",
"@pnp/sp": "^4.20.0",
"@pnp/spfx-controls-react": "^3.25.0",
"@tnsu/ui-kit": "^0.12.0",
"@pnp/telemetry-js": "2.0.0",
"@tnsu/monitoring-web": "^0.3.4",
"@tnsu/ui-kit": "^0.15.0",
"jszip": "~3.8.0",
"react": "17.0.1",
"react-dom": "17.0.1",
@@ -341,7 +343,7 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -351,7 +353,7 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.7",
@@ -382,7 +384,7 @@
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"devOptional": true,
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -408,7 +410,7 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.29.7",
@@ -425,7 +427,7 @@
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"devOptional": true,
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -457,7 +459,7 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.29.7",
@@ -503,7 +505,7 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -513,7 +515,7 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.29.7",
@@ -3979,7 +3981,7 @@
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@@ -8726,6 +8728,28 @@
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@rrweb/rrweb-plugin-console-record": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@rrweb/rrweb-plugin-console-record/-/rrweb-plugin-console-record-2.2.0.tgz",
"integrity": "sha512-2c6LEsoH3+1NFcHckcwuErg7bkpFadt9++uKJh2x9BtWUDxoVKzLp+qLCq6f90MLTjSIABDkAiCswVskENZT3g==",
"license": "MIT",
"peerDependencies": {
"@rrweb/utils": "^2.1.1",
"rrweb": "^2.1.1"
}
},
"node_modules/@rrweb/types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@rrweb/types/-/types-2.2.0.tgz",
"integrity": "sha512-2gTA/pRpIbuET123oSdX5ZUDZfWCOIMYBvpSai3fr5pk9LcbccPqqaXflX+roP4oKenLjNtJPGpFK3BfYeBKnA==",
"license": "MIT"
},
"node_modules/@rrweb/utils": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@rrweb/utils/-/utils-2.2.0.tgz",
"integrity": "sha512-e8FEp8wLswqgjDHpQNUUVfEXeDsrRMOVouvqIGKvnPxKYonjLlh0mU3O/17QNvCixFG5eUFR1B2jgTdiLlZxeQ==",
"license": "MIT"
},
"node_modules/@rushstack/debug-certificate-manager": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/@rushstack/debug-certificate-manager/-/debug-certificate-manager-1.6.2.tgz",
@@ -9885,10 +9909,32 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/@tnsu/monitoring-web": {
"version": "0.3.4",
"resolved": "https://git.tnsu.kz/api/packages/tansu/npm/%40tnsu%2Fmonitoring-web/-/0.3.4/monitoring-web-0.3.4.tgz",
"integrity": "sha512-tQe0ZM1tBVO3XtbNFliRaaooqvCX3gb/Q/j2lV6+rYCXprodBZByQ6PLrCu/P0me/rW8KdQ02hu7avyLXXfkoQ==",
"license": "UNLICENSED",
"dependencies": {
"@rrweb/rrweb-plugin-console-record": "^2.2.0",
"rrweb": "^2.2.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"vue": "^3.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/@tnsu/ui-kit": {
"version": "0.12.0",
"resolved": "http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/%40tnsu%2Fui-kit/-/0.12.0/ui-kit-0.12.0.tgz",
"integrity": "sha512-lJYf2DoYaydrktbSotRNY0umeJlQw8Ekhh6fu1FGB0p+/TXTozpBfAs+TxK/mvOXlS9yYenEuGBi85fd/dRF2A==",
"version": "0.15.0",
"resolved": "https://git.tnsu.kz/api/packages/tansu/npm/%40tnsu%2Fui-kit/-/0.15.0/ui-kit-0.15.0.tgz",
"integrity": "sha512-vs+7NPem7PDaYj00nwaFztkw2K/nOkw6m2bvqgMABZG5rWjLVqfyFyiopKFkPYgqPWcMPwmQuxoFnc9xi7rwjQ==",
"license": "ISC",
"peerDependencies": {
"@fluentui/react": "^8.106.4",
@@ -10022,6 +10068,12 @@
"@types/node": "*"
}
},
"node_modules/@types/css-font-loading-module": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz",
"integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==",
"license": "MIT"
},
"node_modules/@types/ejs": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz",
@@ -10250,7 +10302,7 @@
"version": "26.1.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
"integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
@@ -10279,6 +10331,7 @@
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/qs": {
@@ -10308,6 +10361,7 @@
"version": "17.0.45",
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.45.tgz",
"integrity": "sha512-YfhQ22Lah2e3CHPsb93tRwIGNiSwkuz1/blk4e6QrWS0jQzCSNbGLtOEYhPg02W0yGTTmpajp7dCTbBAMN3qsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -10319,6 +10373,7 @@
"version": "17.0.17",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.17.tgz",
"integrity": "sha512-VjnqEmqGnasQKV0CWLevqMTXBYG9GbwuE6x3VetERLh0cq2LTptFE73MrQi2S7GkKXCf2GgwItB/melLnxfnsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "^17"
@@ -10341,6 +10396,7 @@
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/send": {
@@ -11627,6 +11683,12 @@
"node": ">=10.0.0"
}
},
"node_modules/@xstate/fsm": {
"version": "1.6.5",
"resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-1.6.5.tgz",
"integrity": "sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==",
"license": "MIT"
},
"node_modules/@xtuc/ieee754": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
@@ -12513,6 +12575,15 @@
}
}
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -12537,7 +12608,7 @@
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.7.tgz",
"integrity": "sha512-APw5YuIQAg6L9w4sHDI6j26DGFJI6RpYOhnkMPdC9lWbkKvsyPHzDsve1yd73lk21yz7Y09Kci8B2Pp9FonzWA==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
@@ -12711,7 +12782,7 @@
"version": "4.28.7",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
"integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==",
"devOptional": true,
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -12920,7 +12991,7 @@
"version": "1.0.30001806",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
"integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
"devOptional": true,
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -13345,7 +13416,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
@@ -14234,7 +14305,7 @@
"version": "1.5.398",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.398.tgz",
"integrity": "sha512-AsvhAxopJGh6museTDMIjn6JpDYOfgu4RLlygomt87MUwBUqTfd/1EiPtx10/LZE8xpTvkP2E9Gafq7lkLtodQ==",
"devOptional": true,
"dev": true,
"license": "ISC"
},
"node_modules/embla-carousel": {
@@ -14564,7 +14635,7 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -15984,7 +16055,7 @@
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -19133,7 +19204,7 @@
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
@@ -19813,6 +19884,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/mkdirp": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
@@ -19935,7 +20012,6 @@
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"dev": true,
"funding": [
{
"type": "github",
@@ -20077,7 +20153,7 @@
"version": "2.0.51",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
"integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -20742,7 +20818,6 @@
"version": "8.5.25",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -22018,6 +22093,40 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rrdom": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/rrdom/-/rrdom-2.2.0.tgz",
"integrity": "sha512-gxcBJWVtS/Xz+Ys8xcjPoWqr+C7wz/MS8NgmKw136I47Xygy17FYOGZqoGwEyvBn724jRDK1ljS+8w9FsZStOg==",
"license": "MIT",
"dependencies": {
"rrweb-snapshot": "^2.2.0"
}
},
"node_modules/rrweb": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/rrweb/-/rrweb-2.2.0.tgz",
"integrity": "sha512-ofVzPq11/yIQ7WhVb++rJGI2LoPPrxdqu0QAvRA4KGeXJ7LH52jQ0VlRH4+VXn5PA/7pz85/rRkQyBUqT1LraQ==",
"license": "MIT",
"dependencies": {
"@rrweb/types": "^2.2.0",
"@rrweb/utils": "^2.2.0",
"@types/css-font-loading-module": "0.0.7",
"@xstate/fsm": "^1.4.0",
"base64-arraybuffer": "^1.0.1",
"mitt": "^3.0.0",
"rrdom": "^2.2.0",
"rrweb-snapshot": "^2.2.0"
}
},
"node_modules/rrweb-snapshot": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/rrweb-snapshot/-/rrweb-snapshot-2.2.0.tgz",
"integrity": "sha512-Tg/PiCLy+zV9C0Jfb4IAGABtt5l/UOvwW5xjFk41ZQhBfUjeqPP1rZ9HMDrFPc7cawCUEenWw54+g/4vluO5lg==",
"license": "MIT",
"dependencies": {
"postcss": "^8.4.38"
}
},
"node_modules/rtl-css-js": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz",
@@ -23127,7 +23236,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -24407,7 +24515,7 @@
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/unicorn-magic": {
@@ -24461,7 +24569,7 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"devOptional": true,
"dev": true,
"funding": [
{
"type": "opencollective",
+3 -1
View File
@@ -21,7 +21,9 @@
"@microsoft/sp-webpart-base": "1.22.1",
"@pnp/sp": "^4.20.0",
"@pnp/spfx-controls-react": "^3.25.0",
"@tnsu/ui-kit": "^0.12.0",
"@pnp/telemetry-js": "2.0.0",
"@tnsu/monitoring-web": "^0.3.4",
"@tnsu/ui-kit": "^0.15.0",
"jszip": "~3.8.0",
"react": "17.0.1",
"react-dom": "17.0.1",
+68
View File
@@ -0,0 +1,68 @@
<#
.SYNOPSIS
Диагностика: печатает строки маршрута одной заявки из списка «— согласование»
(StepOrder, статус, фаза, кем переназначено, кто создал/менял) и историю версий
строк исполнения — чтобы понять, откуда в маршруте взялся участник и кто
перевёл его в «На исполнении». Только чтение, ничего не меняет.
.EXAMPLE
./Inspect-Route.ps1 -SiteUrl "https://tnsukz.sharepoint.com/sites/requests" `
-ClientId "7459c965-5ed4-4673-8140-0ab8faf9db64" -Tenant "tnsukz.onmicrosoft.com" `
-Thumbprint "224B64C8E0CAB1E09AF373E92B3AF4AC33C1233C" `
-ListTitle "Прием на работу 1 — согласование" -RequestId 68
#>
param(
[Parameter(Mandatory = $true)][string]$SiteUrl,
[Parameter(Mandatory = $true)][string]$ClientId,
[Parameter(Mandatory = $true)][string]$Tenant,
[Parameter(Mandatory = $true)][string]$Thumbprint,
[Parameter(Mandatory = $true)][string]$ListTitle,
[Parameter(Mandatory = $true)][int]$RequestId
)
$ErrorActionPreference = 'Stop'
Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Tenant $Tenant -Thumbprint $Thumbprint
$query = "<View><Query><Where><Eq><FieldRef Name='Ticket' LookupId='TRUE'/><Value Type='Lookup'>$RequestId</Value></Eq></Where>" +
"<OrderBy><FieldRef Name='StepOrder'/></OrderBy></Query></View>"
$items = Get-PnPListItem -List $ListTitle -Query $query
function UserText($value) {
if ($null -eq $value) { return '' }
return $value.Email
}
Write-Host "`nМаршрут заявки #$RequestId («$ListTitle»):`n" -ForegroundColor Cyan
$items | ForEach-Object {
[pscustomobject]@{
Id = $_.Id
StepOrder = $_['StepOrder']
Phase = $_['Phase']
Status = $_['ApprovalStatus']
Approver = UserText $_['TansuSiteApprover']
DelegatedBy = UserText $_['DelegatedBy']
Reassign = $_['AllowReassignToOtherApprovers']
Created = $_['Created']
CreatedBy = UserText $_['Author']
Modified = $_['Modified']
ModifiedBy = UserText $_['Editor']
}
} | Format-Table -AutoSize
# История версий — только строки исполнения: там видно, кто и когда менял статус.
$items | Where-Object { $_['Phase'] -eq 'Исполнение' } | ForEach-Object {
Write-Host "`nВерсии строки Id=$($_.Id) ($(UserText $_['TansuSiteApprover'])):" -ForegroundColor Yellow
Get-PnPProperty -ClientObject $_ -Property Versions | Out-Null
$_.Versions | ForEach-Object {
Get-PnPProperty -ClientObject $_ -Property CreatedBy | Out-Null
[pscustomobject]@{
Version = $_.VersionLabel
Created = $_.Created
By = $_.CreatedBy.Email
Status = $_['ApprovalStatus']
StepOrder = $_['StepOrder']
}
} | Format-Table -AutoSize
}
Disconnect-PnPOnline
@@ -9,6 +9,8 @@ import {
} from '@microsoft/sp-property-pane';
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
import { IReadonlyTheme } from '@microsoft/sp-component-base';
import PnPTelemetry from '@pnp/telemetry-js';
import { initMonitoring, spfxTokenProvider } from '@tnsu/monitoring-web';
import * as strings from 'RequestPortalWebPartStrings';
import RequestPortal from './components/RequestPortal';
@@ -98,7 +100,14 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
}
protected onInit(): Promise<void> {
// Логи, ошибки, console.error и запись экрана в мониторинг (monitoring.tnsu.kz). Токен — молча через
// AAD веб-части (разрешение Logs.Write в package-solution.json); при отладке в workbench окружение — development.
// Один на страницу: повторный вызов (несколько веб-частей) библиотека игнорирует сама.
initMonitoring({ service: 'request-portal', getAccessToken: spfxTokenProvider(this.context.aadTokenProviderFactory) });
installErrorLogging(); // до всего остального — чтобы поймать и ранние отклонения
// Телеметрия PnP (её включает @pnp/spfx-controls-react) шлёт fetch на pnptelemetryproxy.azurewebsites.net
// без catch: где адрес закрыт прокси или блокировщиком, это необработанный отказ промиса в мониторинге.
PnPTelemetry.getInstance().optOut();
initSP(this.context); // единый клиент PnPjs для записи в списки
// Адрес approval-route должен быть выставлен ДО загрузки партиций — они
@@ -42,6 +42,14 @@ export const REQUEST_TYPES: IRequestType[] = [
icon: 'people',
comingSoon: true
},
// Пока только тестировщикам: каркас без полей по видам; тип и маршрут не заведены в админке approval-route.
{
id: 'hr-contract-change',
categoryId: 'hr',
title: 'Изменение условий трудового договора',
icon: 'document',
comingSoon: true
},
// Склад / Снабжение — формы открыты для ознакомления, но приём заявок ещё не
// запущен (маршруты/списки не готовы): кнопки сохранения/отправки в самих
@@ -2,7 +2,7 @@ import * as React from 'react';
import { useState, useEffect, useMemo } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
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 {
ApproverService,
ApprovalMatrix,
@@ -10,6 +10,7 @@ import {
StatusBadge,
ApprovalHistory,
notifyActiveStep,
notifyInitiatorOfReturn,
useRequestTypeId,
usePhasedRoute
} from '../../shared/approval';
@@ -54,6 +55,7 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>('');
const [employeeName, setEmployeeName] = useState<string>('');
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
@@ -83,6 +85,7 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
setData(loaded.data);
setStatus(loaded.status);
setCode(loaded.code);
setEmployeeName(loaded.employeeName);
notifyCode(loaded.code);
setError(null);
setLoading(false);
@@ -108,14 +111,17 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
);
}
const notify = (): void => {
const notify = (newStatus?: string): void => {
// Решение уже применено; без requestTypeId просто некого уведомлять — UI не роняем.
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
const target = {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined);
};
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
};
const sectionProps = { data, set: NOOP_SET, context };
@@ -124,7 +130,9 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
return (
<div>
<div className={`${styles.toolbar} rp-no-print`}>
<PrintButton />
<PrintButton
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
/>
</div>
<div className="rp-print-root">
@@ -152,6 +160,7 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
{!executors.loading && executors.rows.length > 0 && (
<ApprovalMatrix
editor={executors}
printable={false}
search={searchUsers}
title="Исполнение"
roleLabel="Исполнитель"
@@ -173,7 +182,7 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
notify();
notify(newStatus);
}}
onDelegated={() => {
setDecisionSeq((n) => n + 1);
@@ -11,6 +11,8 @@ export interface ILoadedRequest {
status: string;
/** Уникальный код заявки; '' — если запись создана до появления поля. */
code: string;
/** ФИО командируемого (Employee/Title) — для имени печатного документа. */
employeeName: string;
}
/**
@@ -37,11 +39,12 @@ export async function loadBusinessTripRequest(itemId: number, listId?: string):
'TripObligationAccepted',
'RequestStatus',
'RequestUniqueCode',
'Employee/EMail'
'Employee/EMail',
'Employee/Title'
)
.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 = {
...EMPTY_DATA,
@@ -61,6 +64,7 @@ export async function loadBusinessTripRequest(itemId: number, listId?: string):
return {
data,
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 type { WebPartContext } from '@microsoft/sp-webpart-base';
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 {
ApproverService,
DecisionPanel,
@@ -11,6 +20,7 @@ import {
ApprovalHistory,
useMatrixEditor,
notifyActiveStep,
notifyInitiatorOfReturn,
useRequestTypeId,
APPROVAL_PHASE
} from '../../shared/approval';
@@ -47,6 +57,8 @@ interface IClearanceViewProps {
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
const FORM_TITLE = 'Обходной лист';
// Обходной лист в режиме согласующего. Форма read-only, КРОМЕ отметок, которые
// заполняет профильный отдел на своём шаге: КДП — карта СКУД и сумма обучения,
// отдел подбора — парковочная карта (см. approverRights.ts).
@@ -59,6 +71,8 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>('');
// ФИО увольняемого — для имени печатного документа.
const [employeeName, setEmployeeName] = useState<string>('');
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [hrRecordsEditable, setHrRecordsEditable] = useState<boolean>(false);
@@ -122,6 +136,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
setData(loaded.data);
setStatus(loaded.status);
setCode(loaded.code);
setEmployeeName(loaded.employeeName);
notifyCode(loaded.code);
setError(null);
setLoading(false);
@@ -255,22 +270,30 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
printOrgIndex
};
const notify = (): void => {
const notify = (newStatus?: string): void => {
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
// весь UI ради необязательного оповещения.
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
const target = {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined);
};
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
};
return (
<div>
<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>
{canEditAnything && (
@@ -291,7 +314,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
<div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard
title="Обходной лист"
title={FORM_TITLE}
subtitle={`№${code || itemId}`}
belowSubtitle={
<InitiatorLine
@@ -311,6 +334,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
{!executors.loading && executors.rows.length > 0 && (
<ApprovalMatrix
editor={executors}
printable={false}
search={(q) => approval.searchUsers(q)}
title="Исполнение"
roleLabel="Исполнитель"
@@ -348,7 +372,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
notify();
notify(newStatus);
}}
onDelegated={() => {
setDecisionSeq((n) => n + 1);
@@ -43,7 +43,8 @@ import {
dataListTitle,
dataListTitleForListId
} 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 { loadConcreteRequest } from './load';
import { HeaderSection } from './sections/HeaderSection';
@@ -167,6 +168,19 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
.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) —
// 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 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 => {
if (!isValid) {
return 'Заполните обязательные поля: дата поставки, начальник участка, проект и хотя бы одну позицию (класс смеси + количество).';
return 'Заполните обязательные поля: дата поставки, начальник участка, проект, БСУ и хотя бы одну позицию (класс смеси + количество).';
}
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
@@ -303,7 +317,9 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
set,
context,
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 type { WebPartContext } from '@microsoft/sp-webpart-base';
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 {
ApproverService,
DecisionPanel,
@@ -12,6 +20,7 @@ import {
ApprovalHistory,
useMatrixEditor,
notifyActiveStep,
notifyInitiatorOfReturn,
useRequestTypeId,
useViewExecutors,
approvalPhaseRows
@@ -41,6 +50,8 @@ interface IConcreteRequestViewProps {
onCodeLoaded?: (code: string) => void;
}
const FORM_TITLE = 'Заявка на бетон';
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
const NOOP_SET: SetField = () => undefined;
@@ -118,28 +129,36 @@ const ConcreteRequestView: React.FC<IConcreteRequestViewProps> = ({ context, ite
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
const notify = (): void => {
const notify = (newStatus?: string): void => {
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
// весь UI ради необязательного оповещения.
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
const target = {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined);
};
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
};
return (
<div>
<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 className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard
title="Заявка на бетон"
title={FORM_TITLE}
subtitle={`№${code || itemId}`}
belowSubtitle={
<InitiatorLine
@@ -175,7 +194,7 @@ const ConcreteRequestView: React.FC<IConcreteRequestViewProps> = ({ context, ite
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
notify();
notify(newStatus);
}}
onDelegated={() => {
setDecisionSeq((n) => n + 1);
@@ -51,11 +51,20 @@
здесь другой, поэтому у формы свой редактор. Карточки (`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`): `Заявка на бетон — {ФИО начальника участка} — дд.мм.гггг`.
## Секции формы
HeaderSection (дата поставки · начальник участка · организация (read-only) ·
проект · мастер участка) + ItemsEditor (табличная часть поставок)
проект · БСУ · мастер участка) + ItemsEditor (табличная часть поставок)
## Точки входа
- 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',
'Project',
'ProjectId',
'BsuId',
'BsuName',
'ItemsJson',
'RequestStatus',
'RequestUniqueCode'
@@ -62,6 +64,8 @@ export async function loadConcreteRequest(itemId: number, listId?: string): Prom
Organization: (item.Organization as string) || '',
Project: (item.Project as string) || '',
ProjectId: (item.ProjectId as string) || '',
BsuId: (item.BsuId as string) || '',
BsuName: (item.BsuName as string) || '',
Items: parseItems(item.ItemsJson)
};
@@ -1,6 +1,7 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { ISelectOption } from '../../shared/ui';
import { RequestTypeId } from '../../models/types';
import { IBsuCatalog } from './bsu/useBsuCatalog';
/**
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
@@ -18,8 +19,18 @@ export const FORM_ID: RequestTypeId = 'wh-concrete-request';
* согласования (ByProject, см. IFormData).
*/
export interface IConcreteItem {
/** Класс/марка смеси и краткая характеристика (напр. «C20/25 F75 W6»). */
/**
* Класс/марка смеси. Выбран рецепт БСУ — название рецепта; API БСУ недоступен
* или заявка старая — свободный текст (напр. «C20/25 F75 W6»).
*/
mixClass: string;
/**
* id рецепта БСУ (GET /bsu/{id}/formulas). Нет — рецепт введён вручную или
* заявка подана до справочника БСУ, поэтому поле необязательное.
*/
formulaId?: number;
/** Состав рецепта на момент подачи — снимок: на заводе рецепт могут поменять. */
ingredients?: string;
/** Количество, м³. */
volume: string;
/** Подвижность смеси — см. WORKABILITY_OPTIONS. */
@@ -38,6 +49,12 @@ export const EMPTY_CONCRETE_ITEM: IConcreteItem = {
note: ''
};
/** Позиция без рецепта БСУ. Рецепт, введённый вручную (без formulaId), не трогаем. */
export function withoutFormula(item: IConcreteItem): IConcreteItem {
if (item.formulaId === undefined) return item;
return { ...item, formulaId: undefined, mixClass: '', ingredients: '' };
}
/** Подвижность бетонной смеси (ГОСТ 7473) — фиксированный набор, чтобы не плодить опечатки. */
export const WORKABILITY_OPTIONS: ISelectOption[] = [
{ value: 'П1', label: 'П1' },
@@ -90,6 +107,10 @@ export interface IFormData {
Project: string;
/** oid проекта из справочника; '' — введён вручную без выбора. */
ProjectId: string;
/** uuid БСУ (бетонного завода) из API БСУ; '' — введён вручную (API недоступен). */
BsuId: string;
/** Название БСУ — для просмотра и печати без обращения к API. */
BsuName: string;
/** Табличная часть — позиции поставки (сериализуется в ItemsJson). */
Items: IConcreteItem[];
}
@@ -108,6 +129,10 @@ export interface ISectionProps {
onSiteChiefChange?: (login: string) => void;
/** Выбор мастера участка подтягивает его ФИО. Не передан (просмотр) — правка через set. */
onForemanChange?: (login: string) => void;
/** Справочник БСУ. Не передан (просмотр) — БСУ и рецепты показываются сохранённым текстом. */
bsu?: IBsuCatalog;
/** Смена БСУ сбрасывает выбранные в позициях рецепты (они от прежнего завода). */
onBsuChange?: (bsuId: string, bsuName: string) => void;
}
export const SITE_CHIEF_PLACEHOLDER = 'Начните вводить Ф.И.О. начальника участка…';
@@ -122,5 +147,7 @@ export const EMPTY_DATA: IFormData = {
Organization: '',
Project: '',
ProjectId: '',
BsuId: '',
BsuName: '',
Items: []
};
@@ -12,6 +12,8 @@
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
{ "internalName": "Project", "title": "Проект", "type": "Text", "required": true },
{ "internalName": "ProjectId", "title": "Id проекта (справочник)", "type": "Text" },
{ "internalName": "BsuId", "title": "Id БСУ (API БСУ)", "type": "Text" },
{ "internalName": "BsuName", "title": "БСУ", "type": "Text" },
{ "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 { ProjectPicker } from '../../../shared/dictionary';
import { ISectionProps, SITE_CHIEF_PLACEHOLDER, FOREMAN_PLACEHOLDER } from '../model';
import { BsuField } from './BsuField';
/** Шапка заявки на бетон: дата поставки, начальник участка (составил), мастер участка. */
/** Шапка заявки на бетон: дата поставки, начальник участка (составил), проект, БСУ, мастер участка. */
export const HeaderSection: React.FC<ISectionProps> = ({
data,
set,
context,
printOrgIndex,
onSiteChiefChange,
onForemanChange
onForemanChange,
bsu,
onBsuChange
}) => (
<FormGrid>
<FormField label="Дата поставки" htmlFor="RequestDate" required>
@@ -37,6 +40,18 @@ export const HeaderSection: React.FC<ISectionProps> = ({
placeholder="Начните вводить название или код проекта…"
/>
</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>
<PeopleField
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 {
padding: $sp-3 0;
font-family: $font-sans;
@@ -2,22 +2,25 @@ import * as React from 'react';
import styles from './ItemsEditor.module.scss';
import { Button, Icon, TextInput, Select, FormGrid, FormField, useFormReadOnly } from '../../../shared/ui';
import { EMPTY_CONCRETE_ITEM, IConcreteItem, WORKABILITY_OPTIONS } from '../model';
import { IRecipePicker, MixClassField } from './MixClassField';
export interface IItemsEditorProps {
value: IConcreteItem[];
onChange: (rows: IConcreteItem[]) => void;
/** Рецепты БСУ; не передан — класс смеси вводится текстом (см. MixClassField). */
recipePicker?: IRecipePicker;
}
/**
* Табличная часть заявки на бетон: класс смеси, объём, подвижность, время и
* примечание — карточка на каждую поставку. Проект/объект сюда не входит: он
* Табличная часть заявки на бетон: класс смеси (рецепт БСУ), объём, подвижность,
* время и примечание — карточка на каждую поставку. Проект/объект сюда не входит: он
* один на всю заявку и выбирается в шапке (см. HeaderSection, IFormData.Project) —
* от него зависит маршрут согласования, поэтому поле не дублируется по позициям.
*
* НЕ плоская таблица-простыня (как shared/ui/ItemsTable): карточки с обычным
* FormGrid адаптивнее узкой строки с горизонтальным скроллом на мобильных.
*/
export const ItemsEditor: React.FC<IItemsEditorProps> = ({ value, onChange }) => {
export const ItemsEditor: React.FC<IItemsEditorProps> = ({ value, onChange, recipePicker }) => {
const readOnly = useFormReadOnly();
const setRow = (index: number, patch: Partial<IConcreteItem>): void => {
@@ -43,13 +46,8 @@ export const ItemsEditor: React.FC<IItemsEditorProps> = ({ value, onChange }) =>
)}
</div>
<FormGrid>
<FormField label="Класс смеси и хар-ка" htmlFor={`mixClass-${i}`} required full>
<TextInput
id={`mixClass-${i}`}
value={row.mixClass}
onChange={(v) => setRow(i, { mixClass: v })}
placeholder="C20/25 F75 W6"
/>
<FormField label="Класс смеси (рецепт)" htmlFor={`mixClass-${i}`} required full>
<MixClassField id={`mixClass-${i}`} row={row} picker={recipePicker} onChange={(patch) => setRow(i, patch)} />
</FormField>
<FormField label="Количество, м³" htmlFor={`volume-${i}`} required>
<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 { CollapseSection } from '../../../shared/ui';
import styles from './ItemsEditor.module.scss';
import { Alert, CollapseSection } from '../../../shared/ui';
import { ItemsEditor } from './ItemsEditor';
import { IRecipePicker } from './MixClassField';
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">
<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>
);
@@ -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>
);
};
@@ -0,0 +1,21 @@
@import '../../../../styles/design-tokens';
// Ссылка «К списку заявок» — как в остальных формах (см. DismissalForm.module.scss).
.back {
display: inline-flex;
align-items: center;
gap: $sp-2;
margin-bottom: $sp-4;
padding: 0;
border: 0;
background: transparent;
color: $navy-700;
font-family: $font-sans;
font-size: $fs-sm;
cursor: pointer;
transition: color .18s;
&:hover {
color: $gold-600;
}
}
@@ -0,0 +1,295 @@
import * as React from 'react';
import { useState, useMemo, useEffect } from 'react';
import styles from './ContractChangeForm.module.scss';
import {
Alert,
FormCard,
InitiatorLine,
FormModeProvider,
Icon,
RequestNumber,
SubmitActions,
getDraftDeleteHandler,
useRequestSubmit
} from '../../shared/ui';
import {
composeRemoveRowRule,
ApprovalMatrix,
ExecutorsMatrix,
ApprovalHistory,
StatusBadge,
notifyActiveStep,
useRequestTypeId,
REQUEST_STATUS,
isEditableStatus,
unassignedStepMessage,
resolveWriteListId,
executorsToWrite,
initialSubmitStatus
} from '../../shared/approval';
import { effectiveUserEmail } from '../../shared/impersonation';
import { IRequestFormProps } from '../../models/types';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField, REQUIRED_FIELDS_MESSAGE, routeEmail } from './model';
import { saveContractChangeRequest, contractChangeApprovalService } from './submit';
import { loadContractChangeRequest } from './load';
import { useContractEmployee } from './useContractEmployee';
import { useInitiatorAutofill } from './useInitiatorAutofill';
import { RequestSection } from './sections/RequestSection';
import { ChangeTypeSections } from './byType/ChangeTypeSections';
import { prepareFilesFor } from './byType/attachmentSlots';
import { changeTypeSubmitError } from './byType/submitChecks';
import { useContractRoute, matrixToApprovers } from './useContractRoute';
import { useAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
import { getAttachments } from '../../shared/attachments/attachments.service';
import { useStableCallback } from '../../shared/useStableCallback';
const ContractChangeForm: React.FC<IRequestFormProps> = ({
requestType,
context,
onCancel,
onSaved,
editId,
editListId,
onCodeLoaded
}) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [requestId, setRequestId] = useState<number | undefined>(editId);
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
const submit = useRequestSubmit();
// Стабильная обёртка над колбэком родителя — иначе заявка перезапрашивалась бы
// при каждом рендере родителя (см. DismissalForm).
const notifyCode = useStableCallback(onCodeLoaded);
const { setError: setSubmitError } = submit;
const readOnly = !isEditableStatus(requestStatus);
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
const applicantEmail = effectiveUserEmail(context);
const approval = useMemo(() => contractChangeApprovalService(editListId), [editListId]);
const requestTypeIdState = useRequestTypeId(context, requestType.id);
const requestTypeId = requestTypeIdState.id;
// Вложения (подписанные слоты по ТЗ «Что приложить», см. byType/attachmentSlots).
const dataListRef = editListId || dataListTitle(FORM_ID);
const attachments = useAttachmentEditor(dataListRef, requestId);
const { setItems: setAttachmentItems } = attachments;
// Открыта СВОЯ заявка на редактирование (черновик/возврат) — подгружаем содержимое, статус и вложения.
useEffect(() => {
if (!editId) {
return;
}
let alive = true;
Promise.all([loadContractChangeRequest(editId, editListId), getAttachments(dataListRef, editId)])
.then(([loaded, files]) => {
if (!alive) return;
setData(loaded.data);
setAttachmentItems(files);
setRequestStatus(loaded.status);
setRequestCode(loaded.code || undefined);
if (loaded.code) notifyCode(loaded.code);
})
.catch((e) => {
if (alive) {
setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
}
});
return () => {
alive = false;
};
}, [editId, editListId, dataListRef, notifyCode, setSubmitError, setAttachmentItems]);
// Сотрудник + его организация (как в командировке); новая заявка — по умолчанию на себя.
const employeeControl = useContractEmployee(context, applicantEmail, !editId, data.Employee, (record) =>
set('Employee', record)
);
// Пропсы шапки: сотрудник + инициатор, который следует за сотрудником, пока его не выбрали руками.
const headerProps = useInitiatorAutofill(!!editId, data, set, applicantEmail, employeeControl);
// Маршрут — по сотруднику, без учётной записи — по заявителю (см. routeEmail); при
// переводе в другое подразделение принимающий руководитель — первым шагом.
const { matrix, executors } = useContractRoute({
context,
approval,
requestTypeId,
routeUserEmail: routeEmail(employeeControl.person, applicantEmail),
editId,
requestStatus,
readOnly,
receivingHeadEmail:
!readOnly && data.ChangeTypes.includes('department-transfer') ? data.DepartmentTransferReceivingHead : ''
});
const isValid = !!data.Employee && data.ChangeTypes.length > 0;
const validate = (): string | undefined => {
if (!isValid) return REQUIRED_FIELDS_MESSAGE;
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
return undefined;
};
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
// validate() уже гарантировал requestTypeId, но TS этого через замыкание не видит.
const rtId = requestTypeId;
if (!rtId) {
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
}
// Новая заявка пишется в ТЕКУЩУЮ партицию — спрашиваем её прямо перед записью.
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
const { id, code } = await saveContractChangeRequest(
requestId,
rtId,
data,
matrixToApprovers(matrix.rows),
executorsToWrite(executors.rows),
status,
writeListId
);
// id фиксируем ДО загрузки файлов: если она упадёт, повторное сохранение
// обновит эту же заявку, а не создаст вторую.
setRequestId(id);
setRequestStatus(status);
if (code) setRequestCode(code);
// Файлы — после записи элемента (нужен его id), с подписью-префиксом слота.
try {
await attachments.uploadPending(prepareFilesFor(data), {
listRef: writeListId || dataListTitle(FORM_ID),
itemId: id
});
} catch (e) {
throw new Error(`Заявка сохранена, но файлы не прикрепились: ${friendlyErrorMessage(e)}. Сохраните ещё раз.`);
}
if (onSaved) onSaved();
// Отправка — уведомляем первый активный шаг; списки той партиции, куда записали.
if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) {
await notifyActiveStep(context, approval, id, {
requestTypeId: rtId,
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID)
});
}
};
// На отправку маршрут должен быть укомплектован — шаг без человека молча пропал бы при записи.
const validateSubmit = (): string | undefined => {
const formError = validate();
if (formError) return formError;
// Обязательное по виду изменения (документы, принимающий руководитель и т.п.) — только на отправку.
const typeError = changeTypeSubmitError(data, attachments);
if (typeError) return typeError;
if (matrix.error) {
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
}
const unassignedError = unassignedStepMessage(
matrix.rows,
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
);
if (unassignedError) return unassignedError;
return unassignedStepMessage(
executors.rows,
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
);
};
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
const handleSubmit = (): Promise<void> =>
submit.run(
'submit',
save(
executors.rows.length > 0
? initialSubmitStatus(matrix.rows.length, executors.rows.length)
: REQUEST_STATUS.onApproval
),
validateSubmit
);
const handleDelete = getDraftDeleteHandler({
formKey: FORM_ID,
requestId,
requestStatus,
listId: editListId,
code: requestCode,
submit,
onDeleted: onCancel
});
return (
<div>
<button type="button" className={styles.back} onClick={onCancel}>
<Icon name="arrow-left" size="sm" />
<span>К списку заявок</span>
</button>
<FormCard
title={requestType.title}
subtitle="Категория: Отдел кадров"
belowSubtitle={
<InitiatorLine
listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
itemId={requestId}
/>
}
headerRight={
requestId || requestStatus ? (
<>
{requestStatus && <StatusBadge status={requestStatus} />}
{requestCode && <RequestNumber code={requestCode} />}
</>
) : undefined
}
>
{submit.savedKind && !submit.error && (
<Alert variant="ok">
{submit.savedKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'}
</Alert>
)}
{submit.error && <Alert variant="err">{submit.error}</Alert>}
<FormModeProvider readOnly={readOnly}>
<RequestSection data={data} set={set} context={context} {...headerProps} />
<ChangeTypeSections data={data} set={set} context={context} attachments={attachments} />
</FormModeProvider>
<ApprovalMatrix
editor={matrix}
search={(q) => approval.searchUsers(q)}
canRemoveRow={composeRemoveRowRule(applicantEmail)}
/>
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
{/* Автору на доработке важно видеть, почему вернули, — история и в форме. */}
{editId && (
<div className="rp-no-print">
<ApprovalHistory
approval={approval}
dataListRef={editListId || dataListTitle(FORM_ID)}
requestId={editId}
reloadKey={requestStatus}
/>
</div>
)}
<SubmitActions
onDelete={handleDelete}
submit={submit}
isValid={isValid}
readOnly={readOnly}
onCancel={onCancel}
onSaveDraft={handleSaveDraft}
onSubmit={handleSubmit}
submitLabel="Отправить на согласование"
/>
</FormCard>
</div>
);
};
export default ContractChangeForm;
@@ -0,0 +1,9 @@
@import '../../../../styles/design-tokens';
// Заглушка на время загрузки/ошибки — как в остальных просмотрах.
.state {
padding: $sp-8;
color: $gray-500;
font-size: $fs-sm;
text-align: center;
}
@@ -0,0 +1,171 @@
import * as React from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './ContractChangeView.module.scss';
import { Alert, FormCard, InitiatorLine, FormModeProvider } from '../../shared/ui';
import {
ApproverService,
DecisionPanel,
StatusBadge,
ApprovalMatrix,
ApprovalHistory,
useMatrixEditor,
useViewExecutors,
ExecutorsMatrix,
approvalPhaseRows,
notifyActiveStep,
useRequestTypeId
} from '../../shared/approval';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { loadContractChangeRequest } from './load';
import { contractChangeApprovalService } from './submit';
import { RequestSection } from './sections/RequestSection';
import { ChangeTypeSections } from './byType/ChangeTypeSections';
import { useAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
import { getAttachments } from '../../shared/attachments/attachments.service';
import { useStableCallback } from '../../shared/useStableCallback';
interface IContractChangeViewProps {
context: WebPartContext;
itemId: number;
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
formKey: string;
/** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */
listId?: string;
/** Уведомление о загруженном уникальном коде заявки — см. IApprovalViewProps.onCodeLoaded. */
onCodeLoaded?: (code: string) => void;
}
const VIEW_TITLE = 'Изменение условий трудового договора';
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
const NOOP_SET: SetField = () => undefined;
/**
* Заявка только для просмотра (режим согласующего): та же секция, что в форме
* подачи, под <FormModeProvider readOnly>, + маршрут, история и панель решения.
* Печати пока нет — бланк кадров ещё не согласован.
*/
const ContractChangeView: React.FC<IContractChangeViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
const notifyCode = useStableCallback(onCodeLoaded);
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>('');
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string>('');
const approval = useMemo(() => contractChangeApprovalService(listId), [listId]);
const { id: requestTypeId } = useRequestTypeId(context, formKey);
// listId (url) для архивной партиции, Title текущей — см. ApproverService.dataListRef.
const dataListRef = listId || dataListTitle(FORM_ID);
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
// Вложения — только для показа (секции рисуют их под FormModeProvider readOnly).
const attachments = useAttachmentEditor(dataListRef, itemId);
const { setItems: setAttachmentItems } = attachments;
// decisionSeq в reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
const [decisionSeq, setDecisionSeq] = useState<number>(0);
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]);
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
approval.getJobTitle(l)
);
const executors = useViewExecutors(context, approval, itemId, decisionSeq);
useEffect(() => {
let alive = true;
setLoading(true);
Promise.all([loadContractChangeRequest(itemId, listId), getAttachments(dataListRef, itemId)])
.then(([loaded, files]) => {
if (!alive) return;
setData(loaded.data);
setAttachmentItems(files);
setStatus(loaded.status);
setCode(loaded.code);
notifyCode(loaded.code);
setError('');
setLoading(false);
})
.catch((e) => {
if (!alive) return;
setError(`Не удалось загрузить заявку №${itemId}: ${friendlyErrorMessage(e)}`);
setLoading(false);
});
return () => {
alive = false;
};
}, [itemId, listId, dataListRef, notifyCode, setAttachmentItems]);
if (loading) {
return <div className={styles.state}>Загрузка заявки…</div>;
}
if (error) {
return (
<div className={styles.state}>
<Alert variant="err">{error}</Alert>
</div>
);
}
const notify = (): void => {
// Тип ещё не резолвился — решение уже применилось, просто некого уведомлять;
// не роняем UI ради необязательного оповещения.
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined);
};
const handleRouteChanged = (): void => {
setDecisionSeq((n) => n + 1);
notify();
};
return (
<div>
<FormCard
title={VIEW_TITLE}
subtitle={`№${code || itemId}`}
belowSubtitle={
<InitiatorLine
listTitle={listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID)}
itemId={itemId}
/>
}
headerRight={status ? <StatusBadge status={status} /> : undefined}
>
<FormModeProvider readOnly>
<RequestSection data={data} set={NOOP_SET} context={context} />
<ChangeTypeSections data={data} set={NOOP_SET} context={context} attachments={attachments} />
</FormModeProvider>
</FormCard>
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
{/* История: маршрут пересохраняется патчем (см. writeRoute), прошлые решения — в версиях строк. */}
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
<DecisionPanel
service={approverService}
requestId={itemId}
search={(q) => approval.searchUsers(q)}
onDecided={(newStatus) => {
setStatus(newStatus);
handleRouteChanged();
}}
onDelegated={handleRouteChanged}
/>
</div>
);
};
export default ContractChangeView;
@@ -0,0 +1,127 @@
# Изменение условий трудового договора (hr-contract-change)
**Назначение:** заявка на изменение условий трудового договора сотрудника (ст. 33 ТК РК) —
повышение оклада, надбавка, перевод, смена режима работы и т. д. (10 видов из ТЗ кадров).
**Категория:** Отдел кадров
**Списки SP:** «Изменение условий трудового договора 1 — данные» / «… 1 — согласование»
**RequestTypeId (внешний API маршрутов):** не хардкодится — резолвится в рантайме по
`pageUrl` («hr-contract-change») через `useRequestTypeId` (см. «Формы больше не хардкодят
RequestTypeId» в `docs/forms.md`). Тип и маршрут в админке approval-route ещё не заведены.
> **Статус (2026-10-29):** поля разобраны по всем видам, кроме 6 «Продление срока ТД»
> (это автоматический запрос, не обычная заявка — отдельно). ТЗ кадров — файл
> «ТЗ_ст33_маршруты_v2» (листы «Виды изменений ст.33» и «Маршрут»); оно общее, в форму
> из него попадает только то, что явно согласовано. Ещё не сделано: передача вида и
> инициатора в маршрут (`fieldValues`: фин. блок — виды 1, 2, 3, 5, 9; маршрут А/Б —
> по инициатору), печатный бланк, заведение типа и маршрутов в админке.
## Маршрут
- Фазы: согласование (+ исполнение, если его заведут в админке — форма поддерживает).
- Маршрут строится по **e-mail сотрудника**, а если его нет (рабочий без учётной
записи M365) — по **e-mail заявителя** (`routeEmail` в `model.ts`); на форме под
сотрудником тогда пометка об этом.
- Матрица редактируема заявителем при новой заявке / черновике / возврате.
- От вида изменения маршрут пока не зависит (`fieldValues` не передаются).
## Особое поведение
- **Сотрудник — из `Employee/FullBase`, а не People-picker** (`EmployeeFullBasePicker`,
`shared/dictionary`): так находятся и рабочие без учётной записи. Ищется по части
Ф.И.О. (от 3 букв), только работающие (`dismissalDate` пустая). Записи FullBase
(одна на место работы) собираются в человека по ИИН — в поиске он одной строкой.
- **Организация — отдельный список под сотрудником**, как «Командирующая организация»
в командировке: места работы человека в виде «Компания — должность — подразделение».
Одно место — выбрано само и заблокировано. Условия меняются в ОДНОМ договоре, поэтому
в заявку пишется только выбранное место работы. Логика — `useContractEmployee`.
⚠️ На 2026-10-28 FullBase отдаёт по человеку одну запись; бэкенд доработают, чтобы
возвращались все места работы — тогда у совместителей появится выбор.
- **Хранение сотрудника — снимок в текстовых колонках `Employee*`** (Ф.И.О., ИИН,
табельный, e-mail, организация, должность, подразделение, id записи), без колонки
People. Справочник со временем меняется, а заявка фиксирует данные на момент подачи.
- **По умолчанию — на себя** (`useContractEmployee`): Ф.И.О. из `Employee/ByEmail` →
поиск в `FullBase` → человек, у которого есть место работы с e-mail текущего
пользователя. Совместителю организацию не выбираем молча — выбирает сам.
- **Черновик/возврат:** список организаций подтягивается заново (поиск по Ф.И.О.,
тот же ИИН); не нашлось — в списке только сохранённая.
- **Маршрут строится по человеку** (его e-mail), а не по месту работы — поэтому
грузится сразу после выбора сотрудника, ещё до выбора организации.
- **Вид изменения** — `ChangeTypes`, MultiChoice в SP, массив в модели. По ТЗ выбор
один (`MAX_CHANGE_TYPES`, обычный `Select`); данные уже рассчитаны на несколько,
чтобы снять ограничение правкой UI, без миграции. В коде вид — ключ
(`ChangeType`), в SP — подпись (`CHANGE_TYPE_LABELS`): подпись = choice колонки,
менять только вместе с `provisioning.json`.
- **Заголовок элемента** (`Title`): `Изменение условий ТД — {ФИО} — дд.мм.гггг`.
## Поля по видам
Каждый вид — своя секция в `byType/<Вид>/`, подключается строкой в
`byType/ChangeTypeSections.tsx`; ключи модели и колонки SP — с префиксом вида.
Колонки невыбранного вида при сохранении очищаются (`null`), чтобы в заявке не
оставалось скрытых значений после смены вида.
| Вид | Секция | Поля (все необязательные, без ограничений) |
| --- | --- | --- |
| Повышение должностного оклада | `SalaryIncrease` | текущий оклад, новый оклад (`MoneyInput`, Number), дата изменения, причина (испытательный срок / расширение функционала / иное), обоснование — при «иное» это и есть текст причины |
| Установление надбавки / доплаты | `Allowance` | основание (а–д: надбавка или доплата видно из пункта), дата начала, дата окончания или «бессрочно» (флажок прячет и очищает дату), размер + переключатель «% / ₸» (`UnitSwitch`, по умолчанию %), обоснование — в т.ч. объём доп. работы при совмещении и текст «иное». Письменное согласие работника на совмещение оформляет кадровик после согласования — не в форме. Вложение **«Результаты аттестации»** — только при основании «г», обязательно |
| Перевод на другую должность (постоянный) | `PositionTransfer` | новая должность (`PositionPicker`: справочник `Dictionary/Positions` + ручной ввод), дата перевода, причина (карьерный рост / вакантная должность / новый объект, перераспределение функций / иное), обоснование. Работника и работодателя не различаем — причины видны всем, работник пишет текст заявления в обоснование. Вложение **«Должностная инструкция»** — всегда, обязательно |
| Перевод в другое подразделение | `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): фиксированные
подписанные слоты по виду (подпись — что за документ); обязательны там, где ТЗ
прямо требует документ (вид 2 «г» — аттестация, вид 3 — ДИ, вид 8 — новая ДИ;
вид 9 — текст ИЛИ файл), «можно приложить» — необязательно (вид 7).
Как устроено: вложения элемента списка данных, подпись — префикс в имени файла
(`shared/attachments/attachmentCategories`, тот же механизм, что в «Приёме»), поле
слота — `AttachmentSlotField`. Какие слоты видны и обязательны — одна функция
`byType/attachmentSlots.ts` (`activeAttachmentSlots`); новый слот = строка там +
`AttachmentSlotField` в секции вида. Файлы грузятся после записи элемента; файлы
скрытого слота (сменили вид/основание) не грузятся. Обязательность проверяется
только при отправке — черновик сохраняется и без файла.
## Открытые вопросы (проверить на тесте)
- Выдаёт ли бэкенд (вебхуки) права на элемент по People-колонке `Employee`? Если да —
сотрудник, за которого подали заявку, её не увидит (такой колонки здесь нет).
- `Employee/FullBase`: ищет ли `Fio` по подстроке; что фильтрует `Status`.
## Секции формы
RequestSection (сотрудник · организация · вид изменения) · ChangeTypeSections (секция выбранного вида, см. «Поля по видам»)
## Точки входа
- rptype: `hr-contract-change`
- Режим согласующего (просмотр): есть (`ContractChangeView`), без печати
- «Создать на основе»: нет
@@ -0,0 +1,17 @@
{
"dataListTitle": "Изменение условий трудового договора — согласование",
"columns": [
{ "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
{ "internalName": "ApprovalStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] },
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Изменение условий трудового договора — данные", "lookupShowField": "Title" },
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] },
{ "internalName": "SLAhours", "title": "SLA, часов", "type": "Number" },
{ "internalName": "AskOverdueReason", "title": "Опрос причины просрочки", "type": "Boolean" },
{ "internalName": "AutoApprove", "title": "Автосогласование", "type": "Boolean" },
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
]
}
@@ -0,0 +1,13 @@
@import '../../../../../../styles/design-tokens';
// Сумма + переключатель «% / ₸» в одну строку: поле тянется, переключатель — по содержимому.
.amount {
display: flex;
align-items: stretch;
gap: $sp-2;
}
.amountInput {
flex: 1;
min-width: 0;
}
@@ -0,0 +1,103 @@
import * as React from 'react';
import { FormGrid, FormField, MoneyInput, TextInput, TextArea, Select, Checkbox } from '../../../../shared/ui';
import { useFormReadOnly } from '@tnsu/ui-kit';
import { AttachmentSlotField } from '../../../../shared/attachments/AttachmentSlotField';
import { ISectionProps } from '../../model';
import { ALLOWANCE_REASONS, ATTESTATION_ATTACHMENT, requiresAttestation } from './options';
import { UnitSwitch } from './UnitSwitch';
import { sanitizePercentInput, amountForUnit } from './amount';
import styles from './AllowanceSection.module.scss';
/**
* Поля вида «Установление надбавки / доплаты». Состав согласован с Тимуром по ТЗ
* кадров: основание (а–д), срок с/по или бессрочно, размер в % или ₸, обоснование
* (в т.ч. объём доп. работы при совмещении). Поля необязательные; вложение
* «Результаты аттестации» — только при основании «г» и там обязательно (по ТЗ).
*
* Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал.
*/
export const AllowanceSection: React.FC<ISectionProps> = ({ data, set, attachments }) => {
const readOnly = useFormReadOnly();
return (
<FormGrid>
<FormField label="Основание" htmlFor="AllowanceReason" full>
<Select
id="AllowanceReason"
value={data.AllowanceReason}
onChange={(v) => set('AllowanceReason', v)}
options={ALLOWANCE_REASONS}
placeholder="— выберите —"
/>
</FormField>
<FormField label="Дата начала" htmlFor="AllowanceStartDate">
<TextInput
id="AllowanceStartDate"
type="date"
value={data.AllowanceStartDate}
onChange={(v) => set('AllowanceStartDate', v)}
/>
</FormField>
<FormField label="Дата окончания" htmlFor="AllowanceEndDate">
{/* «Бессрочно» прячет дату окончания — и очищает её, чтобы не сохранить скрытое значение. */}
{!data.AllowanceIndefinite && (
<TextInput
id="AllowanceEndDate"
type="date"
value={data.AllowanceEndDate}
onChange={(v) => set('AllowanceEndDate', v)}
/>
)}
<Checkbox
checked={data.AllowanceIndefinite}
onChange={(checked) => {
set('AllowanceIndefinite', checked);
if (checked) set('AllowanceEndDate', '');
}}
label="Бессрочно"
/>
</FormField>
<FormField label="Размер" htmlFor="AllowanceAmount">
<div className={styles.amount}>
<div className={styles.amountInput}>
{/* Проценты бывают дробными (7,5 %), тенге — целые с разделителем тысяч. */}
{data.AllowanceUnit === '%' ? (
<TextInput
id="AllowanceAmount"
value={data.AllowanceAmount}
onChange={(v) => set('AllowanceAmount', sanitizePercentInput(v))}
placeholder="например, 7.5"
/>
) : (
<MoneyInput id="AllowanceAmount" value={data.AllowanceAmount} onChange={(v) => set('AllowanceAmount', v)} />
)}
</div>
<UnitSwitch
value={data.AllowanceUnit}
onChange={(unit) => {
set('AllowanceUnit', unit);
set('AllowanceAmount', amountForUnit(data.AllowanceAmount, unit));
}}
/>
</div>
</FormField>
<FormField label="Обоснование" htmlFor="AllowanceJustification" full>
<TextArea
id="AllowanceJustification"
value={data.AllowanceJustification}
onChange={(v) => set('AllowanceJustification', v)}
/>
</FormField>
{attachments && requiresAttestation(data.AllowanceReason) && (
<AttachmentSlotField
label={ATTESTATION_ATTACHMENT}
editor={attachments}
editable={!readOnly}
groupKey={ATTESTATION_ATTACHMENT}
category={ATTESTATION_ATTACHMENT}
required
/>
)}
</FormGrid>
);
};
@@ -0,0 +1,40 @@
@import '../../../../../../styles/design-tokens';
// Две кнопки впритык, как сегментный контрол; высота — как у полей ввода рядом.
.switch {
display: inline-flex;
flex-shrink: 0;
border: 1px solid $gray-300;
border-radius: $r-sm;
overflow: hidden;
}
.option {
min-width: 44px;
padding: 0 $sp-3;
border: 0;
background: $white;
font-family: $font-sans;
font-size: 13.5px;
color: $gray-700;
cursor: pointer;
transition: .15s;
& + & {
border-left: 1px solid $gray-300;
}
&:hover:not(:disabled) {
background: $gray-100;
}
&:disabled {
cursor: default;
}
}
.active,
.active:hover:not(:disabled) {
background: $navy-700;
color: $white;
}
@@ -0,0 +1,36 @@
import * as React from 'react';
import { useFormReadOnly } from '@tnsu/ui-kit';
import { AllowanceUnit, ALLOWANCE_UNITS } from './options';
import styles from './UnitSwitch.module.scss';
interface Props {
value: AllowanceUnit;
onChange: (unit: AllowanceUnit) => void;
}
/**
* Переключатель единицы размера «% / ₸» рядом с полем суммы. Сегментного
* контрола в ui-kit нет; если понадобится ещё где-то — перенести туда.
* В режиме чтения кнопки неактивны, выбранная остаётся подсвеченной.
*/
export const UnitSwitch: React.FC<Props> = ({ value, onChange }) => {
const readOnly = useFormReadOnly();
return (
<div className={styles.switch} role="radiogroup" aria-label="Единица размера">
{ALLOWANCE_UNITS.map((unit) => (
<button
key={unit}
type="button"
role="radio"
aria-checked={unit === value}
className={unit === value ? `${styles.option} ${styles.active}` : styles.option}
disabled={readOnly}
onClick={() => onChange(unit)}
>
{unit}
</button>
))}
</div>
);
};
@@ -0,0 +1,33 @@
import { sanitizePercentInput, amountForUnit } from './amount';
describe('sanitizePercentInput', () => {
it('оставляет целое как есть', () => {
expect(sanitizePercentInput('10')).toBe('10');
});
it('запятую превращает в точку', () => {
expect(sanitizePercentInput('7,5')).toBe('7.5');
});
it('выбрасывает буквы, пробелы и знак процента', () => {
expect(sanitizePercentInput(' 1a2 %')).toBe('12');
});
it('оставляет только первый разделитель и не больше двух знаков дроби', () => {
expect(sanitizePercentInput('1.2.345')).toBe('1.23');
});
it('разрешает разделитель в конце — человек ещё вводит дробь', () => {
expect(sanitizePercentInput('7,')).toBe('7.');
});
});
describe('amountForUnit', () => {
it('при переходе в тенге отбрасывает дробную часть', () => {
expect(amountForUnit('7.5', '₸')).toBe('7');
});
it('в процентах значение не меняет', () => {
expect(amountForUnit('7.5', '%')).toBe('7.5');
});
});
@@ -0,0 +1,25 @@
import { AllowanceUnit } from './options';
/** Знаков после разделителя у процента (7,25 %). */
const PERCENT_FRACTION_DIGITS = 2;
/**
* Ввод процента → «сырое» число строкой с точкой: «7,5» → «7.5». Пропускаем
* только цифры и один разделитель (запятая или точка), дробь — до 2 знаков.
* Не type=number: у TextInput нет step, и браузер считал бы 7.5 невалидным при шаге 1.
*/
export function sanitizePercentInput(text: string): string {
const normalized = text.replace(/,/g, '.').replace(/[^\d.]/g, '');
const [whole, ...rest] = normalized.split('.');
if (rest.length === 0) return whole;
return `${whole}.${rest.join('').slice(0, PERCENT_FRACTION_DIGITS)}`;
}
/**
* Значение суммы после смены единицы: в тенге сумма целая (MoneyInput),
* поэтому дробную часть процента отбрасываем, а не показываем «75» из «7.5».
*/
export function amountForUnit(amount: string, unit: AllowanceUnit): string {
if (unit === '₸') return amount.split('.')[0];
return amount;
}
@@ -0,0 +1,41 @@
import { ISelectOption } from '../../../../shared/ui';
/**
* Основания надбавки/доплаты — по шаблону текста ТЗ кадров (а–д). Что это —
* надбавка или доплата — видно из самого пункта, отдельного поля нет. Значения
* совпадают с choices колонки AllowanceReason (provisioning.json).
*/
export const ALLOWANCE_REASON_MENTORING = 'Надбавка: наставничество и обучение новых работников';
export const ALLOWANCE_REASON_TRAVEL = 'Надбавка: разъездной характер работы';
export const ALLOWANCE_REASON_COMBINING = 'Доплата: совмещение должностей / расширение зоны обслуживания';
export const ALLOWANCE_REASON_HAZARDOUS = 'Доплата: работа в тяжёлых, вредных и (или) опасных условиях труда';
export const ALLOWANCE_REASON_OTHER = 'Доплата: иное по решению первого руководителя';
export const ALLOWANCE_REASONS: ISelectOption[] = [
ALLOWANCE_REASON_MENTORING,
ALLOWANCE_REASON_TRAVEL,
ALLOWANCE_REASON_COMBINING,
ALLOWANCE_REASON_HAZARDOUS,
ALLOWANCE_REASON_OTHER
].map((reason) => ({ value: reason, label: reason }));
/**
* Вложение «Результаты аттестации» — по ТЗ прикладывается документ при доплате
* за вредные условия (пункт «г»); там же оно обязательно.
*/
export const ATTESTATION_ATTACHMENT = 'Результаты аттестации';
export function requiresAttestation(reason: string): boolean {
return reason === ALLOWANCE_REASON_HAZARDOUS;
}
/** Единица размера; значения = choices колонки AllowanceUnit. */
export type AllowanceUnit = '%' | '₸';
export const ALLOWANCE_UNITS: AllowanceUnit[] = ['%', '₸'];
/** По умолчанию — проценты: пример в ТЗ «Надбавка 10%». */
export const DEFAULT_ALLOWANCE_UNIT: AllowanceUnit = '%';
/** Строка из SP → единица; неизвестное/пустое — единица по умолчанию. */
export function allowanceUnitFromText(text: string): AllowanceUnit {
return ALLOWANCE_UNITS.find((unit) => unit === text) ?? DEFAULT_ALLOWANCE_UNIT;
}
@@ -0,0 +1,6 @@
// Поля вида идут сразу под шапкой (сотрудник · вид · инициатор) отдельной сеткой
// FormGrid. У сетки ui-kit нет внешнего отступа, а расстояние между строками полей —
// 18px (row-gap FormGrid): тот же отступ, чтобы стык двух сеток был не виден.
.typeFields {
margin-top: 18px;
}
@@ -0,0 +1,47 @@
import * as React from 'react';
import { ChangeType } from '../changeTypes';
import { ISectionProps } from '../model';
import { SalaryIncreaseSection } from './SalaryIncrease/SalaryIncreaseSection';
import { AllowanceSection } from './Allowance/AllowanceSection';
import { PositionTransferSection } from './PositionTransfer/PositionTransferSection';
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: поля видов добавляются по мере
* согласования с кадрами; у вида без секции под выбором просто ничего не появится.
* Новый вид = папка byType/<Вид>/ + строка здесь.
*/
const SECTION_BY_TYPE: Partial<Record<ChangeType, React.FC<ISectionProps>>> = {
'salary-increase': SalaryIncreaseSection,
allowance: AllowanceSection,
'position-transfer': PositionTransferSection,
'department-transfer': DepartmentTransferSection,
'workplace-change': WorkplaceChangeSection,
'schedule-change': ScheduleChangeSection,
'function-change': FunctionChangeSection,
'employer-initiative': EmployerInitiativeSection,
'temporary-transfer': TemporaryTransferSection
};
/**
* Секции всех выбранных видов, в порядке выбора (сейчас вид один — см. MAX_CHANGE_TYPES).
* Заголовка у секции нет — вид выбран прямо над ней; обёртка даёт только отступ от шапки.
*/
export const ChangeTypeSections: React.FC<ISectionProps> = (props) => (
<>
{props.data.ChangeTypes.map((type) => {
const Section = SECTION_BY_TYPE[type];
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>
)}
</>
);
@@ -0,0 +1,66 @@
import * as React from 'react';
import { FormGrid, FormField, TextInput } from '../../../../shared/ui';
import { InitiatorReasonFields } from '../InitiatorReasonFields';
import { useFormReadOnly } from '@tnsu/ui-kit';
import { PositionPicker } from '../../../../shared/dictionary';
import { AttachmentSlotField } from '../../../../shared/attachments/AttachmentSlotField';
import { ISectionProps } from '../../model';
import { POSITION_TRANSFER_REASONS, JOB_DESCRIPTION_ATTACHMENT } from './options';
/**
* Поля вида «Перевод на другую должность (постоянный)». Состав согласован с
* Тимуром по ТЗ кадров: новая должность (справочник с ручным вводом), дата,
* причина (а–г), обоснование и обязательная должностная инструкция. Текущая
* должность уже видна в «Организации» — отдельного «было» нет.
*
* Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал.
*/
export const PositionTransferSection: React.FC<ISectionProps> = ({ data, set, context, attachments }) => {
const readOnly = useFormReadOnly();
return (
<FormGrid>
<FormField label="Новая должность" htmlFor="PositionTransferNewPosition">
<PositionPicker
context={context}
id="PositionTransferNewPosition"
value={data.PositionTransferNewPosition}
onChange={(v) => set('PositionTransferNewPosition', v)}
placeholder="Начните вводить название должности…"
/>
</FormField>
<FormField label="Дата перевода" htmlFor="PositionTransferDate">
<TextInput
id="PositionTransferDate"
type="date"
value={data.PositionTransferDate}
onChange={(v) => set('PositionTransferDate', v)}
/>
</FormField>
<InitiatorReasonFields
initiator={data.ChangeInitiator}
reason={{
id: 'PositionTransferReason',
value: data.PositionTransferReason,
options: POSITION_TRANSFER_REASONS,
onChange: (v) => set('PositionTransferReason', v)
}}
justification={{
id: 'PositionTransferJustification',
value: data.PositionTransferJustification,
onChange: (v) => set('PositionTransferJustification', v)
}}
/>
{attachments && (
<AttachmentSlotField
label={JOB_DESCRIPTION_ATTACHMENT}
editor={attachments}
editable={!readOnly}
groupKey={JOB_DESCRIPTION_ATTACHMENT}
category={JOB_DESCRIPTION_ATTACHMENT}
required
/>
)}
</FormGrid>
);
};
@@ -0,0 +1,17 @@
import { ISelectOption } from '../../../../shared/ui';
/**
* Причины перевода на другую должность — по шаблону текста ТЗ кадров (а–г).
* Работника и работодателя не различаем (решение 2026-10-28): причины видны
* всем, работник при необходимости выбирает «Иное» и пишет текст в обоснование.
* Значения совпадают с choices колонки PositionTransferReason (provisioning.json).
*/
export const POSITION_TRANSFER_REASONS: ISelectOption[] = [
'Карьерный рост (успешное выполнение обязанностей, высокие результаты)',
'Вакантная должность, соответствующая квалификации работника',
'Открытие нового объекта / проекта, перераспределение функций',
'Иное'
].map((reason) => ({ value: reason, label: reason }));
/** Вложение «Должностная инструкция» — по ТЗ ПРИКЛАДЫВАЕТСЯ всегда, обязательно. */
export const JOB_DESCRIPTION_ATTACHMENT = 'Должностная инструкция';
@@ -0,0 +1,54 @@
import * as React from 'react';
import { FormGrid, FormField, MoneyInput, TextInput, TextArea, Select } from '../../../../shared/ui';
import { ISectionProps } from '../../model';
import { SALARY_INCREASE_REASONS } from './reasons';
/**
* Поля вида «Повышение должностного оклада». Состав согласован с Тимуром по ТЗ
* кадров: было/стало, дата, причина (а/б/в) и обоснование. Все необязательные,
* без ограничений — по решению от 2026-10-28.
*
* Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал.
*/
export const SalaryIncreaseSection: React.FC<ISectionProps> = ({ data, set }) => (
<FormGrid>
<FormField label="Текущий оклад, ₸" htmlFor="SalaryIncreaseCurrent">
<MoneyInput
id="SalaryIncreaseCurrent"
value={data.SalaryIncreaseCurrent}
onChange={(v) => set('SalaryIncreaseCurrent', v)}
/>
</FormField>
<FormField label="Новый оклад, ₸" htmlFor="SalaryIncreaseNew">
<MoneyInput
id="SalaryIncreaseNew"
value={data.SalaryIncreaseNew}
onChange={(v) => set('SalaryIncreaseNew', v)}
/>
</FormField>
<FormField label="Дата изменения" htmlFor="SalaryIncreaseDate">
<TextInput
id="SalaryIncreaseDate"
type="date"
value={data.SalaryIncreaseDate}
onChange={(v) => set('SalaryIncreaseDate', v)}
/>
</FormField>
<FormField label="Причина" htmlFor="SalaryIncreaseReason">
<Select
id="SalaryIncreaseReason"
value={data.SalaryIncreaseReason}
onChange={(v) => set('SalaryIncreaseReason', v)}
options={SALARY_INCREASE_REASONS}
placeholder="— выберите —"
/>
</FormField>
<FormField label="Обоснование" htmlFor="SalaryIncreaseJustification" full>
<TextArea
id="SalaryIncreaseJustification"
value={data.SalaryIncreaseJustification}
onChange={(v) => set('SalaryIncreaseJustification', v)}
/>
</FormField>
</FormGrid>
);
@@ -0,0 +1,16 @@
import { ISelectOption } from '../../../../shared/ui';
/**
* Причины повышения оклада — по шаблону текста ТЗ кадров (а/б/в). Значения
* совпадают с choices колонки SalaryIncreaseReason (provisioning.json).
* «Иное» отдельного текста не имеет — его раскрывает поле «Обоснование».
*/
export const SALARY_REASON_PROBATION = 'Успешное прохождение испытательного срока';
export const SALARY_REASON_RESPONSIBILITY = 'Расширение функционала / зоны ответственности';
export const SALARY_REASON_OTHER = 'Иное';
export const SALARY_INCREASE_REASONS: ISelectOption[] = [
{ value: SALARY_REASON_PROBATION, label: SALARY_REASON_PROBATION },
{ value: SALARY_REASON_RESPONSIBILITY, label: SALARY_REASON_RESPONSIBILITY },
{ value: SALARY_REASON_OTHER, label: SALARY_REASON_OTHER }
];
@@ -0,0 +1,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
};
}
@@ -0,0 +1,64 @@
import { PrepareFiles, IAttachmentEditor } from '../../../shared/attachments/useAttachmentEditor';
import { attachmentsOfCategory, prefixFiles } from '../../../shared/attachments/attachmentCategories';
import { IFormData } from '../model';
import { ATTESTATION_ATTACHMENT, requiresAttestation } from './Allowance/options';
import { JOB_DESCRIPTION_ATTACHMENT } from './PositionTransfer/options';
import { NEW_SCHEDULE_ATTACHMENT } from './ScheduleChange/options';
import { NEW_JOB_DESCRIPTION_ATTACHMENT } from './FunctionChange/options';
import { EMPLOYER_JUSTIFICATION_ATTACHMENT } from './EmployerInitiative/options';
/**
* Подписанный слот вложений по колонке ТЗ «Что приложить». Подпись (category)
* одновременно и префикс в имени файла, и ключ группы в editor.pending.
*/
export interface IAttachmentSlot {
category: string;
/** Обязателен там, где ТЗ прямо требует документ. */
required: boolean;
}
/** Слоты, видимые при текущих данных формы. Новый слот вида = строка здесь. */
export function activeAttachmentSlots(data: IFormData): IAttachmentSlot[] {
const slots: IAttachmentSlot[] = [];
if (data.ChangeTypes.includes('allowance') && requiresAttestation(data.AllowanceReason)) {
slots.push({ category: ATTESTATION_ATTACHMENT, required: true });
}
if (data.ChangeTypes.includes('position-transfer')) {
slots.push({ category: JOB_DESCRIPTION_ATTACHMENT, required: true });
}
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;
}
/**
* Подготовка выбранных файлов к загрузке: префикс-подпись в имени. Файлы слота,
* который сейчас скрыт (сменили вид или основание), не загружаем — их не видно.
*/
export function prepareFilesFor(data: IFormData): PrepareFiles {
const visible = activeAttachmentSlots(data).map((slot) => slot.category);
return (groupKey, files) => (visible.indexOf(groupKey) >= 0 ? prefixFiles(files, groupKey) : []);
}
/** Есть ли в слоте файл — уже прикреплённый или выбранный к загрузке. */
export function 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[] {
return activeAttachmentSlots(data)
.filter((slot) => slot.required && !hasAttachment(editor, 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
]);
@@ -0,0 +1,90 @@
import { ISelectOption } from '../../shared/ui';
/**
* Виды изменения условий трудового договора (ст. 33 ТК РК) — по ТЗ кадров.
*
* В коде вид — стабильный ключ (union ниже), в SharePoint — его подпись: колонка
* ChangeTypes (MultiChoice) хранит текст, чтобы кадры видели в списке и отчётах
* понятные значения. Подпись = choice колонки, поэтому менять её можно только
* вместе с choices в provisioning.json (иначе старые заявки перестанут узнаваться).
*/
export type ChangeType =
| 'salary-increase'
| 'allowance'
| 'position-transfer'
| 'department-transfer'
| 'workplace-change'
| 'contract-extension'
| 'schedule-change'
| 'function-change'
| 'employer-initiative'
| 'temporary-transfer';
/** Порядок — как в ТЗ (в нём же показываются в выпадающем списке). */
export const CHANGE_TYPE_LABELS: Record<ChangeType, string> = {
'salary-increase': 'Повышение должностного оклада',
allowance: 'Установление надбавки / доплаты',
'position-transfer': 'Перевод на другую должность (постоянный)',
'department-transfer': 'Перевод в другое подразделение',
'workplace-change': 'Изменение места работы (другая местность)',
'contract-extension': 'Продление срока трудового договора',
// В ТЗ ещё «; неполное рабочее время по просьбе работника» — сокращено по просьбе Тимура
// (неполное время — поля работника внутри вида).
'schedule-change': 'Изменение режима / графика работы по соглашению сторон',
'function-change': 'Изменение трудовой функции (без смены должности)',
// В ТЗ — длинная формулировка с перечнем причин; сокращено по просьбе Тимура (причины — в поле «Причина» вида).
'employer-initiative': 'Изменение условий труда по инициативе работодателя',
'temporary-transfer': 'Временный перевод при производственной необходимости / простое'
};
/**
* Сколько видов можно выбрать в одной заявке. По ТЗ — один; данные (массив
* ChangeTypes + MultiChoice-колонка) уже рассчитаны на несколько, чтобы снять
* ограничение правкой UI, а не миграцией.
*/
export const MAX_CHANGE_TYPES = 1;
export const CHANGE_TYPE_OPTIONS: ISelectOption[] = (Object.keys(CHANGE_TYPE_LABELS) as ChangeType[]).map(
(type) => ({ value: type, label: CHANGE_TYPE_LABELS[type] })
);
/**
* Кто инициирует изменение. Значения = 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. */
export function changeTypeFromLabel(label: string): ChangeType | undefined {
const normalized = label.trim();
return (Object.keys(CHANGE_TYPE_LABELS) as ChangeType[]).find(
(type) => CHANGE_TYPE_LABELS[type] === normalized
);
}
@@ -0,0 +1,264 @@
import { getSP } from '../../shared/pnp/pnpjs';
import { dataListTitle } from '../../shared/listTitles';
import { spDateOnlyToIso } from '../../shared/dateUtils';
import { IEmployeeFullBaseRecord } from '../../shared/dictionary';
import { ChangeType, changeTypeFromLabel, initiatorRoleFromText } from './changeTypes';
import { allowanceUnitFromText } from './byType/Allowance/options';
import { DEFAULT_FIXED_PERCENT } from './byType/EmployerInitiative/salaryParts';
import { FORM_ID, IFormData, EMPTY_DATA } from './model';
export interface ILoadedRequest {
data: IFormData;
status: string;
/** Уникальный код заявки; '' — если запись создана до появления поля. */
code: string;
}
function text(raw: unknown): string {
return typeof raw === 'string' ? raw : '';
}
/** Number-колонка → «сырое» число строкой (для MoneyInput / ввода процента); пусто → ''. */
function numberText(raw: unknown): string {
return typeof raw === 'number' && Number.isFinite(raw) ? String(raw) : '';
}
/**
* Колонки Employee* → запись сотрудника (см. employeeColumns в submit.ts).
* Нет ни id, ни Ф.И.О. — сотрудник не был выбран (черновик) → undefined.
* Даты приёма/увольнения не хранятся — ''; e-mail пустой — у сотрудника нет учётки.
*/
function employeeFromItem(item: Record<string, unknown>): IEmployeeFullBaseRecord | undefined {
const id = text(item.EmployeeRecordId);
const fio = text(item.EmployeeFio);
if (!id && !fio) return undefined;
return {
id,
fio,
iin: text(item.EmployeeIin),
code: text(item.EmployeeCode),
company: text(item.EmployeeCompany),
position: text(item.EmployeePosition),
department: text(item.EmployeeDepartment),
admissionDate: '',
dismissalDate: '',
email: text(item.EmployeeEmail)
};
}
/**
* MultiChoice → ключи видов. Подпись, которой нет в CHANGE_TYPE_LABELS (choice
* переименовали в SP, а код — нет), пропускаем с предупреждением в консоли:
* форма покажет «вид не выбран», и при отправке валидация попросит выбрать заново.
*/
function changeTypesFromItem(raw: unknown): ChangeType[] {
const labels = Array.isArray(raw) ? raw.filter((v): v is string => typeof v === 'string') : [];
const types: ChangeType[] = [];
labels.forEach((label) => {
const type = changeTypeFromLabel(label);
if (type) {
types.push(type);
} else {
console.warn(`[ContractChange] Неизвестный вид изменения «${label}» — нет в CHANGE_TYPE_LABELS`);
}
});
return types;
}
/**
* Прочитать заявку по id: модель формы + статус (правка / просмотр).
*
* listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/
* listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции; задан — идём напрямую
* по url списка.
*/
export async function loadContractChangeRequest(itemId: number, listId?: string): Promise<ILoadedRequest> {
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const item = (await list.items
.getById(itemId)
.select(
'Id',
'RequestStatus',
'RequestUniqueCode',
'EmployeeRecordId',
'EmployeeFio',
'EmployeeIin',
'EmployeeCode',
'EmployeeEmail',
'EmployeeCompany',
'EmployeePosition',
'EmployeeDepartment',
'ChangeTypes',
'ChangeInitiator',
'SalaryIncreaseCurrent',
'SalaryIncreaseNew',
'SalaryIncreaseDate',
'SalaryIncreaseReason',
'SalaryIncreaseJustification',
'AllowanceReason',
'AllowanceStartDate',
'AllowanceEndDate',
'AllowanceIndefinite',
'AllowanceAmount',
'AllowanceUnit',
'AllowanceJustification',
'PositionTransferNewPosition',
'PositionTransferDate',
'PositionTransferReason',
'PositionTransferJustification',
'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 = {
...EMPTY_DATA,
Employee: employeeFromItem(item),
ChangeTypes: changeTypesFromItem(item.ChangeTypes),
ChangeInitiator: initiatorRoleFromText(text(item.ChangeInitiator)),
SalaryIncreaseCurrent: numberText(item.SalaryIncreaseCurrent),
SalaryIncreaseNew: numberText(item.SalaryIncreaseNew),
// Дату только через spDateOnlyToIso — иначе из-за часового пояса уезжает на день назад.
SalaryIncreaseDate: spDateOnlyToIso(text(item.SalaryIncreaseDate)),
SalaryIncreaseReason: text(item.SalaryIncreaseReason),
SalaryIncreaseJustification: text(item.SalaryIncreaseJustification),
AllowanceReason: text(item.AllowanceReason),
AllowanceStartDate: spDateOnlyToIso(text(item.AllowanceStartDate)),
AllowanceEndDate: spDateOnlyToIso(text(item.AllowanceEndDate)),
AllowanceIndefinite: item.AllowanceIndefinite === true,
AllowanceAmount: numberText(item.AllowanceAmount),
AllowanceUnit: allowanceUnitFromText(text(item.AllowanceUnit)),
AllowanceJustification: text(item.AllowanceJustification),
PositionTransferNewPosition: text(item.PositionTransferNewPosition),
PositionTransferDate: spDateOnlyToIso(text(item.PositionTransferDate)),
PositionTransferReason: text(item.PositionTransferReason),
PositionTransferJustification: text(item.PositionTransferJustification),
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 {
data,
status: text(item.RequestStatus),
code: text(item.RequestUniqueCode)
};
}
@@ -0,0 +1,350 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { RequestTypeId } from '../../models/types';
import { IEmployeeFullBasePerson, IEmployeeFullBaseRecord } from '../../shared/dictionary';
import { ChangeType, InitiatorRole, INITIATOR_EMPLOYEE, INITIATOR_EMPLOYER } from './changeTypes';
import { AllowanceUnit, DEFAULT_ALLOWANCE_UNIT } from './byType/Allowance/options';
import { DEFAULT_FIXED_PERCENT } from './byType/EmployerInitiative/salaryParts';
import { IAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
/**
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
* SharePoint выводятся из него, см. shared/listTitles.ts.
*/
export const FORM_ID: RequestTypeId = 'hr-contract-change';
/**
* Данные формы «Изменение условий трудового договора» (ст. 33 ТК РК).
*
* Сотрудник — запись из Employee/FullBase, а не People: заявку подают и за
* рабочих без учётной записи M365. В списке SP запись лежит снимком в текстовых
* колонках Employee* (разборка/сборка — submit.ts/load.ts); даты приёма и
* увольнения не храним — после загрузки они ''.
*
* Поля под конкретные виды изменения добавляются по мере согласования с кадрами
* — каждый вид своим блоком с префиксом имён (см. changeTypes.ts).
*/
export interface IFormData {
/**
* Место работы сотрудника, где меняются условия ТД (человек + организация —
* см. useContractEmployee). undefined — не выбран человек или ещё не выбрана организация.
*/
Employee: IEmployeeFullBaseRecord | undefined;
/**
* Выбранные виды изменения. По ТЗ ровно один (MAX_CHANGE_TYPES), массив —
* задел под несколько, см. changeTypes.ts. [] — не выбран.
*/
ChangeTypes: ChangeType[];
/**
* Инициатор — только у видов, где по ТЗ им может быть и работник
* (hasInitiatorChoice). От роли зависят поля: у работника — текст заявления,
* у работодателя — причины + обоснование. '' — не выбран / у вида выбора нет.
*/
ChangeInitiator: InitiatorRole | '';
// ── Вид «Повышение должностного оклада» (byType/SalaryIncrease). Все необязательные.
/** Текущий оклад — «сырые» цифры без пробелов (так отдаёт MoneyInput); '' — не заполнен. */
SalaryIncreaseCurrent: string;
/** Новый оклад — как SalaryIncreaseCurrent. */
SalaryIncreaseNew: string;
/** С какой даты, ISO YYYY-MM-DD. */
SalaryIncreaseDate: string;
/** Причина — одно из SALARY_INCREASE_REASONS (Choice-колонка); '' — не выбрана. */
SalaryIncreaseReason: string;
/** Обоснование; при причине «Иное» — её текст. */
SalaryIncreaseJustification: string;
// ── Вид «Установление надбавки / доплаты» (byType/Allowance). Все необязательные.
/** Основание — одно из ALLOWANCE_REASONS (Choice-колонка); '' — не выбрано. */
AllowanceReason: string;
/** С какой даты, ISO YYYY-MM-DD. */
AllowanceStartDate: string;
/** По какую дату, ISO; при AllowanceIndefinite всегда ''. */
AllowanceEndDate: string;
/** Бессрочно — тогда даты окончания нет. */
AllowanceIndefinite: boolean;
/**
* Размер — «сырое» число строкой: в ₸ целое (MoneyInput), в % может быть дробным
* с точкой, «7.5» (см. byType/Allowance/amount.ts); единица — в AllowanceUnit.
*/
AllowanceAmount: string;
AllowanceUnit: AllowanceUnit;
/** Обоснование; при совмещении — в т.ч. объём доп. работы, при «иное» — его текст. */
AllowanceJustification: string;
// ── Вид «Перевод на другую должность (постоянный)» (byType/PositionTransfer).
// Поля необязательные; вложение «Должностная инструкция» — обязательно (attachmentSlots).
/** Новая должность — из справочника или вручную (PositionPicker). */
PositionTransferNewPosition: string;
/** С какой даты, ISO YYYY-MM-DD. */
PositionTransferDate: string;
/** Причина — одно из POSITION_TRANSFER_REASONS (Choice-колонка); '' — не выбрана. */
PositionTransferReason: string;
/** Обоснование; при «Иное» — его текст, у работника — текст заявления. */
PositionTransferJustification: string;
// ── Вид «Перевод в другое подразделение» (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;
}
/** Обновление одного поля формы с сохранением типа значения. */
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
/** Пропсы секции формы: срез данных + колбэк обновления + контекст (запросы к справочникам). */
export interface ISectionProps {
data: IFormData;
set: SetField;
context: WebPartContext;
/** Вложения заявки — для подписанных слотов в секциях видов (см. byType/attachmentSlots). */
attachments?: IAttachmentEditor;
}
export const EMPTY_DATA: IFormData = {
Employee: undefined,
ChangeTypes: [],
ChangeInitiator: '',
SalaryIncreaseCurrent: '',
SalaryIncreaseNew: '',
SalaryIncreaseDate: '',
SalaryIncreaseReason: '',
SalaryIncreaseJustification: '',
AllowanceReason: '',
AllowanceStartDate: '',
AllowanceEndDate: '',
AllowanceIndefinite: false,
AllowanceAmount: '',
AllowanceUnit: DEFAULT_ALLOWANCE_UNIT,
AllowanceJustification: '',
PositionTransferNewPosition: '',
PositionTransferDate: '',
PositionTransferReason: '',
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: ''
};
/** Плейсхолдер для поля выбора сотрудника. */
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
/** Плейсхолдер для выбора организации (места работы) сотрудника. */
export const WORKPLACE_PLACEHOLDER = 'Выберите организацию, в которой меняются условия';
/** Плейсхолдер для выбора вида изменения. */
export const CHANGE_TYPE_PLACEHOLDER = '— выберите вид изменения —';
/**
* Пояснение под сотрудником без учётной записи: approval-route резолвит маршрут
* по e-mail, а его нет — маршрут строится по заявителю (см. routeEmail в форме).
*/
export const ROUTE_BY_APPLICANT_NOTE =
'У сотрудника нет учётной записи — маршрут согласования построен по заявителю.';
export const REQUIRED_FIELDS_MESSAGE = 'Заполните обязательные поля: сотрудник, организация, вид изменения.';
/** Подсказка под полем «Инициатор» — откуда взялось значение по умолчанию. */
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, по которому строится маршрут: сотрудника, а если у него нет учётной
* записи (рабочие на объектах) — заявителя. Берётся от человека, а не от места
* работы: маршрут от организации не зависит и грузится, пока её ещё выбирают.
*/
/**
* Инициатор по умолчанию (см. 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 {
if (!person) return '';
return person.email || applicantEmail;
}
@@ -0,0 +1,148 @@
{
"dataListTitle": "Изменение условий трудового договора — данные",
"columns": [
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
{ "internalName": "EmployeeRecordId", "title": "Сотрудник: id записи кадровой базы", "type": "Text" },
{ "internalName": "EmployeeFio", "title": "Сотрудник: Ф.И.О.", "type": "Text" },
{ "internalName": "EmployeeIin", "title": "Сотрудник: ИИН", "type": "Text" },
{ "internalName": "EmployeeCode", "title": "Сотрудник: табельный номер", "type": "Text" },
{ "internalName": "EmployeeEmail", "title": "Сотрудник: e-mail", "type": "Text" },
{ "internalName": "EmployeeCompany", "title": "Сотрудник: организация", "type": "Text" },
{ "internalName": "EmployeePosition", "title": "Сотрудник: должность", "type": "Text" },
{ "internalName": "EmployeeDepartment", "title": "Сотрудник: подразделение", "type": "Text" },
{ "internalName": "ChangeTypes", "title": "Вид изменения", "type": "Choice", "allowMultiple": true, "choices": [
"Повышение должностного оклада",
"Установление надбавки / доплаты",
"Перевод на другую должность (постоянный)",
"Перевод в другое подразделение",
"Изменение места работы (другая местность)",
"Продление срока трудового договора",
"Изменение режима / графика работы по соглашению сторон",
"Изменение трудовой функции (без смены должности)",
"Изменение условий труда по инициативе работодателя",
"Временный перевод при производственной необходимости / простое"
] },
{ "internalName": "ChangeInitiator", "title": "Инициатор изменения", "type": "Choice", "choices": ["Работник", "Работодатель"] },
{ "internalName": "SalaryIncreaseCurrent", "title": "Повышение оклада: текущий оклад", "type": "Number", "decimals": 0 },
{ "internalName": "SalaryIncreaseNew", "title": "Повышение оклада: новый оклад", "type": "Number", "decimals": 0 },
{ "internalName": "SalaryIncreaseDate", "title": "Повышение оклада: дата изменения", "type": "DateTime", "dateOnly": true },
{ "internalName": "SalaryIncreaseReason", "title": "Повышение оклада: причина", "type": "Choice", "choices": [
"Успешное прохождение испытательного срока",
"Расширение функционала / зоны ответственности",
"Иное"
] },
{ "internalName": "SalaryIncreaseJustification", "title": "Повышение оклада: обоснование", "type": "Note" },
{ "internalName": "AllowanceReason", "title": "Надбавка/доплата: основание", "type": "Choice", "choices": [
"Надбавка: наставничество и обучение новых работников",
"Надбавка: разъездной характер работы",
"Доплата: совмещение должностей / расширение зоны обслуживания",
"Доплата: работа в тяжёлых, вредных и (или) опасных условиях труда",
"Доплата: иное по решению первого руководителя"
] },
{ "internalName": "AllowanceStartDate", "title": "Надбавка/доплата: дата начала", "type": "DateTime", "dateOnly": true },
{ "internalName": "AllowanceEndDate", "title": "Надбавка/доплата: дата окончания", "type": "DateTime", "dateOnly": true },
{ "internalName": "AllowanceIndefinite", "title": "Надбавка/доплата: бессрочно", "type": "Boolean" },
{ "internalName": "AllowanceAmount", "title": "Надбавка/доплата: размер", "type": "Number", "decimals": 2 },
{ "internalName": "AllowanceUnit", "title": "Надбавка/доплата: единица размера", "type": "Choice", "choices": ["%", "₸"] },
{ "internalName": "AllowanceJustification", "title": "Надбавка/доплата: обоснование", "type": "Note" },
{ "internalName": "PositionTransferNewPosition", "title": "Перевод на должность: новая должность", "type": "Text" },
{ "internalName": "PositionTransferDate", "title": "Перевод на должность: дата перевода", "type": "DateTime", "dateOnly": true },
{ "internalName": "PositionTransferReason", "title": "Перевод на должность: причина", "type": "Choice", "choices": [
"Карьерный рост (успешное выполнение обязанностей, высокие результаты)",
"Вакантная должность, соответствующая квалификации работника",
"Открытие нового объекта / проекта, перераспределение функций",
"Иное"
] },
{ "internalName": "PositionTransferJustification", "title": "Перевод на должность: обоснование", "type": "Note" },
{ "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" }
]
}
@@ -0,0 +1,8 @@
@import '../../../../../styles/design-tokens';
// Пояснение под полем «Сотрудник» (маршрут по заявителю — см. ROUTE_BY_APPLICANT_NOTE).
.note {
margin-top: $sp-1;
font-size: $fs-label;
color: $gray-500;
}
@@ -0,0 +1,130 @@
import * as React from 'react';
import { FormGrid, FormField, Select, ISelectOption } from '../../../shared/ui';
import { useFormReadOnly } from '@tnsu/ui-kit';
import {
EmployeeFullBasePicker,
formatOrganizationLine,
personFromWorkplace,
workplaceKey
} from '../../../shared/dictionary';
import {
ChangeType,
CHANGE_TYPE_OPTIONS,
INITIATOR_OPTIONS,
InitiatorRole,
hasInitiatorChoice,
initiatorRoleFromText
} from '../changeTypes';
import {
ISectionProps,
EMPLOYEE_PLACEHOLDER,
WORKPLACE_PLACEHOLDER,
CHANGE_TYPE_PLACEHOLDER,
ROUTE_BY_APPLICANT_NOTE,
INITIATOR_HINT
} from '../model';
import { IContractEmployee } from '../useContractEmployee';
import styles from './RequestSection.module.scss';
interface IRequestSectionProps extends ISectionProps {
/** Выбор сотрудника/организации — только на форме подачи; в просмотре не передаётся. */
employeeControl?: IContractEmployee;
/**
* Инициатор по умолчанию при выборе вида с выбором инициатора: подаёт на себя —
* работник, за другого — работодатель (см. ContractChangeForm). В просмотре не нужен.
*/
defaultInitiator?: InitiatorRole;
/** Ручной выбор инициатора (см. useInitiatorAutofill); не задан — просто set. */
onInitiatorChange?: (role: InitiatorRole | '') => void;
}
const NOOP = (): void => undefined;
/**
* Общая часть заявки, не зависящая от вида изменения: чьи условия ТД меняются,
* в какой организации (у совместителя их несколько) и что именно меняется.
* Поля конкретных видов — отдельными секциями (byType/).
*/
export const RequestSection: React.FC<IRequestSectionProps> = ({
data,
set,
context,
employeeControl,
defaultInitiator,
onInitiatorChange
}) => {
const readOnly = useFormReadOnly();
// В просмотре человека нет в состоянии — собираем из сохранённого места работы.
const person = employeeControl?.person ?? (data.Employee ? personFromWorkplace(data.Employee) : undefined);
const workplaceOptions: ISelectOption[] = (person?.workplaces ?? []).map((w) => ({
value: workplaceKey(w),
label: formatOrganizationLine(w.company, w.position, w.department)
}));
const hasNoAccount = !!person && !person.email;
// По ТЗ вид один (MAX_CHANGE_TYPES), поэтому обычный Select: значение — первый
// элемент массива, выбор заменяет массив целиком. Под несколько видов здесь
// встанет MultiChoiceField, модель и SP уже готовы.
const selectedType = data.ChangeTypes[0] || '';
const handleTypeChange = (value: string): void => {
const types: ChangeType[] = value ? [value as ChangeType] : [];
set('ChangeTypes', types);
// Инициатор есть только у видов с выбором: у остальных очищаем, у этих —
// подставляем по умолчанию, если ещё не выбран.
if (!hasInitiatorChoice(types)) set('ChangeInitiator', '');
else if (!data.ChangeInitiator && defaultInitiator) set('ChangeInitiator', defaultInitiator);
};
return (
<FormGrid>
<FormField label="Сотрудник" htmlFor="Employee" required full>
<EmployeeFullBasePicker
context={context}
id="Employee"
value={person}
onChange={employeeControl ? employeeControl.onPersonChange : NOOP}
placeholder={EMPLOYEE_PLACEHOLDER}
/>
{!readOnly && hasNoAccount && <div className={styles.note}>{ROUTE_BY_APPLICANT_NOTE}</div>}
</FormField>
{workplaceOptions.length > 0 && (
<FormField label="Организация" htmlFor="EmployeeWorkplace" required full>
<Select
id="EmployeeWorkplace"
value={data.Employee ? workplaceKey(data.Employee) : ''}
onChange={employeeControl ? employeeControl.onWorkplaceChange : NOOP}
options={workplaceOptions}
placeholder={WORKPLACE_PLACEHOLDER}
// Единственное место работы выбрано автоматически — выбирать не из чего (как в командировке).
readOnly={workplaceOptions.length === 1 && !!data.Employee}
/>
</FormField>
)}
<FormField label="Вид изменения" htmlFor="ChangeTypes" required full>
<Select
id="ChangeTypes"
value={selectedType}
onChange={handleTypeChange}
options={CHANGE_TYPE_OPTIONS}
placeholder={CHANGE_TYPE_PLACEHOLDER}
/>
</FormField>
{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>
);
};
@@ -0,0 +1,325 @@
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
import { IEmployeeFullBaseRecord } from '../../shared/dictionary';
import { ensureEmployee } from '../../shared/employee';
import { CHANGE_TYPE_LABELS, INITIATOR_EMPLOYEE, hasInitiatorChoice } from './changeTypes';
import { FORM_ID, IFormData } from './model';
import { splitSalary } from './byType/EmployerInitiative/salaryParts';
/**
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
* approvalServiceFor (см. shared/approval/approvalServiceFactory.ts).
*/
export function contractChangeApprovalService(listId?: string): ApprovalListService {
return approvalServiceFor(FORM_ID, listId);
}
function pad2(n: number): string {
return n < 10 ? `0${n}` : `${n}`;
}
/** Осмысленный заголовок элемента (системная колонка Title). */
function buildTitle(employeeFio: string): string {
const now = new Date();
const date = `${pad2(now.getDate())}.${pad2(now.getMonth() + 1)}.${now.getFullYear()}`;
return `Изменение условий ТД — ${employeeFio || 'сотрудник не выбран'} — ${date}`;
}
/**
* Снимок сотрудника → текстовые колонки Employee* (см. provisioning.json).
* Не выбран (черновик) — все колонки очищаются null (так SharePoint REST очищает
* поле), а не остаются от прошлого выбора.
*/
function employeeColumns(employee: IEmployeeFullBaseRecord | undefined): Record<string, unknown> {
return {
EmployeeRecordId: employee?.id || null,
EmployeeFio: employee?.fio || null,
EmployeeIin: employee?.iin || null,
EmployeeCode: employee?.code || null,
EmployeeEmail: employee?.email || null,
EmployeeCompany: employee?.company || null,
EmployeePosition: employee?.position || null,
EmployeeDepartment: employee?.department || null
};
}
/**
* Причина из списка — только у работодателя: у работника по ТЗ вместо причин
* текст заявления, и списка причин на форме нет. Иначе в заявке осталась бы
* причина, выбранная до смены инициатора, которую никто не видит.
*/
function employerReason(data: IFormData, reason: string): string | undefined {
if (data.ChangeInitiator === INITIATOR_EMPLOYEE) return undefined;
return reason || undefined;
}
/** «Сырое» число строкой (сумма или процент) → число для Number-колонки; '' или мусор → undefined. */
function numberColumn(raw: string): number | undefined {
const amount = Number(raw);
return raw.trim() !== '' && Number.isFinite(amount) ? amount : undefined;
}
/**
* Колонки вида «Повышение оклада». Вид не выбран — колонки очищаются: иначе
* после смены вида в заявке остались бы скрытые поля прежнего, которых никто не видит.
*/
function salaryIncreaseColumns(data: IFormData): Record<string, unknown> {
if (!data.ChangeTypes.includes('salary-increase')) {
return {
SalaryIncreaseCurrent: null,
SalaryIncreaseNew: null,
SalaryIncreaseDate: null,
SalaryIncreaseReason: null,
SalaryIncreaseJustification: null
};
}
return {
SalaryIncreaseCurrent: numberColumn(data.SalaryIncreaseCurrent) ?? null,
SalaryIncreaseNew: numberColumn(data.SalaryIncreaseNew) ?? null,
SalaryIncreaseDate: data.SalaryIncreaseDate || null,
SalaryIncreaseReason: data.SalaryIncreaseReason || null,
SalaryIncreaseJustification: data.SalaryIncreaseJustification || null
};
}
/** Колонки вида «Надбавка / доплата»; вид не выбран — очищаются (см. salaryIncreaseColumns). */
function allowanceColumns(data: IFormData): Record<string, unknown> {
if (!data.ChangeTypes.includes('allowance')) {
return {
AllowanceReason: null,
AllowanceStartDate: null,
AllowanceEndDate: null,
AllowanceIndefinite: null,
AllowanceAmount: null,
AllowanceUnit: null,
AllowanceJustification: null
};
}
return {
AllowanceReason: data.AllowanceReason || null,
AllowanceStartDate: data.AllowanceStartDate || null,
// Бессрочно — даты окончания нет, даже если она осталась в состоянии формы.
AllowanceEndDate: data.AllowanceIndefinite ? null : data.AllowanceEndDate || null,
AllowanceIndefinite: data.AllowanceIndefinite,
AllowanceAmount: numberColumn(data.AllowanceAmount) ?? null,
AllowanceUnit: data.AllowanceUnit,
AllowanceJustification: data.AllowanceJustification || null
};
}
/** Колонки вида «Перевод на другую должность»; вид не выбран — очищаются (см. salaryIncreaseColumns). */
function positionTransferColumns(data: IFormData): Record<string, unknown> {
if (!data.ChangeTypes.includes('position-transfer')) {
return {
PositionTransferNewPosition: null,
PositionTransferDate: null,
PositionTransferReason: null,
PositionTransferJustification: null
};
}
return {
PositionTransferNewPosition: data.PositionTransferNewPosition.trim() || null,
PositionTransferDate: data.PositionTransferDate || null,
PositionTransferReason: employerReason(data, data.PositionTransferReason) ?? 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, receivingHeadId: number | undefined): Record<string, unknown> {
return {
Title: buildTitle(data.Employee?.fio || ''),
RequestStatus: status,
...employeeColumns(data.Employee),
// MultiChoice пишется плоским массивом подписей (как «База 1С» в Ticket1C); [] очищает колонку.
ChangeTypes: data.ChangeTypes.map((type) => CHANGE_TYPE_LABELS[type]),
ChangeInitiator: hasInitiatorChoice(data.ChangeTypes) ? data.ChangeInitiator || null : null,
...salaryIncreaseColumns(data),
...allowanceColumns(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;
/**
* Сохранить заявку: содержимое + маршрут согласования.
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
* executors — фаза «Исполнение» маршрута из админки (пусто, если её нет).
*
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadContractChangeRequest); новая
* заявка всегда создаётся в текущей партиции.
*/
export async function saveContractChangeRequest(
existingId: number | undefined,
requestTypeId: number,
data: IFormData,
approvers: IApproverToWrite[],
executors: IApproverToWrite[],
status: string,
listId?: string
): Promise<ISaveResult> {
const item = buildItem(data, status, await receivingHeadUserId(data));
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
await writeRoute(contractChangeApprovalService(listId), result.id, approvers, executors, status);
return result;
}
@@ -0,0 +1,116 @@
import { useEffect, useRef, useState } from 'react';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import {
IEmployeeFullBasePerson,
IEmployeeFullBaseRecord,
getEmployeeOrganizations,
groupEmployeesByPerson,
personFromWorkplace,
personKey,
searchActiveEmployees,
workplaceKey
} from '../../shared/dictionary';
// Совпадений по Ф.И.О. немного (тёзки × места работы) — запас с лихвой.
const MAX_NAMESAKE_RECORDS = 60;
/** Человек + выбор его места работы — то, что нужно секции «Сотрудник / Организация». */
export interface IContractEmployee {
person: IEmployeeFullBasePerson | undefined;
onPersonChange: (person: IEmployeeFullBasePerson | undefined) => void;
/** key — workplaceKey выбранного места работы. */
onWorkplaceChange: (key: string) => void;
}
/** Место работы, если выбирать не из чего (оно одно) — иначе выбирает пользователь. */
function singleWorkplace(person: IEmployeeFullBasePerson): IEmployeeFullBaseRecord | undefined {
return person.workplaces.length === 1 ? person.workplaces[0] : undefined;
}
/**
* Себя — по e-mail. Параметра Email у FullBase нет, поэтому: Ф.И.О. из
* Employee/ByEmail → поиск по Ф.И.О. → человек, у кого есть место работы с этим e-mail.
*/
async function findPersonByEmail(context: WebPartContext, email: string): Promise<IEmployeeFullBasePerson | undefined> {
const organizations = await getEmployeeOrganizations(context, email);
const fio = organizations[0]?.fio || '';
if (!fio) return undefined;
const people = groupEmployeesByPerson(await searchActiveEmployees(context, fio, MAX_NAMESAKE_RECORDS));
const target = email.toLowerCase();
return people.find((p) => p.workplaces.some((w) => w.email.toLowerCase() === target));
}
/** Все места работы человека, чьё одно место сохранено в заявке (черновик/возврат). */
async function findPersonOfWorkplace(
context: WebPartContext,
record: IEmployeeFullBaseRecord
): Promise<IEmployeeFullBasePerson | undefined> {
const people = groupEmployeesByPerson(await searchActiveEmployees(context, record.fio, MAX_NAMESAKE_RECORDS));
return people.find((p) => p.key === personKey(record));
}
/**
* Выбор сотрудника и его организации, как в «Заявке на командировку»: человек —
* пикером, место работы — списком; одно место выбирается само.
*
* В заявке хранится только выбранное место работы (setEmployee); человек со
* всеми местами — состояние формы, при открытии черновика подтягивается заново.
*/
export function useContractEmployee(
context: WebPartContext,
applicantEmail: string,
isNew: boolean,
employee: IEmployeeFullBaseRecord | undefined,
setEmployee: (record: IEmployeeFullBaseRecord | undefined) => void
): IContractEmployee {
const [person, setPerson] = useState<IEmployeeFullBasePerson | undefined>(undefined);
// Пользователь уже выбрал сам — фоновые поиски его выбор не перетирают.
const isTouchedRef = useRef(false);
const applyPerson = (next: IEmployeeFullBasePerson | undefined): void => {
setPerson(next);
setEmployee(next ? singleWorkplace(next) : undefined);
};
// Новая заявка — по умолчанию на себя.
useEffect(() => {
if (!isNew || !applicantEmail) return;
let alive = true;
findPersonByEmail(context, applicantEmail)
.then((found) => {
if (alive && found && !isTouchedRef.current) applyPerson(found);
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [context, applicantEmail, isNew]);
// Черновик/возврат: восстановить список организаций; не нашлось — хотя бы сохранённая.
const restoreKey = !isNew && employee && !person ? personKey(employee) : '';
useEffect(() => {
if (!restoreKey || !employee) return;
let alive = true;
findPersonOfWorkplace(context, employee)
.then((found) => {
if (alive && !isTouchedRef.current) setPerson(found ?? personFromWorkplace(employee));
})
.catch(() => {
if (alive && !isTouchedRef.current) setPerson(personFromWorkplace(employee));
});
return () => {
alive = false;
};
}, [context, restoreKey]);
return {
person,
onPersonChange: (next) => {
isTouchedRef.current = true;
applyPerson(next);
},
onWorkplaceChange: (key) => {
setEmployee(person?.workplaces.find((w) => workplaceKey(w) === key));
}
};
}
@@ -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,
SubmitActions,
getDraftDeleteHandler,
useRequestSubmit
useRequestSubmit,
FormNotice
} from '../../shared/ui';
import {
composeRemoveRowRule,
@@ -44,7 +45,16 @@ import {
dataListTitle,
dataListTitleForListId
} 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 { loadDismissalRequest } from './load';
import { RequestSection } from './sections/RequestSection';
@@ -374,6 +384,8 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
)}
{submit.error && <Alert variant="err">{submit.error}</Alert>}
<FormNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
<FormModeProvider readOnly={readOnly}>
<RequestSection {...sectionProps} />
</FormModeProvider>
@@ -2,7 +2,7 @@ import * as React from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './DismissalView.module.scss';
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton } from '../../shared/ui';
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton, FormNotice, buildPrintTitle } from '../../shared/ui';
import {
ApproverService,
DecisionPanel,
@@ -12,6 +12,7 @@ import {
ApprovalHistory,
useMatrixEditor,
notifyActiveStep,
notifyInitiatorOfReturn,
useRequestTypeId,
REQUEST_STATUS,
approvalPhaseRows,
@@ -27,7 +28,7 @@ import {
dataListTitleForListId
} from '../../shared/listTitles';
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 { dismissalApprovalService, updateAssetsAcceptor } from './submit';
import { loadClearanceRequest } from '../Clearance/load';
@@ -57,6 +58,8 @@ interface IDismissalViewProps {
onCodeLoaded?: (code: string) => void;
}
const FORM_TITLE = 'Заявление на увольнение';
// Заявление на увольнение только для просмотра (режим согласующего).
// Переиспользует ту же секцию, что и форма подачи; редактирование выключено через
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
@@ -69,6 +72,8 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>('');
// ФИО увольняющегося — для имени печатного документа.
const [employeeName, setEmployeeName] = useState<string>('');
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Итог автосоздания обходного листа после согласования (см. onDecided ниже).
@@ -106,6 +111,7 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
setData(loaded.data);
setStatus(loaded.status);
setCode(loaded.code);
setEmployeeName(loaded.employeeName);
notifyCode(loaded.code);
setError(null);
setLoading(false);
@@ -218,16 +224,19 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
await updateAssetsAcceptor(itemId, data.AssetsAcceptor, listId);
};
const notify = (): void => {
const notify = (newStatus?: string): void => {
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
// весь UI ради необязательного оповещения.
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
const target = {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined);
};
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
};
// Заявление согласовано целиком → сразу заводим обходной лист по нему (данные
@@ -261,7 +270,9 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
<div>
<div className={`${styles.toolbar} rp-no-print`}>
{printGate === 'allowed' ? (
<PrintButton />
<PrintButton
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
/>
) : (
<span className={styles.printBlocked} title="Печать откроется после согласования обходного листа по этому увольнению">
Печать станет доступна после согласования обходного листа
@@ -271,7 +282,7 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
<div className="rp-print-root">
<FormCard
title="Заявление на увольнение"
title={FORM_TITLE}
subtitle={`№${code || itemId}`}
belowSubtitle={
<InitiatorLine
@@ -281,6 +292,7 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
}
headerRight={status ? <StatusBadge status={status} /> : undefined}
>
<FormNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
<FormModeProvider readOnly>
<RequestSection {...sectionProps} />
</FormModeProvider>
@@ -311,7 +323,7 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
notify();
notify(newStatus);
// «Согласовано» = маршрут заявления пройден целиком (фазы исполнения у
// него нет, см. README формы) — значит увольнение подтверждено и пора
// заводить обходной лист. Промис ВОЗВРАЩАЕМ: пока он не разрешится,
@@ -61,6 +61,8 @@ RequestTypeId» в `docs/forms.md`). Тип заведён в админке app
- **Комментарий** (`Comment`) — необязательный свободный текст. Причины увольнения
отдельного поля нет: в шаблоне формулировка фиксированная («по собственному желанию»).
- **Заголовок элемента** (`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 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 type { WebPartContext } from '@microsoft/sp-webpart-base';
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 {
ApproverService,
DecisionPanel,
@@ -14,6 +22,7 @@ import {
ExecutorsMatrix,
approvalPhaseRows,
notifyActiveStep,
notifyInitiatorOfReturn,
useRequestTypeId
} from '../../shared/approval';
import { friendlyErrorMessage } from '../../shared/diagnostics';
@@ -40,6 +49,8 @@ interface IEmployeeTransferViewProps {
onCodeLoaded?: (code: string) => void;
}
const FORM_TITLE = 'Заявка на перемещение сотрудника';
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
const NOOP_SET: SetField = () => undefined;
@@ -55,6 +66,8 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>('');
// ФИО перемещаемого — для имени печатного документа.
const [employeeName, setEmployeeName] = useState<string>('');
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Какая из организаций сотрудника (см. OrganizationField) идёт в печать —
@@ -88,6 +101,7 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
setData(loaded.data);
setStatus(loaded.status);
setCode(loaded.code);
setEmployeeName(loaded.employeeName);
notifyCode(loaded.code);
setError(null);
setLoading(false);
@@ -117,28 +131,36 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
const notify = (): void => {
const notify = (newStatus?: string): void => {
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
// весь UI ради необязательного оповещения.
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
const target = {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined);
};
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
};
return (
<div>
<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 className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard
title="Заявка на перемещение сотрудника"
title={FORM_TITLE}
subtitle={`№${code || itemId}`}
belowSubtitle={
<InitiatorLine
@@ -172,7 +194,7 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
notify();
notify(newStatus);
}}
onDelegated={() => {
setDecisionSeq((n) => n + 1);
@@ -1,32 +0,0 @@
import * as React from 'react';
import { AttachmentList, FileDropzone, FormField } from '../../shared/ui';
import { IAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
import { attachmentsOfCategory, categoryPrefix, uncategorizedAttachments } from './attachmentCategories';
import styles from './HireAttachmentField.module.scss';
interface Props {
label: string;
editor: IAttachmentEditor;
editable: boolean;
/** Группа новых файлов в editor.pending. */
groupKey: string;
/** Категория в имени файла; не задана — «прочие» вложения без префикса. */
category?: string;
}
/** Поле одной категории вложений: прикреплённые файлы + (в правке) выбор новых. */
export const HireAttachmentField: React.FC<Props> = ({ label, editor, editable, groupKey, category }) => {
const items = category ? attachmentsOfCategory(editor.items, category) : uncategorizedAttachments(editor.items);
// В списке имена показаны без префикса категории — для удаления возвращаем его.
const removeWithPrefix = (name: string): void => editor.remove(category ? categoryPrefix(category) + name : name);
return (
<FormField label={label} full>
{items.length > 0 && <AttachmentList items={items} onRemove={editable ? removeWithPrefix : undefined} />}
{items.length === 0 && !editable && <span className={styles.empty}>—</span>}
{editable && (
<FileDropzone files={editor.pending[groupKey] || []} onChange={(files) => editor.setPending(groupKey, files)} />
)}
</FormField>
);
};
@@ -1,8 +1,8 @@
import * as React from 'react';
import { Alert, CollapseSection } from '../../shared/ui';
import { IAttachmentEditor, PrepareFiles } from '../../shared/attachments/useAttachmentEditor';
import { AttachmentSlotField } from '../../shared/attachments/AttachmentSlotField';
import { ATT_CATEGORY_ID, ATT_CATEGORY_RESUME, prefixFiles, uncategorizedAttachments } from './attachmentCategories';
import { HireAttachmentField } from './HireAttachmentField';
/**
* Группы новых файлов → категория в имени файла (см. attachmentCategories).
@@ -37,9 +37,16 @@ export const HireAttachments: React.FC<Props> = ({ editor, editable }) => {
return (
<CollapseSection title="Вложения" icon="upload">
{editor.error && <Alert variant="err">{editor.error}</Alert>}
<HireAttachmentField label="Удостоверение" groupKey={GROUP_ID} category={ATT_CATEGORY_ID} {...fieldProps} />
<HireAttachmentField label="Резюме" groupKey={GROUP_RESUME} category={ATT_CATEGORY_RESUME} {...fieldProps} />
{showOther && <HireAttachmentField label="Прочие вложения" groupKey={GROUP_OTHER} {...fieldProps} />}
<AttachmentSlotField label="Удостоверение" groupKey={GROUP_ID} category={ATT_CATEGORY_ID} {...fieldProps} />
<AttachmentSlotField label="Резюме" groupKey={GROUP_RESUME} category={ATT_CATEGORY_RESUME} {...fieldProps} />
{showOther && (
<AttachmentSlotField
label="Прочие вложения"
groupKey={GROUP_OTHER}
knownCategories={[ATT_CATEGORY_ID, ATT_CATEGORY_RESUME]}
{...fieldProps}
/>
)}
</CollapseSection>
);
};
@@ -12,7 +12,8 @@ import {
PrintButton,
FieldChangesProvider,
FieldChangesBanner,
issueLabels
issueLabels,
buildPrintTitle
} from '../../shared/ui';
import { IFieldChange, mergeFieldChanges, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
import { HIRE_CHANGE_TRACKING } from './changeTracking';
@@ -24,6 +25,7 @@ import {
ApprovalHistory,
useMatrixEditor,
notifyActiveStep,
notifyInitiatorOfReturn,
useRequestTypeId,
APPROVAL_PHASE,
isApprovedStatus,
@@ -86,6 +88,7 @@ const HIRE_DATE_REQUIRED_MESSAGE = 'укажите дату приёма (п. 2.
const PAYROLL_HINT = 'Как согласующий вы можете указать оклады (п. 4.2, 4.3) и отдел/участок (п. 2.4).';
const HIRE_DATE_HINT = 'Как исполнитель укажите дату приёма (п. 2.2) — она обязательна для отметки «Выполнено».';
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.';
const FORM_TITLE = 'Заявка на прием';
// Заявка «на прием» только для просмотра (режим согласующего). Переиспользует
// те же секции, что и форма подачи; редактирование выключено через
@@ -283,13 +286,16 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
);
// Выше ранних return (загрузка/ошибка): зовётся и из колбэков хуков ниже.
const notify = (): void => {
const notify = (newStatus?: string): void => {
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
const target = {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID),
}).catch(() => undefined);
};
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
};
// Добавить согласующего/исполнителя в поданную заявку — то же право
@@ -378,7 +384,11 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
<Icon name="copy" size="sm" />
Организация рабочего места на основе этой
</Button>
{isApprovedStatus(status) && <PrintButton />}
{isApprovedStatus(status) && (
<PrintButton
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.HireCandidateName, code, itemId })}
/>
)}
</div>
{hasEditableFields && (
@@ -396,7 +406,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
<div className="rp-print-root">
<FormCard
title="Заявка на прием"
title={FORM_TITLE}
subtitle={`№${code || itemId}`}
belowSubtitle={
<InitiatorLine
@@ -448,6 +458,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
{!executors.loading && (executors.rows.length > 0 || canAddExecutor) && (
<ApprovalMatrix
editor={executors}
printable={false}
search={(q) => approval.searchUsers(q)}
title="Исполнение"
roleLabel="Исполнитель"
@@ -479,7 +490,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
// Очередь сдвинулась — уведомляем следующий активный шаг.
notify();
notify(newStatus);
}}
onDelegated={() => {
// Шаг переназначен: активной стала строка делегата — обновляем
@@ -1,50 +1,24 @@
import { IAttachmentInfo } from '../../shared/attachments/attachments.service';
import { attachmentsWithoutCategories } from '../../shared/attachments/attachmentCategories';
/**
* Категории вложений заявки на прием. Вложения SharePoint — плоская коллекция
* файлов на элементе списка, «категории» у них нет. Поэтому категорию кодируем
* ПРЕФИКСОМ в имени файла: «<Категория> — <исходное имя>». При загрузке имя
* файла переписываем, при показе — группируем по префиксу и префикс срезаем.
* Категории вложений заявки на прием. Механизм подписи префиксом в имени файла —
* общий (shared/attachments/attachmentCategories); здесь только категории этой
* формы. Общие функции реэкспортируются, чтобы импорты формы не менялись.
*/
export const ATT_CATEGORY_ID = 'Удостоверение';
export const ATT_CATEGORY_RESUME = 'Резюме';
const SEP = ' — ';
/** Префикс имени файла для категории: «Удостоверение — ». */
export function categoryPrefix(category: string): string {
return `${category}${SEP}`;
}
/**
* Копии File с префиксом категории в имени — для загрузки. File.name неизменяемо,
* поэтому создаём новый File с тем же содержимым и типом, но другим именем.
*/
export function prefixFiles(files: File[], category: string): File[] {
const prefix = categoryPrefix(category);
return files.map((f) => new File([f], prefix + f.name, { type: f.type }));
}
/**
* Вложения указанной категории, с ОБРЕЗАННЫМ префиксом в имени (для показа).
* url сохраняется как есть; чтобы удалить такой файл, префикс надо вернуть
* обратно (см. categoryPrefix в обработчике удаления формы).
*/
export function attachmentsOfCategory(items: IAttachmentInfo[], category: string): IAttachmentInfo[] {
const prefix = categoryPrefix(category);
return items
.filter((a) => a.fileName.indexOf(prefix) === 0)
.map((a) => ({ url: a.url, fileName: a.fileName.slice(prefix.length) }));
}
export {
categoryPrefix,
prefixFiles,
attachmentsOfCategory
} from '../../shared/attachments/attachmentCategories';
/**
* Вложения без известной категории (старые заявки, загруженные до разделения).
* Показываем как есть, чтобы файлы не «исчезали» с экрана.
*/
export function uncategorizedAttachments(items: IAttachmentInfo[]): IAttachmentInfo[] {
const idPrefix = categoryPrefix(ATT_CATEGORY_ID);
const resumePrefix = categoryPrefix(ATT_CATEGORY_RESUME);
return items.filter(
(a) => a.fileName.indexOf(idPrefix) !== 0 && a.fileName.indexOf(resumePrefix) !== 0
);
return attachmentsWithoutCategories(items, [ATT_CATEGORY_ID, ATT_CATEGORY_RESUME]);
}
@@ -10,7 +10,8 @@ import {
FormModeProvider,
PrintButton,
FieldChangesProvider,
FieldChangesBanner
FieldChangesBanner,
buildPrintTitle
} from '../../shared/ui';
import { IFieldChange, mergeFieldChanges, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
import { SELECTION_CHANGE_TRACKING } from './changeTracking';
@@ -22,6 +23,7 @@ import {
ApprovalHistory,
useMatrixEditor,
notifyActiveStep,
notifyInitiatorOfReturn,
useRequestTypeId,
isApprovedStatus,
resolveApproverOrgInfo,
@@ -79,6 +81,7 @@ interface IRecruitingSelectionViewProps {
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.';
const FORM_TITLE = 'Заявка на подбор';
// Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же
// секции, что и форма подачи; редактирование выключено через <FormModeProvider readOnly>,
@@ -204,20 +207,25 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
await saveAllFields();
};
// rowId — оповестить конкретную активную строку (см. notifyActiveStep).
const notify = (rowId?: number): void => {
if (!requestTypeId) return;
notifyActiveStep(
context,
approval,
itemId,
{
// Куда смотрит поток оповещения; undefined — тип заявки ещё не резолвился.
const notifyTarget = requestTypeId
? {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
},
rowId
).catch(() => undefined);
}
: undefined;
// rowId — оповестить конкретную активную строку (см. notifyActiveStep).
const notify = (rowId?: number): void => {
if (!notifyTarget) return;
notifyActiveStep(context, approval, itemId, notifyTarget, rowId).catch(() => undefined);
};
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
const notifyInitiator = (newStatus: string): void => {
if (!notifyTarget) return;
notifyInitiatorOfReturn(context, approval, itemId, notifyTarget, newStatus).catch(() => undefined);
};
// Удалить ещё не решённый шаг маршрута (см. ApproverService.deleteQueuedStep).
@@ -360,7 +368,10 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
return (
<div>
<div className={`${styles.toolbar} rp-no-print`}>
{isApprovedStatus(status) && <PrintButton />}
{/* Подбор — на вакансию, конкретного человека ещё нет: имя файла без ФИО. */}
{isApprovedStatus(status) && (
<PrintButton documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: '', code, itemId })} />
)}
</div>
{fullEditable && (
@@ -378,7 +389,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
<div className="rp-print-root">
<FormCard
title="Заявка на подбор"
title={FORM_TITLE}
subtitle={`№${code || itemId}`}
belowSubtitle={
<InitiatorLine
@@ -432,6 +443,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
{!executors.loading && (executors.rows.length > 0 || canAddExecutor) && (
<ApprovalMatrix
editor={executors}
printable={false}
search={(q) => approval.searchUsers(q)}
title="Исполнение"
roleLabel="Исполнитель"
@@ -467,6 +479,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
notify();
notifyInitiator(newStatus);
}}
onDelegated={() => {
setDecisionSeq((n) => n + 1);
@@ -207,28 +207,6 @@ export const LANGUAGE_LEVEL_OPTIONS: ISelectOption[] = [
{ value: 'Свободный', label: 'Свободный' }
];
/**
* Программы (ПО) — ВРЕМЕННЫЙ список, пока нет справочника в API. Используется в
* мультивыборе «Необходимые знания в программах». Меняется здесь одним местом.
*/
export const SOFTWARE_OPTIONS: ISelectOption[] = [
{ value: '1С:Предприятие', label: '1С:Предприятие' },
{ value: 'MS Word', label: 'MS Word' },
{ value: 'MS Excel', label: 'MS Excel' },
{ value: 'MS PowerPoint', label: 'MS PowerPoint' },
{ value: 'MS Outlook', label: 'MS Outlook' },
{ value: 'MS Project', label: 'MS Project' },
{ value: 'AutoCAD', label: 'AutoCAD' },
{ value: 'Autodesk Forma', label: 'Autodesk Forma' },
{ value: 'BIM', label: 'BIM' },
{ value: 'Revit', label: 'Revit' },
{ value: 'ArchiCAD', label: 'ArchiCAD' },
{ value: 'Компас-3D', label: 'Компас-3D' },
{ value: 'Primavera', label: 'Primavera' },
{ value: 'SAP', label: 'SAP' },
{ value: 'Adobe Photoshop', label: 'Adobe Photoshop' }
];
/** Категории «Обеспечение рабочего места» (+ «Другое» через allowOther). */
export const WORKPLACE_PROVISION_OPTIONS: ISelectOption[] = [
{ value: 'Оргтехника', label: 'Оргтехника' },
@@ -1,6 +1,6 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput, TextArea, Select, MultiChoiceField } from '../../../shared/ui';
import { PositionPicker } from '../../../shared/dictionary';
import { PositionPicker, SOFTWARE_OPTIONS } from '../../../shared/dictionary';
import { LanguagesField } from './LanguagesField';
import {
ISectionProps,
@@ -8,7 +8,6 @@ import {
EDUCATION_OPTIONS,
WORK_EXPERIENCE_OPTIONS,
RESIDENCE_OPTIONS,
SOFTWARE_OPTIONS,
OTHER_VALUE
} from '../model';
@@ -11,7 +11,8 @@ import {
FormField,
AttachmentList,
PrintSplitButton,
OrgLetterhead
OrgLetterhead,
buildPrintTitle
} from '../../shared/ui';
import {
ApproverService,
@@ -21,6 +22,7 @@ import {
ApprovalHistory,
useMatrixEditor,
notifyActiveStep,
notifyInitiatorOfReturn,
useRequestTypeId,
APPROVAL_PHASE,
isApprovedStatus,
@@ -59,6 +61,8 @@ interface IReferralViewProps {
onCodeLoaded?: (code: string) => void;
}
const FORM_TITLE = 'Реферальная рекомендация';
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
const NOOP_SET: SetField = () => undefined;
@@ -169,30 +173,38 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
const notify = (): void => {
const notify = (newStatus?: string): void => {
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
// весь UI ради необязательного оповещения.
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
const target = {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined);
};
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
};
return (
<div>
<div className={`${styles.toolbar} rp-no-print`}>
{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 className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard
title="Реферальная рекомендация"
title={FORM_TITLE}
subtitle={`№${code || itemId}`}
belowSubtitle={
<InitiatorLine
@@ -230,6 +242,7 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
{!executors.loading && executors.rows.length > 0 && (
<ApprovalMatrix
editor={executors}
printable={false}
search={(q) => approval.searchUsers(q)}
title="Исполнение"
roleLabel="Исполнитель"
@@ -253,7 +266,7 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
notify();
notify(newStatus);
}}
onDelegated={() => {
setDecisionSeq((n) => n + 1);
@@ -10,7 +10,8 @@ import {
FormModeProvider,
PrintSplitButton,
OrgLetterhead,
Icon
Icon,
buildPrintTitle
} from '../../shared/ui';
import { navigate } from '../../shared/routing';
import {
@@ -24,6 +25,7 @@ import {
ExecutorsMatrix,
approvalPhaseRows,
notifyActiveStep,
notifyInitiatorOfReturn,
useRequestTypeId,
isEditableStatus
} from '../../shared/approval';
@@ -56,6 +58,8 @@ interface IServiceRequestViewProps {
onCodeLoaded?: (code: string) => void;
}
const FORM_TITLE = 'Заявка на услуги';
// Заявка «Заявка на услуги» только для просмотра (режим согласующего).
const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
// onCodeLoaded — колбэк родителя, его ссылка меняется на каждом рендере; в зависимостях эффекта
@@ -141,13 +145,16 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
const sectionProps = { data, set, context, printOrgIndex, attachmentUrls };
const notify = (): void => {
const notify = (newStatus?: string): void => {
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
const target = {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined);
};
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
};
return (
@@ -162,13 +169,18 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
Редактировать
</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 className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard
title="Заявка на услуги"
title={FORM_TITLE}
subtitle={`№${code || itemId}`}
belowSubtitle={
<InitiatorLine
@@ -205,7 +217,7 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
notify();
notify(newStatus);
}}
onDelegated={() => {
setDecisionSeq((n) => n + 1);

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