27 Commits

Author SHA1 Message Date
gorbenko.pavel 7dff5b21c1 Merge remote-tracking branch 'origin/main' into fired-request
Tests / frontend-test (pull_request) Successful in 1m15s
# Conflicts:
#	src/webparts/requestPortal/forms/Clearance/sections/RequestSection.tsx
#	src/webparts/requestPortal/forms/Clearance/submit.ts
#	src/webparts/requestPortal/forms/UnpaidLeave/sections/RequestSection.tsx
#	src/webparts/requestPortal/forms/Vacation/sections/RequestSection.tsx
2026-08-26 16:09:07 +05:00
gorbenko.pavel 6d154b7608 fix: убрать заголовок «Заявка», вернуть стиль секции для сумм списания
Tests / frontend-test (pull_request) Successful in 1m10s
Заменил CollapseSection на FormGrid (без заголовка/иконки) в Clearance,
Dismissal, Vacation и UnpaidLeave — заголовок «Заявка» не нёс смысла
и полностью убран, а не переименован. В Clearance «Суммы к списанию»
вынесены в отдельную CollapseSection — тот же визуальный стиль
заголовка, что раньше был у общей «Заявка», вместо самодельной подписи
через RequestSection.module.scss (файл удалён как более не нужный).
2026-08-26 15:06:06 +05:00
gorbenko.pavel 4eaf133954 fix: убрать дублирующий заголовок секции у односекционных форм
CollapseSection.title у единственной секции формы дословно повторял заголовок
страницы (FormCard title = requestType.title): «Обходной лист» под «Обходной
лист», «Заявление на увольнение» под «Заявление на увольнение» — и так же у
«Заявки на трудовой отпуск» и «Заявки на отпуск без содержания». Затронуто и
форму подачи, и режим просмотра — RequestSection общий на оба.

Заменил на «Заявка» — как уже сделано в Ticket1C (там секция единственная и
тоже называется просто «Заявка», без повтора заголовка страницы).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 14:46:45 +05:00
gorbenko.pavel 2062ce7aa9 fix: подпись поля «Обучение: сумма» → «Обучение»
Только текст подписи в форме — данные/доступность/колонка (WriteOffTrainingAmount) не менялись.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 14:37:34 +05:00
gorbenko.pavel 5758101243 fix: «Обучение: сумма» — та же доступность, но пишет в WriteOffTrainingAmount
Правка предыдущего коммита: там «Обучение» ошибочно стало одной из
WRITE_OFF_CATEGORIES (доступно любому активному согласующему, блокируется
навсегда) — доступность должна была остаться КАК БЫЛА (только КДП, без
блокировки), просто колонка хранения меняется на WriteOffTrainingAmount.

Откат: WRITE_OFF_CATEGORIES снова 5 категорий (без «Обучение»). TrainingAmount
остаётся отдельным полем формы с прежней доступностью (hrRecordsEditable) —
просто updateApproverFields/buildItem пишут его в колонку WriteOffTrainingAmount,
а load.ts читает оттуда же. Одноимённая колонка TrainingAmount в схеме списка
больше не используется (как и другие столбцы, из которых код этой сессии
перестал писать/читать).

Визуально TrainingAmount остаётся под общим заголовком «Суммы к списанию»
(из прошлого коммита) — только права и правило блокировки у него свои.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 14:31:24 +05:00
gorbenko.pavel 27e156abf8 fix: «Обучение» подведено под общий заголовок «Суммы к списанию»
TrainingAmount («Обучение: сумма», правит только КДП, не блокируется после
ввода) физически перенесено рядом с 5 суммами списания (правит любой активный
согласующий, блокируются навсегда) — под один заголовок-подпись «СУММЫ К
СПИСАНИЮ». Права и поведение остались разными (readOnly у TrainingAmount
по-прежнему считается отдельно, hrRecordsEditable, а не writeOffEditableKeys),
изменилось только визуальное соседство: раньше поле стояло сразу после «Дата
расторжения договора», далеко от одноимённой группы, из которой недавно убрали
дублирующее «Обучение».

Заголовок группы рисуется независимо от TrainingAmount (только когда
writeOffEditableKeys задан, т.е. в режиме просмотра) — само поле показывается
и на форме подачи, без заголовка, как и раньше.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 14:23:08 +05:00
gorbenko.pavel 89fceaab1d fix: убрать «Обучение» из сумм к списанию — дублировало TrainingAmount
Осталось 5 категорий (ОС, СИЗ, подотчётная сумма, НМА, ТМЦ). У обучения уже
было отдельное поле TrainingAmount («Обучение: сумма», правит только КДП) —
второе одноимённое поле с другим поведением (любой согласующий) только
путало, даже с подписью группы над сеткой. WriteOffTrainingAmount убран из
модели/load/submit/манифеста; ClearanceView и RequestSection код не трогали —
они уже обходят WRITE_OFF_CATEGORIES обобщённо.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 14:17:54 +05:00
gorbenko.pavel b010e3ec4a fix: подпись «Суммы к списанию» над сеткой — «Обучение» больше не дублируется визуально
Без заголовка группы «Обучение» (WriteOffTrainingAmount, любой согласующий)
читалось продолжением «Обучение: сумма» чуть выше (TrainingAmount, поле КДП) —
два разных поля выглядели одним и тем же. Добавлена overline-подпись «Суммы к
списанию» перед сеткой из 6 сумм — теперь понятно, что это отдельная группа.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 14:13:07 +05:00
gorbenko.pavel eacf4baeb1 fix: «за что списание» — переиспользовать стандартный комментарий решения
Правка предыдущего коммита: там для «за что конкретно» завёл отдельное поле
WriteOffComment (с собственной колонкой в списке) — избыточно, раз на экране
уже есть стандартный комментарий решения («Ваше решение» в DecisionPanel),
которым и так сопровождают Согласовать/Вернуть/Отклонить.

Теперь: DecisionPanel получил два новых пропа — commentRequired (сверх обычного
«обязателен при возврате/отклонении» — можно потребовать его на ЛЮБОЕ решение)
и commentPlaceholder (переопределить текст подсказки). ClearanceView передаёt
их, когда есть незаблокированная сумма к списанию с непустым значением
(writeOffTouchedCategories) — комментарий обязателен, а плейсхолдер называет,
за какие категории писать («Введите, за что сумма: ОС, ТМЦ»). Записывается он
туда же, где и всегда — в строку маршрута этого согласующего (Comment списка
согласования), без нового поля на данных заявки.

WriteOffComment (поле, столбец в манифесте, вся связанная логика) убран.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:51:48 +05:00
gorbenko.pavel 10963081c6 feat: один общий комментарий к списанию вместо шести, «Подразделение» вместо «Структурное»
Обходной лист: 6 отдельных Comment-колонок (по одной на категорию списания)
консолидированы в одну WriteOffComment — комментарий и так один по смыслу
(«за что списываем»), дублировать текст в 6 колонок не было смысла. IWriteOffCategory
больше не хранит commentField, WriteOffFieldKey сузился до 6 сумм.

Заявление на увольнение: заголовок колонки таблицы организаций — «Подразделение»
вместо «Структурное подразделение» (данные по-прежнему из Section справочника,
только подпись короче).

Со схемой ничего руками не делал — команда для провижининга ниже.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:43:37 +05:00
gorbenko.pavel cd1fd76c76 feat: убрано «Самостоятельное подразделение», суммы к списанию компактно с общим комментарием
Заявление на увольнение: убрано поле «Самостоятельное подразделение»
(StandaloneUnit) — из модели, формы и манифеста провижинера. Колонка на
тенанте остаётся (провижинер их не удаляет), но код её больше не пишет и не
читает.

Обходной лист: убраны 6 отдельных textarea-комментариев под каждой суммой —
сетка сумм теперь компактная (короткие подписи, обычная 2-колоночная сетка
секции, без full). Вместо пары «сумма+комментарий» на категорию — ОДИН общий
комментарий, который появляется и становится обязательным, как только хоть
одна редактируемая сумма заполнена (при сохранении бросает ДО записи, если
сумма есть, а комментарий пуст). Плейсхолдер называет категории, для которых
он сейчас пишется («Введите, за что сумма: ОС, ТМЦ»).

При записи общий текст дублируется в Comment-колонку КАЖДОЙ заполняемой сейчас
категории (SP-схема из 6 пар Amount/Comment не менялась) — так по каждой
категории в списке остаётся свой комментарий, а форма не заставляет писать
одно и то же несколько раз подряд.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:37:04 +05:00
gorbenko.pavel 04f1d0d547 Merge branch 'main' into fired-request 2026-08-26 12:24:45 +05:00
gorbenko.pavel 40f16c1d22 fix: сообщение о создании обходного листа — вниз, к блоку согласования
Tests / frontend-test (pull_request) Successful in 1m9s
Было в самой шапке, над заявлением — теперь рядом с панелью решения: это
следствие решения «Согласовать», логичнее видеть его там же, а не отдельно
от того действия, которое его вызвало.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 11:59:23 +05:00
gorbenko.pavel d28a316645 fix: «Лицо, принимающее ОС/НМА» не разблокировалось под подменой личности
Сравнивал непосредственного руководителя с context.pageContext.user.email —
РЕАЛЬНЫМ логином, а не с эффективным. getPendingRowForCurrentUser (через
ApprovalListService.getCurrentUserId) отладочную подмену (shared/
impersonation.ts) уже учитывает, поэтому «активный шаг есть» под подменой
срабатывал верно, а «это я» — нет: сравнение велось с разными личностями.
Теперь оба используют effectiveUserEmail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 11:53:49 +05:00
gorbenko.pavel d433265b5c fix: не показывать плейсхолдер «Лицо, принимающее ОС/НМА» в read-only
Поле почти всегда пустое, пока непосредственный руководитель его не заполнил
(см. assetsAcceptorEditable в DismissalView), и до этого момента плейсхолдер
«Начните вводить Ф.И.О. сотрудника…» в заблокированном поле выглядел так,
будто в него можно печатать. Плейсхолдер теперь только когда поле реально
редактируемо.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 11:48:20 +05:00
gorbenko.pavel e90cad7e88 fix: таблица организаций — «Структурное подразделение», должности все редактируемы, без лишней подписи
Три правки в OrganizationsTable по месту (без изменений схемы/формата хранения
— поле по-прежнему называется department, источник тот же Section):

1. Заголовок колонки «Департамент» → «Структурное подразделение».
2. Должность редактируема В КАЖДОЙ строке, не только у первой (основной)
   компании — справочник не всегда точен, поправить нужно любую. На маршрут и
   валидацию по-прежнему влияет только должность ПЕРВОЙ записи
   (primaryPosition) — остальные редактируются, но ни на что не влияют.
   onChangePosition получил индекс строки вместо неявного «всегда нулевая».
