55 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 09:20:15 +05:00
Тимур Городецкий 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
Тимур Городецкий a750db9d7f Merge branch 'main' of http://git.tnsu.kz/timur.gorodetskiy/request-portal
Tests / frontend-test (push) Successful in 1m25s
2026-10-27 15:33:45 +05:00
Тимур Городецкий ea04602606 feat: переезд внутри карточки точки, время выезда обязательно
Транспорт и время выезда переехали в карточку точки, куда едут («Как
добраться: Алматы → Астана»), обратный переезд — под переключателем
«Возвращение». Отдельной строкой на линии маршрута участок пропускали.
Время выезда теперь обязательно — оно нужно тревел-координатору под билеты.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 16:06:29 +05:00
Тимур Городецкий 57feeddcfe feat: дни суточных и ночи проживания можно поправить вручную
По умолчанию считаются по датам точки; карандаш открывает ручной ввод — любое
число (форс-мажор, задержался). Под полем видно, сколько по датам, и можно
вернуть расчёт. В просмотре, печати и колонке SP ручное значение помечено
«(вручную)». Хранится в TripEstimateJson (quantityManual), колонок нет.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 15:53:06 +05:00
Тимур Городецкий fa2e9593f1 feat: секция «Обязательство и согласие» в командировке
Переименована по требованию кадров; так же подписана и в списке незаполненных полей.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 15:46:16 +05:00
Тимур Городецкий 8d162db4ba docs: требования кадров к командировке в общей таблице форм
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 15:21:19 +05:00
Тимур Городецкий 51f3c6fb9a feat: предпочтительное время выезда на участках командировки
У каждого участка маршрута рядом с видом транспорта — «Утро / День / Вечер /
Ночь» (необязательно, повторный клик снимает выбор). Хранится в TripRouteJson
(legDepartureTime) — новых колонок нет. На схеме — только в подсказке плашки
транспорта. Стили плашек вынесены в общий ChoiceChips.module.scss.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 15:21:13 +05:00
Тимур Городецкий 63448e60aa feat: комментарий после итога сметы в командировке
Необязательное поле «Комментарий» (колонка TripComment) после итога сметы;
в просмотре пустой комментарий не показывается.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 15:16:43 +05:00
Тимур Городецкий 50a1ae2ce3 feat: командировка не дольше 40 дней
От прибытия в первую точку до отъезда из последней включительно; при
отправке подсвечивается отъезд последней точки.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 15:14:45 +05:00
Тимур Городецкий d07056937a feat: лимиты суточных и проживания, ночи считаются сами
Суточные — не больше 6 МРП в день по Казахстану и 8 за рубежом (по признаку
точки). Проживание теперь строится по точкам маршрута, как суточные: ночи из
дат точки, человек вводит только цену за ночь — не больше 15 000 ₸ по
Казахстану; лимит для заграницы — константа, пока без значения. Превышение
видно сразу под полем и блокирует отправку. syncPerDiem → syncRouteItems.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 15:12:50 +05:00
Тимур Городецкий 17f448a1c2 feat: точки командировки по Казахстану и за рубежом
У каждой точки маршрута — «Казахстан | Другая страна» (бывают смешанные
поездки). За рубежом страна и город вводятся вручную, подпись «Китай, Пекин»,
объект можно вписать своим текстом (завод, выставка — их нет в справочнике).
Новая колонка TripKind («По Казахстану» / «Зарубежная») — хотя бы одна точка
за рубежом делает поездку зарубежной; пригодится для маршрута и отчётов.
Переключатель «Форма | Схема» вынесен в общий SegmentedSwitch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 15:02:22 +05:00
Тимур Городецкий 6122370bbf feat: убрано поле «Цель» из заявки на командировку
Поле убрано из формы, модели, чтения/записи и проверки. Колонка TripPurpose
убрана из манифеста; в уже созданном списке SP она остаётся с данными
тестовых заявок (провизионер колонки не удаляет).

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 14:26:02 +05:00
Тимур Городецкий fa84dd6ad5 feat: такси вместо автобуса в видах транспорта командировки
По просьбе кадров. Иконка — local_taxi (машина с шашкой), чтобы такси
отличалось от служебного и личного автотранспорта.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 14:25:00 +05:00
Тимур Городецкий 08dd151c56 feat: город в командировке из справочника на русском и казахском
Город отправления и города точек выбираются из справочника КАТО; в маршруте
хранятся оба названия и код (city/cityKz/cityKato, origin/originKz/originKato
внутри TripRouteJson — новых колонок нет). Везде, включая TripCity/TripRoute и
заголовок, город показывается как «Кульсары (Құлсары)». Введённый вручную
город (село, заграница) сохраняется текстом. README дополнен и описанием
командирующей организации.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 14:24:10 +05:00
Тимур Городецкий c892fb26a9 feat: справочник городов КАТО и поле выбора города
Города и посёлки РК (178) из официального КАТО stat.gov.kz — на русском и
казахском, с кодом и областью. Справочник генерируется скриптом
scripts/Build-KatoCities.ps1 и лежит на фронте: внешних API и ключей не нужно.
CityPicker ищет по обоим языкам с учётом раскладки (Құлсары = Кулсары) и
оставляет свой вариант для сёл и заграницы.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 14:21:26 +05:00
Тимур Городецкий 513f6c1d30 feat: выбор командирующей организации в заявке на командировку
Сотрудник может работать в нескольких компаниях, а командирует одна —
теперь её выбирают из организаций сотрудника (Employee/ByEmail), должность
подставляется из выбранной компании. Одна организация выбирается сама.
Выбор хранится в TripCompany + TripCompanyId (id — под будущий маршрут).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 11:50:24 +05:00
267 changed files with 9377 additions and 4385 deletions
+1 -1
View File
@@ -1 +1 @@
@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/
@tnsu:registry=https://git.tnsu.kz/api/packages/tansu/npm/
+2 -2
View File
@@ -269,7 +269,7 @@ Info "Подключаем @tnsu/ui-kit..."
# .npmrc: адрес registry для скоупа @tnsu (НЕ секрет, можно коммитить).
# Токен на чтение — у каждого разработчика свой, в ~/.npmrc (в проект не кладём).
$npmrc = @'
@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/
@tnsu:registry=https://git.tnsu.kz/api/packages/tansu/npm/
'@
$npmrc | Set-Content -Encoding UTF8 ".npmrc"
Success ".npmrc создан (адрес registry @tnsu)."
@@ -282,7 +282,7 @@ try {
} catch {
Warn "Не удалось поставить @tnsu/ui-kit. Скорее всего нет токена @tnsu в ~/.npmrc."
Warn "Создай токен (Gitea → Настройки → Приложения → package: Read) и пропиши в ~/.npmrc:"
Warn " //git.tnsu.kz/api/packages/timur.gorodetskiy/npm/:_authToken=ТВОЙ_ТОКЕН"
Warn " //git.tnsu.kz/api/packages/tansu/npm/:_authToken=ТВОЙ_ТОКЕН"
Warn "Потом выполни: npm install @tnsu/ui-kit"
}
+2 -2
View File
@@ -49,10 +49,10 @@
| [`clearance-form`](../src/webparts/requestPortal/forms/Clearance/README.md) | Обходной лист | Отдел кадров | да | нет | да | подаётся HR за увольняемого (People-picker) либо создаётся сам по согласованному «Заявлению на увольнение»; условие «IsManager» резолвится по должности из самой заявки; да/нет-поля (парковка, СКУД); 5 независимых сумм к списанию компактной сеткой (ОС, СИЗ, подотчётная сумма, НМА, ТМЦ — обучение не входит, у него своё поле) — вводит любой активный согласующий, блокируется навсегда после первого ввода; заполнил хоть одну — стандартный комментарий решения (в блоке «Ваше решение») становится обязательным на любое решение, не только возврат/отклонение | ✅ |
| [`hr-dismissal`](../src/webparts/requestPortal/forms/Dismissal/README.md) | Заявление на увольнение | Отдел кадров | да | нет | да | совместители — таблица компаний с удалением строки (снимает и согласующего из матрицы); должность/организация из справочника; **после согласования сам создаёт обходной лист**; печать заблокирована до согласования обходного листа; «Лицо, принимающее ОС/НМА» — заполняет только непосредственный руководитель | ✅ |
| [`hr-skud-explanation`](../src/webparts/requestPortal/forms/SkudExplanation/README.md) | Объяснительная об отсутствии отметок в СКУД | Отдел кадров | да | нет | да | только «на себя»; дни выбираются кликом в календаре «Мой СКУД» (проходы Hikvision текущего пользователя), сохраняются снимком для согласующего; текст записки собирается автоматически, печать — бланк объяснительной | ⏳ код готов, списки/тип/маршрут не заведены |
| [`hr-business-trip`](../src/webparts/requestPortal/forms/BusinessTrip/README.md) | Заявка на командировку | Отдел кадров | да | да | да | можно за другого сотрудника; маршрут лентой (точки = город + объект из справочника + даты, участки с видом транспорта); задание — список задач; смета: статьи с позициями, привязанными к точкам/участкам (суммой или кол-во × цена — всё вручную), итог с долями и по объектам; обязательство по авансовому отчёту — флажок; печать без шапки адресата | ⏳ код готов, списки/тип/маршрут не заведены |
| [`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 × цена), итог дублируется числовой колонкой для отчётов | ✅ |
| [`acc-payment-request`](../src/webparts/requestPortal/forms/PaymentRequest/README.md) | Заявка на оплату | Бухгалтерия | да | нет | да | основание — согласованная СТ (сайт СТ) или потребность; счёт PDF → библиотека → OCR → ИИ (реквизиты, суммы, позиции); сверка позиций счёта с СТ; подсветка чужого получателя и превышения общей суммы СТ; справочники 1С | ⏳ списки, библиотека, тип (id 24) и маршрут заведены; не проверена на живых данных |
Легенда: **Согл.** — есть фаза согласования; **Исп.** — есть фаза исполнения;
**Просмотр** — есть режим согласующего (`<Форма>View`).
+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` |
+4 -4
View File
@@ -17,7 +17,7 @@
"@microsoft/sp-webpart-base": "1.22.1",
"@pnp/sp": "^4.20.0",
"@pnp/spfx-controls-react": "^3.25.0",
"@tnsu/ui-kit": "^0.12.0",
"@tnsu/ui-kit": "^0.15.0",
"jszip": "~3.8.0",
"react": "17.0.1",
"react-dom": "17.0.1",
@@ -9886,9 +9886,9 @@
"license": "0BSD"
},
"node_modules/@tnsu/ui-kit": {
"version": "0.12.0",
"resolved": "http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/%40tnsu%2Fui-kit/-/0.12.0/ui-kit-0.12.0.tgz",
"integrity": "sha512-lJYf2DoYaydrktbSotRNY0umeJlQw8Ekhh6fu1FGB0p+/TXTozpBfAs+TxK/mvOXlS9yYenEuGBi85fd/dRF2A==",
"version": "0.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",
+1 -1
View File
@@ -21,7 +21,7 @@
"@microsoft/sp-webpart-base": "1.22.1",
"@pnp/sp": "^4.20.0",
"@pnp/spfx-controls-react": "^3.25.0",
"@tnsu/ui-kit": "^0.12.0",
"@tnsu/ui-kit": "^0.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
+99
View File
@@ -0,0 +1,99 @@
<#
.SYNOPSIS
Собирает справочник городов и посёлков РК для выпадающих списков портала из
официального КАТО (Бюро национальной статистики, stat.gov.kz) и пишет его
TS-модулем src/webparts/requestPortal/shared/dictionary/kato/katoCities.ts.
.DESCRIPTION
Берём только города («г.») и посёлки («п.»): ~180 записей, несколько KB в бандле.
Сёла (~10 тыс.) не берём — тяжело для фронта; их вводят вручную в то же поле.
Административные единицы («Г.А.», «п.а.», «г.а.» — администрации, не населённые
пункты) отбрасываются. КАТО обновляется редко: перезапустить скрипт со ссылкой
на свежий файл со страницы https://stat.gov.kz/ru/classifiers/statistical/21/
и закоммитить сгенерированный модуль.
.EXAMPLE
./scripts/Build-KatoCities.ps1
./scripts/Build-KatoCities.ps1 -XlsxUrl "https://stat.gov.kz/upload/iblock/.../КАТО_ДД.ММ.ГГГГ.xlsx"
#>
param(
[string]$XlsxUrl = 'https://stat.gov.kz/upload/iblock/a7d/eqogm92xc0udumlzfzffhcrutk0fjhb4/КАТО_18.09.2026.xlsx',
[string]$OutFile = (Join-Path $PSScriptRoot '..\src\webparts\requestPortal\shared\dictionary\kato\katoCities.ts')
)
$ErrorActionPreference = 'Stop'
$work = Join-Path ([System.IO.Path]::GetTempPath()) "kato-$([guid]::NewGuid())"
New-Item -ItemType Directory -Path $work | Out-Null
try {
# xlsx — это zip: читаем XML листа напрямую, без Excel и сторонних модулей.
$zip = Join-Path $work 'kato.zip'
Invoke-WebRequest -Uri $XlsxUrl -OutFile $zip -UseBasicParsing
Expand-Archive -Path $zip -DestinationPath $work
[xml]$sharedXml = Get-Content -Raw -Encoding UTF8 (Join-Path $work 'xl\sharedStrings.xml')
$shared = @($sharedXml.sst.si | ForEach-Object { $_.InnerText })
[xml]$sheetXml = Get-Content -Raw -Encoding UTF8 (Join-Path $work 'xl\worksheets\sheet1.xml')
# Колонки листа: A — код КАТО, B — код области (ab), C/D/E — нижние уровни, G/H — название на казахском/русском.
$rows = foreach ($row in $sheetXml.worksheet.sheetData.row) {
$cells = @{}
foreach ($c in $row.c) {
$column = $c.r -replace '\d', ''
$value = if ($c.t -eq 's') { $shared[[int]$c.v] } elseif ($c.t -eq 'inlineStr') { $c.is.InnerText } else { $c.v }
$cells[$column] = "$value".Trim()
}
[pscustomobject]@{ Kato = $cells['A']; Region = $cells['B']; Level2 = $cells['C']; Level3 = $cells['D']; Level4 = $cells['E']; Name = $cells['H']; NameKz = $cells['G'] }
}
# Области — строки без нижних уровней. Города республиканского значения (Астана,
# Алматы, Шымкент) сами себе «область» — для них регион не показываем.
$regions = @{}
$rows | Where-Object { $_.Level2 -eq '00' -and $_.Level3 -eq '00' -and $_.Level4 -eq '000' } | ForEach-Object {
$regions[$_.Region] = if ($_.Name -match '^г\.') { '' } else { $_.Name }
}
# «г.Атырау», «п. Асыката» — населённые пункты; «п.а.», «г.а.» — администрации.
$cities = $rows |
Where-Object { $_.Name -match '^(г|п)\.\s*[^\s.]' -and $_.Name -notmatch '^(г|п)\.а\.' } |
ForEach-Object {
[pscustomobject]@{
Kato = $_.Kato
Name = ($_.Name -replace '^(г|п)\.\s*', '')
# «Құлсары қ.» / «Шаған к.» — тип пункта последним словом с точкой (в источнике
# встречается и опечатка «ќ.»), поэтому отрезаем любое такое слово.
NameKz = ($_.NameKz -replace '\s+\S+\.$', '')
Kind = if ($_.Name -match '^г\.') { 'city' } else { 'settlement' }
Region = $regions[$_.Region]
}
} |
Sort-Object Name, Region
$esc = { param($s) $s -replace '\\', '\\' -replace "'", "\'" }
$lines = $cities | ForEach-Object {
" { kato: '$($_.Kato)', name: '$(& $esc $_.Name)', nameKz: '$(& $esc $_.NameKz)', kind: '$($_.Kind)', region: '$(& $esc $_.Region)' },"
}
$source = [System.Uri]::UnescapeDataString(($XlsxUrl -split '/')[-1])
$content = @"
// СГЕНЕРИРОВАНО scripts/Build-KatoCities.ps1 — руками не править, перезапустить скрипт.
// Источник: КАТО, Бюро национальной статистики РК (stat.gov.kz), файл «$source».
import { IKatoCity } from './IKatoCity';
export const KATO_CITIES: ReadonlyArray<IKatoCity> = [
$($lines -join "`n")
];
"@
$outPath = [System.IO.Path]::GetFullPath($OutFile)
New-Item -ItemType Directory -Force -Path (Split-Path $outPath) | Out-Null
# Без BOM и с LF — как остальные исходники репозитория.
[System.IO.File]::WriteAllText($outPath, $content.Replace("`r`n", "`n") + "`n", (New-Object System.Text.UTF8Encoding $false))
$cityCount = @($cities | Where-Object Kind -eq 'city').Count
Write-Host "Готово: $($cities.Count) записей (городов $cityCount, посёлков $($cities.Count - $cityCount)) → $OutFile"
}
finally {
Remove-Item -Recurse -Force $work
}
+8 -6
View File
@@ -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
},
// Склад / Снабжение — формы открыты для ознакомления, но приём заявок ещё не
// запущен (маршруты/списки не готовы): кнопки сохранения/отправки в самих
@@ -53,12 +61,6 @@ export const REQUEST_TYPES: IRequestType[] = [
title: 'Заявка на услуги',
icon: 'document'
},
{
id: 'acc-payment-request',
categoryId: 'accounting',
title: 'Заявка на оплату',
icon: 'calculator'
},
{
id: 'wh-concrete-request',
categoryId: 'warehouse',
@@ -43,7 +43,7 @@ import { useStableCallback } from '../../shared/useStableCallback';
import { submitDateOnOpen } from '../../shared/submitDate';
import { FORM_ID, FORM_TITLE, IFormData, EMPTY_DATA, SetField } from './model';
import { draftIssues, submitIssues } from './validation';
import { syncPerDiem } from './estimate';
import { syncRouteItems } from './estimate';
import { saveBusinessTripRequest, businessTripApprovalService } from './submit';
import { loadBusinessTripRequest } from './load';
import { useTripEmployee } from './useTripEmployee';
@@ -86,10 +86,10 @@ const BusinessTripForm: React.FC<IRequestFormProps> = ({
const readOnly = !isEditableStatus(requestStatus);
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
// Суточные строятся по маршруту (см. syncPerDiem): секции, проверки и сохранение
// Суточные и проживание строятся по маршруту (см. syncRouteItems): секции, проверки и сохранение
// работают со сметой, уже приведённой к текущим точкам и датам.
const formData: IFormData = { ...data, Estimate: syncPerDiem(data.Estimate, data.Route) };
const handleEmployeeChange = useTripEmployee(context, editId, setData);
const formData: IFormData = { ...data, Estimate: syncRouteItems(data.Estimate, data.Route) };
const employee = useTripEmployee(context, editId, data.Employee, setData);
const approval = useMemo(() => businessTripApprovalService(editListId), [editListId]);
const requestTypeIdState = useRequestTypeId(context, requestType.id);
@@ -130,7 +130,7 @@ const BusinessTripForm: React.FC<IRequestFormProps> = ({
// Черновику нужен лишь сотрудник, отправке — все обязательные поля (см. validation.ts).
const draftMissing = draftIssues(formData);
const submitMissing = submitIssues(formData);
const submitMissing = submitIssues(formData, employee.organizations);
const validate = (): string | undefined => {
if (draftMissing.length > 0) return `Заполните обязательные поля: ${issueLabels(draftMissing)}.`;
@@ -204,7 +204,7 @@ const BusinessTripForm: React.FC<IRequestFormProps> = ({
onDeleted: onCancel
});
const sectionProps = { data: formData, set, context, onEmployeeChange: handleEmployeeChange };
const sectionProps = { data: formData, set, context, employee };
return (
<FieldIssuesProvider draftIssues={draftMissing} submitIssues={submitMissing}>
@@ -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);
@@ -18,13 +18,46 @@
## Особое поведение
- **Можно подать за другого сотрудника.** По умолчанию — сам инициатор; при выборе другого
подтягиваются его должность и организации (`Employee/ByEmail`, `useTripEmployee`).
- **Командирующая организация** (с 10-23, логика — `tripCompany.ts`). Сотрудник может работать
в нескольких компаниях — выбирают, от какой он едет (`Select` «Компания — должность»).
Одна организация — выбирается сама, поле заблокировано. Несколько — обязательна при отправке,
должность пустая до выбора и подставляется из выбранной компании. Сотрудника нет в
справочнике — поля нет, отправку не блокирует. Смена сотрудника сбрасывает выбор. Хранение:
`TripCompany` (название) + `TripCompanyId` (CompanyId — под будущий маршрут по компании);
`Organization` — по-прежнему все организации сотрудника. Заявки до 10-23 в просмотре
показывают прежний список организаций.
- **Маршрут — лента точек** (`components/route`, логика — `route.ts`; гибрид «маршрут + смета»
по образцу Concur/Navan/TripIt). Город отправления → точки (город + объект из `/Project` +
даты прибытия/отъезда) → (возвращение — переключатель). Простая поездка — одна точка;
«+ Добавить точку маршрута» — для поездок по нескольким объектам.
- **Города — из справочника КАТО** (`shared/dictionary/kato`, поле `CityPicker`): 178 городов
и посёлков РК из официального КАТО (stat.gov.kz), сгенерированы `scripts/Build-KatoCities.ps1`.
Поиск по русскому и казахскому названию с учётом раскладки (Құлсары = Кулсары = Кульсары);
набранное целиком однозначное название подставляется само. Выбор сохраняет оба названия и
код КАТО (`city`/`cityKz`/`cityKato`, у города отправления — `origin`/`originKz`/`originKato`,
всё внутри `TripRouteJson`), показывается везде как «Кульсары (Құлсары)» (`cityLabel`), в том
числе в `TripCity`/`TripRoute` и заголовке. Нет в справочнике (село, заграница) — сохраняется
введённый текст без казахского названия.
- **Казахстан / другая страна — у каждой точки** (с 10-23, `SegmentedSwitch` в карточке точки):
признак у точки, а не у поездки — бывают смешанные маршруты (Алматы → Москва → Атырау).
За рубежом — «Страна» и «Город» вручную (`abroad`/`country` в `TripRouteJson`), подпись
«Китай, Пекин» (страна первой). Смена признака: город остаётся, за рубежом сбрасываются казахское название
и код КАТО, у точки по Казахстану — страна (`stopLocationPatch`). Новая точка — в той же
стране, что предыдущая. Город отправления — всегда из справочника (можно и свой текст).
Колонка `TripKind` («По Казахстану» / «Зарубежная»): хотя бы одна точка за рубежом —
зарубежная; пригодится для маршрута согласования и отчётов. Старые заявки — по Казахстану.
- Участки пути не хранятся, а выводятся из точек (`routeLegs`, ключ `откуда>куда`); у
каждого участка — вид транспорта плашками (`legTransport`, `TRANSPORT_TYPES`), обязателен.
- Объект точки — только выбор из справочника (`projectId`): по объектам разносят затраты.
каждого участка — вид транспорта плашками (`legTransport`, `TRANSPORT_TYPES`: авиа, ж/д,
такси, служебный и личный автотранспорт; «Автобус» заменён на «Такси» 10-23), обязателен.
Там же **предпочтительное время выезда** (с 10-23): «Утро / День / Вечер / Ночь»
(`legDepartureTime`, `DEPARTURE_TIMES`), тоже обязательно — нужно тревел-координатору под
билеты. На схеме — только в подсказке плашки транспорта, вёрстку схемы не трогали.
- **Переезд — внутри карточки точки** (с 10-23, `LegFields`): блок «Как добраться: Алматы →
Астана» внизу карточки точки, куда едут; обратный — под переключателем «Возвращение».
Раньше участок был отдельной строкой на линии маршрута — его пропускали при заполнении.
- Объект точки по Казахстану — только выбор из справочника (`projectId`): по объектам
разносят затраты. У зарубежной точки поле «Объект / место назначения» принимает и свой
текст (завод, выставка — в справочнике их нет), обязательно лишь непустое значение.
- **Два вида маршрута** — переключатель «Форма | Схема» (`RouteViewSwitch`). Схема
(`RouteScheme`) — только просмотр, как схема метро: узлы-точки (город, объект, даты, ночи,
расходы по точке из сметы — `stopTotals`), на линиях — вид транспорта, внизу сводка
@@ -33,24 +66,40 @@
узкий — сверху вниз. Сумму под точками Тимур может попросить убрать, если будет громоздко.
- Новая точка начинается с даты отъезда из предыдущей (то, что человек уже ввёл);
прибытие раньше отъезда из предыдущей точки — ошибка.
- **Задним числом нельзя** (с 10-23): прибытие в любую точку — не раньше сегодняшнего дня
(сегодня можно). Прошедшие дни недоступны в календаре (`min`); набранную вручную или
«устаревшую» в черновике дату ловит `validation.ts` при отправке, черновик сохраняется.
- **Не дольше 40 дней** (с 10-23, `MAX_TRIP_DAYS`): от прибытия в первую точку до отъезда из
последней включительно; при отправке подсвечивается отъезд последней точки.
- Хранение: `TripRouteJson`; для отчётов из маршрута пишутся `TripRoute` (текстом),
`TripCity` (города), `Project`/`ProjectId` (объект ПЕРВОЙ точки — колонка у всех форм одна),
`TripProjects` (все объекты), `TripStartDate`/`TripEndDate`/`TripDaysCount` (весь период).
- **Задание** — нумерованный список задач, хранится в `TripTasksJson` (+ читаемая копия
`TripTasks` для представлений SP). Пустые строки не сохраняются.
- **Смета** (`components/estimate`, логика — `estimate.ts`). Всё вводится **вручную**, кроме
суточных:
- **Суточные считаются сами** (с 10-09, по просьбе кадров): по позиции на каждую точку
маршрута (`syncPerDiem`, `itemsFromRoute` в `ESTIMATE_KINDS`), город и проект — из точки,
суточных и проживания — они строятся по точкам маршрута (`syncRouteItems`, `itemsFromRoute`
в `ESTIMATE_KINDS`), по позиции на точку:
- **Суточные считаются сами** (с 10-09, по просьбе кадров): город и проект — из точки,
дни — из её дат включительно, но день переезда считается один раз (`stopPerDiemDays`).
Человек вводит только кол-во МРП в день; ставка = кол-во × `MRP_VALUE` (захардкожен в
model.ts вместе с `MRP_YEAR` — обновлять раз в год). Ставка сохраняется в заявке (`price`),
поэтому просмотр старой заявки не пересчитывается по новому МРП — `syncPerDiem` применяется
только в форме.
поэтому просмотр старой заявки не пересчитывается по новому МРП — `syncRouteItems`
применяется только в форме. **Лимит** (с 10-23): не больше 6 МРП в день по Казахстану и 8 за
рубежом (`MAX_MRP_PER_DAY`, по признаку точки) — подпись у поля, ошибка сразу под строкой
и при отправке.
- **Проживание** (с 10-23): ночи — из дат точки (`stopNights`), человек вводит только цену за
ночь. Цена необязательна (в точке могут жить на объекте). **Лимит** — 15 000 ₸ за ночь по
Казахстану; за рубежом `MAX_NIGHT_PRICE.abroad` пока `undefined` (без лимита) — вписать,
когда кадры назовут сумму. Кнопки «+ Ещё гостиница» и ввода «суммой» у проживания больше нет.
- **Дни/ночи можно поправить вручную** (с 10-23, `AutoQuantityField`): карандаш у поля
открывает ввод, число любое (форс-мажор — задержался в точке). Хранится в
`quantityManual` (`''` — по датам); под полем «вручную · по датам: N · вернуть». Даты
потом поменяли — ручное число остаётся, расхождение видно по подписи. В расчёте для
просмотра/печати/колонки SP — пометка «(вручную)».
- Статьи: основные Суточные / Проезд / Проживание (переключатель «Требуется / Не требуется»,
выключенная печатается «не требуется»), Страховка и Виза (по одной) и «Другой расход»
со своим названием (сколько угодно) — кнопками «+», удаляются корзиной.
- **Внутри статьи — позиции** («+ Ещё гостиница», «+ Ещё участок пути»; у суточных
- **Внутри статьи — позиции** («+ Ещё участок пути»; у суточных и проживания
позиции только из маршрута). Позиция привязана к точке маршрута (суточные, проживание, другой расход) или
к участку пути (проезд); страховка/виза — без привязки. Вариант один — выбор скрыт и
подставляется сам (`resolveLinkId`); точку удалили — позицию нужно привязать заново.
@@ -64,7 +113,10 @@
привязкой, у «Другого расхода» — название, у суточных — кол-во МРП.
- Хранение: `TripEstimateJson` (статьи + total), читаемо — `TripEstimate` (позиция на
строку), итог числом — `TripEstimateTotal`. Просмотр/печать — таблица по статьям.
- **Обязательство** по авансовому отчёту (текст бланка) — флажок, без него не отправить.
- **Комментарий** (с 10-23, `TripComment`, Note) — необязательное поле после итога сметы; в
просмотре пустой не показывается.
- **Обязательство и согласие** (так названа секция с 10-23, по требованию кадров) — обязательство
по авансовому отчёту (текст бланка) и флажок согласия, без него не отправить.
- **Дата подачи** (`SubmitDate`, общий столбец сайта) — ставится сегодняшней, пока заявку
можно править (см. shared/submitDate.ts).
- **Подсветка незаполненных полей** — `FieldIssuesProvider` + правила в `validation.ts`.
@@ -76,8 +128,8 @@
заголовком (см. грабли коллизий internalName).
## Секции формы
EmployeeSection (сотрудник · должность · дата подачи · организация) → RouteSection (маршрут
лентой + цель) → TasksSection (задачи) → EstimateSection (смета + итог) →
EmployeeSection (сотрудник · должность · дата подачи · командирующая организация) → RouteSection (маршрут
лентой; поле «Цель» убрано 10-23, колонка `TripPurpose` в SP осталась от тестовых заявок) → TasksSection (задачи) → EstimateSection (смета + итог) →
ObligationSection (текст обязательства + согласие).
## Точки входа
@@ -1,4 +1,5 @@
@import '../../../../../styles/design-tokens';
// Плашки выбора в один клик: вид транспорта (TransportChips) и время выезда (DepartureTimeChips).
.chips {
display: flex;
@@ -0,0 +1,45 @@
import * as React from 'react';
import styles from './ChoiceChips.module.scss';
import { useFormReadOnly } from '../../../shared/ui';
import { DEPARTURE_TIMES } from '../model';
interface Props {
/** Значение из DEPARTURE_TIMES; '' — не указано. */
value: string;
onChange: (value: string) => void;
/** Подпись группы для экранных дикторов, напр. «Время выезда: Алматы → Атырау». */
ariaLabel: string;
}
/**
* Предпочтительное время выезда плашками (обязательно — см. validation.ts).
* Повторный клик по выбранной плашке снимает выбор. В режиме чтения — только
* выбранная плашка.
*/
export const DepartureTimeChips: React.FC<Props> = ({ value, onChange, ariaLabel }) => {
const readOnly = useFormReadOnly();
if (readOnly) {
return <span className={`${styles.chip} ${styles.selected} ${styles.static}`}>{value || 'не указано'}</span>;
}
return (
<div className={styles.chips} role="radiogroup" aria-label={ariaLabel}>
{DEPARTURE_TIMES.map((time) => {
const selected = time === value;
return (
<button
key={time}
type="button"
role="radio"
aria-checked={selected}
className={selected ? `${styles.chip} ${styles.selected}` : styles.chip}
onClick={() => onChange(selected ? '' : time)}
>
{time}
</button>
);
})}
</div>
);
};
@@ -1,6 +1,6 @@
@import '../../../../../../styles/design-tokens';
@import '../../../../../styles/design-tokens';
// Сегментный переключатель — тот же вид, что «Суммой / Кол-во × цена» в смете.
// Сегментный переключатель — тот же вид, что «Суммой / Кол-во × цена» в смете (AmountFields).
.switch {
display: inline-flex;
align-self: flex-start;
@@ -0,0 +1,36 @@
import * as React from 'react';
import styles from './SegmentedSwitch.module.scss';
export interface ISegmentedOption<T extends string> {
value: T;
label: string;
}
interface Props<T extends string> {
value: T;
options: ReadonlyArray<ISegmentedOption<T>>;
onChange: (value: T) => void;
/** Подпись группы для экранных дикторов. */
ariaLabel: string;
className?: string;
}
/** Сегментный переключатель из 2–3 вариантов: «Форма | Схема», «Казахстан | Другая страна». */
export function SegmentedSwitch<T extends string>({ value, options, onChange, ariaLabel, className }: Props<T>): JSX.Element {
return (
<div className={className ? `${styles.switch} ${className}` : styles.switch} role="radiogroup" aria-label={ariaLabel}>
{options.map((option) => (
<button
key={option.value}
type="button"
role="radio"
aria-checked={value === option.value}
className={value === option.value ? `${styles.option} ${styles.active}` : styles.option}
onClick={() => onChange(option.value)}
>
{option.label}
</button>
))}
</div>
);
}
@@ -1,5 +1,5 @@
import * as React from 'react';
import styles from './TransportChips.module.scss';
import styles from './ChoiceChips.module.scss';
import { useFormReadOnly } from '../../../shared/ui';
import { TRANSPORT_TYPES } from '../model';
import { TransportIcon } from './TransportIcon';
@@ -15,8 +15,9 @@ const PLANE =
'M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z';
const TRAIN =
'M12 2c-4 0-8 .5-8 4v9.5C4 17.43 5.57 19 7.5 19L6 20.5v.5h2.23l2-2H14l2 2h2v-.5L16.5 19c1.93 0 3.5-1.57 3.5-3.5V6c0-3.5-3.58-4-8-4zM7.5 17c-.83 0-1.5-.67-1.5-1.5S6.67 14 7.5 14s1.5.67 1.5 1.5S8.33 17 7.5 17zm3.5-7H6V6h5v4zm2 0V6h5v4h-5zm3.5 7c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z';
const BUS =
'M4 16c0 .88.39 1.67 1 2.22V20c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h8v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1.78c.61-.55 1-1.34 1-2.22V6c0-3.5-3.58-4-8-4s-8 .5-8 4v10zm3.5 1c-.83 0-1.5-.67-1.5-1.5S6.67 14 7.5 14s1.5.67 1.5 1.5S8.33 17 7.5 17zm9 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm1.5-6H6V6h12v5z';
// local_taxi: та же машина, что CAR, но с шашкой на крыше — чтобы такси отличалось от служебного/личного авто.
const TAXI =
'M18.92 6.01C18.72 5.42 18.16 5 17.5 5H15V3H9v2H6.5c-.66 0-1.21.42-1.42 1.01L3 12v8c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h12v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-8l-2.08-5.99zM6.5 16c-.83 0-1.5-.67-1.5-1.5S5.67 13 6.5 13s1.5.67 1.5 1.5S7.33 16 6.5 16zm11 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zM5 11l1.5-4.5h11L19 11H5z';
const CAR =
'M18.92 6.01C18.72 5.42 18.16 5 17.5 5h-11c-.66 0-1.21.42-1.42 1.01L3 12v8c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h12v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-8l-2.08-5.99zM6.5 16c-.83 0-1.5-.67-1.5-1.5S5.67 13 6.5 13s1.5.67 1.5 1.5S7.33 16 6.5 16zm11 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zM5 11l1.5-4.5h11L19 11H5z';
@@ -24,7 +25,7 @@ const CAR =
const PATH_BY_TYPE: Record<string, string> = {
Авиа: PLANE,
Железнодорожный: TRAIN,
Автобус: BUS,
Такси: TAXI,
'Служебный автотранспорт': CAR,
'Личный автотранспорт': CAR
};
@@ -0,0 +1,68 @@
import * as React from 'react';
import styles from './StopItemFields.module.scss';
import { FormField, MoneyInput } from '../../../../shared/ui';
import { IEstimateItem, IRouteStop } from '../../model';
import { formatAmount, itemAmount, nightPriceLimit } from '../../estimate';
import { stopCityLabel } from '../../route';
import { AutoQuantityField } from './AutoQuantityField';
import { fieldKey } from '../../validation';
interface Props {
item: IEstimateItem;
/** Точка маршрута позиции; проживание строится по точкам (см. syncRouteItems). */
stop: IRouteStop | undefined;
/** Дни/ночи по датам точки (routeItemAutoQuantity) — рядом с ручным значением. */
autoQuantity: string;
onChange: (patch: Partial<IEstimateItem>) => void;
}
/**
* Проживание в одной точке маршрута: ночи — из дат точки (или вручную — карандаш,
* см. AutoQuantityField), человек вводит цену за ночь (не выше лимита — см.
* nightPriceLimit). Цену можно не вводить — в точке могут жить на объекте, тогда
* позиция просто 0.
*/
export const AccommodationFields: React.FC<Props> = ({ item, stop, autoQuantity, onChange }) => {
const hasNights = item.quantity !== '';
const limit = nightPriceLimit(stop);
const isOverLimit = limit !== undefined && Number(item.price) > limit;
const priceLabel = limit === undefined ? 'Цена за ночь' : `Цена за ночь (до ${formatAmount(limit)} ₸)`;
return (
<div className={styles.wrap}>
<div className={styles.place}>
<span className={styles.city}>{(stop && stopCityLabel(stop)) || 'Точка маршрута'}</span>
</div>
<div className={`${styles.calcRow} ${styles.calcRowShort}`}>
<AutoQuantityField
id={fieldKey.itemQuantity(item)}
label="Ночей"
autoValue={autoQuantity}
manualValue={item.quantityManual}
onManualChange={(v) => onChange({ quantityManual: v })}
/>
<span className={styles.sign} aria-hidden>
×
</span>
<FormField label={priceLabel} htmlFor={fieldKey.itemPrice(item)}>
<div className={styles.number}>
<MoneyInput id={fieldKey.itemPrice(item)} value={item.price} onChange={(v) => onChange({ price: v })} />
</div>
</FormField>
<span className={styles.sign} aria-hidden>
=
</span>
<div className={styles.result}>
<span className={styles.resultLabel}>Итого</span>
<span className={styles.resultValue}>{formatAmount(itemAmount(item))} ₸</span>
</div>
</div>
{isOverLimit && limit !== undefined && (
<div className={styles.limitError}>Не больше {formatAmount(limit)} ₸ за ночь по Казахстану.</div>
)}
{!hasNights && <div className={styles.hint}>Укажите даты точки в маршруте — по ним считаются ночи.</div>}
</div>
);
};
@@ -0,0 +1,75 @@
import * as React from 'react';
import { useEffect, useState } from 'react';
import styles from './StopItemFields.module.scss';
import { FormField, Icon, MoneyInput, useFormReadOnly } from '../../../../shared/ui';
interface Props {
id: string;
/** «Дней» / «Ночей». */
label: string;
/** Посчитано по датам точки; '' — даты не заданы. */
autoValue: string;
/** Введено вручную; '' — берём autoValue. */
manualValue: string;
onManualChange: (value: string) => void;
}
/**
* Дни (суточные) или ночи (проживание): по умолчанию считаются по датам точки и
* не редактируются; карандаш открывает ручной ввод — на случай форс-мажора
* (задержался в точке). Ручное число любое; рядом видно, сколько по датам, и
* его можно сбросить обратно к расчёту.
*/
export const AutoQuantityField: React.FC<Props> = ({ id, label, autoValue, manualValue, onManualChange }) => {
const readOnly = useFormReadOnly();
// Отдельно от manualValue: пока человек стёр число и печатает новое, поле не должно схлопываться.
const [editing, setEditing] = useState(false);
const isManual = manualValue !== '';
const showInput = !readOnly && (editing || isManual);
// Карандаш нажали — сразу ставим курсор в поле.
useEffect(() => {
if (editing) document.getElementById(id)?.focus();
}, [editing, id]);
const startEditing = (): void => {
onManualChange(autoValue);
setEditing(true);
};
const resetToAuto = (): void => {
onManualChange('');
setEditing(false);
};
return (
<FormField label={label} htmlFor={id}>
{showInput ? (
<div className={styles.number} onBlur={() => !isManual && setEditing(false)}>
<MoneyInput id={id} value={manualValue} onChange={onManualChange} />
</div>
) : (
<div className={styles.autoValue}>
<div className={styles.readonly} title="Считается по датам точки в маршруте">
{(isManual ? manualValue : autoValue) || '—'}
</div>
{!readOnly && (
<button type="button" className={styles.iconButton} title="Изменить вручную" onClick={startEditing}>
<Icon name="edit" size="sm" />
</button>
)}
</div>
)}
{isManual && (
<span className={styles.manualNote}>
вручную · по датам: {autoValue || '—'}
{!readOnly && (
<button type="button" className={styles.resetButton} onClick={resetToAuto}>
<Icon name="refresh" size="sm" />
вернуть
</button>
)}
</span>
)}
</FormField>
);
};
@@ -3,10 +3,11 @@ import styles from './EstimateItemRow.module.scss';
import { FormField, Icon, Select, TextInput } from '../../../../shared/ui';
import type { ISelectOption } from '../../../../shared/ui';
import { ESTIMATE_KINDS, EstimateKind, IEstimateItem, ITripRoute } from '../../model';
import { linkOptions, resolveLinkId } from '../../estimate';
import { linkOptions, resolveLinkId, routeItemAutoQuantity } from '../../estimate';
import { fieldKey } from '../../validation';
import { AmountFields } from './AmountFields';
import { PerDiemFields } from './PerDiemFields';
import { AccommodationFields } from './AccommodationFields';
interface Props {
kind: EstimateKind;
@@ -21,13 +22,15 @@ interface Props {
* Позиция статьи: к чему относится (точка маршрута / участок пути), сумма и
* пояснение. Выбор «к чему относится» показываем, только когда вариантов
* больше одного — в простой поездке в одну точку выбирать нечего. Суточные
* привязаны к точке раз и навсегда (строятся по маршруту) — у них свои поля.
* (и проживание) привязаны к точке раз и навсегда (строятся по маршруту) — у них свои поля.
*/
export const EstimateItemRow: React.FC<Props> = ({ kind, item, route, onChange, onRemove }) => {
const info = ESTIMATE_KINDS[kind];
const options: ISelectOption[] = linkOptions(kind, route).map((o) => ({ value: o.id, label: o.label }));
const showLink = !info.itemsFromRoute && options.length > 1;
const linkLabel = info.link === 'leg' ? 'Участок пути' : 'Точка маршрута';
const stop = route.stops.find((s) => s.id === item.linkId);
const autoQuantity = routeItemAutoQuantity(kind, route, item.linkId);
return (
<div className={styles.row}>
@@ -53,11 +56,9 @@ export const EstimateItemRow: React.FC<Props> = ({ kind, item, route, onChange,
)}
</div>
)}
{info.itemsFromRoute ? (
<PerDiemFields item={item} stop={route.stops.find((s) => s.id === item.linkId)} onChange={onChange} />
) : (
<AmountFields kind={kind} item={item} onChange={onChange} />
)}
{kind === 'perDiem' && <PerDiemFields item={item} stop={stop} autoQuantity={autoQuantity} onChange={onChange} />}
{kind === 'accommodation' && <AccommodationFields item={item} stop={stop} autoQuantity={autoQuantity} onChange={onChange} />}
{!info.itemsFromRoute && <AmountFields kind={kind} item={item} onChange={onChange} />}
<TextInput
value={item.comment}
onChange={(v) => onChange({ comment: v })}
@@ -1,42 +1,51 @@
import * as React from 'react';
import styles from './PerDiemFields.module.scss';
import styles from './StopItemFields.module.scss';
import { FormField, MoneyInput } from '../../../../shared/ui';
import { IEstimateItem, IRouteStop, MRP_VALUE, MRP_YEAR } from '../../model';
import { formatAmount, itemAmount } from '../../estimate';
import { formatAmount, itemAmount, mrpLimit } from '../../estimate';
import { stopCityLabel } from '../../route';
import { AutoQuantityField } from './AutoQuantityField';
import { fieldKey } from '../../validation';
interface Props {
item: IEstimateItem;
/** Точка маршрута позиции; суточные всегда строятся по точкам (см. syncPerDiem). */
/** Точка маршрута позиции; суточные всегда строятся по точкам (см. syncRouteItems). */
stop: IRouteStop | undefined;
/** Дни/ночи по датам точки (routeItemAutoQuantity) — рядом с ручным значением. */
autoQuantity: string;
onChange: (patch: Partial<IEstimateItem>) => void;
}
/**
* Суточные одной точки маршрута: город и проект — из точки, дни — из её дат,
* человек вводит только количество МРП в день. Итог = дни × МРП × MRP_VALUE.
* Суточные одной точки маршрута: город и проект — из точки, дни — из её дат (или
* вручную — карандаш, см. AutoQuantityField), человек вводит количество МРП в
* день (не больше лимита — см. mrpLimit). Итог = дни × МРП × MRP_VALUE.
*/
export const PerDiemFields: React.FC<Props> = ({ item, stop, onChange }) => {
export const PerDiemFields: React.FC<Props> = ({ item, stop, autoQuantity, onChange }) => {
const hasDays = item.quantity !== '';
const project = stop?.project.trim();
const limit = mrpLimit(stop);
const isOverLimit = Number(item.mrpCount) > limit;
return (
<div className={styles.wrap}>
<div className={styles.place}>
<span className={styles.city}>{stop?.city.trim() || 'Точка маршрута'}</span>
<span className={styles.city}>{(stop && stopCityLabel(stop)) || 'Точка маршрута'}</span>
<span className={styles.project}>Проект: {project || 'не выбран'}</span>
</div>
<div className={styles.calcRow}>
<FormField label="Дней">
<div className={styles.readonly} title="Считается по датам точки в маршруте">
{hasDays ? item.quantity : '—'}
</div>
</FormField>
<AutoQuantityField
id={fieldKey.itemQuantity(item)}
label="Дней"
autoValue={autoQuantity}
manualValue={item.quantityManual}
onManualChange={(v) => onChange({ quantityManual: v })}
/>
<span className={styles.sign} aria-hidden>
×
</span>
<FormField label="Кол-во МРП в день" htmlFor={fieldKey.itemMrpCount(item)}>
<FormField label={`Кол-во МРП в день (до ${limit})`} htmlFor={fieldKey.itemMrpCount(item)}>
<div className={styles.number}>
<MoneyInput id={fieldKey.itemMrpCount(item)} value={item.mrpCount} onChange={(v) => onChange({ mrpCount: v })} />
</div>
@@ -56,6 +65,11 @@ export const PerDiemFields: React.FC<Props> = ({ item, stop, onChange }) => {
</div>
</div>
{isOverLimit && (
<div className={styles.limitError}>
Не больше {limit} МРП в день {stop?.abroad ? 'за рубежом' : 'по Казахстану'}.
</div>
)}
{!hasDays && <div className={styles.hint}>Укажите даты точки в маршруте — по ним считаются дни.</div>}
</div>
);
@@ -1,5 +1,6 @@
@import '../../../../../../styles/design-tokens';
// Общие стили позиций «по точке маршрута»: суточные (PerDiemFields) и проживание (AccommodationFields).
// С этой ширины «дни × МРП × значение = итог» помещается в одну строку.
$calc-inline-breakpoint: 640px;
@@ -115,3 +116,78 @@ $calc-inline-breakpoint: 640px;
padding-bottom: 8px;
}
}
// Превышен лимит (МРП, цена за ночь) — сразу под строкой, не дожидаясь отправки.
.limitError {
font-family: $font-sans;
font-size: $fs-sm;
color: $red-500;
}
// Проживание: «ночи × цена = итог» — на две колонки короче, чем у суточных.
@media (min-width: $calc-inline-breakpoint) {
.calcRowShort {
grid-template-columns: minmax(0, 0.6fr) auto minmax(0, 1fr) auto minmax(120px, auto);
}
}
// Дни/ночи по датам + карандаш «изменить вручную» (AutoQuantityField).
.autoValue {
display: flex;
align-items: center;
gap: $sp-1;
.readonly {
flex: 1;
min-width: 0;
}
}
.iconButton {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 6px;
border: 0;
border-radius: $r-sm;
background: transparent;
color: $gray-500;
cursor: pointer;
transition: .18s;
&:hover {
background: $gray-100;
color: $navy-900;
}
&:focus-visible {
outline: 2px solid $gold-500;
outline-offset: 1px;
}
}
// «вручную · по датам: 4 · вернуть» — под полем, мелко: число правили руками.
.manualNote {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: $sp-1 $sp-2;
font-family: $font-sans;
font-size: $fs-caption;
color: $amber-700;
}
.resetButton {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0;
border: 0;
background: transparent;
color: $navy-700;
font-family: $font-sans;
font-size: $fs-caption;
text-decoration: underline;
cursor: pointer;
}
@@ -0,0 +1,18 @@
@import '../../../../../../styles/design-tokens';
// Переезд внутри карточки точки — отдельная плашка с фоном, чтобы её не пропускали.
.leg {
display: flex;
flex-direction: column;
gap: $sp-3;
padding: $sp-3;
border-radius: $r-sm;
background: $gray-50;
}
.title {
font-family: $font-sans;
font-size: $fs-sm;
font-weight: 600;
color: $navy-900;
}
@@ -0,0 +1,32 @@
import * as React from 'react';
import styles from './LegFields.module.scss';
import { FormField } from '../../../../shared/ui';
import { IRouteLeg, legLabel } from '../../route';
import { fieldKey } from '../../validation';
import { TransportChips } from '../TransportChips';
import { DepartureTimeChips } from '../DepartureTimeChips';
interface Props {
leg: IRouteLeg;
transport: string;
onTransportChange: (value: string) => void;
departureTime: string;
onDepartureTimeChange: (value: string) => void;
}
/**
* Переезд «как добраться»: вид транспорта и время выезда (оба обязательны).
* Живёт внутри карточки точки, куда едут (обратный — в блоке «Возвращение»):
* отдельной строкой на линии маршрута его пропускали при заполнении.
*/
export const LegFields: React.FC<Props> = ({ leg, transport, onTransportChange, departureTime, onDepartureTimeChange }) => (
<div className={styles.leg}>
<span className={styles.title}>Как добраться: {legLabel(leg)}</span>
<FormField label="Вид транспорта" fieldKey={fieldKey.legTransport(leg.id)} required>
<TransportChips value={transport} onChange={onTransportChange} ariaLabel={`Вид транспорта: ${legLabel(leg)}`} />
</FormField>
<FormField label="Время выезда" fieldKey={fieldKey.legDepartureTime(leg.id)} required>
<DepartureTimeChips value={departureTime} onChange={onDepartureTimeChange} ariaLabel={`Время выезда: ${legLabel(leg)}`} />
</FormField>
</div>
);
@@ -108,7 +108,11 @@ $marker-size: 26px;
}
}
// Возвращение: переключатель, а под ним — обратный переезд (транспорт, время выезда).
.end {
display: flex;
flex-direction: column;
gap: $sp-3;
padding-top: $sp-2;
}
@@ -1,12 +1,13 @@
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './RouteEditor.module.scss';
import { FormField, Icon, TextInput, Toggle, useFormReadOnly } from '../../../../shared/ui';
import { IRouteStop, ITripRoute } from '../../model';
import { IRouteLeg, createStop, routeLegs } from '../../route';
import { FormField, Icon, Toggle, useFormReadOnly } from '../../../../shared/ui';
import { CityPicker } from '../../../../shared/dictionary';
import { CITY_PLACEHOLDER, IRouteStop, ITripRoute } from '../../model';
import { IRouteLeg, createStop, originLabel, routeLegs, withoutStopLegs } from '../../route';
import { fieldKey } from '../../validation';
import { RouteStopCard } from './RouteStopCard';
import { RouteLegRow } from './RouteLegRow';
import { LegFields } from './LegFields';
interface Props {
route: ITripRoute;
@@ -32,9 +33,10 @@ const TimelineRow: React.FC<{ marker: MarkerKind; label?: string; children: Reac
/**
* Маршрут командировки лентой: город отправления → точки (город + объект +
* даты) → обратно. Между точками — участки пути с видом транспорта; участки
* не хранятся, а выводятся из точек (route.ts/routeLegs). Простая поездка — одна
* точка; «+ Добавить точку» — для поездок по нескольким объектам.
* даты) → обратно. Переезд (транспорт, время выезда) — внутри карточки точки, куда
* едут, обратный — в блоке «Возвращение»: отдельной строкой на линии его
* пропускали. Участки не хранятся, а выводятся из точек (route.ts/routeLegs).
* Простая поездка — одна точка; «+ Добавить точку» — для поездок по нескольким объектам.
*/
export const RouteEditor: React.FC<Props> = ({ route, onChange, context }) => {
const readOnly = useFormReadOnly();
@@ -46,24 +48,28 @@ export const RouteEditor: React.FC<Props> = ({ route, onChange, context }) => {
onChange({ ...route, stops: route.stops.map((s) => (s.id === id ? { ...s, ...patch } : s)) });
const addStop = (): void =>
onChange({ ...route, stops: [...route.stops, createStop(route.stops[route.stops.length - 1])] });
const removeStop = (id: string): void => {
// Виды транспорта участков, которые шли через удалённую точку, больше ни к чему не относятся.
const legTransport: Record<string, string> = {};
Object.keys(route.legTransport)
.filter((key) => key.split('>').indexOf(id) === -1)
.forEach((key) => {
legTransport[key] = route.legTransport[key];
// Транспорт и время выезда участков, которые шли через удалённую точку, больше ни к чему не относятся.
const removeStop = (id: string): void =>
onChange({
...route,
stops: route.stops.filter((s) => s.id !== id),
legTransport: withoutStopLegs(route.legTransport, id),
legDepartureTime: withoutStopLegs(route.legDepartureTime, id)
});
onChange({ ...route, stops: route.stops.filter((s) => s.id !== id), legTransport });
};
const setTransport = (id: string, value: string): void =>
onChange({ ...route, legTransport: { ...route.legTransport, [id]: value } });
const setDepartureTime = (id: string, value: string): void =>
onChange({ ...route, legDepartureTime: { ...route.legDepartureTime, [id]: value } });
const legRow = (leg: IRouteLeg | undefined): React.ReactNode =>
const legFields = (leg: IRouteLeg | undefined): React.ReactNode =>
leg && (
<TimelineRow marker="line">
<RouteLegRow leg={leg} transport={route.legTransport[leg.id] || ''} onTransportChange={(v) => setTransport(leg.id, v)} />
</TimelineRow>
<LegFields
leg={leg}
transport={route.legTransport[leg.id] || ''}
onTransportChange={(v) => setTransport(leg.id, v)}
departureTime={route.legDepartureTime[leg.id] || ''}
onDepartureTimeChange={(v) => setDepartureTime(leg.id, v)}
/>
);
return (
@@ -71,29 +77,27 @@ export const RouteEditor: React.FC<Props> = ({ route, onChange, context }) => {
<TimelineRow marker="endpoint">
<div className={styles.origin}>
<FormField label="Откуда (город отправления)" htmlFor={fieldKey.origin} required>
<TextInput
<CityPicker
id={fieldKey.origin}
value={route.origin}
onChange={(v) => onChange({ ...route, origin: v })}
placeholder="Напр.: Алматы"
value={{ name: route.origin, nameKz: route.originKz, kato: route.originKato }}
onChange={(city) => onChange({ ...route, origin: city.name, originKz: city.nameKz, originKato: city.kato })}
placeholder={CITY_PLACEHOLDER}
/>
</FormField>
</div>
</TimelineRow>
{route.stops.map((stop, i) => (
<React.Fragment key={stop.id}>
{legRow(legTo(stop.id))}
<TimelineRow marker="stop" label={String(i + 1)}>
<TimelineRow key={stop.id} marker="stop" label={String(i + 1)}>
<RouteStopCard
stop={stop}
index={i}
context={context}
onChange={(patch) => updateStop(stop.id, patch)}
onRemove={!readOnly && route.stops.length > 1 ? () => removeStop(stop.id) : undefined}
inbound={legFields(legTo(stop.id))}
/>
</TimelineRow>
</React.Fragment>
))}
{!readOnly && (
@@ -105,21 +109,21 @@ export const RouteEditor: React.FC<Props> = ({ route, onChange, context }) => {
</TimelineRow>
)}
{legRow(returnLeg)}
<TimelineRow marker="endpoint">
<div className={styles.end}>
{readOnly ? (
<span className={styles.endText}>
{route.returnToOrigin ? `Возвращение: ${route.origin || 'город отправления'}` : 'Без возвращения в город отправления'}
{route.returnToOrigin ? `Возвращение: ${originLabel(route) || 'город отправления'}` : 'Без возвращения в город отправления'}
</span>
) : (
<Toggle
checked={route.returnToOrigin}
onChange={(returnToOrigin) => onChange({ ...route, returnToOrigin })}
onLabel={`Возвращение в ${route.origin.trim() || 'город отправления'}`}
onLabel={`Возвращение в ${originLabel(route) || 'город отправления'}`}
/>
)}
{/* Обратный переезд — здесь: своей карточки точки у него нет. */}
{legFields(returnLeg)}
</div>
</TimelineRow>
</div>
@@ -1,6 +0,0 @@
@import '../../../../../../styles/design-tokens';
// Участок — без карточки: это «переезд» между точками, он лежит прямо на линии маршрута.
.leg {
padding-block: $sp-2;
}
@@ -1,25 +0,0 @@
import * as React from 'react';
import styles from './RouteLegRow.module.scss';
import { FormField } from '../../../../shared/ui';
import { IRouteLeg, legLabel } from '../../route';
import { fieldKey } from '../../validation';
import { TransportChips } from '../TransportChips';
interface Props {
leg: IRouteLeg;
transport: string;
onTransportChange: (value: string) => void;
}
/** Участок пути между точками на линии маршрута: «Алматы → Атырау» и вид транспорта. */
export const RouteLegRow: React.FC<Props> = ({ leg, transport, onTransportChange }) => (
<div className={styles.leg}>
<FormField label={legLabel(leg)} fieldKey={fieldKey.legTransport(leg.id)}>
<TransportChips
value={transport}
onChange={onTransportChange}
ariaLabel={`Вид транспорта: ${legLabel(leg)}`}
/>
</FormField>
</div>
);
@@ -1,7 +1,7 @@
import * as React from 'react';
import styles from './RouteScheme.module.scss';
import { IEstimateArticle, ITripRoute } from '../../model';
import { routeLegs, routePeriod, routeProjects } from '../../route';
import { originLabel, routeLegs, routePeriod, routeProjects, stopCityLabel } from '../../route';
import { estimateTotal, formatAmount, stopTotals } from '../../estimate';
import { plural, tripDays } from '../../tripData';
import { ISchemeNode } from './schemeNode';
@@ -18,14 +18,14 @@ interface Props {
/** Узлы схемы по порядку: город отправления → точки → (возвращение). */
function schemeNodes(route: ITripRoute, amounts: Map<string, number>): ISchemeNode[] {
const period = routePeriod(route);
const origin = route.origin.trim();
const origin = originLabel(route);
const withDate = (caption: string, iso: string): string => (iso ? `${caption} ${shortDate(iso)}` : caption);
const nodes: ISchemeNode[] = [
{ key: 'origin', kind: 'endpoint', city: origin, caption: withDate('выезд', period.start), amount: 0 }
];
route.stops.forEach((stop, i) =>
nodes.push({ key: stop.id, kind: 'stop', number: i + 1, stop, city: stop.city.trim(), amount: amounts.get(stop.id) || 0 })
nodes.push({ key: stop.id, kind: 'stop', number: i + 1, stop, city: stopCityLabel(stop), amount: amounts.get(stop.id) || 0 })
);
if (route.returnToOrigin) {
nodes.push({ key: 'return', kind: 'endpoint', city: origin, caption: withDate('возвращение', period.end), amount: 0 });
@@ -56,7 +56,7 @@ export const RouteScheme: React.FC<Props> = ({ route, articles, onSelectStop })
return (
<div className={styles.scheme}>
<SchemeTrack nodes={nodes} legs={legs} legTransport={route.legTransport} onSelectStop={onSelectStop} />
<SchemeTrack nodes={nodes} legs={legs} legTransport={route.legTransport} legDepartureTime={route.legDepartureTime} onSelectStop={onSelectStop} />
<div className={styles.stats}>{stats.join(' · ')}</div>
</div>
);
@@ -1,11 +1,14 @@
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './RouteStopCard.module.scss';
import { FormField, Icon, TextInput } from '../../../../shared/ui';
import { FormField, Icon, TextInput, useFormReadOnly } from '../../../../shared/ui';
import { ProjectPicker } from '../../../../shared/dictionary';
import { IRouteStop, PROJECT_PLACEHOLDER } from '../../model';
import { stopNights } from '../../route';
import { stopLocationPatch, stopNights } from '../../route';
import { ISegmentedOption, SegmentedSwitch } from '../SegmentedSwitch';
import { StopPlaceFields } from './StopPlaceFields';
import { plural } from '../../tripData';
import { todaySubmitDate } from '../../../../shared/submitDate';
import { fieldKey } from '../../validation';
interface Props {
@@ -15,6 +18,8 @@ interface Props {
onChange: (patch: Partial<IRouteStop>) => void;
/** Не передан — точку удалить нельзя (она единственная). */
onRemove?: () => void;
/** Переезд в эту точку («Как добраться») — собирает RouteEditor, он знает участки. */
inbound?: React.ReactNode;
}
/** id карточки точки в DOM — к ней прокручивает клик по точке на схеме маршрута. */
@@ -22,8 +27,20 @@ export function stopCardDomId(stopId: string): string {
return `route-stop-${stopId}`;
}
/** Точка маршрута: город, объект из справочника и даты пребывания. */
export const RouteStopCard: React.FC<Props> = ({ stop, index, context, onChange, onRemove }) => {
type StopLocation = 'domestic' | 'abroad';
const LOCATIONS: ReadonlyArray<ISegmentedOption<StopLocation>> = [
{ value: 'domestic', label: 'Казахстан' },
{ value: 'abroad', label: 'Другая страна' }
];
/**
* Точка маршрута: Казахстан или другая страна, город, объект из справочника, даты
* пребывания и переезд сюда (транспорт, время выезда). В режиме чтения переключатель
* не нужен — страна видна в полях.
*/
export const RouteStopCard: React.FC<Props> = ({ stop, index, context, onChange, onRemove, inbound }) => {
const readOnly = useFormReadOnly();
const nights = stopNights(stop);
return (
@@ -39,11 +56,23 @@ export const RouteStopCard: React.FC<Props> = ({ stop, index, context, onChange,
</button>
)}
</div>
{!readOnly && (
<SegmentedSwitch
value={stop.abroad ? 'abroad' : 'domestic'}
options={LOCATIONS}
onChange={(location) => onChange(stopLocationPatch(location === 'abroad'))}
ariaLabel={`Где точка ${index + 1}`}
/>
)}
<div className={styles.grid}>
<FormField label="Город, населённый пункт" htmlFor={fieldKey.stopCity(stop)} required>
<TextInput id={fieldKey.stopCity(stop)} value={stop.city} onChange={(v) => onChange({ city: v })} />
</FormField>
<FormField label="Объект" htmlFor={fieldKey.stopProject(stop)} required>
<StopPlaceFields stop={stop} onChange={onChange} />
{/* За рубежом страна и город занимают строку — объект встаёт на всю ширину следующей. */}
<FormField
label={stop.abroad ? 'Объект / место назначения' : 'Объект'}
htmlFor={fieldKey.stopProject(stop)}
required
full={stop.abroad}
>
<ProjectPicker
id={fieldKey.stopProject(stop)}
context={context}
@@ -58,6 +87,8 @@ export const RouteStopCard: React.FC<Props> = ({ stop, index, context, onChange,
type="date"
value={stop.dateFrom}
onChange={(v) => onChange({ dateFrom: v })}
// Прошедшие дни в календаре недоступны; набранную вручную дату ловит validation.ts.
min={todaySubmitDate()}
/>
</FormField>
<FormField label="Отъезд" htmlFor={fieldKey.stopTo(stop)} required>
@@ -66,10 +97,12 @@ export const RouteStopCard: React.FC<Props> = ({ stop, index, context, onChange,
type="date"
value={stop.dateTo}
onChange={(v) => onChange({ dateTo: v })}
min={stop.dateFrom || undefined}
min={stop.dateFrom || todaySubmitDate()}
/>
</FormField>
</div>
{/* После города и дат — тогда в заголовке переезда уже видно, куда едут. */}
{inbound}
</div>
);
};
@@ -1,5 +1,5 @@
import * as React from 'react';
import styles from './RouteViewSwitch.module.scss';
import { ISegmentedOption, SegmentedSwitch } from '../SegmentedSwitch';
export type RouteViewMode = 'form' | 'scheme';
@@ -8,25 +8,12 @@ interface Props {
onChange: (mode: RouteViewMode) => void;
}
const MODES: ReadonlyArray<{ mode: RouteViewMode; label: string }> = [
{ mode: 'form', label: 'Форма' },
{ mode: 'scheme', label: 'Схема' }
const MODES: ReadonlyArray<ISegmentedOption<RouteViewMode>> = [
{ value: 'form', label: 'Форма' },
{ value: 'scheme', label: 'Схема' }
];
/** Переключатель вида маршрута: поля для заполнения или схема для просмотра. */
/** Переключатель вида маршрута: поля для заполнения или схема для просмотра. На печати не нужен. */
export const RouteViewSwitch: React.FC<Props> = ({ value, onChange }) => (
<div className={`${styles.switch} rp-no-print`} role="radiogroup" aria-label="Вид маршрута">
{MODES.map(({ mode, label }) => (
<button
key={mode}
type="button"
role="radio"
aria-checked={value === mode}
className={value === mode ? `${styles.option} ${styles.active}` : styles.option}
onClick={() => onChange(mode)}
>
{label}
</button>
))}
</div>
<SegmentedSwitch value={value} options={MODES} onChange={onChange} ariaLabel="Вид маршрута" className="rp-no-print" />
);
@@ -39,7 +39,7 @@ export const SchemeNodeLabel: React.FC<Props> = ({ node }) => {
const dates = stopDates(stop);
return (
<span className={styles.text}>
<span className={styles.city}>{stop.city.trim() || `Город ${NOT_SET}`}</span>
<span className={styles.city}>{node.city || `Город ${NOT_SET}`}</span>
{/* Названия объектов в справочнике бывают с полным адресом — режем до 2 строк, полное — в подсказке. */}
<span className={styles.project} title={stop.project.trim() || undefined}>
{stop.project.trim() || 'объект не выбран'}
@@ -10,6 +10,8 @@ interface Props {
/** Участки между соседними узлами: legs[i] — от nodes[i] к nodes[i + 1]. */
legs: IRouteLeg[];
legTransport: Record<string, string>;
/** Время выезда по ключу участка — только в подсказке плашки, вёрстку схемы не меняет. */
legDepartureTime: Record<string, string>;
onSelectStop?: (stopId: string) => void;
}
@@ -77,7 +79,7 @@ const NodeCell: React.FC<{ node: ISchemeNode; onSelectStop?: (stopId: string) =>
};
/** Широкий экран: ветка слева направо — SVG по координатам + сетка подписей с теми же колонками. */
const HorizontalTrack: React.FC<Props> = ({ nodes, legs, legTransport, onSelectStop }) => {
const HorizontalTrack: React.FC<Props> = ({ nodes, legs, legTransport, legDepartureTime, onSelectStop }) => {
const count = nodes.length;
return (
<div className={styles.hTrack} style={{ minWidth: count * MIN_COLUMN_WIDTH }}>
@@ -116,7 +118,7 @@ const HorizontalTrack: React.FC<Props> = ({ nodes, legs, legTransport, onSelectS
</svg>
{legs.map((leg, i) => (
<span key={leg.id} className={styles.hBadge} style={{ left: `${((i + 1) / count) * 100}%` }}>
<TransportBadge leg={leg} transport={legTransport[leg.id] || ''} />
<TransportBadge leg={leg} transport={legTransport[leg.id] || ''} departureTime={legDepartureTime[leg.id] || ''} />
</span>
))}
</div>
@@ -130,7 +132,7 @@ const HorizontalTrack: React.FC<Props> = ({ nodes, legs, legTransport, onSelectS
};
/** Телефон: ветка сверху вниз; линия — фоном колонки маркеров, без вычислений геометрии. */
const VerticalTrack: React.FC<Props> = ({ nodes, legs, legTransport, onSelectStop }) => (
const VerticalTrack: React.FC<Props> = ({ nodes, legs, legTransport, legDepartureTime, onSelectStop }) => (
<div className={styles.vTrack}>
{nodes.map((node, i) => {
const leg = legs[i];
@@ -151,7 +153,7 @@ const VerticalTrack: React.FC<Props> = ({ nodes, legs, legTransport, onSelectSto
<div className={styles.vRow}>
<span className={styles.vRail} />
<span className={styles.vLeg}>
<TransportBadge leg={leg} transport={legTransport[leg.id] || ''} />
<TransportBadge leg={leg} transport={legTransport[leg.id] || ''} departureTime={legDepartureTime[leg.id] || ''} />
</span>
</div>
)}
@@ -0,0 +1,51 @@
import * as React from 'react';
import { FormField, TextInput } from '../../../../shared/ui';
import { CityPicker } from '../../../../shared/dictionary';
import { ABROAD_CITY_PLACEHOLDER, CITY_PLACEHOLDER, COUNTRY_PLACEHOLDER, IRouteStop } from '../../model';
import { fieldKey } from '../../validation';
interface Props {
stop: IRouteStop;
onChange: (patch: Partial<IRouteStop>) => void;
}
/**
* Где находится точка. По Казахстану — город из справочника КАТО (оба языка + код);
* за рубежом — страна и город вручную: справочник только по РК.
* Рендерит ячейки сетки карточки точки (без своей обёртки).
*/
export const StopPlaceFields: React.FC<Props> = ({ stop, onChange }) => {
if (!stop.abroad) {
return (
<FormField label="Город, населённый пункт" htmlFor={fieldKey.stopCity(stop)} required>
<CityPicker
id={fieldKey.stopCity(stop)}
value={{ name: stop.city, nameKz: stop.cityKz, kato: stop.cityKato }}
onChange={(city) => onChange({ city: city.name, cityKz: city.nameKz, cityKato: city.kato })}
placeholder={CITY_PLACEHOLDER}
/>
</FormField>
);
}
return (
<>
<FormField label="Страна" htmlFor={fieldKey.stopCountry(stop)} required>
<TextInput
id={fieldKey.stopCountry(stop)}
value={stop.country}
onChange={(v) => onChange({ country: v })}
placeholder={COUNTRY_PLACEHOLDER}
/>
</FormField>
<FormField label="Город" htmlFor={fieldKey.stopCity(stop)} required>
<TextInput
id={fieldKey.stopCity(stop)}
value={stop.city}
onChange={(v) => onChange({ city: v })}
placeholder={ABROAD_CITY_PLACEHOLDER}
/>
</FormField>
</>
);
};
@@ -7,13 +7,20 @@ import { TransportIcon } from '../TransportIcon';
interface Props {
leg: IRouteLeg;
transport: string;
/** Время выезда; '' — не указано. */
departureTime: string;
}
/** Плашка вида транспорта на линии схемы: иконка + короткая подпись; полная — в подсказке. */
export const TransportBadge: React.FC<Props> = ({ leg, transport }) => (
/**
* Плашка вида транспорта на линии схемы: иконка + короткая подпись; полная и время
* выезда — в подсказке (на линию не помещаются).
*/
export const TransportBadge: React.FC<Props> = ({ leg, transport, departureTime }) => (
<span
className={transport ? styles.transport : `${styles.transport} ${styles.missing}`}
title={`${leg.from} → ${leg.to}${transport ? `: ${transport}` : ''}`}
title={[`${leg.from} → ${leg.to}`, transport, departureTime && `выезд: ${departureTime.toLowerCase()}`]
.filter(Boolean)
.join(', ')}
>
<TransportIcon type={transport} className={styles.transportIcon} />
{transport ? TRANSPORT_SHORT[transport] || transport : 'транспорт?'}
@@ -8,7 +8,7 @@ export interface ISchemeNode {
number?: number;
/** Сама точка — только у точки маршрута. */
stop?: IRouteStop;
/** Город отправления — у endpoint. */
/** Город на двух языках (см. route.ts/stopCityLabel): отправления — у endpoint, точки — у stop. */
city: string;
/** «выезд 02.10» / «возвращение 05.10» — у endpoint. */
caption?: string;
@@ -1,4 +1,13 @@
import { EMPTY_ESTIMATE, EMPTY_ROUTE, IEstimateArticle, IEstimateItem, ITripRoute, MRP_VALUE } from './model';
import {
EMPTY_ESTIMATE,
EMPTY_ROUTE,
IEstimateArticle,
IEstimateItem,
ITripRoute,
MAX_MRP_PER_DAY,
MAX_NIGHT_PRICE,
MRP_VALUE
} from './model';
import {
addItem,
articleAmount,
@@ -7,22 +16,28 @@ import {
estimateTotal,
formatEstimateText,
itemCalculation,
mrpLimit,
nightPriceLimit,
parseEstimate,
projectShares,
resolveLinkId,
routeItemAutoQuantity,
serializeEstimate,
stopTotals,
syncPerDiem
syncRouteItems
} from './estimate';
const ROUTE: ITripRoute = {
origin: 'Алматы',
originKz: '',
originKato: '',
stops: [
{ id: 's1', city: 'Атырау', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' },
{ id: 's2', city: 'Актау', project: 'Порт Курык', projectId: 'p2', dateFrom: '2026-10-08', dateTo: '2026-10-10' }
{ id: 's1', abroad: false, country: '', city: 'Атырау', cityKz: '', cityKato: '', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' },
{ id: 's2', abroad: false, country: '', city: 'Актау', cityKz: '', cityKato: '', project: 'Порт Курык', projectId: 'p2', dateFrom: '2026-10-08', dateTo: '2026-10-10' }
],
returnToOrigin: true,
legTransport: {}
legTransport: {},
legDepartureTime: {}
};
const item = (patch: Partial<IEstimateItem>): IEstimateItem => ({
@@ -34,6 +49,7 @@ const item = (patch: Partial<IEstimateItem>): IEstimateItem => ({
price: '',
comment: '',
mrpCount: '',
quantityManual: '',
...patch
});
@@ -121,7 +137,7 @@ describe('суточные по маршруту', () => {
};
it('по позиции на точку; день переезда считается один раз', () => {
const [synced] = syncPerDiem([perDiem], ROUTE);
const [synced] = syncRouteItems([perDiem], ROUTE);
expect(synced.items.map((i) => [i.linkId, i.quantity])).toEqual([
['s1', '4'],
['s2', '2']
@@ -129,32 +145,58 @@ describe('суточные по маршруту', () => {
});
it('ставка — кол-во МРП × МРП; введённое за точкой сохраняется', () => {
const [synced] = syncPerDiem([perDiem], ROUTE);
const [synced] = syncRouteItems([perDiem], ROUTE);
expect(synced.items[0]).toMatchObject({ id: 'p1', mrpCount: '6', comment: 'объект', price: String(6 * MRP_VALUE) });
expect(articleAmount(synced)).toBe(4 * 6 * MRP_VALUE);
});
it('новая точка — пустая позиция со стабильным id; удалённая точка — позиция уходит', () => {
const [synced] = syncPerDiem([perDiem], ROUTE);
const [synced] = syncRouteItems([perDiem], ROUTE);
expect(synced.items[1]).toMatchObject({ id: 'perDiem-s2', mrpCount: '', price: '' });
const [oneStop] = syncPerDiem([perDiem], { ...ROUTE, stops: [ROUTE.stops[1]] });
const [oneStop] = syncRouteItems([perDiem], { ...ROUTE, stops: [ROUTE.stops[1]] });
expect(oneStop.items.map((i) => i.linkId)).toEqual(['s2']);
});
it('без дат дней нет — и суммы нет', () => {
const noDates = { ...ROUTE, stops: [{ ...ROUTE.stops[0], dateTo: '' }] };
const [synced] = syncPerDiem([perDiem], noDates);
const [synced] = syncRouteItems([perDiem], noDates);
expect(synced.items[0].quantity).toBe('');
expect(articleAmount(synced)).toBe(0);
});
it('другие статьи не трогает', () => {
expect(syncPerDiem([hotels], ROUTE)[0]).toBe(hotels);
it('статьи с ручными позициями не трогает', () => {
expect(syncRouteItems([travel], ROUTE)[0]).toBe(travel);
});
it('проживание: позиция на точку, ночи из дат, введённая цена сохраняется', () => {
const [synced] = syncRouteItems([hotels], ROUTE);
expect(synced.items.map((i) => [i.linkId, i.quantity, i.price])).toEqual([
['s1', '3', '36000'],
['s2', '2', '35000']
]);
expect(synced.items[0]).toMatchObject({ id: 'h1', mode: 'calc', comment: 'Ренессанс' });
});
it('дни/ночи вручную — любое число поверх расчёта по датам; пусто — снова по датам', () => {
const manual = { ...perDiem, items: [{ ...perDiem.items[0], quantityManual: '7' }] };
const [synced] = syncRouteItems([manual], ROUTE);
expect(synced.items[0].quantity).toBe('7');
expect(articleAmount({ ...synced, items: [synced.items[0]] })).toBe(7 * 6 * MRP_VALUE);
expect(itemCalculation('perDiem', synced.items[0])).toContain('7 дней (вручную)');
const [auto] = syncRouteItems([{ ...manual, items: [{ ...manual.items[0], quantityManual: '' }] }], ROUTE);
expect(auto.items[0].quantity).toBe('4');
expect(routeItemAutoQuantity('accommodation', ROUTE, 's1')).toBe('3');
});
it('лимиты зависят от страны точки', () => {
const abroad = { ...ROUTE.stops[0], abroad: true };
expect([mrpLimit(ROUTE.stops[0]), mrpLimit(abroad)]).toEqual([MAX_MRP_PER_DAY.domestic, MAX_MRP_PER_DAY.abroad]);
expect([nightPriceLimit(ROUTE.stops[0]), nightPriceLimit(abroad)]).toEqual([MAX_NIGHT_PRICE.domestic, MAX_NIGHT_PRICE.abroad]);
});
it('расчёт в тексте — с МРП, по сохранённой ставке', () => {
const NBSP = String.fromCharCode(160);
const [synced] = syncPerDiem([perDiem], ROUTE);
const [synced] = syncRouteItems([perDiem], ROUTE);
expect(itemCalculation('perDiem', synced.items[0]).split(NBSP).join(' ')).toBe(
`4 дня × 6 МРП × ${new Intl.NumberFormat('ru-RU').format(MRP_VALUE).split(NBSP).join(' ')} ₸`
);
@@ -6,10 +6,13 @@ import {
EstimateKind,
IEstimateArticle,
IEstimateItem,
IRouteStop,
ITripRoute,
MAX_MRP_PER_DAY,
MAX_NIGHT_PRICE,
MRP_VALUE
} from './model';
import { legLabel, newId, routeLegs, stopLabel, stopPerDiemDays } from './route';
import { legLabel, newId, routeLegs, stopLabel, stopNights, stopPerDiemDays } from './route';
import { plural } from './tripData';
/** Цифры из поля ввода числом: '' или мусор — 0. MoneyInput хранит только цифры. */
@@ -54,10 +57,15 @@ function itemMrpValue(item: IEstimateItem): number {
}
/** Расчёт суточных: «4 дня × 6 МРП × 4 325 ₸»; дни или МРП не заданы — ''. */
/** Пометка у дней/ночей, введённых вручную, — согласующему важно видеть, что их правили. */
function manualMark(item: IEstimateItem): string {
return item.quantityManual ? ' (вручную)' : '';
}
function perDiemCalculation(item: IEstimateItem): string {
if (!item.quantity || !toNumber(item.mrpCount)) return '';
const days = plural(toNumber(item.quantity), ['день', 'дня', 'дней']);
return `${days} × ${item.mrpCount} МРП × ${formatAmount(itemMrpValue(item))} ₸`;
return `${days}${manualMark(item)} × ${item.mrpCount} МРП × ${formatAmount(itemMrpValue(item))} ₸`;
}
/**
@@ -66,9 +74,9 @@ function perDiemCalculation(item: IEstimateItem): string {
* понятно из статьи. Суточные — исключение: там важно показать МРП.
*/
export function itemCalculation(kind: EstimateKind, item: IEstimateItem): string {
if (ESTIMATE_KINDS[kind].itemsFromRoute) return perDiemCalculation(item);
if (kind === 'perDiem') return perDiemCalculation(item);
if (item.mode !== 'calc' || !item.quantity || !item.price) return '';
return `${formatAmount(toNumber(item.quantity))} × ${formatAmount(toNumber(item.price))} ₸`;
return `${formatAmount(toNumber(item.quantity))}${manualMark(item)} × ${formatAmount(toNumber(item.price))} ₸`;
}
// ---------------------------------------------------------------------------
@@ -116,7 +124,8 @@ export function createItem(kind: EstimateKind, linkId: string): IEstimateItem {
quantity: '',
price: '',
comment: '',
mrpCount: ''
mrpCount: '',
quantityManual: ''
};
}
@@ -132,9 +141,22 @@ export function createArticle(kind: EstimateKind, route: ITripRoute): IEstimateA
}
// ---------------------------------------------------------------------------
// Суточные — строятся по маршруту
// Суточные и проживание — строятся по маршруту
// ---------------------------------------------------------------------------
/** Сколько МРП в день можно поставить в точке: по Казахстану и за рубежом лимиты разные. */
export function mrpLimit(stop: IRouteStop | undefined): number {
return stop?.abroad ? MAX_MRP_PER_DAY.abroad : MAX_MRP_PER_DAY.domestic;
}
/** Предельная цена ночи в точке, ₸; undefined — без лимита (за рубежом пока не задан). */
export function nightPriceLimit(stop: IRouteStop | undefined): number | undefined {
return stop?.abroad ? MAX_NIGHT_PRICE.abroad : MAX_NIGHT_PRICE.domestic;
}
/** Число или пустая строка для поля: undefined (дат нет) — ''. */
const countText = (count: number | undefined): string => (count === undefined ? '' : String(count));
/** Ставка суточных в день цифрами: кол-во МРП × МРП. Кол-во не введено — ''. */
function perDiemRate(mrpCount: string): string {
const count = toNumber(mrpCount);
@@ -142,26 +164,39 @@ function perDiemRate(mrpCount: string): string {
}
/**
* Позиции статей «по маршруту» (суточные) приводятся к маршруту: ровно одна
* позиция на точку, дни — из дат точки, ставка — кол-во МРП × MRP_VALUE.
* Человек вводит только кол-во МРП (и пояснение) — они сохраняются за точкой.
* Дни/ночи позиции «по маршруту» по датам точки: суточные — дни (день переезда
* один раз), проживание — ночи. '' — даты точки не заданы или точки нет.
*/
export function routeItemAutoQuantity(kind: EstimateKind, route: ITripRoute, stopId: string): string {
const index = route.stops.findIndex((s) => s.id === stopId);
if (index === -1) return '';
return kind === 'accommodation' ? countText(stopNights(route.stops[index])) : countText(stopPerDiemDays(route, index));
}
/**
* Количество и цена позиции «по маршруту»: дни/ночи — введённые вручную, иначе по
* датам точки; цена — суточные: кол-во МРП × MRP_VALUE, проживание: что ввёл человек.
*/
function routeItemFigures(kind: EstimateKind, base: IEstimateItem, route: ITripRoute, stopId: string): Partial<IEstimateItem> {
const quantity = base.quantityManual || routeItemAutoQuantity(kind, route, stopId);
return { quantity, price: kind === 'accommodation' ? base.price : perDiemRate(base.mrpCount) };
}
/**
* Позиции статей «по маршруту» (суточные, проживание) приводятся к маршруту:
* ровно одна позиция на точку, дни/ночи — из дат точки. Человек вводит только
* кол-во МРП или цену за ночь (и пояснение) — они сохраняются за точкой.
* Применяется в форме при каждом рендере и перед сохранением; просмотр берёт
* сохранённое как есть — чтобы не пересчитать старую заявку по новому МРП.
*/
export function syncPerDiem(articles: IEstimateArticle[], route: ITripRoute): IEstimateArticle[] {
export function syncRouteItems(articles: IEstimateArticle[], route: ITripRoute): IEstimateArticle[] {
return articles.map((article) => {
if (!ESTIMATE_KINDS[article.kind].itemsFromRoute) return article;
const items = route.stops.map((stop, index): IEstimateItem => {
const items = route.stops.map((stop): IEstimateItem => {
const existing = article.items.find((item) => item.linkId === stop.id);
// id от точки, а не случайный: иначе строка пересоздавалась бы на каждом рендере.
const base = existing || { ...createItem(article.kind, stop.id), id: `${article.kind}-${stop.id}` };
const days = stopPerDiemDays(route, index);
return {
...base,
mode: 'calc',
quantity: days === undefined ? '' : String(days),
price: perDiemRate(base.mrpCount)
};
return { ...base, mode: 'calc', ...routeItemFigures(article.kind, base, route, stop.id) };
});
return { ...article, items };
});
@@ -314,7 +349,8 @@ function toItem(value: unknown): IEstimateItem | undefined {
quantity: text(raw.quantity),
price: text(raw.price),
comment: text(raw.comment),
mrpCount: text(raw.mrpCount)
mrpCount: text(raw.mrpCount),
quantityManual: text(raw.quantityManual)
};
}
@@ -11,6 +11,8 @@ export interface ILoadedRequest {
status: string;
/** Уникальный код заявки; '' — если запись создана до появления поля. */
code: string;
/** ФИО командируемого (Employee/Title) — для имени печатного документа. */
employeeName: string;
}
/**
@@ -28,18 +30,21 @@ export async function loadBusinessTripRequest(itemId: number, listId?: string):
'SubmitDate',
'Position',
'Organization',
'TripCompany',
'TripCompanyId',
'TripRouteJson',
'TripPurpose',
'TripTasksJson',
'TripEstimateJson',
'TripComment',
'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,
@@ -47,16 +52,19 @@ export async function loadBusinessTripRequest(itemId: number, listId?: string):
Employee: employee?.EMail || '',
Position: (item.Position as string) || '',
Organization: (item.Organization as string) || '',
TripCompany: (item.TripCompany as string) || '',
TripCompanyId: (item.TripCompanyId as string) || '',
Route: parseRoute(item.TripRouteJson),
TripPurpose: (item.TripPurpose as string) || '',
Tasks: parseTasks(item.TripTasksJson),
Estimate: parseEstimate(item.TripEstimateJson),
TripComment: (item.TripComment as string) || '',
TripObligationAccepted: item.TripObligationAccepted === true
};
return {
data,
status: (item.RequestStatus as string) || '',
code: (item.RequestUniqueCode as string) || ''
code: (item.RequestUniqueCode as string) || '',
employeeName: employee?.Title || ''
};
}
@@ -1,6 +1,7 @@
// import type — чтобы тесты (route/estimate/validation) не тянули SPFx-рантайм через модель.
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import type { RequestTypeId } from '../../models/types';
import type { IEmployeeOrganization } from '../../shared/dictionary';
/**
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
@@ -19,10 +20,26 @@ export const FORM_TITLE = 'Заявка на командировку';
export interface IRouteStop {
/** Стабильный ключ точки: на него ссылаются позиции сметы (IEstimateItem.linkId). */
id: string;
/**
* true — точка за рубежом: страна и город вводятся вручную (справочник КАТО только по РК),
* у суточных и проживания свои лимиты. Признак у точки, а не у поездки: бывают смешанные
* маршруты (Алматы → Москва → Атырау).
*/
abroad: boolean;
/** Страна — только у зарубежной точки; у точки по Казахстану ''. */
country: string;
/** Город на русском — из справочника КАТО (shared/dictionary/kato) или введён вручную. */
city: string;
/** Город на казахском; '' — введён вручную (в справочнике не нашёлся). */
cityKz: string;
/** Код КАТО города; '' — введён вручную. */
cityKato: string;
/** Объект — проект из справочника /Project (см. shared/dictionary/ProjectPicker). */
project: string;
/** oid проекта; '' — текст введён вручную без выбора из списка (отправка не пропустит). */
/**
* oid проекта; '' — текст введён вручную без выбора из списка. По Казахстану отправка
* такое не пропустит; за рубежом можно — там объект часто чужой (завод, выставка).
*/
projectId: string;
/** YYYY-MM-DD — прибытие в точку. */
dateFrom: string;
@@ -37,18 +54,37 @@ export interface IRouteStop {
* участка по его ключу.
*/
export interface ITripRoute {
/** Город отправления (обычно город работы). */
/** Город отправления (обычно город работы), на русском. */
origin: string;
/** Город отправления на казахском; '' — введён вручную. */
originKz: string;
/** Код КАТО города отправления; '' — введён вручную. */
originKato: string;
stops: IRouteStop[];
/** Обратный путь из последней точки в город отправления. */
returnToOrigin: boolean;
/** Вид транспорта по ключу участка (ключ — legId из route.ts). */
legTransport: Record<string, string>;
/** Предпочтительное время выезда по ключу участка (DEPARTURE_TIMES); нет ключа — не указано. */
legDepartureTime: Record<string, string>;
}
/** Предпочтительное время выезда на участке (требования кадров 10-23); необязательно. */
export const DEPARTURE_TIMES: ReadonlyArray<string> = ['Утро', 'День', 'Вечер', 'Ночь'];
/** Ключ «точки» города отправления в ключах участков. */
export const ORIGIN_ID = 'origin';
/**
* Вид поездки — значения Choice-колонки TripKind. Выводится из точек: хотя бы одна
* за рубежом — вся поездка зарубежная (по ней будут маршрут согласования и отчёты).
*/
export const TRIP_KIND = {
domestic: 'По Казахстану',
abroad: 'Зарубежная'
} as const;
export type TripKind = (typeof TRIP_KIND)[keyof typeof TRIP_KIND];
/**
* Вид транспорта участка. Предположение: в бланке список не задан — варианты
* взяты типовые; поправить здесь, если кадры используют другие.
@@ -56,7 +92,7 @@ export const ORIGIN_ID = 'origin';
export const TRANSPORT_TYPES: ReadonlyArray<string> = [
'Авиа',
'Железнодорожный',
'Автобус',
'Такси',
'Служебный автотранспорт',
'Личный автотранспорт'
];
@@ -65,7 +101,7 @@ export const TRANSPORT_TYPES: ReadonlyArray<string> = [
export const TRANSPORT_SHORT: Record<string, string> = {
Авиа: 'Авиа',
Железнодорожный: 'Ж/д',
Автобус: 'Автобус',
Такси: 'Такси',
'Служебный автотранспорт': 'Служебное авто',
'Личный автотранспорт': 'Личное авто'
};
@@ -101,9 +137,15 @@ export interface IEstimateItem {
/**
* Только у суточных: количество МРП в день (цифры). Из него и MRP_VALUE
* считается ставка (price), дни (quantity) — из дат точки маршрута, см.
* estimate.ts/syncPerDiem. У остальных статей — ''.
* estimate.ts/syncRouteItems. У остальных статей — ''.
*/
mrpCount: string;
/**
* Только у позиций «по маршруту» (суточные, проживание): дни/ночи, введённые
* вручную поверх расчёта по датам — бывает форс-мажор (задержался в точке).
* Любое число, без ограничений. '' — считать по датам точки.
*/
quantityManual: string;
}
/** Статья сметы — заголовок с позициями внутри. */
@@ -130,7 +172,7 @@ export interface IEstimateKindInfo {
link: EstimateLinkTarget;
/**
* true — позиции не добавляются вручную, а строятся по точкам маршрута (по
* одной на точку) и считаются сами — так устроены суточные.
* одной на точку) и считаются сами — так устроены суточные (дни) и проживание (ночи).
*/
itemsFromRoute: boolean;
quantityLabel: string;
@@ -168,7 +210,8 @@ export const ESTIMATE_KINDS: Record<EstimateKind, IEstimateKindInfo> = {
core: true,
defaultMode: 'calc',
link: 'stop',
itemsFromRoute: false,
// Ночи — из дат точки, человек вводит только цену за ночь (с 10-23, по требованию кадров).
itemsFromRoute: true,
quantityLabel: 'Ночей',
priceLabel: 'Цена за ночь',
commentPlaceholder: 'Гостиница, адрес (необязательно)',
@@ -228,6 +271,21 @@ export const ESTIMATE_KIND_ORDER: ReadonlyArray<EstimateKind> = [
export const MRP_VALUE = 4325;
export const MRP_YEAR = 2026;
/** Самая длинная командировка, дней включительно (требования кадров 10-23). */
export const MAX_TRIP_DAYS = 40;
/** Лимит суточных, МРП в день (требования кадров 10-23): за рубежом выше. */
export const MAX_MRP_PER_DAY = { domestic: 6, abroad: 8 } as const;
/**
* Лимит цены проживания за ночь, ₸. За рубежом кадры лимит пока не назвали —
* undefined значит «без лимита»; когда назовут, вписать число сюда.
*/
export const MAX_NIGHT_PRICE: { domestic: number; abroad: number | undefined } = {
domestic: 15000,
abroad: undefined
};
/** Статьи, которые добавляются кнопкой не больше одного раза (другой расход — сколько угодно). */
export const SINGLE_OPTIONAL_KINDS: ReadonlyArray<EstimateKind> = ['insurance', 'visa'];
@@ -253,15 +311,23 @@ export interface IFormData {
Position: string;
/** Организации сотрудника (`\n`-joined «Компания — должность»), read-only, см. OrganizationField. */
Organization: string;
/**
* От какой компании едет сотрудник — одна из его организаций (Employee/ByEmail).
* Сотрудник может работать в нескольких компаниях, а командирует только одна.
*/
TripCompany: string;
/** CompanyId выбранной организации из справочника; '' — не выбрана. */
TripCompanyId: string;
Route: ITripRoute;
TripPurpose: string;
/** Задание на командировку — список задач по порядку. */
Tasks: string[];
/** Смета: основные статьи + добавленные (суммы вводятся вручную). */
Estimate: IEstimateArticle[];
/** Комментарий к заявке — после итога сметы, необязательный. */
TripComment: string;
/** Сотрудник принял обязательство по авансовому отчёту (см. OBLIGATION_TEXT). */
TripObligationAccepted: boolean;
@@ -270,28 +336,40 @@ export interface IFormData {
/** Обновление одного поля формы с сохранением типа значения. */
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
/** Сотрудник заявки и выбор организации, от которой он едет (см. useTripEmployee). */
export interface ITripEmployee {
/** Организации текущего сотрудника — варианты «Командирующей организации». */
organizations: IEmployeeOrganization[];
onEmployeeChange: (login: string) => void;
/** key — значение варианта (см. tripCompany.ts/companyKey); '' — сбросить выбор. */
onCompanyChange: (key: string) => void;
}
/** Пропсы секции формы: срез данных + колбэк обновления + контекст (people picker / справочники). */
export interface ISectionProps {
data: IFormData;
set: SetField;
context: WebPartContext;
/**
* Выбор сотрудника подтягивает его должность и организации — поэтому у поля
* отдельный обработчик. Не передан (режим чтения) — правка через set.
* Выбор сотрудника и организации подтягивает должность и организации — поэтому
* у этих полей свои обработчики. Не передан (режим чтения) — правка через set.
*/
onEmployeeChange?: (login: string) => void;
employee?: ITripEmployee;
}
export const DEFAULT_PURPOSE = 'Производственная необходимость';
/** Первая точка новой заявки. id фиксированный: у новой заявки на неё ссылаются позиции сметы. */
export const FIRST_STOP_ID = 'stop-1';
export const EMPTY_ROUTE: ITripRoute = {
origin: '',
stops: [{ id: FIRST_STOP_ID, city: '', project: '', projectId: '', dateFrom: '', dateTo: '' }],
originKz: '',
originKato: '',
stops: [
{ id: FIRST_STOP_ID, abroad: false, country: '', city: '', cityKz: '', cityKato: '', project: '', projectId: '', dateFrom: '', dateTo: '' }
],
returnToOrigin: true,
legTransport: {}
legTransport: {},
legDepartureTime: {}
};
function emptyItem(kind: EstimateKind, linkId: string): IEstimateItem {
@@ -303,7 +381,8 @@ function emptyItem(kind: EstimateKind, linkId: string): IEstimateItem {
quantity: '',
price: '',
comment: '',
mrpCount: ''
mrpCount: '',
quantityManual: ''
};
}
@@ -327,11 +406,13 @@ export const EMPTY_DATA: IFormData = {
Employee: '',
Position: '',
Organization: '',
TripCompany: '',
TripCompanyId: '',
Route: EMPTY_ROUTE,
TripPurpose: DEFAULT_PURPOSE,
// Новая заявка сразу показывает одну пустую строку задачи — понятнее, что заполнять.
Tasks: [''],
Estimate: EMPTY_ESTIMATE,
TripComment: '',
TripObligationAccepted: false
};
@@ -345,4 +426,7 @@ export const OBLIGATION_TEXT =
export const OBLIGATION_LABEL = 'Ознакомлен(а) и согласен(на)';
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
export const COUNTRY_PLACEHOLDER = 'Напр.: Китай';
export const ABROAD_CITY_PLACEHOLDER = 'Напр.: Пекин';
export const CITY_PLACEHOLDER ='Начните вводить город: Атырау, Құлсары…';
export const PROJECT_PLACEHOLDER ='Начните вводить название или код объекта…';
@@ -8,7 +8,10 @@
{ "internalName": "Employee", "title": "Сотрудник", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
{ "internalName": "Position", "title": "Должность", "type": "Text" },
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
{ "internalName": "TripCompany", "title": "Командирующая организация", "type": "Text" },
{ "internalName": "TripCompanyId", "title": "Id командирующей организации (справочник)", "type": "Text" },
{ "internalName": "TripKind", "title": "Вид командировки", "type": "Choice", "choices": ["По Казахстану", "Зарубежная"] },
{ "internalName": "TripRouteJson", "title": "Маршрут (JSON)", "type": "Note" },
{ "internalName": "TripRoute", "title": "Маршрут", "type": "Note" },
{ "internalName": "TripCity", "title": "Города", "type": "Text" },
@@ -18,7 +21,6 @@
{ "internalName": "TripStartDate", "title": "Командировка с", "type": "DateTime", "dateOnly": true },
{ "internalName": "TripEndDate", "title": "Командировка по", "type": "DateTime", "dateOnly": true },
{ "internalName": "TripDaysCount", "title": "Дней командировки", "type": "Number" },
{ "internalName": "TripPurpose", "title": "Цель командировки", "type": "Note" },
{ "internalName": "TripTasks", "title": "Задание на командировку", "type": "Note" },
{ "internalName": "TripTasksJson", "title": "Задание на командировку (JSON)", "type": "Note" },
@@ -26,6 +28,7 @@
{ "internalName": "TripEstimate", "title": "Смета на командировку", "type": "Note" },
{ "internalName": "TripEstimateJson", "title": "Смета на командировку (JSON)", "type": "Note" },
{ "internalName": "TripEstimateTotal", "title": "Итого по смете, тенге", "type": "Number" },
{ "internalName": "TripComment", "title": "Комментарий", "type": "Note" },
{ "internalName": "TripObligationAccepted", "title": "Обязательство по авансовому отчёту принято", "type": "Boolean" }
]
@@ -1,4 +1,4 @@
import { EMPTY_ROUTE, ITripRoute } from './model';
import { EMPTY_ROUTE, ITripRoute, TRIP_KIND } from './model';
import {
createStop,
legLabel,
@@ -9,17 +9,23 @@ import {
routeProjects,
routeText,
serializeRoute,
stopNights
stopLocationPatch,
stopNights,
tripKind,
withoutStopLegs
} from './route';
const ROUTE: ITripRoute = {
origin: 'Алматы',
originKz: '',
originKato: '',
stops: [
{ id: 's1', city: 'Атырау', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' },
{ id: 's2', city: 'Актау', project: 'Порт Курык', projectId: 'p2', dateFrom: '2026-10-08', dateTo: '2026-10-10' }
{ id: 's1', abroad: false, country: '', city: 'Атырау', cityKz: '', cityKato: '', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' },
{ id: 's2', abroad: false, country: '', city: 'Актау', cityKz: '', cityKato: '', project: 'Порт Курык', projectId: 'p2', dateFrom: '2026-10-08', dateTo: '2026-10-10' }
],
returnToOrigin: true,
legTransport: { 'origin>s1': 'Авиа' }
legTransport: { 'origin>s1': 'Авиа' },
legDepartureTime: {}
};
describe('участки пути', () => {
@@ -48,10 +54,42 @@ describe('сводка маршрута', () => {
);
});
it('город из справочника — на двух языках, если названия различаются', () => {
const route: ITripRoute = {
...ROUTE,
origin: 'Уральск',
originKz: 'Орал',
stops: [{ ...ROUTE.stops[0], city: 'Кульсары', cityKz: 'Құлсары' }, { ...ROUTE.stops[1], cityKz: 'Актау' }],
returnToOrigin: false
};
expect(routeCities(route)).toBe('Кульсары (Құлсары), Актау');
expect(routeLegs(route).map(legLabel)).toEqual(['Уральск (Орал) → Кульсары (Құлсары)', 'Кульсары (Құлсары) → Актау']);
});
it('новая точка начинается с даты отъезда из предыдущей', () => {
expect(createStop(ROUTE.stops[1]).dateFrom).toBe('2026-10-10');
expect(createStop(undefined).dateFrom).toBe('');
});
it('зарубежная точка: страна и город, поездка зарубежная', () => {
const beijing = { ...ROUTE.stops[1], abroad: true, country: 'Китай', city: 'Пекин' };
const route: ITripRoute = { ...ROUTE, stops: [ROUTE.stops[0], beijing] };
expect(routeCities(route)).toBe('Атырау, Китай, Пекин');
expect(tripKind(route)).toBe(TRIP_KIND.abroad);
expect(tripKind(ROUTE)).toBe(TRIP_KIND.domestic);
// Следующая точка — в той же стране, что предыдущая.
expect([createStop(beijing).abroad, createStop(beijing).country]).toEqual([true, 'Китай']);
});
it('удалили точку — транспорт и время выезда её участков уходят', () => {
const times = { 'origin>s1': 'Утро', 's1>s2': 'Вечер', 's2>origin': 'Ночь' };
expect(withoutStopLegs(times, 's2')).toEqual({ 'origin>s1': 'Утро' });
});
it('смена «Казахстан / другая страна» сбрасывает чужие поля, город остаётся', () => {
expect(stopLocationPatch(true)).toEqual({ abroad: true, cityKz: '', cityKato: '' });
expect(stopLocationPatch(false)).toEqual({ abroad: false, country: '' });
});
});
describe('хранение маршрута', () => {
@@ -59,6 +97,15 @@ describe('хранение маршрута', () => {
expect(parseRoute(serializeRoute(ROUTE))).toEqual(ROUTE);
});
it('старая заявка (до КАТО и признака страны) — город как был, точка по Казахстану', () => {
const old = { origin: 'Алматы', stops: [{ id: 's1', city: 'Атырау', project: '', projectId: '', dateFrom: '', dateTo: '' }] };
const route = parseRoute(JSON.stringify(old));
expect([route.originKz, route.stops[0].cityKz, route.stops[0].cityKato]).toEqual(['', '', '']);
expect([route.stops[0].abroad, route.stops[0].country]).toEqual([false, '']);
expect(route.legDepartureTime).toEqual({});
expect(routeCities(route)).toBe('Атырау');
});
it('битый JSON или маршрут без точек — маршрут новой заявки', () => {
expect(parseRoute('{oops')).toEqual(EMPTY_ROUTE);
expect(parseRoute(JSON.stringify({ origin: 'Алматы', stops: [] }))).toEqual(EMPTY_ROUTE);
@@ -1,5 +1,7 @@
import { formatIsoDate } from '../../shared/dateUtils';
import { EMPTY_ROUTE, IRouteStop, ITripRoute, ORIGIN_ID } from './model';
// Прямой импорт, не через shared/dictionary: индекс тянет сервисы с sp-http, а route.ts тестируется без SPFx.
import { cityLabel } from '../../shared/dictionary/kato/cityLabel';
import { EMPTY_ROUTE, IRouteStop, ITripRoute, ORIGIN_ID, TRIP_KIND, TripKind } from './model';
import { tripDays } from './tripData';
/** Участок пути (переезд) между двумя соседними точками маршрута. */
@@ -24,8 +26,36 @@ export function newId(prefix: string): string {
const ORIGIN_FALLBACK = 'Город отправления';
const STOP_FALLBACK = 'Точка маршрута';
/**
* Город точки: по Казахстану — на двух языках «Кульсары (Құлсары)», за рубежом —
* страна и город «Китай, Пекин». '' — не указан.
*/
export function stopCityLabel(stop: IRouteStop): string {
if (!stop.abroad) return cityLabel(stop.city, stop.cityKz);
return [stop.country.trim(), stop.city.trim()].filter(Boolean).join(', ');
}
/**
* Поля точки при смене «Казахстан / другая страна». Введённый город остаётся, но
* за рубежом он уже не из справочника КАТО (казахское название и код сбрасываются),
* а у точки по Казахстану страны нет.
*/
export function stopLocationPatch(abroad: boolean): Partial<IRouteStop> {
return abroad ? { abroad: true, cityKz: '', cityKato: '' } : { abroad: false, country: '' };
}
/** Вид поездки для колонки TripKind: хотя бы одна точка за рубежом — зарубежная. */
export function tripKind(route: ITripRoute): TripKind {
return route.stops.some((s) => s.abroad) ? TRIP_KIND.abroad : TRIP_KIND.domestic;
}
/** Город отправления на двух языках; '' — не указан. */
export function originLabel(route: ITripRoute): string {
return cityLabel(route.origin, route.originKz);
}
function stopCity(stop: IRouteStop): string {
return stop.city.trim() || STOP_FALLBACK;
return stopCityLabel(stop) || STOP_FALLBACK;
}
/** Подпись точки в выпадающих списках сметы: «Атырау · Нефтебаза-2». */
@@ -40,7 +70,7 @@ export function stopLabel(stop: IRouteStop): string {
* участки пересобрались сами.
*/
export function routeLegs(route: ITripRoute): IRouteLeg[] {
const origin = { id: ORIGIN_ID, name: route.origin.trim() || ORIGIN_FALLBACK };
const origin = { id: ORIGIN_ID, name: originLabel(route) || ORIGIN_FALLBACK };
const points = [origin, ...route.stops.map((s) => ({ id: s.id, name: stopCity(s) }))];
if (route.returnToOrigin) points.push(origin);
@@ -91,7 +121,7 @@ const unique = (values: string[]): string[] =>
/** Города точек через запятую — для колонки TripCity и заголовка элемента. */
export function routeCities(route: ITripRoute): string {
return unique(route.stops.map((s) => s.city)).join(', ');
return unique(route.stops.map(stopCityLabel)).join(', ');
}
/** Объекты точек (без повторов) — для колонки TripProjects. */
@@ -106,23 +136,28 @@ function stopDates(stop: IRouteStop): string {
/** Маршрут читаемым текстом для колонки SP: «Алматы → Атырау (Нефтебаза-2, 05.10.2026–08.10.2026) → Алматы». */
export function routeText(route: ITripRoute): string {
const parts: string[] = [route.origin.trim() || ORIGIN_FALLBACK];
const parts: string[] = [originLabel(route) || ORIGIN_FALLBACK];
route.stops.forEach((stop) => {
const details = [stop.project.trim(), stopDates(stop)].filter(Boolean).join(', ');
parts.push(details ? `${stopCity(stop)} (${details})` : stopCity(stop));
});
if (route.returnToOrigin) parts.push(route.origin.trim() || ORIGIN_FALLBACK);
if (route.returnToOrigin) parts.push(originLabel(route) || ORIGIN_FALLBACK);
return parts.join(' → ');
}
/**
* Новая точка после последней. Дата прибытия — дата отъезда из предыдущей точки:
* это не догадка, а то, что человек сам уже ввёл; поле остаётся редактируемым.
* Новая точка после последней. Дата прибытия — дата отъезда из предыдущей точки,
* страна (Казахстан / другая) — как у предыдущей: это не догадка, а то, что
* человек сам уже ввёл; поля остаются редактируемыми.
*/
export function createStop(previous: IRouteStop | undefined): IRouteStop {
return {
id: newId('stop'),
abroad: previous?.abroad ?? false,
country: previous?.abroad ? previous.country : '',
city: '',
cityKz: '',
cityKato: '',
project: '',
projectId: '',
dateFrom: previous?.dateTo || '',
@@ -143,7 +178,13 @@ function toStop(value: unknown): IRouteStop | undefined {
if (!id) return undefined;
return {
id,
// Заявки до признака «другая страна» — все точки по Казахстану.
abroad: raw.abroad === true,
country: text(raw.country),
city: text(raw.city),
// Заявки до справочника КАТО — без этих полей: город остаётся введённым вручную.
cityKz: text(raw.cityKz),
cityKato: text(raw.cityKato),
project: text(raw.project),
projectId: text(raw.projectId),
dateFrom: text(raw.dateFrom),
@@ -151,12 +192,24 @@ function toStop(value: unknown): IRouteStop | undefined {
};
}
function toTransport(value: unknown): Record<string, string> {
/** Словарь «ключ участка → значение» (вид транспорта, время выезда); не строки отбрасываются. */
function toLegRecord(value: unknown): Record<string, string> {
if (!value || typeof value !== 'object') return {};
const result: Record<string, string> = {};
Object.keys(value as object).forEach((key) => {
const type = (value as Record<string, unknown>)[key];
if (typeof type === 'string') result[key] = type;
const entry = (value as Record<string, unknown>)[key];
if (typeof entry === 'string') result[key] = entry;
});
return result;
}
/** Убрать из словаря участков те, что шли через точку stopId (её удалили из маршрута). */
export function withoutStopLegs(record: Record<string, string>, stopId: string): Record<string, string> {
const result: Record<string, string> = {};
Object.keys(record)
.filter((key) => key.split('>').indexOf(stopId) === -1)
.forEach((key) => {
result[key] = record[key];
});
return result;
}
@@ -177,8 +230,12 @@ export function parseRoute(raw: unknown): ITripRoute {
if (stops.length === 0) return EMPTY_ROUTE;
return {
origin: text(rawRoute.origin),
originKz: text(rawRoute.originKz),
originKato: text(rawRoute.originKato),
stops,
returnToOrigin: rawRoute.returnToOrigin !== false,
legTransport: toTransport(rawRoute.legTransport)
legTransport: toLegRecord(rawRoute.legTransport),
// Заявки до времени выезда — без этого поля: время просто не указано.
legDepartureTime: toLegRecord(rawRoute.legDepartureTime)
};
}
@@ -1,19 +1,26 @@
import * as React from 'react';
import { FormGrid, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui';
import { FormGrid, FormField, TextInput, PeopleField, OrganizationField, Select } from '../../../shared/ui';
import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model';
import { companyOptions, selectedCompanyKey } from '../tripCompany';
/**
* Командируемый: по умолчанию сам инициатор, но можно выбрать другого
* сотрудника (руководитель оформляет за подчинённого) — должность и
* организации подтянутся по выбранному. Дату подачи ставит форма.
* Сотрудник может работать в нескольких компаниях — тогда выбирают, от какой он едет.
*/
export const EmployeeSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange }) => (
export const EmployeeSection: React.FC<ISectionProps> = ({ data, set, context, employee }) => {
const options = companyOptions(employee?.organizations ?? [], data);
// Заявки, поданные до появления выбора организации, показывают прежний список всех организаций.
const isLegacyRequest = !employee && !data.TripCompany;
return (
<FormGrid>
<FormField label="Сотрудник" htmlFor="Employee" required full>
<PeopleField
context={context}
value={data.Employee}
onChange={onEmployeeChange ?? ((v) => set('Employee', v))}
onChange={employee?.onEmployeeChange ?? ((v) => set('Employee', v))}
placeholder={EMPLOYEE_PLACEHOLDER}
/>
</FormField>
@@ -23,6 +30,20 @@ export const EmployeeSection: React.FC<ISectionProps> = ({ data, set, context, o
<FormField label="Дата подачи" htmlFor="SubmitDate">
<TextInput id="SubmitDate" type="date" value={data.SubmitDate} onChange={(v) => set('SubmitDate', v)} readOnly />
</FormField>
<OrganizationField value={data.Organization} />
{isLegacyRequest && <OrganizationField value={data.Organization} />}
{!isLegacyRequest && options.length > 0 && (
<FormField label="Командирующая организация" htmlFor="TripCompany" required full>
<Select
id="TripCompany"
value={selectedCompanyKey(data)}
onChange={(key) => employee?.onCompanyChange(key)}
options={options}
placeholder="Выберите, от какой организации командировка"
// Единственная организация выбрана автоматически — выбирать не из чего.
readOnly={options.length === 1 && !!data.TripCompany}
/>
</FormField>
)}
</FormGrid>
);
};
@@ -1,6 +1,6 @@
import * as React from 'react';
import styles from './EstimateSection.module.scss';
import { CollapseSection, useFormReadOnly } from '../../../shared/ui';
import { CollapseSection, FormField, TextArea, useFormReadOnly } from '../../../shared/ui';
import { ISectionProps } from '../model';
import { EstimateEditor } from '../components/estimate/EstimateEditor';
import { EstimateTable } from '../components/estimate/EstimateTable';
@@ -9,7 +9,8 @@ import { EstimateSummary } from '../components/estimate/EstimateSummary';
/**
* Смета на командировку. Заполнение — карточками статей с позициями, привязанными
* к точкам и участкам маршрута (EstimateEditor); просмотр и печать — таблицей.
* Итог с долями и разбивкой по объектам — в обоих режимах.
* Итог с долями и разбивкой по объектам — в обоих режимах. После итога —
* необязательный комментарий к заявке.
*/
export const EstimateSection: React.FC<ISectionProps> = ({ data, set }) => {
const readOnly = useFormReadOnly();
@@ -26,6 +27,12 @@ export const EstimateSection: React.FC<ISectionProps> = ({ data, set }) => {
<EstimateEditor data={data} set={set} />
)}
</div>
{/* Пустой комментарий в просмотре не показываем — пустое поле только мешает читать. */}
{(!readOnly || data.TripComment.trim()) && (
<FormField label="Комментарий" htmlFor="TripComment" full>
<TextArea id="TripComment" value={data.TripComment} onChange={(v) => set('TripComment', v)} rows={3} />
</FormField>
)}
</CollapseSection>
);
};
@@ -5,7 +5,7 @@ import { ISectionProps, OBLIGATION_LABEL, OBLIGATION_TEXT } from '../model';
/** Обязательство по авансовому отчёту из бланка — без согласия заявку не отправить. */
export const ObligationSection: React.FC<ISectionProps> = ({ data, set }) => (
<CollapseSection title="Обязательство" icon="document">
<CollapseSection title="Обязательство и согласие" icon="document">
<p className={styles.text}>{OBLIGATION_TEXT}</p>
<FormField label="Подтверждение" htmlFor="TripObligationAccepted" required full>
<Checkbox
@@ -1,7 +1,7 @@
import * as React from 'react';
import { useEffect, useState } from 'react';
import styles from './RouteSection.module.scss';
import { CollapseSection, FormField, TextArea, useFormReadOnly } from '../../../shared/ui';
import { CollapseSection, useFormReadOnly } from '../../../shared/ui';
import { ISectionProps } from '../model';
import { RouteEditor } from '../components/route/RouteEditor';
import { RouteScheme } from '../components/route/RouteScheme';
@@ -9,8 +9,8 @@ import { RouteViewMode, RouteViewSwitch } from '../components/route/RouteViewSwi
import { stopCardDomId } from '../components/route/RouteStopCard';
/**
* Куда и зачем: маршрут (города + объекты + даты) и цель командировки. Маршрут —
* в двух видах: «Форма» для заполнения и «Схема» для просмотра. Заявитель
* Куда едет сотрудник: маршрут (города + объекты + даты) в двух видах — «Форма»
* для заполнения и «Схема» для просмотра. Заявитель
* начинает с формы (он пришёл заполнять), согласующий — со схемы (ему важнее
* увидеть маршрут целиком).
*/
@@ -41,9 +41,6 @@ export const RouteSection: React.FC<ISectionProps> = ({ data, set, context }) =>
<RouteScheme route={data.Route} articles={data.Estimate} onSelectStop={openStopInForm} />
)}
</div>
<FormField label="Цель" htmlFor="TripPurpose" required full>
<TextArea id="TripPurpose" value={data.TripPurpose} onChange={(v) => set('TripPurpose', v)} rows={2} />
</FormField>
</CollapseSection>
);
};
@@ -5,7 +5,7 @@ import { submitDateOnSave } from '../../shared/submitDate';
import { formatIsoDate } from '../../shared/dateUtils';
import { FORM_ID, FORM_TITLE, IFormData } from './model';
import { filledTasks, formatTasks, tripDays } from './tripData';
import { routeCities, routePeriod, routeProjects, routeText, serializeRoute } from './route';
import { routeCities, routePeriod, routeProjects, routeText, serializeRoute, tripKind } from './route';
import { estimateTotal, formatEstimateText, serializeEstimate } from './estimate';
/** Сервис списка согласования этой формы — см. shared/approval/approvalServiceFactory.ts. */
@@ -34,7 +34,10 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee
EmployeeId: employeeId,
Position: data.Position || null,
Organization: data.Organization || null,
TripCompany: data.TripCompany || null,
TripCompanyId: data.TripCompanyId || null,
TripKind: tripKind(route),
TripRouteJson: serializeRoute(route),
TripRoute: routeText(route) || null,
TripCity: routeCities(route) || null,
@@ -44,7 +47,6 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee
TripStartDate: period.start || null,
TripEndDate: period.end || null,
TripDaysCount: tripDays(period.start, period.end) ?? null,
TripPurpose: data.TripPurpose || null,
// Читаемая строка — для представлений списка; источник для формы — JSON.
TripTasks: formatTasks(data.Tasks) || null,
@@ -54,6 +56,7 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee
TripEstimateJson: serializeEstimate(data.Estimate),
// Итог дублируется числовой колонкой — для отчётов и сортировки в SP.
TripEstimateTotal: estimateTotal(data.Estimate),
TripComment: data.TripComment.trim() || null,
TripObligationAccepted: data.TripObligationAccepted
};
@@ -0,0 +1,57 @@
import type { IEmployeeOrganization } from '../../shared/dictionary';
import { EMPTY_DATA, IFormData } from './model';
import { applyCompany, applyEmployeeCompany, companyOptions } from './tripCompany';
const org = (companyId: string, company: string, position: string): IEmployeeOrganization => ({
id: `${companyId}-${position}`,
fio: 'Нурумова Виктория',
email: 'nurumova@tnsu.kz',
iin: '',
company,
companyId,
section: '',
departmentId: '',
position,
mobile: ''
});
const MONTAZHNIK = org('c1', 'ТОО Монтажник-Т', 'Директор');
const ASTEL = org('c2', 'ТОО АСТЕЛ-К', 'Казначей');
describe('tripCompany: варианты', () => {
it('«Компания — должность», дубль компании отбрасывается', () => {
const options = companyOptions([MONTAZHNIK, ASTEL, org('c2', 'ТОО АСТЕЛ-К', 'Бухгалтер')], EMPTY_DATA);
expect(options).toEqual([
{ value: 'c1', label: 'ТОО Монтажник-Т — Директор' },
{ value: 'c2', label: 'ТОО АСТЕЛ-К — Казначей' }
]);
});
it('сохранённая организация видна, даже если справочник её не вернул', () => {
const data: IFormData = { ...EMPTY_DATA, TripCompany: 'ТОО Старая', TripCompanyId: 'old' };
expect(companyOptions([], data)).toEqual([{ value: 'old', label: 'ТОО Старая' }]);
});
});
describe('tripCompany: выбор', () => {
it('выбор организации ставит должность в ней', () => {
const data = applyCompany({ ...EMPTY_DATA, Position: 'Директор' }, ASTEL);
expect([data.TripCompany, data.TripCompanyId, data.Position]).toEqual(['ТОО АСТЕЛ-К', 'c2', 'Казначей']);
});
it('одна организация выбирается сразу', () => {
const data = applyEmployeeCompany(EMPTY_DATA, [MONTAZHNIK]);
expect([data.TripCompanyId, data.Position]).toEqual(['c1', 'Директор']);
});
it('несколько — выбор и должность пустые', () => {
const prev = { ...EMPTY_DATA, TripCompany: 'ТОО Монтажник-Т', TripCompanyId: 'c1', Position: 'Директор' };
const data = applyEmployeeCompany(prev, [MONTAZHNIK, ASTEL]);
expect([data.TripCompanyId, data.Position]).toEqual(['', '']);
});
it('нет в справочнике — должность остаётся (введут руками)', () => {
const data = applyEmployeeCompany({ ...EMPTY_DATA, Position: 'Инженер' }, []);
expect([data.TripCompanyId, data.Position]).toEqual(['', 'Инженер']);
});
});
@@ -0,0 +1,57 @@
// Только типы: модуль чистый и тестируется без SharePoint (сервисы справочника тянут sp-http).
import type { IEmployeeOrganization } from '../../shared/dictionary';
import type { ISelectOption } from '../../shared/ui';
import { IFormData } from './model';
/** Значение варианта в списке: CompanyId из справочника, у записи без id — название. */
export function companyKey(org: IEmployeeOrganization): string {
return org.companyId || org.company;
}
/** Значение выбранной организации в списке ('' — не выбрана). */
export function selectedCompanyKey(data: IFormData): string {
return data.TripCompanyId || data.TripCompany;
}
/**
* Варианты «Командирующей организации» — «Компания — должность». Если в одной
* компании у сотрудника несколько записей, остаётся первая: выбирают компанию,
* а не ставку. Сохранённая организация, которой нет среди вариантов (просмотр
* заявки, справочник недоступен, сотрудник уже ушёл из компании), всё равно
* показывается — иначе поле выглядело бы пустым.
*/
export function companyOptions(organizations: IEmployeeOrganization[], data: IFormData): ISelectOption[] {
const options: ISelectOption[] = [];
organizations.forEach((org) => {
const value = companyKey(org);
if (!value || options.some((o) => o.value === value)) return;
options.push({ value, label: org.position ? `${org.company} — ${org.position}` : org.company });
});
const saved = selectedCompanyKey(data);
if (saved && !options.some((o) => o.value === saved)) {
options.push({ value: saved, label: data.TripCompany });
}
return options;
}
/** Выбрать организацию: компания + должность сотрудника именно в ней. undefined — сбросить выбор. */
export function applyCompany(data: IFormData, org: IEmployeeOrganization | undefined): IFormData {
return {
...data,
TripCompany: org?.company || '',
TripCompanyId: org?.companyId || '',
Position: org?.position || data.Position
};
}
/**
* Организация и должность нового сотрудника заявки. Одна организация —
* выбирается сразу. Несколько — выбор за человеком, а должность пустая до
* выбора: в каждой компании она своя, и угадывать её по первой записи нельзя.
* Сотрудник не найден в справочнике — должность остаётся как была (введут руками).
*/
export function applyEmployeeCompany(data: IFormData, organizations: IEmployeeOrganization[]): IFormData {
if (organizations.length === 1) return applyCompany(data, organizations[0]);
const cleared = applyCompany(data, undefined);
return organizations.length > 1 ? { ...cleared, Position: '' } : cleared;
}
@@ -1,22 +1,20 @@
import * as React from 'react';
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
import type { IEmployeeOrganization } from '../../shared/dictionary';
import { todaySubmitDate } from '../../shared/submitDate';
import { IFormData } from './model';
import { IFormData, ITripEmployee } from './model';
import { applyCompany, applyEmployeeCompany, companyKey } from './tripCompany';
type SetData = React.Dispatch<React.SetStateAction<IFormData>>;
/** Подставить должность и организации сотрудника из Employee/ByEmail (должность — из первой записи). */
/** Подставить организации, командирующую организацию и должность сотрудника из Employee/ByEmail. */
function fillProfile(context: WebPartContext, email: string, setData: SetData, isAlive: () => boolean): void {
getEmployeeOrganizations(context, email)
.then((records) => {
if (!isAlive()) return;
setData((prev) => ({
...prev,
Position: records[0]?.position || prev.Position,
Organization: formatEmployeeOrganizations(records)
}));
setData((prev) => applyEmployeeCompany({ ...prev, Organization: formatEmployeeOrganizations(records) }, records));
})
// Справочник недоступен — не страшно: должность введут руками, организация просто не покажется.
.catch(() => undefined);
@@ -24,10 +22,18 @@ function fillProfile(context: WebPartContext, email: string, setData: SetData, i
/**
* Сотрудник заявки. Новая заявка — по умолчанию на себя: текущий пользователь,
* его должность, организации и сегодняшняя дата подачи. Возвращает обработчик
* выбора другого сотрудника — он подтягивает должность и организации выбранного.
* его должность, организации и сегодняшняя дата подачи. Выбор другого сотрудника
* подтягивает профиль выбранного. Список организаций грузится и для открытого
* черновика — но там сохранённые должность и организацию не перезаписываем.
*/
export function useTripEmployee(context: WebPartContext, editId: number | undefined, setData: SetData): (login: string) => void {
export function useTripEmployee(
context: WebPartContext,
editId: number | undefined,
employee: string,
setData: SetData
): ITripEmployee {
const [organizations, setOrganizations] = useState<IEmployeeOrganization[]>([]);
useEffect(() => {
if (editId) return;
let alive = true;
@@ -40,8 +46,36 @@ export function useTripEmployee(context: WebPartContext, editId: number | undefi
// context — единственный экземпляр WebPartContext на всё время жизни веб-части.
}, [editId, context, setData]);
return (login: string): void => {
setData((prev) => ({ ...prev, Employee: login, Organization: login ? prev.Organization : '' }));
if (login) fillProfile(context, login, setData, () => true);
// Ответ справочника кешируется по e-mail (см. employeeOrganizations.service.ts) — повторного запроса нет.
useEffect(() => {
let alive = true;
const load = employee ? getEmployeeOrganizations(context, employee) : Promise.resolve<IEmployeeOrganization[]>([]);
load
.then((records) => {
if (alive) setOrganizations(records);
})
.catch(() => {
if (alive) setOrganizations([]);
});
return () => {
alive = false;
};
}, [context, employee]);
const onEmployeeChange = (login: string): void => {
if (!login) {
// Сотрудник очищен — его организации и выбранная компания к заявке больше не относятся.
setData((prev) => applyCompany({ ...prev, Employee: '', Organization: '' }, undefined));
return;
}
setData((prev) => ({ ...prev, Employee: login }));
fillProfile(context, login, setData, () => true);
};
const onCompanyChange = (key: string): void => {
const org = organizations.find((o) => companyKey(o) === key);
setData((prev) => applyCompany(prev, org));
};
return { organizations, onEmployeeChange, onCompanyChange };
}
@@ -1,14 +1,19 @@
import { EMPTY_DATA, IEstimateArticle, IFormData, ITripRoute } from './model';
import { createArticle } from './estimate';
import type { IEmployeeOrganization } from '../../shared/dictionary';
import type { IFieldIssue } from '../../shared/ui';
import { draftIssues, submitIssues } from './validation';
const off = (article: IEstimateArticle): IEstimateArticle => ({ ...article, enabled: false });
const ROUTE: ITripRoute = {
origin: 'Алматы',
stops: [{ id: 's1', city: 'Атырау', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' }],
originKz: '',
originKato: '',
stops: [{ id: 's1', abroad: false, country: '', city: 'Атырау', cityKz: '', cityKato: '', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' }],
returnToOrigin: true,
legTransport: { 'origin>s1': 'Авиа', 's1>origin': 'Авиа' }
legTransport: { 'origin>s1': 'Авиа', 's1>origin': 'Авиа' },
legDepartureTime: { 'origin>s1': 'Утро', 's1>origin': 'Вечер' }
};
const FILLED: IFormData = {
@@ -22,33 +27,74 @@ const FILLED: IFormData = {
TripObligationAccepted: true
};
// Фиксированное «сегодня» — раньше дат маршрута в фикстурах, чтобы тесты не зависели от текущей даты.
const TODAY = '2026-10-01';
const submit = (data: IFormData, organizations: IEmployeeOrganization[] = []): IFieldIssue[] => submitIssues(data, organizations, TODAY);
const fields = (issues: { field: string }[]): string[] => issues.map((i) => i.field);
describe('validation: форма', () => {
it('заполненная заявка — без проблем', () => {
expect(submitIssues(FILLED)).toEqual([]);
expect(submit(FILLED)).toEqual([]);
expect(draftIssues(FILLED)).toEqual([]);
});
it('командирующая организация обязательна, только если есть из чего выбрать', () => {
const orgs = [{ id: '1', fio: '', email: '', iin: '', company: 'ТОО А', companyId: 'a', section: '', departmentId: '', position: '', mobile: '' }];
expect(fields(submit(FILLED, orgs))).toEqual(['TripCompany']);
expect(submit({ ...FILLED, TripCompany: 'ТОО А', TripCompanyId: 'a' }, orgs)).toEqual([]);
expect(submit(FILLED, [])).toEqual([]);
});
it('черновику нужен только сотрудник', () => {
expect(fields(draftIssues(EMPTY_DATA))).toEqual(['Employee']);
});
it('пустые задачи и обязательство', () => {
const data = { ...FILLED, Tasks: [' '], TripObligationAccepted: false };
expect(fields(submitIssues(data))).toEqual(['Tasks', 'TripObligationAccepted']);
expect(fields(submit(data))).toEqual(['Tasks', 'TripObligationAccepted']);
});
});
describe('validation: маршрут', () => {
it('объект только из справочника', () => {
const route = { ...ROUTE, stops: [{ ...ROUTE.stops[0], projectId: '' }] };
expect(fields(submitIssues({ ...FILLED, Route: route }))).toEqual(['stop-s1-project']);
expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['stop-s1-project']);
});
it('зарубежная точка — нужна страна', () => {
const route = { ...ROUTE, stops: [{ ...ROUTE.stops[0], abroad: true, country: ' ' }] };
expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['stop-s1-country']);
});
it('зарубежная точка — объект можно ввести вручную, но не пустым', () => {
const abroad = { ...ROUTE.stops[0], abroad: true, country: 'Китай', project: 'Завод Китайский', projectId: '' };
expect(submit({ ...FILLED, Route: { ...ROUTE, stops: [abroad] } })).toEqual([]);
const empty = { ...abroad, project: ' ' };
expect(fields(submit({ ...FILLED, Route: { ...ROUTE, stops: [empty] } }))).toEqual(['stop-s1-project']);
});
it('у каждого участка нужен вид транспорта', () => {
const route = { ...ROUTE, legTransport: { 'origin>s1': 'Авиа' } };
expect(fields(submitIssues({ ...FILLED, Route: route }))).toEqual(['leg-s1>origin']);
expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['leg-s1>origin']);
});
it('у каждого участка нужно время выезда', () => {
const route = { ...ROUTE, legDepartureTime: { 'origin>s1': 'Утро' } };
expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['leg-s1>origin-time']);
});
it('прибытие задним числом не отправить, сегодня — можно; черновик сохраняется', () => {
expect(fields(submitIssues(FILLED, [], '2026-10-06'))).toEqual(['stop-s1-from']);
expect(submitIssues(FILLED, [], '2026-10-05')).toEqual([]);
expect(draftIssues(FILLED)).toEqual([]);
});
it('командировка не дольше 40 дней включительно', () => {
const until = (dateTo: string): ITripRoute => ({ ...ROUTE, stops: [{ ...ROUTE.stops[0], dateTo }] });
// 05.10 – 13.11 = 40 дней, 05.10 – 14.11 = 41.
expect(submit({ ...FILLED, Route: until('2026-11-13') })).toEqual([]);
expect(fields(submit({ ...FILLED, Route: until('2026-11-14') }))).toEqual(['stop-s1-to']);
});
it('отъезд раньше прибытия блокирует даже черновик', () => {
@@ -57,9 +103,9 @@ describe('validation: маршрут', () => {
});
it('прибытие в следующую точку раньше отъезда из предыдущей', () => {
const second = { id: 's2', city: 'Актау', project: 'Порт', projectId: 'p2', dateFrom: '2026-10-06', dateTo: '2026-10-09' };
const route = { ...ROUTE, stops: [...ROUTE.stops, second], legTransport: { 'origin>s1': 'Авиа', 's1>s2': 'Авиа', 's2>origin': 'Авиа' } };
expect(fields(submitIssues({ ...FILLED, Route: route }))).toEqual(['stop-s2-from']);
const second = { id: 's2', abroad: false, country: '', city: 'Актау', cityKz: '', cityKato: '', project: 'Порт', projectId: 'p2', dateFrom: '2026-10-06', dateTo: '2026-10-09' };
const route = { ...ROUTE, stops: [...ROUTE.stops, second], legTransport: { 'origin>s1': 'Авиа', 's1>s2': 'Авиа', 's2>origin': 'Авиа' }, legDepartureTime: { 'origin>s1': 'Утро', 's1>s2': 'День', 's2>origin': 'Вечер' } };
expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['stop-s2-from']);
});
});
@@ -67,29 +113,46 @@ describe('validation: смета', () => {
const [perDiem, travel, accommodation] = EMPTY_DATA.Estimate;
it('включённая статья без суммы — подсвечивается пустое поле', () => {
const filledQty = { ...accommodation, items: [{ ...accommodation.items[0], linkId: 's1', quantity: '3' }] };
const data = { ...FILLED, Estimate: [off(perDiem), off(travel), filledQty] };
expect(fields(submitIssues(data))).toEqual([`item-${accommodation.items[0].id}-price`]);
const tickets = { ...travel, items: [{ ...travel.items[0], linkId: 'origin>s1', mode: 'calc' as const, quantity: '1' }] };
const data = { ...FILLED, Estimate: [off(perDiem), tickets, off(accommodation)] };
expect(fields(submit(data))).toEqual([`item-${travel.items[0].id}-price`]);
});
it('лимит суточных: 6 МРП по Казахстану, 8 за рубежом', () => {
const withMrp = (mrpCount: string): IEstimateArticle => ({ ...perDiem, items: [{ ...perDiem.items[0], linkId: 's1', mrpCount }] });
const estimate = (mrpCount: string): IEstimateArticle[] => [withMrp(mrpCount), off(travel), off(accommodation)];
expect(fields(submit({ ...FILLED, Estimate: estimate('7') }))).toEqual([`item-${perDiem.items[0].id}-mrp`]);
const abroad = { ...ROUTE, stops: [{ ...ROUTE.stops[0], abroad: true, country: 'Китай' }] };
expect(submit({ ...FILLED, Route: abroad, Estimate: estimate('8') })).toEqual([]);
expect(fields(submit({ ...FILLED, Route: abroad, Estimate: estimate('9') }))).toEqual([`item-${perDiem.items[0].id}-mrp`]);
});
it('проживание: цена необязательна, но по Казахстану не больше 15 000 за ночь', () => {
const withPrice = (price: string): IEstimateArticle => ({ ...accommodation, items: [{ ...accommodation.items[0], linkId: 's1', price }] });
const estimate = (price: string): IEstimateArticle[] => [off(perDiem), off(travel), withPrice(price)];
expect(submit({ ...FILLED, Estimate: estimate('') })).toEqual([]);
expect(submit({ ...FILLED, Estimate: estimate('15000') })).toEqual([]);
expect(fields(submit({ ...FILLED, Estimate: estimate('15001') }))).toEqual([`item-${accommodation.items[0].id}-price`]);
});
it('суточные: дни из маршрута, от человека — только кол-во МРП', () => {
const noMrp = { ...perDiem, items: [{ ...perDiem.items[0], linkId: 's1', quantity: '4' }] };
const withMrp = { ...perDiem, items: [{ ...noMrp.items[0], mrpCount: '6' }] };
const estimate = (article: IEstimateArticle): IEstimateArticle[] => [article, off(travel), off(accommodation)];
expect(fields(submitIssues({ ...FILLED, Estimate: estimate(noMrp) }))).toEqual([`item-${perDiem.items[0].id}-mrp`]);
expect(submitIssues({ ...FILLED, Estimate: estimate(withMrp) })).toEqual([]);
expect(fields(submit({ ...FILLED, Estimate: estimate(noMrp) }))).toEqual([`item-${perDiem.items[0].id}-mrp`]);
expect(submit({ ...FILLED, Estimate: estimate(withMrp) })).toEqual([]);
});
it('при нескольких вариантах позицию нужно привязать', () => {
// В маршруте «туда и обратно» у проезда два участка — без привязки не понять, какой оплачен.
const unlinked = { ...travel, items: [{ ...travel.items[0], linkId: '', amount: '50000' }] };
const data = { ...FILLED, Estimate: [off(perDiem), unlinked, off(accommodation)] };
expect(fields(submitIssues(data))).toEqual([`item-${travel.items[0].id}-link`]);
expect(fields(submit(data))).toEqual([`item-${travel.items[0].id}-link`]);
});
it('другой расход требует название и сумму', () => {
const other = createArticle('other', ROUTE);
const data = { ...FILLED, Estimate: [...FILLED.Estimate, other] };
expect(fields(submitIssues(data))).toEqual([`article-${other.id}-title`, `item-${other.items[0].id}-amount`]);
expect(fields(submit(data))).toEqual([`article-${other.id}-title`, `item-${other.items[0].id}-amount`]);
});
});
@@ -1,8 +1,28 @@
import type { IFieldIssue } from '../../shared/ui';
import { ESTIMATE_KINDS, IEstimateArticle, IEstimateItem, IFormData, IRouteStop, ITripRoute } from './model';
import { filledTasks } from './tripData';
import { legLabel, routeLegs } from './route';
import { articleTitle, itemAmount, itemLinkLabel, linkOptions, resolveLinkId } from './estimate';
import type { IEmployeeOrganization } from '../../shared/dictionary';
import { todaySubmitDate } from '../../shared/submitDate';
import {
ESTIMATE_KINDS,
EstimateKind,
IEstimateArticle,
IEstimateItem,
IFormData,
IRouteStop,
ITripRoute,
MAX_TRIP_DAYS
} from './model';
import { filledTasks, plural, tripDays } from './tripData';
import { legLabel, routeLegs, routePeriod, stopCityLabel } from './route';
import {
articleTitle,
formatAmount,
itemAmount,
itemLinkLabel,
linkOptions,
mrpLimit,
nightPriceLimit,
resolveLinkId
} from './estimate';
/**
* Ключи полей маршрута и сметы — те же, что fieldKey/htmlFor у их FormField:
@@ -10,11 +30,13 @@ import { articleTitle, itemAmount, itemLinkLabel, linkOptions, resolveLinkId } f
*/
export const fieldKey = {
origin: 'route-origin',
stopCountry: (stop: IRouteStop): string => `stop-${stop.id}-country`,
stopCity: (stop: IRouteStop): string => `stop-${stop.id}-city`,
stopProject: (stop: IRouteStop): string => `stop-${stop.id}-project`,
stopFrom: (stop: IRouteStop): string => `stop-${stop.id}-from`,
stopTo: (stop: IRouteStop): string => `stop-${stop.id}-to`,
legTransport: (legId: string): string => `leg-${legId}`,
legDepartureTime: (legId: string): string => `leg-${legId}-time`,
articleTitle: (article: IEstimateArticle): string => `article-${article.id}-title`,
itemLink: (item: IEstimateItem): string => `item-${item.id}-link`,
itemAmount: (item: IEstimateItem): string => `item-${item.id}-amount`,
@@ -25,7 +47,8 @@ export const fieldKey = {
/** «Точка 2 (Актау)» — как точку видно в форме. */
function stopName(stop: IRouteStop, index: number): string {
return stop.city.trim() ? `Точка ${index + 1} (${stop.city.trim()})` : `Точка ${index + 1}`;
const city = stopCityLabel(stop);
return city ? `Точка ${index + 1} (${city})` : `Точка ${index + 1}`;
}
/** Дата окончания раньше начала — блокирует даже черновик: такие даты не сохранить осмысленно. */
@@ -40,13 +63,24 @@ function stopDateOrderIssues(route: ITripRoute): IFieldIssue[] {
}
/** Проблемы одной точки маршрута (кроме порядка дат внутри точки — см. stopDateOrderIssues). */
function stopIssues(stop: IRouteStop, index: number, previous: IRouteStop | undefined): IFieldIssue[] {
function stopIssues(stop: IRouteStop, index: number, previous: IRouteStop | undefined, today: string): IFieldIssue[] {
const label = stopName(stop, index);
const issues: IFieldIssue[] = [];
if (stop.abroad && !stop.country.trim()) issues.push({ field: fieldKey.stopCountry(stop), label, message: 'укажите страну' });
if (!stop.city.trim()) issues.push({ field: fieldKey.stopCity(stop), label, message: 'укажите город' });
// Объект — именно из справочника (projectId): по нему потом разносят затраты по проектам.
if (!stop.projectId) issues.push({ field: fieldKey.stopProject(stop), label, message: 'выберите объект из списка' });
// По Казахстану объект — именно из справочника (projectId): по нему разносят затраты по проектам.
// За рубежом едут на чужую площадку (завод, выставка) — её в справочнике нет, достаточно текста.
if (stop.abroad && !stop.project.trim()) {
issues.push({ field: fieldKey.stopProject(stop), label, message: 'укажите объект или место назначения' });
}
if (!stop.abroad && !stop.projectId) {
issues.push({ field: fieldKey.stopProject(stop), label, message: 'выберите объект из списка' });
}
if (!stop.dateFrom) issues.push({ field: fieldKey.stopFrom(stop), label, message: 'укажите дату прибытия' });
// Задним числом командировку не оформляют. Сегодня — можно: выезд в день подачи.
if (stop.dateFrom && stop.dateFrom < today) {
issues.push({ field: fieldKey.stopFrom(stop), label, message: 'дата прибытия уже прошла' });
}
if (!stop.dateTo) issues.push({ field: fieldKey.stopTo(stop), label, message: 'укажите дату отъезда' });
if (previous?.dateTo && stop.dateFrom && stop.dateFrom < previous.dateTo) {
issues.push({ field: fieldKey.stopFrom(stop), label, message: 'прибытие раньше отъезда из предыдущей точки' });
@@ -54,15 +88,39 @@ function stopIssues(stop: IRouteStop, index: number, previous: IRouteStop | unde
return issues;
}
/** Маршрут для отправки: город отправления, все точки, вид транспорта на каждом участке. */
function routeIssues(route: ITripRoute): IFieldIssue[] {
/**
* Вся поездка — не дольше MAX_TRIP_DAYS дней (от прибытия в первую точку до отъезда
* из последней, включительно). Подсвечиваем отъезд последней точки — его обычно и правят.
*/
function tripLengthIssues(route: ITripRoute): IFieldIssue[] {
const period = routePeriod(route);
const days = tripDays(period.start, period.end);
const last = route.stops[route.stops.length - 1];
if (days === undefined || days <= MAX_TRIP_DAYS || !last) return [];
return [
{
field: fieldKey.stopTo(last),
label: 'Маршрут',
message: `командировка — ${plural(days, ['день', 'дня', 'дней'])}, можно не больше ${MAX_TRIP_DAYS}`
}
];
}
/** Маршрут для отправки: город отправления, все точки, вид транспорта и время выезда на каждом участке. */
function routeIssues(route: ITripRoute, today: string): IFieldIssue[] {
const issues: IFieldIssue[] = [];
if (!route.origin.trim()) issues.push({ field: fieldKey.origin, label: 'Маршрут', message: 'укажите город отправления' });
route.stops.forEach((stop, i) => issues.push(...stopIssues(stop, i, route.stops[i - 1])));
route.stops.forEach((stop, i) => issues.push(...stopIssues(stop, i, route.stops[i - 1], today)));
issues.push(...stopDateOrderIssues(route));
issues.push(...tripLengthIssues(route));
routeLegs(route).forEach((leg) => {
const label = `Переезд ${legLabel(leg)}`;
if (!route.legTransport[leg.id]) {
issues.push({ field: fieldKey.legTransport(leg.id), label: `Участок ${legLabel(leg)}`, message: 'выберите вид транспорта' });
issues.push({ field: fieldKey.legTransport(leg.id), label, message: 'выберите вид транспорта' });
}
// Время выезда нужно тревел-координатору под билеты — без него не отправить.
if (!route.legDepartureTime[leg.id]) {
issues.push({ field: fieldKey.legDepartureTime(leg.id), label, message: 'выберите время выезда' });
}
});
return issues;
@@ -75,6 +133,27 @@ function amountIssue(item: IEstimateItem, label: string): IFieldIssue {
return { field: fieldKey.itemPrice(item), label, message: 'укажите цену' };
}
/**
* Позиция «по маршруту» — точку и дни/ночи форма ставит сама (пустые даты подсветит
* маршрут). Суточные: нужно кол-во МРП, не больше лимита (0 дней в день переезда —
* не ошибка). Проживание: цена необязательна — в точке могут жить на объекте, — но
* не выше лимита за ночь.
*/
function routeItemIssues(kind: EstimateKind, item: IEstimateItem, label: string, route: ITripRoute): IFieldIssue[] {
const stop = route.stops.find((s) => s.id === item.linkId);
if (kind === 'accommodation') {
const limit = nightPriceLimit(stop);
if (limit === undefined || Number(item.price) <= limit) return [];
return [{ field: fieldKey.itemPrice(item), label, message: `цена за ночь не больше ${formatAmount(limit)} ₸` }];
}
if (!Number(item.mrpCount)) return [{ field: fieldKey.itemMrpCount(item), label, message: 'укажите количество МРП' }];
const limit = mrpLimit(stop);
if (Number(item.mrpCount) > limit) {
return [{ field: fieldKey.itemMrpCount(item), label, message: `не больше ${limit} МРП в день` }];
}
return [];
}
/**
* Смета. Сама не обязательна — выключенные статьи не проверяются; но у включённой
* или добавленной статьи каждая позиция должна иметь сумму и (если вариантов
@@ -92,12 +171,8 @@ function estimateIssues(articles: IEstimateArticle[], route: ITripRoute): IField
article.items.forEach((item) => {
const where = itemLinkLabel(article.kind, item, route);
const label = where ? `Смета: ${articleTitle(article)} (${where})` : `Смета: ${articleTitle(article)}`;
// Суточные привязаны к точке и считают дни сами (пустые даты подсветит маршрут) —
// от человека нужно только кол-во МРП. 0 дней (точка в день переезда) — не ошибка.
if (ESTIMATE_KINDS[article.kind].itemsFromRoute) {
if (!Number(item.mrpCount)) {
issues.push({ field: fieldKey.itemMrpCount(item), label, message: 'укажите количество МРП' });
}
issues.push(...routeItemIssues(article.kind, item, label, route));
return;
}
if (needsLink && !resolveLinkId(article.kind, item.linkId, route)) {
@@ -116,19 +191,30 @@ export function draftIssues(data: IFormData): IFieldIssue[] {
return [...issues, ...stopDateOrderIssues(data.Route)];
}
/** Всё, что мешает отправке на согласование, в порядке формы (пусто — отправлять можно). */
export function submitIssues(data: IFormData): IFieldIssue[] {
/**
* Всё, что мешает отправке на согласование, в порядке формы (пусто — отправлять можно).
* organizations — организации сотрудника (см. useTripEmployee): из них выбирают командирующую.
* today — YYYY-MM-DD; параметром, чтобы тесты не зависели от текущей даты.
*/
export function submitIssues(
data: IFormData,
organizations: IEmployeeOrganization[] = [],
today: string = todaySubmitDate()
): IFieldIssue[] {
const issues: IFieldIssue[] = [];
if (!data.Employee) issues.push({ field: 'Employee', label: 'Сотрудник' });
if (!data.Position.trim()) issues.push({ field: 'Position', label: 'Должность' });
issues.push(...routeIssues(data.Route));
if (!data.TripPurpose.trim()) issues.push({ field: 'TripPurpose', label: 'Цель' });
// Нет организаций (сотрудника нет в справочнике / справочник недоступен) — выбирать не из чего, не блокируем.
if (organizations.length > 0 && !data.TripCompany) {
issues.push({ field: 'TripCompany', label: 'Командирующая организация', message: 'выберите, от какой организации командировка' });
}
issues.push(...routeIssues(data.Route, today));
if (filledTasks(data.Tasks).length === 0) {
issues.push({ field: 'Tasks', label: 'Задание на командировку', message: 'добавьте хотя бы одну задачу' });
}
issues.push(...estimateIssues(data.Estimate, data.Route));
if (!data.TripObligationAccepted) {
issues.push({ field: 'TripObligationAccepted', label: 'Обязательство', message: 'подтвердите согласие' });
issues.push({ field: 'TripObligationAccepted', label: 'Обязательство и согласие', message: 'подтвердите согласие' });
}
return issues;
}
@@ -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>
);
};
@@ -1,5 +1,6 @@
@import '../../../../styles/design-tokens';
// Ссылка «К списку заявок» — как в остальных формах (см. DismissalForm.module.scss).
.back {
display: inline-flex;
align-items: center;
@@ -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`), без печати
- «Создать на основе»: нет
@@ -1,9 +1,9 @@
{
"dataListTitle": "Заявка на оплату — согласование",
"dataListTitle": "Изменение условий трудового договора — согласование",
"columns": [
{ "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
{ "internalName": "ApprovalStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] },
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Заявка на оплату — данные", "lookupShowField": "Title" },
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Изменение условий трудового договора — данные", "lookupShowField": "Title" },
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
@@ -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>
);
};

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