3. Убрана обёртка FormField с подписью «Организация · подразделение ·
   должность *» над таблицей — избыточна, у таблицы уже есть заголовки
   колонок. full-ширина (grid-column: 1/-1) взята напрямую в CSS, без FormField
   (у него label обязателен).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 10:43:40 +05:00
gorbenko.pavel 435aceb83f feat: таблица организаций хранится в списке — три JSON-столбца, видна в просмотре
Tests / frontend-test (pull_request) Successful in 1m11s
Position, Department, Organization (Заявление на увольнение) теперь хранят
JSON-массив строк каждый — компания/департамент/должность по каждой компании
сотрудника, один и тот же индекс во всех трёх столбцах = одна запись
(model.ts: IOrganizationRecord, zipOrganization/parseOrganizationColumn на
чтение, submit.ts: organizationColumn на запись). Раньше Position/Department
хранили только ПЕРВУЮ компанию, а Organization — плоский текст без
департамента; полную таблицу теперь можно восстановить и показать где угодно.

Модель данных формы существенно упростилась: вместо трёх независимых
data.Position/Department/Organization + отдельного live-состояния orgRecords —
один data.Organization: IOrganizationRecord[], primaryPosition() читает
должность основной компании для маршрута и валидации. Загрузка на
редактирование больше не ходит в Employee/ByEmail за живыми организациями —
таблица целиком восстанавливается из уже сохранённой заявки.

OrganizationsTable — теперь ЕДИНСТВЕННЫЙ рендерер таблицы и на форме подачи
(редактируемая, editable-проп передан), и в режиме просмотра (read-only, проп
не передан) — отдельных read-only полей для просмотра больше нет.

createClearance.ts обновлён под новый формат (иначе автосоздание обходного
листа читало бы Position/Department как сырой JSON-текст).

Легаси: значения, сохранённые ДО перехода на JSON (не валидный JSON), не роняют
чтение — трактуются как одна компания (parseOrganizationColumn).

Риск: Position/Department — ОБЩИЙ столбец сайта на 8 форм портала, тип Text
(лимит SharePoint 255 символов) — менять тип нельзя, задело бы все 8. Явная
ошибка при сохранении, если JSON не укладывается (TEXT_COLUMN_LIMIT в
submit.ts), вместо тихого обрезания данных.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 10:13:02 +05:00
gorbenko.pavel 0b7729a6b9 feat: департамент в таблице организаций, минимум месяц до увольнения
Заявление на увольнение:

1. Последний рабочий день — минимум через MIN_NOTICE_MONTHS (1) КАЛЕНДАРНЫЙ
   месяц от сегодня (новый isoDatePlusMonths в shared/dateUtils.ts, а не
   приближение через 30 дней — setMonth сам разносит месяцы разной длины).

2. Департамент (Section из Employee/ByEmail) — колонка в OrganizationsTable
   вместо отдельного профильного поля: источник сменился со SharePoint-профиля
   (SPS-Department) на тот же справочник api.tnsu.kz, что уже даёт компанию и
   должность. Заодно убраны отдельные «Должность»/«Структурное подразделение»
   над таблицей — дублировали то, что таблица и так показывает. Редактируется
   по-прежнему только должность ПЕРВОЙ (основной) компании — она и есть
   data.Position, читает её маршрут; должности остальных компаний и
   департамент — информационные. Пустой справочник (сотрудник не найден) —
   таблица не исчезает, а показывает одну строку с прочерками и редактируемой
   должностью, иначе заявление на такого человека нельзя было бы подать.
   Режим просмотра (DismissalView) не тронут — там по-прежнему простые
   read-only поля, без live-запроса к справочнику.

На тенанте: Title колонки Department синхронизирован под «Департамент» —
ТОЛЬКО на списке «Заявление на увольнение», списочным override, а не через
общий столбец сайта. Через -UpdateExistingLists один раз случайно затёрло
Department и на «Обходном листе», и на «Заявке в 1С» (internalName у них общий,
Title разный) — заметил и починил в этой же сессии; см. memory
provisioning-shared-site-columns для деталей на будущее.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 09:39:10 +05:00
gorbenko.pavel 179999c02a feat: ОС, СИЗ и подотчётная сумма — отдельные суммы к списанию
Было одной парой «ОС/СИЗ/подотчётная сумма» — стало тремя независимыми, всего
6 категорий (ОС, СИЗ, подотчётная сумма, обучение, НМА, ТМЦ). Списывают их
разные подразделения и за разное, поэтому и сумма с комментарием у каждой своя,
и блокируется каждая отдельно (правило «введено — больше не меняется» работает
на пару, а не на весь блок).

Существующая колонка WriteOffAssetsAmount/Comment переиспользована под «ОС»
(данных по ней ещё нет), добавлены WriteOffPpe* и WriteOffAccountable*.
Список типов полей теперь выводится из WriteOffFieldKey — IWriteOffFields в
submit.ts больше не дублирует их перечислением.

На тенанте: 4 новые колонки созданы провижинером; отображаемые имена ОС-пары
досинхронизированы отдельно — Provision-Lists.ps1 колонки только СОЗДАЁТ и
переименование в манифесте существующей колонке не применяет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 17:20:26 +05:00
gorbenko.pavel 2d2194047b feat: совместители в заявлении на увольнение, печать после обходного листа, лицо принимающее ОС/НМА, суммы списания в обходном листе
Четыре независимых доработки поверх связки «Заявление на увольнение» ↔
«Обходной лист»:

1. Совместители (несколько компаний у одного сотрудника): approval-route уже
   резолвит директора/должностного эквивалента КАЖДОЙ компании отдельным шагом
   маршрута (HeadType.CompanyDirectors, шаблон route 17: Chief → CompanyDirectors).
   Organization на форме подачи заявления теперь интерактивная таблица
   (OrganizationsTable) вместо read-only списка — строку компании можно убрать,
   если сотрудник реально не увольняется отовсюду сразу; убранная компания сразу
   снимается и из уже спланированной матрицы (matrix.remove) — искать нужную
   строку вручную не надо. Новый shared/dictionary/employeeLookup.service.ts
   зеркалит DirectorPositionByCompany бэкенда (KazpromService → Главный механик,
   СТ Global → Генеральный директор, иначе Директор) и Employee/Manager
   (managerType=chief) — используется и здесь, и в следующем пункте.

2. Печать заявления заблокирована, пока парный обходной лист не согласован
   (статус читается напрямую по ClearanceUrl, без отдельного поля-дублёра).

3. «Лицо, принимающее ОС/НМА» (AssetsAcceptor, People) — обязательное поле,
   которое заполняет НЕ заявитель, а непосредственный руководитель увольняемого
   (identity-check по Employee/Manager, а не по должности, как в approverRights
   «Обходного листа») прямо перед своим решением (DecisionPanel.onBeforeDecide);
   пустое поле блокирует решение.

4. В обходном листе — 4 независимые пары «сумма к списанию + обязательный
   комментарий» (ОС/СИЗ/подотчёт, обучение, НМА, ТМЦ), отдельно от уже
   существующего TrainingAmount. Заполнить может любой активный согласующий
   (не только профильный), но как только сумма введена — пара блокируется
   навсегда для всех (лок по снимку на момент загрузки, не реактивно от текущего
   ввода — иначе поле блокировалось бы прямо во время набора цифр).

Списки на /sites/requests обновлены: AssetsAcceptor (Заявление на увольнение),
8 колонок WriteOff* (Обходной лист). tsc --noEmit и heft test (build+lint+179
тестов) чистые.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:46:41 +05:00
gorbenko.pavel 467ceb0d48 fix: не сообщать об успехе раньше времени — ни о решении, ни о пустом списке
1) DecisionPanel ждёт onDecided, если тот вернул Promise: «Решение сохранено.
Статус заявки: «Согласовано»» появляется только после побочных действий решения —
для заявления на увольнение это созданный обходной лист. Пока идёт создание,
кнопки остаются заблокированными, а на месте результата — «Создаём обходной
лист…». Провал побочного действия статус решения не отменяет (оно уже применено на
сервере) — о проблеме сообщает сам обработчик своим предупреждением.

2) «Вы ещё не создавали заявок» больше не мигает на недогруженном списке.
«Мои заявки» и «Доработка» собираются из двух источников (Search + REST-довесок по
свежести), и onPartial показывал тот, что ответил первым, даже если он пустой — на
экране на мгновение появлялась пустота вместо «Загрузка…». Теперь пустой
промежуточный результат не показывается вовсе, а вывод «заявок нет» делается только
по ЗАВЕРШЁННОЙ загрузке именно этой таблицы (loadedKey), а не по любому пустому rows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:04:57 +05:00
gorbenko.pavel b3dee2047c feat: переход по ссылке между парными заявками — без перезагрузки страницы
Tests / frontend-test (pull_request) Successful in 1m15s
navigateToRequestLink (shared/routing): если абсолютная ссылка ведёт на ЭТУ ЖЕ
страницу портала и на просмотр заявки — открываем её через navigate(), то есть
сменой rp-параметров, а не перезагрузкой SPFx-страницы (она грузится секунды, а
показать нужно ту же страницу с другими параметрами). Раздел бокового меню берётся
текущий: заявка показывается в панели поверх раздела, и переход по ссылке не должен
переключать раздел под ней.

LinkField перехватывает только «обычный» клик: с Ctrl/⌘/Shift/Alt и не левой
кнопкой отдаёт браузеру, href настоящий — ссылку по-прежнему можно скопировать или
открыть в отдельной вкладке. Чужой сайт, другая страница и ссылка не на заявку
возвращают false и обрабатываются браузером как раньше.

Тесты на requestLinkUrl/navigateToRequestLink в routing.test.ts; heft test — 179
успешных, 0 падений. В docs/routing.md добавлены hr-dismissal и clearance-form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:44:10 +05:00
gorbenko.pavel 0f6d1b7ddf fix: ссылка на парную заявку открывается в той же вкладке
Было target="_blank" — на проде это читалось как «портал зачем-то плодит окна».
Ссылка ведёт на ту же страницу портала с другими rp-параметрами, так что обычная
навигация даёт нормальный переход с работающей кнопкой «назад».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:40:22 +05:00
gorbenko.pavel cda68f86b0 Merge remote-tracking branch 'origin/main' into fired-request
Tests / frontend-test (pull_request) Successful in 1m22s
2026-08-25 13:24:23 +05:00
gorbenko.pavel 0e582dceb8 feat: ссылки между заявлением на увольнение и обходным листом видны на формах
Поле-ссылка LinkField (shared/ui): read-only «карточка» как у OrganizationField,
но кликабельная, открывается в новой вкладке (портал — одна страница, переход в
текущей означал бы перезагрузку с потерей открытого). Пустой адрес — поле не
рендерится: связь есть не у всех заявок.

В заявлении показывается «Обходной лист» (ClearanceUrl), в обходном листе —
«Заявление на увольнение» (DismissalUrl); обе колонки читаются в load.ts (адрес из
объекта {Url, Description}) и не пишутся формами. Согласующему, который только что
одобрил заявление, ссылка дописывается в модель сразу после автосоздания листа —
иначе появилась бы только после перезагрузки.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:04:52 +05:00
gorbenko.pavel c174eb8688 feat: согласованное заявление на увольнение само создаёт обходной лист
Как только маршрут заявления пройден целиком (статус «Согласовано»), портал
заводит обходной лист по нему: форма не открывается — маршрут резолвится через
approval-route (обе фазы, по Employee и Position заявления), элемент пишется в
текущую партицию списка «Обходной лист … — данные» и сразу уходит на согласование
с оповещением активного шага.

Поля выводятся из заявления (сотрудник, должность, подразделение, организация,
последний рабочий день → дата расторжения договора); отметки парковки/СКУД/обучения
остаются профильным согласующим. Пара связывается перекрёстными гиперссылками
ClearanceUrl / DismissalUrl — для них добавлен requestLinkUrl: абсолютная ссылка на
заявку без посторонних параметров страницы, в отличие от routeUrl.

Повторное создание отсекается непустой ClearanceUrl: заявление можно согласовать
второй раз после возврата на доработку, и без этой проверки на каждый цикл
появлялся бы новый обходной лист. Создание идёт в браузере последнего согласующего —
если оно упало, согласование не отменяется, а портал показывает предупреждение
«заведите вручную».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 12:56:50 +05:00
gorbenko.pavel ca1d7512d6 feat: форма «Заявление на увольнение» (hr-dismissal)
Новая форма отдела кадров по бумажному шаблону «Заявление на увольнение ШТАТ»:
сотрудник, должность, структурное и самостоятельное подразделение, последний
рабочий день, контактный телефон, комментарий. По умолчанию заявка на себя, но
можно подать за другого — маршрут резолвится по выбранному сотруднику.

Подразделение подтягивается из профиля (SPS-Department, read-only), должность и
организации — из справочника Employee/ByEmail. Последний рабочий день не может
быть в прошлом; срок предупреждения об увольнении форма не проверяет — дату
согласуют с работодателем.

Перекрёстные ссылки с «Обходным листом»: колонки-гиперссылки ClearanceUrl /
DismissalUrl (только в списках, формы их пока не пишут). Для них в провижинере
появился тип колонки Url, а сам скрипт разбит на два прохода — сначала все
списки, потом колонки: иначе Lookup на список другой формы держится на алфавитном
порядке обхода манифестов.

Списки «Заявление на увольнение 1 — данные» / «… — согласование» созданы на
/sites/requests. Тип заявки в админке approval-route ещё не заведён — до этого
форма откроется, но отправить заявку не даст.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 12:45:17 +05:00
18 changed files with 271 additions and 244 deletions
+1 -1
View File
@@ -45,7 +45,7 @@
| [`recruiting-referral`](../src/webparts/requestPortal/forms/Referral/README.md) | Реферальная рекомендация | Рекрутинг | да | да | да | кандидат — внешний (текстовые поля, не People); маршрут по рекомендателю; исполнитель переназначает на реального; условное уточнение «откуда знаете» | ✅ | | [`recruiting-referral`](../src/webparts/requestPortal/forms/Referral/README.md) | Реферальная рекомендация | Рекрутинг | да | да | да | кандидат — внешний (текстовые поля, не People); маршрут по рекомендателю; исполнитель переназначает на реального; условное уточнение «откуда знаете» | ✅ |
| [`it-workplace-setup`](../src/webparts/requestPortal/forms/WorkplaceSetup/README.md) | Организация рабочего места | IT | да | да | да | префилл из «прием», поля ПО/доступы правит руководитель | ✅ | | [`it-workplace-setup`](../src/webparts/requestPortal/forms/WorkplaceSetup/README.md) | Организация рабочего места | IT | да | да | да | префилл из «прием», поля ПО/доступы правит руководитель | ✅ |
| [`it-1c-ticket`](../src/webparts/requestPortal/forms/Ticket1C/README.md) | Заявка в отдел 1С | IT | да | нет | да | контакты предзаполняются из Graph (/me); вместо фазы исполнения — карточка в Microsoft Planner (согласовано/сразу без согласования — рабочий бакет, отклонено — отдельный бакет) | ✅ | | [`it-1c-ticket`](../src/webparts/requestPortal/forms/Ticket1C/README.md) | Заявка в отдел 1С | IT | да | нет | да | контакты предзаполняются из Graph (/me); вместо фазы исполнения — карточка в Microsoft Planner (согласовано/сразу без согласования — рабочий бакет, отклонено — отдельный бакет) | ✅ |
| [`clearance-form`](../src/webparts/requestPortal/forms/Clearance/README.md) | Обходной лист | Отдел кадров | да | нет | да | подаётся HR за увольняемого (People-picker) либо создаётся сам по согласованному «Заявлению на увольнение»; условие «IsManager» резолвится по должности из самой заявки; да/нет-поля (парковка, СКУД); 6 независимых пар «сумма к списанию + комментарий» (ОС, СИЗ, подотчётная сумма, обучение, НМА, ТМЦ) — вводит любой активный согласующий, блокируется навсегда после первого ввода | ✅ | | [`clearance-form`](../src/webparts/requestPortal/forms/Clearance/README.md) | Обходной лист | Отдел кадров | да | нет | да | подаётся HR за увольняемого (People-picker) либо создаётся сам по согласованному «Заявлению на увольнение»; условие «IsManager» резолвится по должности из самой заявки; да/нет-поля (парковка, СКУД); 5 независимых сумм к списанию компактной сеткой (ОС, СИЗ, подотчётная сумма, НМА, ТМЦ — обучение не входит, у него своё поле) — вводит любой активный согласующий, блокируется навсегда после первого ввода; заполнил хоть одну — стандартный комментарий решения (в блоке «Ваше решение») становится обязательным на любое решение, не только возврат/отклонение | ✅ |
| [`hr-dismissal`](../src/webparts/requestPortal/forms/Dismissal/README.md) | Заявление на увольнение | Отдел кадров | да | нет | да | совместители — таблица компаний с удалением строки (снимает и согласующего из матрицы); должность/организация из справочника; **после согласования сам создаёт обходной лист**; печать заблокирована до согласования обходного листа; «Лицо, принимающее ОС/НМА» — заполняет только непосредственный руководитель | ✅ | | [`hr-dismissal`](../src/webparts/requestPortal/forms/Dismissal/README.md) | Заявление на увольнение | Отдел кадров | да | нет | да | совместители — таблица компаний с удалением строки (снимает и согласующего из матрицы); должность/организация из справочника; **после согласования сам создаёт обходной лист**; печать заблокирована до согласования обходного листа; «Лицо, принимающее ОС/НМА» — заполняет только непосредственный руководитель | ✅ |
| `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) | | `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
@@ -172,11 +172,19 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
[canEditWriteOffs, lockedWriteOffs] [canEditWriteOffs, lockedWriteOffs]
); );
// Категории, чью сумму заполняют ПРЯМО СЕЙЧАС (редактируема и не пуста) — от
// них зависит, обязателен ли комментарий решения (DecisionPanel, «Ваше
// решение») и что подставить в его плейсхолдер. «За что конкретно» здесь не
// хранится отдельным полем — вместо этого требуем стандартный комментарий
// решения, который и так сопровождает Согласовать/Вернуть/Отклонить (см.
// commentRequired/commentPlaceholder у DecisionPanel ниже).
const writeOffTouchedCategories = WRITE_OFF_CATEGORIES.filter(
(cat) => editableWriteOffKeys.has(cat.key) && data[cat.amountField].trim() !== ''
);
// Пишем ТОЛЬКО те отметки, на которые у согласующего есть права: иначе он // Пишем ТОЛЬКО те отметки, на которые у согласующего есть права: иначе он
// затёр бы чужие поля значениями, которые видел у себя на экране. Пары сумм к // затёр бы чужие поля значениями, которые видел у себя на экране. Суммы к
// списанию — только НЕПУСТЫЕ и только среди editableWriteOffKeys; частично // списанию — только НЕПУСТЫЕ и только среди editableWriteOffKeys.
// заполненная пара (сумма без комментария или наоборот) бросает ДО записи
// чего бы то ни было — половинчатая запись хуже отказа сохранить.
const saveApproverFields = useCallback(async (): Promise<void> => { const saveApproverFields = useCallback(async (): Promise<void> => {
const fields: Partial<IApproverEditableFields> = {}; const fields: Partial<IApproverEditableFields> = {};
if (hrRecordsEditable) { if (hrRecordsEditable) {
@@ -188,25 +196,15 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
} }
const writeOffFields: Partial<IWriteOffFields> = {}; const writeOffFields: Partial<IWriteOffFields> = {};
for (const cat of WRITE_OFF_CATEGORIES) { for (const cat of writeOffTouchedCategories) {
if (!editableWriteOffKeys.has(cat.key)) continue;
const amount = data[cat.amountField].trim();
const comment = data[cat.commentField].trim();
if (!amount && !comment) continue; // не трогали — не пишем
if (!amount || !comment) {
throw new Error(
`«${cat.label}»: заполните и сумму, и комментарий (за что конкретно) — или оставьте пару пустой.`
);
}
writeOffFields[cat.amountField] = data[cat.amountField]; writeOffFields[cat.amountField] = data[cat.amountField];
writeOffFields[cat.commentField] = data[cat.commentField];
} }
await updateApproverFields(itemId, fields, listId); await updateApproverFields(itemId, fields, listId);
if (Object.keys(writeOffFields).length > 0) { if (Object.keys(writeOffFields).length > 0) {
await updateWriteOffFields(itemId, writeOffFields, listId); await updateWriteOffFields(itemId, writeOffFields, listId);
} }
}, [itemId, listId, hrRecordsEditable, parkingEditable, editableWriteOffKeys, data]); }, [itemId, listId, hrRecordsEditable, parkingEditable, writeOffTouchedCategories, data]);
const handleSaveFields = async (): Promise<void> => { const handleSaveFields = async (): Promise<void> => {
setSaveState('saving'); setSaveState('saving');
@@ -321,6 +319,17 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
// Отметки сохраняются вместе с решением — иначе согласующий, забывший // Отметки сохраняются вместе с решением — иначе согласующий, забывший
// нажать «Сохранить», согласовал бы заявку со старыми значениями. // нажать «Сохранить», согласовал бы заявку со старыми значениями.
onBeforeDecide={canEditAnything ? saveApproverFields : undefined} onBeforeDecide={canEditAnything ? saveApproverFields : undefined}
// Заполнил сумму к списанию — комментарий решения обязателен (не
// только при возврате/отклонении, как обычно): это и есть «за что
// конкретно», отдельного поля под него нет (см. writeOffTouchedCategories).
commentRequired={writeOffTouchedCategories.length > 0}
commentPlaceholder={
writeOffTouchedCategories.length > 0
? `Введите, за что сумма${
writeOffTouchedCategories.length > 1 ? 'ы' : ''
}: ${writeOffTouchedCategories.map((c) => c.label).join(', ')}`
: undefined
}
onDecided={(newStatus) => { onDecided={(newStatus) => {
setStatus(newStatus); setStatus(newStatus);
setDecisionSeq((n) => n + 1); setDecisionSeq((n) => n + 1);
@@ -55,30 +55,57 @@ RequestTypeId» в `docs/forms.md`). Тип уже зарегистрирова
заведённых руками, ссылки нет и поле не рендерится. заведённых руками, ссылки нет и поле не рендерится.
## Суммы к списанию (согласование) ## Суммы к списанию (согласование)
6 НЕЗАВИСИМЫХ пар «сумма + обязательный комментарий (за что конкретно)», колонки Отдельная `CollapseSection title="Суммы к списанию"` (в стиле бывшего общего
`WriteOff{Assets,Ppe,Accountable,Training,Nma,Tmc}{Amount,Comment}` (см. `model.ts заголовка формы «Заявка», удалённого — см. `docs/forms.md`) объединяет
WRITE_OFF_CATEGORIES`): «ОС», «СИЗ», «Подотчётная сумма», «Обучение», «НМА», «ТМЦ». визуально ДВЕ РАЗНЫЕ по правам вещи:
1. **`TrainingAmount`** («Обучение») — доступность КАК И РАНЬШЕ: правит
только КДП (`hrRecordsEditable`, `approverRights.ts`), сумма НЕ блокируется
после ввода, никак не участвует в WRITE_OFF_CATEGORIES/`writeOffTouchedCategories`
(не требует комментария решения). Изменилась ТОЛЬКО колонка хранения — пишется
и читается через `WriteOffTrainingAmount` (`ClearanceView.saveApproverFields`,
`submit.ts.buildItem`, `load.ts`), а не через одноимённую `TrainingAmount` —
та осталась в схеме списка неиспользуемой. Секция видна независимо от
`writeOffEditableKeys` (само поле — без этого условия), поэтому на форме
подачи (`ClearanceForm`, `writeOffEditableKeys` не передан) поле видно и без
пяти сумм ниже.
2. **5 НЕЗАВИСИМЫХ сумм**, компактной сеткой (короткие подписи, обычная
2-колоночная сетка секции), колонки `WriteOff{Assets,Ppe,Accountable,Nma,
Tmc}Amount` (см. `model.ts WRITE_OFF_CATEGORIES`): «ОС», «СИЗ», «Подотчётная
сумма», «НМА», «ТМЦ» — правит любой активный согласующий, см. ниже. Раньше
среди них было ещё и «Обучение» — убрали как дубль `TrainingAmount` с ДРУГИМ
поведением под тем же названием; общий заголовок группы остался.
- **Не привязаны к должности**, в отличие от отметок выше: заполнить может ЛЮБОЙ - **Не привязаны к должности**, в отличие от отметок выше: заполнить может ЛЮБОЙ
согласующий, у которого сейчас активный шаг (`ClearanceView.canEditWriteOffs`, согласующий, у которого сейчас активный шаг (`ClearanceView.canEditWriteOffs`,
фаза «Согласование» — исполнителям недоступно, как и остальным отметкам). фаза «Согласование» — исполнителям недоступно, как и остальным отметкам).
- **Каждая пара блокируется НАВСЕГДА после первого ввода** — как только сумма - **Каждая сумма блокируется НАВСЕГДА после первого ввода** — как только она
непустая, поле становится read-only для всех, включая того, кто её ввёл. непустая, поле становится read-only для всех, включая того, кто её ввёл.
Признак блокировки — сама непустая сумма на МОМЕНТ ЗАГРУЗКИ страницы Признак блокировки — сама непустая сумма на МОМЕНТ ЗАГРУЗКИ страницы
(`ClearanceView.lockedWriteOffs`, снимок при загрузке, а не реактивно от (`ClearanceView.lockedWriteOffs`, снимок при загрузке, а не реактивно от
текущего `data` — иначе поле блокировалось бы прямо во время набора цифр). текущего `data` — иначе поле блокировалось бы прямо во время набора цифр).
- **Комментарий обязателен вместе с суммой**: заполнить можно либо обе половины - **«За что конкретно» — своего поля НЕТ.** Заполнил хоть одну сумму
пары, либо ни одной — `saveApproverFields` бросает ДО записи чего бы то ни (`ClearanceView.writeOffTouchedCategories`) — становится обязательным
было, если половина пары осталась пустой. СТАНДАРТНЫЙ комментарий решения: то самое поле «Комментарий» в блоке «Ваше
- Сохраняются той же кнопкой «Сохранить» (`reviewBar`), что и остальные отметки, решение» (`DecisionPanel`), которым и так сопровождают Согласовать/Вернуть/
и тем же `onBeforeDecide` — при решении несохранённые суммы уйдут вместе с ним. Отклонить — обычно обязательное только при возврате/отклонении, но не здесь
- Это ОТДЕЛЬНЫЙ механизм от `TrainingAmount` («Обучение: сумма» выше, привязана к (`commentRequired` у `DecisionPanel`, см. её же новый проп). Плейсхолдер в этот
момент называет категории, которые нужно объяснить («Введите, за что сумма:
ОС, ТМЦ», см. `commentPlaceholder`). Комментарий уходит в СТРОКУ маршрута этого
согласующего (колонка `Comment` списка согласования) — как обычно, отдельного
поля под него на данных заявки не заводили.
- Суммы сохраняются кнопкой «Сохранить» (`reviewBar`) и тем же `onBeforeDecide`
— при решении несохранённые суммы уйдут вместе с ним, ДО проверки
обязательности комментария (см. `DecisionPanel.decide`).
- Это ОТДЕЛЬНЫЙ механизм от `TrainingAmount` («Обучение» выше, привязана к
КДП) — сознательно не объединялись, чтобы не менять уже установившееся КДП) — сознательно не объединялись, чтобы не менять уже установившееся
поведение существующего поля. поведение существующего поля.
## Секции формы ## Секции формы
RequestSection (сотрудник · должность · подразделение · дата расторжения · сумма RequestSection (сотрудник · должность · подразделение · дата расторжения ·
обучения · парковочная карта · карта СКУД · суммы к списанию · ссылка на заявление) парковочная карта · карта СКУД — без заголовка, `FormGrid`) + отдельная
CollapseSection «Суммы к списанию» (обучение + 5 сумм списания) + ссылка на
заявление
## Точки входа ## Точки входа
- rptype: `clearance-form` - rptype: `clearance-form`
@@ -35,23 +35,16 @@ export async function loadClearanceRequest(itemId: number, listId?: string): Pro
'TerminationDate', 'TerminationDate',
'ParkingCard', 'ParkingCard',
'AccessCard', 'AccessCard',
'TrainingAmount',
'Organization', 'Organization',
'Project', 'Project',
'ProjectId', 'ProjectId',
'DismissalUrl', 'DismissalUrl',
'WriteOffAssetsAmount', 'WriteOffAssetsAmount',
'WriteOffAssetsComment',
'WriteOffPpeAmount', 'WriteOffPpeAmount',
'WriteOffPpeComment',
'WriteOffAccountableAmount', 'WriteOffAccountableAmount',
'WriteOffAccountableComment',
'WriteOffTrainingAmount', 'WriteOffTrainingAmount',
'WriteOffTrainingComment',
'WriteOffNmaAmount', 'WriteOffNmaAmount',
'WriteOffNmaComment',
'WriteOffTmcAmount', 'WriteOffTmcAmount',
'WriteOffTmcComment',
'RequestStatus', 'RequestStatus',
'RequestUniqueCode', 'RequestUniqueCode',
'Employee/Title', 'Employee/Title',
@@ -61,7 +54,6 @@ export async function loadClearanceRequest(itemId: number, listId?: string): Pro
const employee = item.Employee as { Title?: string; EMail?: string } | undefined; const employee = item.Employee as { Title?: string; EMail?: string } | undefined;
const termination = (item.TerminationDate as string) || ''; const termination = (item.TerminationDate as string) || '';
const amount = item.TrainingAmount;
const data: IFormData = { const data: IFormData = {
...EMPTY_DATA, ...EMPTY_DATA,
@@ -71,24 +63,19 @@ export async function loadClearanceRequest(itemId: number, listId?: string): Pro
TerminationDate: termination.slice(0, 10), // ISO → YYYY-MM-DD для input type=date TerminationDate: termination.slice(0, 10), // ISO → YYYY-MM-DD для input type=date
ParkingCard: !!item.ParkingCard, ParkingCard: !!item.ParkingCard,
AccessCard: !!item.AccessCard, AccessCard: !!item.AccessCard,
TrainingAmount: amount === null || amount === undefined ? '' : String(amount), // Хранится в WriteOffTrainingAmount, не в одноимённом TrainingAmount — см.
// IFormData.TrainingAmount.
TrainingAmount: numberField(item.WriteOffTrainingAmount),
Organization: (item.Organization as string) || '', Organization: (item.Organization as string) || '',
Project: (item.Project as string) || '', Project: (item.Project as string) || '',
ProjectId: (item.ProjectId as string) || '', ProjectId: (item.ProjectId as string) || '',
// Гиперссылка приходит объектом {Url, Description} — модели нужен только адрес. // Гиперссылка приходит объектом {Url, Description} — модели нужен только адрес.
DismissalUrl: (item.DismissalUrl as { Url?: string } | undefined)?.Url || '', DismissalUrl: (item.DismissalUrl as { Url?: string } | undefined)?.Url || '',
WriteOffAssetsAmount: numberField(item.WriteOffAssetsAmount), WriteOffAssetsAmount: numberField(item.WriteOffAssetsAmount),
WriteOffAssetsComment: (item.WriteOffAssetsComment as string) || '',
WriteOffPpeAmount: numberField(item.WriteOffPpeAmount), WriteOffPpeAmount: numberField(item.WriteOffPpeAmount),
WriteOffPpeComment: (item.WriteOffPpeComment as string) || '',
WriteOffAccountableAmount: numberField(item.WriteOffAccountableAmount), WriteOffAccountableAmount: numberField(item.WriteOffAccountableAmount),
WriteOffAccountableComment: (item.WriteOffAccountableComment as string) || '',
WriteOffTrainingAmount: numberField(item.WriteOffTrainingAmount),
WriteOffTrainingComment: (item.WriteOffTrainingComment as string) || '',
WriteOffNmaAmount: numberField(item.WriteOffNmaAmount), WriteOffNmaAmount: numberField(item.WriteOffNmaAmount),
WriteOffNmaComment: (item.WriteOffNmaComment as string) || '', WriteOffTmcAmount: numberField(item.WriteOffTmcAmount)
WriteOffTmcAmount: numberField(item.WriteOffTmcAmount),
WriteOffTmcComment: (item.WriteOffTmcComment as string) || ''
}; };
return { return {
@@ -23,7 +23,17 @@ export interface IFormData {
TerminationDate: string; TerminationDate: string;
ParkingCard: boolean; ParkingCard: boolean;
AccessCard: boolean; AccessCard: boolean;
/** Сумма обучения — «сырые» цифры без пробелов (см. MoneyInput). Пусто = не указана. */ /**
* Сумма обучения — «сырые» цифры без пробелов (см. MoneyInput). Пусто = не
* указана. Доступность — КАК И РАНЬШЕ: правит только начальник КДП
* (`hrRecordsEditable`, `approverRights.ts`), не блокируется после ввода (в
* отличие от WRITE_OFF_CATEGORIES ниже). Единственное, что изменилось, —
* КОЛОНКА ХРАНЕНИЯ: пишется и читается через `WriteOffTrainingAmount` (см.
* ClearanceView.saveApproverFields/load.ts), а не через одноимённый
* `TrainingAmount` в списке — тот остался в схеме неиспользуемым (как и
* другие столбцы, из которых код этой сессии перестал писать/читать, см.
* README «Убрано поле…»).
*/
TrainingAmount: string; TrainingAmount: string;
/** /**
* Организации сотрудника (`\n`-joined «Компания — должность» на строку) — * Организации сотрудника (`\n`-joined «Компания — должность» на строку) —
@@ -43,74 +53,62 @@ export interface IFormData {
DismissalUrl: string; DismissalUrl: string;
/** /**
* Суммы к списанию по обходному листу — 6 независимых пар (сумма + * Суммы к списанию по обходному листу — 5 независимых сумм (компактная сетка
* обязательный комментарий, «за что конкретно»), см. WRITE_OFF_CATEGORIES. * полей, см. WRITE_OFF_CATEGORIES). Обучение сюда НЕ входит — у него другая
* «Сырые» цифры без пробелов, как TrainingAmount (см. MoneyInput). Пусто = * доступность (см. TrainingAmount выше): эти пять доступны ЛЮБОМУ активному
* не заполнено. КАЖДАЯ пара — независимая: заполнить может любой активный * согласующему и блокируются НАВСЕГДА после первого ввода (включая того, кто
* согласующий (см. ClearanceView.canEditWriteOffs), но как только сумма * ввёл) — признак блокировки: сама непустая сумма, отдельного флага в
* непустая — пара блокируется НАВСЕГДА, для всех (включая того, кто её ввёл). * списке нет. «Сырые» цифры без пробелов (см. MoneyInput). Пусто = не
* Признак блокировки — сама непустая сумма, отдельного флага в списке нет. * заполнено.
*
* «За что конкретно» — своего поля НЕТ: заполнил хоть одну сумму — становится
* обязательным СТАНДАРТНЫЙ комментарий решения (DecisionPanel, «Ваше решение»),
* тот же, каким и так сопровождают Согласовать/Вернуть/Отклонить. Он уже
* хранится построчно в списке согласования (колонка Comment на шаге этого
* согласующего) — заводить для него отдельное поле на данных заявки незачем
* (см. ClearanceView — commentRequired/commentPlaceholder у DecisionPanel).
*/ */
WriteOffAssetsAmount: string; WriteOffAssetsAmount: string;
WriteOffAssetsComment: string;
WriteOffPpeAmount: string; WriteOffPpeAmount: string;
WriteOffPpeComment: string;
WriteOffAccountableAmount: string; WriteOffAccountableAmount: string;
WriteOffAccountableComment: string;
WriteOffTrainingAmount: string;
WriteOffTrainingComment: string;
WriteOffNmaAmount: string; WriteOffNmaAmount: string;
WriteOffNmaComment: string;
WriteOffTmcAmount: string; WriteOffTmcAmount: string;
WriteOffTmcComment: string; /**
* Колонка ХРАНЕНИЯ для TrainingAmount (см. выше) — НЕ участвует в
* WRITE_OFF_CATEGORIES/WriteOffFieldKey и в общем механизме «любой
* согласующий, блокировка навсегда»: доступность и незаблокируемость у
* «Обучение: сумма» остались как были, изменилась только SharePoint-колонка.
*/
WriteOffTrainingAmount: string;
} }
/** Имена всех 12 строковых полей сумм/комментариев к списанию — см. IFormData.WriteOff*. */ /** Имена 5 сумм к списанию (не считая WriteOffTrainingAmount — у него своя доступность) — см. IFormData.WriteOff*Amount. */
export type WriteOffFieldKey = export type WriteOffFieldKey =
| 'WriteOffAssetsAmount' | 'WriteOffAssetsAmount'
| 'WriteOffAssetsComment'
| 'WriteOffPpeAmount' | 'WriteOffPpeAmount'
| 'WriteOffPpeComment'
| 'WriteOffAccountableAmount' | 'WriteOffAccountableAmount'
| 'WriteOffAccountableComment'
| 'WriteOffTrainingAmount'
| 'WriteOffTrainingComment'
| 'WriteOffNmaAmount' | 'WriteOffNmaAmount'
| 'WriteOffNmaComment' | 'WriteOffTmcAmount';
| 'WriteOffTmcAmount'
| 'WriteOffTmcComment';
/** /**
* Одна из 6 независимых категорий списания — см. IFormData.WriteOff*. * Одна из 5 независимых категорий списания — см. IFormData.WriteOff*Amount.
* amountField/commentField сужены до WriteOffFieldKey (а не keyof IFormData * amountField сужен до WriteOffFieldKey (а не keyof IFormData целиком): у
* целиком): у IFormData есть и булевы поля (ParkingCard/AccessCard), и * IFormData есть и булевы поля (ParkingCard/AccessCard), и `data[cat.amountField]`
* `data[cat.amountField]` должен типизироваться строго строкой — иначе * должен типизироваться строго строкой — иначе `.trim()` в валидации и
* `.trim()` в валидации и передача в MoneyInput/TextArea не собираются. * передача в MoneyInput не собираются.
*/ */
export interface IWriteOffCategory { export interface IWriteOffCategory {
key: string; key: string;
label: string; label: string;
amountField: WriteOffFieldKey; amountField: WriteOffFieldKey;
commentField: WriteOffFieldKey;
} }
export const WRITE_OFF_CATEGORIES: IWriteOffCategory[] = [ export const WRITE_OFF_CATEGORIES: IWriteOffCategory[] = [
{ key: 'assets', label: 'ОС', amountField: 'WriteOffAssetsAmount', commentField: 'WriteOffAssetsComment' }, { key: 'assets', label: 'ОС', amountField: 'WriteOffAssetsAmount' },
{ key: 'ppe', label: 'СИЗ', amountField: 'WriteOffPpeAmount', commentField: 'WriteOffPpeComment' }, { key: 'ppe', label: 'СИЗ', amountField: 'WriteOffPpeAmount' },
{ { key: 'accountable', label: 'Подотчётная сумма', amountField: 'WriteOffAccountableAmount' },
key: 'accountable', { key: 'nma', label: 'НМА', amountField: 'WriteOffNmaAmount' },
label: 'Подотчётная сумма', { key: 'tmc', label: 'ТМЦ', amountField: 'WriteOffTmcAmount' }
amountField: 'WriteOffAccountableAmount',
commentField: 'WriteOffAccountableComment'
},
{
key: 'training',
label: 'Обучение',
amountField: 'WriteOffTrainingAmount',
commentField: 'WriteOffTrainingComment'
},
{ key: 'nma', label: 'НМА', amountField: 'WriteOffNmaAmount', commentField: 'WriteOffNmaComment' },
{ key: 'tmc', label: 'ТМЦ', amountField: 'WriteOffTmcAmount', commentField: 'WriteOffTmcComment' }
]; ];
/** Обновление одного поля формы с сохранением типа значения. */ /** Обновление одного поля формы с сохранением типа значения. */
@@ -143,17 +141,11 @@ export const EMPTY_DATA: IFormData = {
ProjectId: '', ProjectId: '',
DismissalUrl: '', DismissalUrl: '',
WriteOffAssetsAmount: '', WriteOffAssetsAmount: '',
WriteOffAssetsComment: '',
WriteOffPpeAmount: '', WriteOffPpeAmount: '',
WriteOffPpeComment: '',
WriteOffAccountableAmount: '', WriteOffAccountableAmount: '',
WriteOffAccountableComment: '',
WriteOffTrainingAmount: '', WriteOffTrainingAmount: '',
WriteOffTrainingComment: '',
WriteOffNmaAmount: '', WriteOffNmaAmount: '',
WriteOffNmaComment: '', WriteOffTmcAmount: ''
WriteOffTmcAmount: '',
WriteOffTmcComment: ''
}; };
/** Плейсхолдер для поля выбора сотрудника. */ /** Плейсхолдер для поля выбора сотрудника. */
@@ -19,16 +19,10 @@
{ "internalName": "DismissalUrl", "title": "Ссылка на заявление на увольнение", "type": "Url" }, { "internalName": "DismissalUrl", "title": "Ссылка на заявление на увольнение", "type": "Url" },
{ "internalName": "WriteOffAssetsAmount", "title": "Сумма к списанию: ОС", "type": "Number", "decimals": 2 }, { "internalName": "WriteOffAssetsAmount", "title": "Сумма к списанию: ОС", "type": "Number", "decimals": 2 },
{ "internalName": "WriteOffAssetsComment", "title": "Комментарий: ОС", "type": "Note" },
{ "internalName": "WriteOffPpeAmount", "title": "Сумма к списанию: СИЗ", "type": "Number", "decimals": 2 }, { "internalName": "WriteOffPpeAmount", "title": "Сумма к списанию: СИЗ", "type": "Number", "decimals": 2 },
{ "internalName": "WriteOffPpeComment", "title": "Комментарий: СИЗ", "type": "Note" },
{ "internalName": "WriteOffAccountableAmount", "title": "Сумма к списанию: подотчётная сумма", "type": "Number", "decimals": 2 }, { "internalName": "WriteOffAccountableAmount", "title": "Сумма к списанию: подотчётная сумма", "type": "Number", "decimals": 2 },
{ "internalName": "WriteOffAccountableComment", "title": "Комментарий: подотчётная сумма", "type": "Note" },
{ "internalName": "WriteOffTrainingAmount", "title": "Сумма к списанию: обучение", "type": "Number", "decimals": 2 }, { "internalName": "WriteOffTrainingAmount", "title": "Сумма к списанию: обучение", "type": "Number", "decimals": 2 },
{ "internalName": "WriteOffTrainingComment", "title": "Комментарий: обучение", "type": "Note" },
{ "internalName": "WriteOffNmaAmount", "title": "Сумма к списанию: НМА", "type": "Number", "decimals": 2 }, { "internalName": "WriteOffNmaAmount", "title": "Сумма к списанию: НМА", "type": "Number", "decimals": 2 },
{ "internalName": "WriteOffNmaComment", "title": "Комментарий: НМА", "type": "Note" }, { "internalName": "WriteOffTmcAmount", "title": "Сумма к списанию: ТМЦ", "type": "Number", "decimals": 2 }
{ "internalName": "WriteOffTmcAmount", "title": "Сумма к списанию: ТМЦ", "type": "Number", "decimals": 2 },
{ "internalName": "WriteOffTmcComment", "title": "Комментарий: ТМЦ", "type": "Note" }
] ]
} }
@@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { CollapseSection, FormField, TextArea, TextInput, MoneyInput, Toggle, PeopleField, OrganizationField, LinkField } from '../../../shared/ui'; import { FormGrid, CollapseSection, FormField, TextInput, MoneyInput, Toggle, PeopleField, OrganizationField, LinkField } from '../../../shared/ui';
import { ProjectPicker } from '../../../shared/dictionary'; import { ProjectPicker } from '../../../shared/dictionary';
import { ISectionProps, EMPLOYEE_PLACEHOLDER, WRITE_OFF_CATEGORIES } from '../model'; import { ISectionProps, EMPLOYEE_PLACEHOLDER, WRITE_OFF_CATEGORIES } from '../model';
@@ -33,39 +33,41 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
parkingEditable, parkingEditable,
printOrgIndex, printOrgIndex,
writeOffEditableKeys writeOffEditableKeys
}) => ( }) => {
<CollapseSection title="Обходной лист" icon="people"> return (
<FormField label="Сотрудник" htmlFor="Employee" required full> <>
<PeopleField <FormGrid>
context={context} <FormField label="Сотрудник" htmlFor="Employee" required full>
value={data.Employee} <PeopleField
onChange={onEmployeeChange ?? ((v) => set('Employee', v))} context={context}
placeholder={EMPLOYEE_PLACEHOLDER} value={data.Employee}
/> onChange={onEmployeeChange ?? ((v) => set('Employee', v))}
</FormField> placeholder={EMPLOYEE_PLACEHOLDER}
{/* Подтягивается из профиля при выборе сотрудника, но поле остаётся />
редактируемым: если профиль не синхронизирован (SPS-JobTitle пуст), </FormField>
HR должен суметь ввести должность вручную — иначе маршрут {/* Подтягивается из профиля при выборе сотрудника, но поле остаётся
согласования (условие «IsManager» резолвится именно по ней) не редактируемым: если профиль не синхронизирован (SPS-JobTitle пуст),
соберётся, и список исполнителей ниже останется пустым. */} HR должен суметь ввести должность вручную — иначе маршрут
<FormField label="Должность" htmlFor="Position" required> согласования (условие «IsManager» резолвится именно по ней) не
<TextInput соберётся, и список исполнителей ниже останется пустым. */}
id="Position" <FormField label="Должность" htmlFor="Position" required>
value={data.Position} <TextInput
onChange={(v) => set('Position', v)} id="Position"
placeholder="Подставится автоматически по выбранному сотруднику" value={data.Position}
/> onChange={(v) => set('Position', v)}
</FormField> placeholder="Подставится автоматически по выбранному сотруднику"
<FormField label="Подразделение" htmlFor="Department"> />
<TextInput </FormField>
id="Department" <FormField label="Подразделение" htmlFor="Department">
value={data.Department} <TextInput
onChange={NOOP} id="Department"
readOnly value={data.Department}
placeholder="Подставится автоматически по выбранному сотруднику" onChange={NOOP}
/> readOnly
</FormField> placeholder="Подставится автоматически по выбранному сотруднику"
<OrganizationField value={data.Organization} printIndex={printOrgIndex} /> />
</FormField>
<OrganizationField value={data.Organization} printIndex={printOrgIndex} />
<FormField label="Проект" htmlFor="Project"> <FormField label="Проект" htmlFor="Project">
<ProjectPicker <ProjectPicker
id="Project" id="Project"
@@ -78,52 +80,65 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
placeholder="Начните вводить название или код проекта…" placeholder="Начните вводить название или код проекта…"
/> />
</FormField> </FormField>
<FormField label="Дата расторжения договора" htmlFor="TerminationDate" required> <FormField label="Дата расторжения договора" htmlFor="TerminationDate" required>
<TextInput <TextInput
id="TerminationDate" id="TerminationDate"
type="date" type="date"
value={data.TerminationDate} value={data.TerminationDate}
onChange={(v) => set('TerminationDate', v)} onChange={(v) => set('TerminationDate', v)}
/> />
</FormField> </FormField>
{/* Явный readOnly={false} перебивает read-only контекст просмотра (профильный <FormField label="Парковочная карта">
согласующий правит своё поле); undefined — обычное поведение из FormMode. */} <Toggle
<FormField label="Обучение: сумма" htmlFor="TrainingAmount"> checked={data.ParkingCard}
<MoneyInput onChange={(v) => set('ParkingCard', v)}
id="TrainingAmount" onLabel="Да"
value={data.TrainingAmount} readOnly={parkingEditable ? false : undefined}
onChange={(v) => set('TrainingAmount', v)} />
readOnly={hrRecordsEditable ? false : undefined} </FormField>
/> <FormField label="Карта СКУД">
</FormField> <Toggle
<FormField label="Парковочная карта"> checked={data.AccessCard}
<Toggle onChange={(v) => set('AccessCard', v)}
checked={data.ParkingCard} onLabel="Да"
onChange={(v) => set('ParkingCard', v)} readOnly={hrRecordsEditable ? false : undefined}
onLabel="Да" />
readOnly={parkingEditable ? false : undefined} </FormField>
/> </FormGrid>
</FormField> {/* Суммы к списанию — своя CollapseSection (тот же стиль заголовка, что
<FormField label="Карта СКУД"> раньше был у общего «Заявка»), объединяет визуально ДВЕ РАЗНЫЕ по
<Toggle правам вещи:
checked={data.AccessCard} - TrainingAmount («Обучение») — как раньше: правит только КДП
onChange={(v) => set('AccessCard', v)} (hrRecordsEditable), без блокировки после ввода; хранится в
onLabel="Да" WriteOffTrainingAmount (не одноимённой колонке, см.
readOnly={hrRecordsEditable ? false : undefined} IFormData.TrainingAmount), но это деталь хранения, на форму не
/> влияет. Показывается всегда, даже без writeOffEditableKeys (форма
</FormField> подачи).
{/* Суммы к списанию — 6 независимых пар (сумма + обязательный комментарий, - WRITE_OFF_CATEGORIES (5 сумм) — заполнить может ЛЮБОЙ активный
за что конкретно). Каждую может заполнить ЛЮБОЙ активный согласующий согласующий (не только профильный), но как только сумма указана,
(не только профильный, в отличие от отметок выше) — но как только сумма блокируется НАВСЕГДА для всех, поэтому у незаблокированных
указана, пара блокируется навсегда для всех, поэтому у незаблокированных (writeOffEditableKeys.has(cat.key)) readOnly={false}, у остальных
(writeOffEditableKeys.has(cat.key)) есть readOnly={false}, у остальных — — обычное поведение FormMode. «За что конкретно» у них — своего
обычное поведение FormMode (просмотр — заблокировано и так). */} поля нет: заполнил хоть одну сумму — обязательным становится
{writeOffEditableKeys && СТАНДАРТНЫЙ комментарий решения в DecisionPanel («Ваше решение»),
WRITE_OFF_CATEGORIES.map((cat) => { см. ClearanceView (commentRequired/commentPlaceholder). У
const editable = writeOffEditableKeys.has(cat.key); TrainingAmount этого правила нет — комментарий не требует. Сама
return ( секция видна всегда (Обучение доступно и на форме подачи), а 5
<React.Fragment key={cat.key}> категорий — только на согласовании (writeOffEditableKeys задан). */}
<FormField label={`${cat.label}: сумма`} htmlFor={cat.amountField}> <CollapseSection title="Суммы к списанию" icon="calculator">
<FormField label="Обучение" htmlFor="TrainingAmount">
<MoneyInput
id="TrainingAmount"
value={data.TrainingAmount}
onChange={(v) => set('TrainingAmount', v)}
readOnly={hrRecordsEditable ? false : undefined}
/>
</FormField>
{writeOffEditableKeys &&
WRITE_OFF_CATEGORIES.map((cat) => {
const editable = writeOffEditableKeys.has(cat.key);
return (
<FormField key={cat.key} label={cat.label} htmlFor={cat.amountField}>
<MoneyInput <MoneyInput
id={cat.amountField} id={cat.amountField}
value={data[cat.amountField]} value={data[cat.amountField]}
@@ -131,22 +146,9 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
readOnly={editable ? false : undefined} readOnly={editable ? false : undefined}
/> />
</FormField> </FormField>
<FormField );
label={`${cat.label}: за что конкретно`} })}
htmlFor={cat.commentField} </CollapseSection>
required={editable}
full
>
<TextArea
id={cat.commentField}
value={data[cat.commentField]}
onChange={(v) => set(cat.commentField, v)}
readOnly={editable ? false : undefined}
/>
</FormField>
</React.Fragment>
);
})}
{/* Есть только у листов, созданных автоматически по согласованному заявлению {/* Есть только у листов, созданных автоматически по согласованному заявлению
на увольнение (см. forms/Dismissal/createClearance.ts). */} на увольнение (см. forms/Dismissal/createClearance.ts). */}
<LinkField <LinkField
@@ -154,5 +156,6 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
url={data.DismissalUrl} url={data.DismissalUrl}
text="Открыть заявление, по которому создан этот лист" text="Открыть заявление, по которому создан этот лист"
/> />
</CollapseSection> </>
); );
};
@@ -3,18 +3,17 @@ import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/
import { ensureEmployee } from '../../shared/employee'; import { ensureEmployee } from '../../shared/employee';
import { FORM_ID, IFormData, WriteOffFieldKey } from './model'; import { FORM_ID, IFormData, WriteOffFieldKey } from './model';
/** Одна из 12 колонок сумм/комментариев к списанию — см. model.ts WRITE_OFF_CATEGORIES. */ /** 5 сумм к списанию — см. model.ts WRITE_OFF_CATEGORIES. «За что конкретно» — не здесь, см. IFormData. */
export type IWriteOffFields = Pick<IFormData, WriteOffFieldKey>; export type IWriteOffFields = Pick<IFormData, WriteOffFieldKey>;
/** /**
* Записать сумму(ы)/комментарий(и) к списанию. Пишет РОВНО переданные поля — * Записать сумму(ы) к списанию. Пишет РОВНО переданные поля — как
* как updateApproverFields, каждая пара независима. * updateApproverFields, каждая независимо.
* *
* ВАЖНО: вызывающая сторона (ClearanceView) обязана передавать сюда ТОЛЬКО те * ВАЖНО: вызывающая сторона (ClearanceView) обязана передавать сюда ТОЛЬКО ещё
* пары, что ещё не заблокированы (см. IFormData.WriteOff* — блокировка по * не заблокированные суммы (см. IFormData.WriteOff* — блокировка по непустой
* непустой сумме) и заполнены целиком (сумма+комментарий вместе, см. * сумме) — эта функция сама повторно ничего не проверяет и молча перезапишет
* validateWriteOffPairs) — эта функция сама повторно ничего не проверяет и * то, что ей передали.
* молча перезапишет то, что ей передали.
*/ */
export async function updateWriteOffFields( export async function updateWriteOffFields(
id: number, id: number,
@@ -57,6 +56,11 @@ export interface IApproverEditableFields {
* Пишутся ровно переданные поля: у каждого отдела свой набор прав, и лишние * Пишутся ровно переданные поля: у каждого отдела свой набор прав, и лишние
* ключи затёрли бы чужие отметки значениями из формы этого согласующего. * ключи затёрли бы чужие отметки значениями из формы этого согласующего.
* *
* fields.TrainingAmount пишется в колонку WriteOffTrainingAmount, а НЕ в
* одноимённый TrainingAmount — см. IFormData.TrainingAmount; доступность
* (только КДП, без блокировки навсегда) не изменилась, изменилась только
* колонка хранения.
*
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadClearanceRequest); не задан — * listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadClearanceRequest); не задан —
* текущая партиция, как раньше. * текущая партиция, как раньше.
*/ */
@@ -70,7 +74,7 @@ export async function updateApproverFields(
if (fields.ParkingCard !== undefined) item.ParkingCard = fields.ParkingCard; if (fields.ParkingCard !== undefined) item.ParkingCard = fields.ParkingCard;
if (fields.TrainingAmount !== undefined) { if (fields.TrainingAmount !== undefined) {
const amount = Number(fields.TrainingAmount); const amount = Number(fields.TrainingAmount);
item.TrainingAmount = item.WriteOffTrainingAmount =
Number.isFinite(amount) && fields.TrainingAmount.trim() !== '' ? amount : null; Number.isFinite(amount) && fields.TrainingAmount.trim() !== '' ? amount : null;
} }
if (Object.keys(item).length === 0) return; if (Object.keys(item).length === 0) return;
@@ -88,7 +92,13 @@ function buildTitle(employeeName: string): string {
return `Обходной лист — ${employeeName} — ${pad2(now.getDate())}.${pad2(now.getMonth() + 1)}.${now.getFullYear()}`; return `Обходной лист — ${employeeName} — ${pad2(now.getDate())}.${pad2(now.getMonth() + 1)}.${now.getFullYear()}`;
} }
/** Поля элемента списка данных из модели формы (+ Employee по Id). */ /**
* Поля элемента списка данных из модели формы (+ Employee по Id).
*
* Обычно data.TrainingAmount тут пуст (заявитель его не заполняет, только КДП
* на своём шаге — см. updateApproverFields), но пишем на случай редактирования
* уже созданной заявки: колонка WriteOffTrainingAmount, та же, что и там.
*/
function buildItem(data: IFormData, status: string, employeeId: number, employeeName: string): Record<string, unknown> { function buildItem(data: IFormData, status: string, employeeId: number, employeeName: string): Record<string, unknown> {
const amount = Number(data.TrainingAmount); const amount = Number(data.TrainingAmount);
return { return {
@@ -100,7 +110,7 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee
TerminationDate: data.TerminationDate || null, TerminationDate: data.TerminationDate || null,
ParkingCard: data.ParkingCard, ParkingCard: data.ParkingCard,
AccessCard: data.AccessCard, AccessCard: data.AccessCard,
TrainingAmount: Number.isFinite(amount) && data.TrainingAmount.trim() !== '' ? amount : null, WriteOffTrainingAmount: Number.isFinite(amount) && data.TrainingAmount.trim() !== '' ? amount : null,
Organization: data.Organization || null, Organization: data.Organization || null,
Project: data.Project || null, Project: data.Project || null,
ProjectId: data.ProjectId || null ProjectId: data.ProjectId || null
@@ -36,7 +36,7 @@ RequestTypeId» в `docs/forms.md`). Тип заведён в админке app
отдельных полей для них нет (были бы дублированием). `data.Organization: отдельных полей для них нет (были бы дублированием). `data.Organization:
IOrganizationRecord[]` — массив `{company, department, position}`; поле называется IOrganizationRecord[]` — массив `{company, department, position}`; поле называется
`department` (тот же справочник `Section`, что и раньше — см. следующий абзац), `department` (тот же справочник `Section`, что и раньше — см. следующий абзац),
колонка называется «Структурное подразделение», и то и другое read-only. Должность колонка называется «Подразделение», и то и другое read-only. Должность
редактируема В ЛЮБОЙ строке (справочник не всегда точен), но маршрут (условие редактируема В ЛЮБОЙ строке (справочник не всегда точен), но маршрут (условие
«IsManager») и обязательность по валидации завязаны ТОЛЬКО на должность ПЕРВОЙ «IsManager») и обязательность по валидации завязаны ТОЛЬКО на должность ПЕРВОЙ
(основной) записи — `primaryPosition(data.Organization)`; должности остальных (основной) записи — `primaryPosition(data.Organization)`; должности остальных
@@ -52,8 +52,6 @@ RequestTypeId» в `docs/forms.md`). Тип заведён в админке app
/ `organizationColumn` в `model.ts`/`submit.ts`; не-JSON значение (заявка, / `organizationColumn` в `model.ts`/`submit.ts`; не-JSON значение (заявка,
сохранённая до перехода на этот формат) не роняет чтение — трактуется как одна сохранённая до перехода на этот формат) не роняет чтение — трактуется как одна
компания (см. `parseOrganizationColumn`). компания (см. `parseOrganizationColumn`).
- **Самостоятельное подразделение** (`StandaloneUnit`) — в справочнике его нет,
свободный ввод, единственное текстовое поле, оставшееся отдельно от таблицы.
- **Последний рабочий день** (`LastWorkingDay`) — обязателен, не раньше чем через - **Последний рабочий день** (`LastWorkingDay`) — обязателен, не раньше чем через
`MIN_NOTICE_MONTHS` (1) календарный месяц от сегодня (`isoDatePlusMonths`, `MIN_NOTICE_MONTHS` (1) календарный месяц от сегодня (`isoDatePlusMonths`,
`min` у `input type=date` + проверка в `validate` на случай обхода UI) — срок `min` у `input type=date` + проверка в `validate` на случай обхода UI) — срок
@@ -173,9 +171,8 @@ approval-route (`getPlannedMatrix` для фаз «Согласование» и
## Секции формы ## Секции формы
RequestSection (сотрудник · организация/департамент/должность (таблица — RequestSection (сотрудник · организация/департамент/должность (таблица —
редактируемая на форме подачи, read-only в просмотре) · самостоятельное редактируемая на форме подачи, read-only в просмотре) · последний рабочий день ·
подразделение · последний рабочий день · телефон · комментарий · ссылка на телефон · комментарий · ссылка на обходной лист · лицо, принимающее ОС/НМА)
обходной лист · лицо, принимающее ОС/НМА)
## Точки входа ## Точки входа
- rptype: `hr-dismissal` - rptype: `hr-dismissal`
@@ -27,7 +27,6 @@ export async function loadDismissalRequest(itemId: number, listId?: string): Pro
'Id', 'Id',
'Position', 'Position',
'Department', 'Department',
'StandaloneUnit',
'LastWorkingDay', 'LastWorkingDay',
'Phone', 'Phone',
'Comment', 'Comment',
@@ -48,7 +47,6 @@ export async function loadDismissalRequest(itemId: number, listId?: string): Pro
const data: IFormData = { const data: IFormData = {
...EMPTY_DATA, ...EMPTY_DATA,
Employee: employee?.EMail || '', Employee: employee?.EMail || '',
StandaloneUnit: (item.StandaloneUnit as string) || '',
LastWorkingDay: lastDay.slice(0, 10), // ISO → YYYY-MM-DD для input type=date LastWorkingDay: lastDay.slice(0, 10), // ISO → YYYY-MM-DD для input type=date
Phone: (item.Phone as string) || '', Phone: (item.Phone as string) || '',
Comment: (item.Comment as string) || '', Comment: (item.Comment as string) || '',
@@ -86,8 +86,6 @@ export function zipOrganization(
export interface IFormData { export interface IFormData {
/** E-mail сотрудника, за которого заявка (People). Пусто = не выбран. */ /** E-mail сотрудника, за которого заявка (People). Пусто = не выбран. */
Employee: string; Employee: string;
/** Самостоятельное подразделение — в справочнике его нет, вводится вручную. */
StandaloneUnit: string;
/** Последний рабочий день, ISO YYYY-MM-DD (input type=date). */ /** Последний рабочий день, ISO YYYY-MM-DD (input type=date). */
LastWorkingDay: string; LastWorkingDay: string;
/** Контактный телефон (маска, см. PhoneInput). */ /** Контактный телефон (маска, см. PhoneInput). */
@@ -139,7 +137,6 @@ export interface ISectionProps {
export const EMPTY_DATA: IFormData = { export const EMPTY_DATA: IFormData = {
Employee: '', Employee: '',
StandaloneUnit: '',
LastWorkingDay: '', LastWorkingDay: '',
Phone: '', Phone: '',
Comment: '', Comment: '',
@@ -7,7 +7,6 @@
{ "internalName": "Employee", "title": "Сотрудник", "type": "User", "userSelectionMode": "PeopleOnly", "required": true }, { "internalName": "Employee", "title": "Сотрудник", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
{ "internalName": "Position", "title": "Должность", "type": "Text" }, { "internalName": "Position", "title": "Должность", "type": "Text" },
{ "internalName": "Department", "title": "Департамент", "type": "Text" }, { "internalName": "Department", "title": "Департамент", "type": "Text" },
{ "internalName": "StandaloneUnit", "title": "Самостоятельное подразделение", "type": "Text" },
{ "internalName": "LastWorkingDay", "title": "Последний рабочий день", "type": "DateTime", "dateOnly": true }, { "internalName": "LastWorkingDay", "title": "Последний рабочий день", "type": "DateTime", "dateOnly": true },
{ "internalName": "Phone", "title": "Контактный телефон", "type": "Text" }, { "internalName": "Phone", "title": "Контактный телефон", "type": "Text" },
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" }, { "internalName": "Comment", "title": "Комментарий", "type": "Note" },
@@ -56,7 +56,7 @@ export const OrganizationsTable: React.FC<IOrganizationsTableProps> = ({ records
<thead> <thead>
<tr> <tr>
<th className={styles.company}>Компания</th> <th className={styles.company}>Компания</th>
<th className={styles.department}>Структурное подразделение</th> <th className={styles.department}>Подразделение</th>
<th className={styles.position}>Должность</th> <th className={styles.position}>Должность</th>
{onRemove && <th className={styles.action} />} {onRemove && <th className={styles.action} />}
</tr> </tr>
@@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { CollapseSection, FormField, TextInput, TextArea, PhoneInput, PeopleField, LinkField } from '../../../shared/ui'; import { FormGrid, FormField, TextInput, TextArea, PhoneInput, PeopleField, LinkField } from '../../../shared/ui';
import { ISectionProps, EMPLOYEE_PLACEHOLDER, MIN_NOTICE_MONTHS } from '../model'; import { ISectionProps, EMPLOYEE_PLACEHOLDER, MIN_NOTICE_MONTHS } from '../model';
import { isoDatePlusMonths } from '../../../shared/dateUtils'; import { isoDatePlusMonths } from '../../../shared/dateUtils';
import { OrganizationsTable } from './OrganizationsTable'; import { OrganizationsTable } from './OrganizationsTable';
@@ -28,7 +28,7 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
onRemoveOrganization, onRemoveOrganization,
assetsAcceptorEditable assetsAcceptorEditable
}) => ( }) => (
<CollapseSection title="Заявление на увольнение" icon="document"> <FormGrid>
<FormField label="Сотрудник" htmlFor="Employee" required full> <FormField label="Сотрудник" htmlFor="Employee" required full>
<PeopleField <PeopleField
context={context} context={context}
@@ -55,9 +55,6 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
} }
onRemove={onRemoveOrganization} onRemove={onRemoveOrganization}
/> />
<FormField label="Самостоятельное подразделение" htmlFor="StandaloneUnit">
<TextInput id="StandaloneUnit" value={data.StandaloneUnit} onChange={(v) => set('StandaloneUnit', v)} />
</FormField>
{/* Заявление подаётся заранее — датой увольнения нельзя назначить ближе, {/* Заявление подаётся заранее — датой увольнения нельзя назначить ближе,
чем через MIN_NOTICE_MONTHS месяц от сегодня (min). */} чем через MIN_NOTICE_MONTHS месяц от сегодня (min). */}
<FormField label="Последний рабочий день" htmlFor="LastWorkingDay" required> <FormField label="Последний рабочий день" htmlFor="LastWorkingDay" required>
@@ -94,5 +91,5 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
readOnly={assetsAcceptorEditable ? false : true} readOnly={assetsAcceptorEditable ? false : true}
/> />
</FormField> </FormField>
</CollapseSection> </FormGrid>
); );
@@ -85,7 +85,6 @@ function buildItem(
Position: organizationColumn(data.Organization, (r) => r.position, 'Должность', TEXT_COLUMN_LIMIT), Position: organizationColumn(data.Organization, (r) => r.position, 'Должность', TEXT_COLUMN_LIMIT),
Department: organizationColumn(data.Organization, (r) => r.department, 'Департамент', TEXT_COLUMN_LIMIT), Department: organizationColumn(data.Organization, (r) => r.department, 'Департамент', TEXT_COLUMN_LIMIT),
Organization: organizationColumn(data.Organization, (r) => r.company, 'Организация'), Organization: organizationColumn(data.Organization, (r) => r.company, 'Организация'),
StandaloneUnit: data.StandaloneUnit || null,
LastWorkingDay: data.LastWorkingDay || null, LastWorkingDay: data.LastWorkingDay || null,
Phone: data.Phone || null, Phone: data.Phone || null,
Comment: data.Comment || null Comment: data.Comment || null
@@ -1,12 +1,12 @@
import * as React from 'react'; import * as React from 'react';
import { CollapseSection, FormField, TextInput, TextArea, PeopleField, OrganizationField } from '../../../shared/ui'; import { FormGrid, FormField, TextInput, TextArea, PeopleField, OrganizationField } from '../../../shared/ui';
import { DepartmentPicker, ProjectPicker } from '../../../shared/dictionary'; import { DepartmentPicker, ProjectPicker } from '../../../shared/dictionary';
import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS } from '../model'; import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS } from '../model';
import { isoDatePlusDays } from '../../../shared/dateUtils'; import { isoDatePlusDays } from '../../../shared/dateUtils';
/** Единственная секция формы «Отпуск без содержания»: сотрудник, должность, период, основание, замещающий. */ /** Единственная секция формы «Отпуск без содержания»: сотрудник, должность, период, основание, замещающий. */
export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange, printOrgIndex }) => ( export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange, printOrgIndex }) => (
<CollapseSection title="Заявка на отпуск без содержания" icon="people"> <FormGrid>
<FormField label="Сотрудник" htmlFor="Employee" required full> <FormField label="Сотрудник" htmlFor="Employee" required full>
<PeopleField <PeopleField
context={context} context={context}
@@ -65,5 +65,5 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
placeholder={SUBSTITUTE_PLACEHOLDER} placeholder={SUBSTITUTE_PLACEHOLDER}
/> />
</FormField> </FormField>
</CollapseSection> </FormGrid>
); );
@@ -1,12 +1,12 @@
import * as React from 'react'; import * as React from 'react';
import { CollapseSection, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui'; import { FormGrid, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui';
import { DepartmentPicker, ProjectPicker } from '../../../shared/dictionary'; import { DepartmentPicker, ProjectPicker } from '../../../shared/dictionary';
import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS } from '../model'; import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS } from '../model';
import { isoDatePlusDays } from '../../../shared/dateUtils'; import { isoDatePlusDays } from '../../../shared/dateUtils';
/** Единственная секция формы «Заявка на отпуск»: сотрудник, должность, даты. */ /** Единственная секция формы «Заявка на отпуск»: сотрудник, должность, даты. */
export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange, printOrgIndex }) => ( export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange, printOrgIndex }) => (
<CollapseSection title="Заявка на трудовой отпуск" icon="people"> <FormGrid>
<FormField label="Сотрудник" htmlFor="Employee" required full> <FormField label="Сотрудник" htmlFor="Employee" required full>
<PeopleField <PeopleField
context={context} context={context}
@@ -62,5 +62,5 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
placeholder={SUBSTITUTE_PLACEHOLDER} placeholder={SUBSTITUTE_PLACEHOLDER}
/> />
</FormField> </FormField>
</CollapseSection> </FormGrid>
); );
@@ -33,6 +33,17 @@ interface IDecisionPanelProps {
search?: (query: string) => Promise<ISiteUser[]>; search?: (query: string) => Promise<ISiteUser[]>;
/** Вызывается после успешного переназначения (обновить экран/матрицу). */ /** Вызывается после успешного переназначения (обновить экран/матрицу). */
onDelegated?: () => void; onDelegated?: () => void;
/**
* Дополнительное условие обязательности комментария — СВЕРХ обычного
* «возврат/отклонение». Форме есть что объяснять не только при отказе:
* например, «Обходной лист» требует его на ЛЮБОЕ решение, если согласующий
* только что заполнил сумму к списанию — своего поля «за что конкретно» у
* формы нет, вместо него используется этот самый комментарий (см.
* ClearanceView.writeOffTouchedCategories).
*/
commentRequired?: boolean;
/** Переопределить плейсхолдер поля комментария (по умолчанию — про возврат/отклонение). */
commentPlaceholder?: string;
} }
// Панель решения текущего шага: согласующий (Согласовать/Вернуть/Отклонить) или // Панель решения текущего шага: согласующий (Согласовать/Вернуть/Отклонить) или
@@ -45,6 +56,8 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
onBeforeDecide, onBeforeDecide,
search, search,
onDelegated, onDelegated,
commentRequired,
commentPlaceholder,
}) => { }) => {
const [checking, setChecking] = useState<boolean>(true); const [checking, setChecking] = useState<boolean>(true);
const [pendingRow, setPendingRow] = useState<IPendingRow | undefined>(undefined); const [pendingRow, setPendingRow] = useState<IPendingRow | undefined>(undefined);
@@ -84,10 +97,15 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
if (!pendingRow) { if (!pendingRow) {
return; return;
} }
if ((decision === 'return' || decision === 'reject') && comment.trim().length === 0) { const commentEmpty = comment.trim().length === 0;
if (commentEmpty && (decision === 'return' || decision === 'reject')) {
setError('Для возврата или отклонения укажите комментарий.'); setError('Для возврата или отклонения укажите комментарий.');
return; return;
} }
if (commentEmpty && commentRequired) {
setError('Укажите комментарий.');
return;
}
setError(null); setError(null);
setSubmitting(true); setSubmitting(true);
try { try {
@@ -300,12 +318,12 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
<div className={styles.panel}> <div className={styles.panel}>
<div className={styles.title}>Ваше решение</div> <div className={styles.title}>Ваше решение</div>
{error && <Alert variant="err">{error}</Alert>} {error && <Alert variant="err">{error}</Alert>}
<FormField label="Комментарий" htmlFor="decisionComment"> <FormField label="Комментарий" htmlFor="decisionComment" required={commentRequired}>
<TextArea <TextArea
id="decisionComment" id="decisionComment"
value={comment} value={comment}
onChange={setComment} onChange={setComment}
placeholder="Комментарий (обязателен при возврате или отклонении)" placeholder={commentPlaceholder || 'Комментарий (обязателен при возврате или отклонении)'}
/> />
</FormField> </FormField>
<div className={styles.actions}> <div className={styles.actions}>