33 Commits

Author SHA1 Message Date
gorbenko.pavel 22e37b6e36 feat: прежняя заявка на оплату открывается в панели справа, а не в новой вкладке
Tests / frontend-test (pull_request) Successful in 1m27s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:17:53 +05:00
gorbenko.pavel f654db9bcf docs: заявка на оплату — прежние оплаты через approval-route
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:01:46 +05:00
gorbenko.pavel 5cfe54f33b feat: прежние оплаты по основанию берутся из approval-route, ссылка — только при правах
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:01:34 +05:00
gorbenko.pavel 0c0104f6c9 feat: прежние заявки на оплату по основанию открываются по ссылке, видно статус
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:45:54 +05:00
gorbenko.pavel f664b468a2 fix: черновик заявки на оплату перечитывает прежние оплаты по основанию
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:42:15 +05:00
gorbenko.pavel 2bf0ad1da7 feat: разряды в полях сумм заявки на оплату
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:14:17 +05:00
gorbenko.pavel 6460a2a22d fix: счёт на оплату кладётся в библиотеку под латинским именем (OCR терял расширение)
Tests / frontend-test (pull_request) Successful in 1m24s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 14:49:25 +05:00
gorbenko.pavel edd5008120 fix: поля заявки на оплату занимают всю ширину секции
Тело CollapseSection из ui-kit уже сетка в две колонки, а поля были
завёрнуты ещё в один FormGrid и попадали в половину первой колонки.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 14:44:46 +05:00
gorbenko.pavel 5107f62e54 fix: таблицы заявки на оплату растягиваются на всю ширину секции
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 14:42:22 +05:00
gorbenko.pavel 780d92cd3d docs: заявка на оплату — списки, тип и маршрут заведены
Списки «Заявка на оплату 1 — данные / согласование» созданы провижинингом,
в approval-route заведены тип 24 (раздел «Бухгалтерия») и маршрут 25 с одним
шагом согласования на фиксированного человека.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:29:01 +05:00
gorbenko.pavel 023c0913f1 fix: заявка на оплату под реальный API справочников и OCR
Tests / frontend-test (pull_request) Successful in 1m32s
Сверено с api-tnsu-portal (DictionaryController, DocumentController):
- статьи ДДС — CashFlowItems?id=<группа> (было groupId, сервер отвечал 400);
- OCR — Document/extractPDFText, текст в file_content пишется до ответа,
  поэтому колонка читается сразу, без опроса;
- договоры и банковские счета — постраничные, поиск на сервере (search),
  иначе находились только первые 50 записей; фильтра по компании у
  договоров нет;
- счета по проекту с откатом на фильтр только по компании: совпадение Id
  проекта из /Project с bank_accounts.project_id не гарантировано;
- Id-фильтры передаются только в формате GUID, иначе API отвечает 400;
- подписи записей по реальным полям (account_number, bank, valuta,
  counterparty_name); разбор вынесен в oneCRecords.ts с тестами;
- код позиции потребности — lineNumber;
- пустой ответ Gemini (200 с error) считается ошибкой.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:35:53 +05:00
gorbenko.pavel e976b4e8d4 feat: форма «Заявка на оплату» (СТ/потребность, счёт через OCR и ИИ)
Tests / frontend-test (pull_request) Successful in 2m12s
Новая форма acc-payment-request в категории «Бухгалтерия»: основание —
согласованная СТ (сайт СТ) или потребность; из основания подставляются
получатель, раздел, вид работ, условия, общая сумма и позиции. Счёт (PDF)
сохраняется в библиотеку, уходит на OCR, ИИ достаёт реквизиты, суммы и позиции
и сверяет их с СТ. Подсвечиваются чужой получатель в счёте и превышение
общей суммы СТ всеми оплатами. Справочники 1С — общий DictionaryPicker.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:01:28 +05:00
gorbenko.pavel 093bbecd9f fix: в заявке на отпуск не показываются сообщения об отправке в ЭДО
Tests / frontend-test (push) Successful in 2m17s
Плашки об отправке, ошибке и повторе, а также текст «Документы
отправлены на подписание в ЭДО» показываются только при включённом
VACATION_EDO_SIGNING_ENABLED.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 16:38:17 +05:00
gorbenko.pavel 59b0fbc569 feat: временно выключена отправка заявления на отпуск в ЭДО
Флаг VACATION_EDO_SIGNING_ENABLED в shared/config.ts: пока false,
решение последнего согласующего применяется без обращения к ЭДО.
Код отправки оставлен, включается заменой флага на true.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 16:37:34 +05:00
Тимур Городецкий 984eb65f5b feat: поле «Время работы» в заявке на приём
Tests / frontend-test (push) Successful in 2m6s
Пункт 4.5 — тот же выбор, что 5.6 в заявке на подбор, с «Другое» и ручным
вводом; подставляется из связанного подбора и обязателен для отправки.
Колонка сайта WorkHours общая с подбором (тот же тип, заголовок и варианты),
в список приёма её подключает провизионинг — до деплоя, иначе заявка не
сохранится. «Сдельная оплата труда» сдвинута на 4.6.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 15:59:32 +05:00
Тимур Городецкий b3c46f32d5 feat: подача заявок на отпуск открыта для всех
Оплачиваемый отпуск и отпуск без сохранения з/п добавлены в UNFROZEN_FORMS:
сохранять черновик и отправлять их теперь могут все, а не только группа
«Тестирование». Формы начали передавать formId в SubmitActions — без него
точечное снятие заморозки не срабатывало.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 15:47:04 +05:00
Тимур Городецкий 739f247268 feat: подсветка незаполненных полей в заявке в отдел 1С
Проверки обязательных полей вынесены в validation.ts и показываются под
полями через FieldIssuesProvider, как в приёме, подборе и отпуске: форма
прокручивается к первому полю, над кнопками — кликабельный список.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 15:44:36 +05:00
Тимур Городецкий 4f0d02fe59 feat: в заявке 1С дата запрета по АТП оформляется только на базу АТП
При виде работ «Дата запрета (открытие/закрытие)» и выбранной базе АТП
остальные базы снимаются и добавить их нельзя — дата запрета по АТП
открывается/закрывается отдельно. Над полем «База 1С» при этом виде работ
показывается предупреждение.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 15:44:35 +05:00
Тимур Городецкий ac1b66d0df feat: памятка HR и подсветка ошибок полей в заявках на отпуск
Tests / frontend-test (push) Successful in 2m5s
LeavePrintNotice заменён общим LeaveNotice с заголовком и пунктами из model.ts
каждой формы: в отпуске — примечание HR (ст. 92 ТК РК, срок подачи, подписание
в отделе кадров), в отпуске без содержания — прежняя памятка про печать.
Проверки отпуска вынесены в validation.ts и показываются под полями через
FieldIssuesProvider, как в приёме и подборе.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 15:13:02 +05:00
Тимур Городецкий e811186cec feat: маршрут отпуска и отпуска без содержания учитывает организацию сотрудника
Кадровый сотрудник и исполнитель в этих маршрутах зависят от компании
сотрудника (Ольга Кудрящова и Айнур — Тансу/КПС/CT, Мольдир Хамит —
остальные), но формы не передавали организацию в расчёт маршрута, и
условные шаги в админке не срабатывали. Теперь названия компаний из поля
«Организация» (Employee/ByEmail) уходят как Organization через пробел — и в
согласование, и в исполнителей; маршрут пересчитывается, когда организация
подгрузится. Общий хелпер — shared/dictionary/organizationRouteFields.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 12:44:29 +05:00
Тимур Городецкий 91c66bbf81 Merge branch 'main' of http://git.tnsu.kz/timur.gorodetskiy/request-portal 2026-10-15 12:32:19 +05:00
Тимур Городецкий 7fce5a789c chore: доработки скриптов браузерного QA
guard пропускает GetClientSideComponents (без него страница не грузит
SPFx), probe с --local-build запускает Chromium без проверок Local Network
Access, которые режут загрузку https://localhost:4321 с SharePoint.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 12:31:59 +05:00
Тимур Городецкий ea6eb3663b refactor: расчёт маршрута заявки в 1С вынесен в planRoute
Сбор условий маршрута (вид работ, база 1С, отдел) и запрос маршрута с
схлопыванием повторов переехали из формы в planRoute.ts. Поведение формы
не меняется; функцию сможет использовать страница «Проверка маршрутов»,
чтобы проверять ровно тот маршрут, что видит заявитель.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 12:31:59 +05:00
gorbenko.pavel 5d2abaf430 Чат заявки: отметки «@» и оповещения через поток (#26)
Tests / frontend-test (push) Successful in 2m1s
## Что сделано

**Чат заявки: отметки и оповещения**
- В поле ввода «@» открывает подсказку с участниками заявки: инициатор, согласующие и исполнители из маршрута, а также те, кому открыты «Все заявки» этого типа (список из request-type-access).
- Отметка — обычный текст «@ФИО», без штатного упоминания SharePoint, чтобы не уходило его письмо.
- После отправки сообщения портал вызывает поток Power Automate (`FLOW_CHAT_NOTIFY_URL`): отмеченным — «Вас упомянули», инициатору — «Новое сообщение по вашей заявке». Сбой потока не мешает отправке.
- В письмо добавляется строка «Прикреплены файлы: …», чтобы сообщение из одного файла не приходило пустым.
- Свойство веб-части «Чат: уведомлять о своих сообщениях (для теста)», по умолчанию выключено: письма и о своих сообщениях, можно отметить себя.
- Когда пишет сам инициатор с отметками, в `initiatorEmail` уходит первый отмеченный — поток шлёт инициатору всегда, если его нет среди отмеченных, и иначе он получил бы письмо о своём же сообщении.

**Чат включён для:** Обходной лист, заявка на подбор, заявка на приём, заявка в отдел 1С.

**Исправление:** редактор маршрута может удалять исполнителей в Обходном листе, Командировке и заявке в 1С (не передавалось `canRemoveRow`).

**Прочее:** убраны 4 предупреждения линтера (`no-new-null`, `no-script-url`).

## Не проверено на тенанте
- Ответ потока 200 через AadHttpClient и доставка писем.
- Права согласующих на комментарии и на запись в библиотеку «Файлы чата».
- Папки в «Файлы чата» наследуют права библиотеки, а не закрыты на заявку — для подбора и приёма это важно решить до выкладки.
- Если поток вставляет `messageText` в HTML-письмо, перенос строки перед «Прикреплены файлы» может не отобразиться.

---------

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/26
2026-10-14 10:05:45 +00:00
Тимур Городецкий 99f3558e4a fix: один согласующий не дублируется на шаге маршрута заявки в 1С
База 1С — мультивыбор, поэтому на одном шаге могут сработать сразу
несколько строк матрицы с одним исполнителем (Гульжан и за ЗУП, и за
АТП от финдепа). Он получал бы два уведомления и решал шаг дважды.
Запланированный маршрут теперь оставляет одну строку на человека в
пределах шага; один человек на разных шагах не схлопывается.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 09:37:10 +05:00
Тимур Городецкий a6f4218e7e feat: маршрут заявки в 1С учитывает базу 1С
Новая матрица маршрута #8 назначает согласующих по выбранной базе
(ЗУП, АТП, ATS), а форма передавала в approval-route только вид работ
и отдел — такие шаги никогда не срабатывали. Теперь DB1C уходит в
fieldValues одной строкой через пробел (мультивыбор, value у API
только строка), и маршрут пересчитывается при смене набора баз.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 09:36:48 +05:00
Тимур Городецкий 96f53da020 feat: столбец «Дата выхода» в разделах при фильтре по рабочему месту
Tests / frontend-test (push) Successful in 2m7s
Раньше дата выхода показывалась только во «Все заявки». Теперь — в любом
разделе (Мои заявки, очереди), когда фильтр по типу оставляет только заявки
на организацию рабочего места.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 15:05:42 +05:00
Тимур Городецкий d6bb76fe47 feat: памятка о бумажном заявлении в заявках на отпуск
После согласования заявление нужно распечатать, подписать и сдать в
отдел кадров — напоминаем об этом в трудовом отпуске и отпуске без
содержания (подача и просмотр). Общий компонент LeavePrintNotice,
на печать не выводится.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 13:59:58 +05:00
Тимур Городецкий 4b05da5469 feat: трудовой отпуск — не более 24 дней в одной заявке
Требование HR: ограничение на поле «Количество дней» и проверка при
отправке с понятным сообщением.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 13:59:58 +05:00
Тимур Городецкий 41cce93b41 fix: плитки заявок одной высоты, плашка «В разработке» по размеру текста
Tests / frontend-test (push) Successful in 2m3s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 11:24:44 +05:00
Тимур Городецкий 664b58a44a fix: трудовой отпуск подаётся за 7 дней вместо 14
По требованию HR минимальный срок подачи заявления сокращён до недели.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 11:24:43 +05:00
Тимур Городецкий 755cfc8329 fix: отпуск без содержания — без лимита 5 дней, «Иная причина»
5 дней работодатель обязан дать только при браке/рождении/смерти,
сверх — по договорённости, поэтому форма больше не ограничивает число
дней. Вариант «В иных случаях» подписан как «Иная причина» — читается
как ответ на «Причина»; значение в SP прежнее.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 11:24:42 +05:00
Тимур Городецкий e46632d67a fix: примечание СБ в заявке на приём — необязательное
По просьбе заказчика примечание при отметке «Согласован СБ» больше не
требуется для отправки: поле по-прежнему появляется только при отметке,
но его можно оставить пустым.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 15:44:17 +05:00
117 changed files with 4871 additions and 192 deletions
+1
View File
@@ -17,6 +17,7 @@ const READ_VIA_POST = [
/\/GetItems\b/i, /\/GetItems\b/i,
/\/_api\/search\/postquery/i, /\/_api\/search\/postquery/i,
/\/GetUpdatedFormDigest/i, /\/GetUpdatedFormDigest/i,
/\/_api\/web\/GetClientSideComponents/i, // манифесты веб-партов, без них страница не грузит SPFx
/\/_api\/SP\.Utilities\.Utility\.ResolvePrincipalInCurrentContext/i, /\/_api\/SP\.Utilities\.Utility\.ResolvePrincipalInCurrentContext/i,
/\/_api\/SP\.UI\.ApplicationPages\.ClientPeoplePickerWebServiceInterface\.clientPeoplePickerSearchUser/i, /\/_api\/SP\.UI\.ApplicationPages\.ClientPeoplePickerWebServiceInterface\.clientPeoplePickerSearchUser/i,
/\/_api\/sp\.userprofiles\.peoplemanager/i, /\/_api\/sp\.userprofiles\.peoplemanager/i,
+3 -1
View File
@@ -46,7 +46,9 @@ let AxeBuilder = null;
try { ({ default: AxeBuilder } = await import('@axe-core/playwright')); } catch { /* axe не установлен */ } try { ({ default: AxeBuilder } = await import('@axe-core/playwright')); } catch { /* axe не установлен */ }
await fs.mkdir(out, { recursive: true }); await fs.mkdir(out, { recursive: true });
const browser = await chromium.launch(); // Local Network Access в свежем Chromium режет загрузку https://localhost:4321 с публичного сайта SharePoint
const LOCAL_BUILD_ARGS = ['--disable-features=LocalNetworkAccessChecks,PrivateNetworkAccessSendPreflights,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessRespectPreflightResults'];
const browser = await chromium.launch(args['local-build'] ? { args: LOCAL_BUILD_ARGS } : {});
const result = { url, surface: tenant ? 'tenant' : 'harness', date: new Date().toISOString(), axe: Boolean(AxeBuilder), runs: [] }; const result = { url, surface: tenant ? 'tenant' : 'harness', date: new Date().toISOString(), axe: Boolean(AxeBuilder), runs: [] };
// Признаки непереведённых строк и утёкших значений в тексте // Признаки непереведённых строк и утёкших значений в тексте
+1
View File
@@ -52,6 +52,7 @@
| [`hr-business-trip`](../src/webparts/requestPortal/forms/BusinessTrip/README.md) | Заявка на командировку | Отдел кадров | да | да | да | можно за другого сотрудника; маршрут лентой (точки = город + объект из справочника + даты, участки с видом транспорта); задание — список задач; смета: статьи с позициями, привязанными к точкам/участкам (суммой или кол-во × цена — всё вручную), итог с долями и по объектам; обязательство по авансовому отчёту — флажок; печать без шапки адресата | ⏳ код готов, списки/тип/маршрут не заведены | | [`hr-business-trip`](../src/webparts/requestPortal/forms/BusinessTrip/README.md) | Заявка на командировку | Отдел кадров | да | да | да | можно за другого сотрудника; маршрут лентой (точки = город + объект из справочника + даты, участки с видом транспорта); задание — список задач; смета: статьи с позициями, привязанными к точкам/участкам (суммой или кол-во × цена — всё вручную), итог с долями и по объектам; обязательство по авансовому отчёту — флажок; печать без шапки адресата | ⏳ код готов, списки/тип/маршрут не заведены |
| `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) | | `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
| [`wh-service-request`](../src/webparts/requestPortal/forms/ServiceRequest/README.md) | Заявка на услуги | Склад / Снабжение | да | нет | да | маршрут по проекту (руководитель проекта/ПТО); табличные части «Приложения» и «Смета» (JSON-колонки); сумма по смете считается на лету (qty × цена), итог дублируется числовой колонкой для отчётов | ✅ | | [`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`). **Просмотр** — есть режим согласующего (`<Форма>View`).
@@ -24,7 +24,8 @@ import {
EDO_API_BASE_URL_STAGE, EDO_API_BASE_URL_STAGE,
setApprovalApiBaseUrl, setApprovalApiBaseUrl,
setEdoApiBaseUrl, setEdoApiBaseUrl,
setNotificationSettings setNotificationSettings,
setChatSelfNotify
} from './shared/config'; } from './shared/config';
import { initListPartitions } from './shared/approval'; import { initListPartitions } from './shared/approval';
import { ErrorOverlay } from './shared/ui'; import { ErrorOverlay } from './shared/ui';
@@ -54,6 +55,11 @@ export interface IRequestPortalWebPartProps {
notificationsEnabled?: boolean; notificationsEnabled?: boolean;
/** Непустой адрес — письма только если активный шаг назначен на него (обкатка). Пусто — всем. */ /** Непустой адрес — письма только если активный шаг назначен на него (обкатка). Пусто — всем. */
notifyOnlyTo?: string; notifyOnlyTo?: string;
/**
* Для проверки чата: письма и о СВОИХ сообщениях, и можно отметить себя через «@».
* По умолчанию выключено (undefined — тоже «выкл»).
*/
chatSelfNotify?: boolean;
} }
export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequestPortalWebPartProps> { export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequestPortalWebPartProps> {
@@ -126,6 +132,7 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
private _applyNotificationSettings(): void { private _applyNotificationSettings(): void {
setNotificationSettings(this.properties.notificationsEnabled !== false, this.properties.notifyOnlyTo || ''); setNotificationSettings(this.properties.notificationsEnabled !== false, this.properties.notifyOnlyTo || '');
setChatSelfNotify(this.properties.chatSelfNotify === true);
} }
private _getEnvironmentMessage(): Promise<string> { private _getEnvironmentMessage(): Promise<string> {
@@ -259,6 +266,12 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
description: 'Для обкатки: письмо уходит, только если шаг назначен на этот e-mail. Пусто — всем.', description: 'Для обкатки: письмо уходит, только если шаг назначен на этот e-mail. Пусто — всем.',
placeholder: 'user@tnsu.kz', placeholder: 'user@tnsu.kz',
disabled: this.properties.notificationsEnabled === false disabled: this.properties.notificationsEnabled === false
}),
PropertyPaneToggle('chatSelfNotify', {
label: 'Чат: уведомлять о своих сообщениях (для теста)',
onText: 'Да — и можно отметить себя через «@»',
offText: 'Нет',
checked: this.properties.chatSelfNotify === true
}) })
] ]
} }
@@ -40,6 +40,8 @@
align-items: center; align-items: center;
gap: 16px; gap: 16px;
width: 100%; width: 100%;
// Плитки одного ряда — одной высоты, даже если заголовок у соседа в 3 строки.
height: 100%;
padding: 22px 24px; padding: 22px 24px;
text-align: left; text-align: left;
background: $white; background: $white;
@@ -122,7 +124,9 @@
.tileBody { .tileBody {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; // Без этого плашка «В разработке» растягивается на ширину заголовка.
align-items: flex-start;
gap: 4px;
min-width: 0; min-width: 0;
} }
@@ -53,6 +53,12 @@ export const REQUEST_TYPES: IRequestType[] = [
title: 'Заявка на услуги', title: 'Заявка на услуги',
icon: 'document' icon: 'document'
}, },
{
id: 'acc-payment-request',
categoryId: 'accounting',
title: 'Заявка на оплату',
icon: 'calculator'
},
{ {
id: 'wh-concrete-request', id: 'wh-concrete-request',
categoryId: 'warehouse', categoryId: 'warehouse',
@@ -53,7 +53,7 @@ const ApproveFeature: React.FC<IApproveFeatureProps> = ({ context, formKey, item
{/* Доступно всем, кто может открыть заявку: автору, всем согласующим {/* Доступно всем, кто может открыть заявку: автору, всем согласующим
(и уже решившим, и ещё ждущим своей очереди) и исполнителям — (и уже решившим, и ещё ждущим своей очереди) и исполнителям —
в отличие от панели решения, которая видна только текущему шагу. в отличие от панели решения, которая видна только текущему шагу.
Пока включено только для «Обходного листа» — см. isChatEnabledFor. */} Включено не для всех типов заявок — см. isChatEnabledFor. */}
{isChatEnabledFor(formKey) && ( {isChatEnabledFor(formKey) && (
<RequestChat context={context} formKey={formKey} itemId={itemId} listId={listId} /> <RequestChat context={context} formKey={formKey} itemId={itemId} listId={listId} />
)} )}
@@ -73,7 +73,7 @@ const EditFeature: React.FC<IEditFeatureProps> = ({ context, formKey, itemId, li
{/* Обсуждение — то же самое, что видят согласующие (комментарии к строке {/* Обсуждение — то же самое, что видят согласующие (комментарии к строке
заявки). Здесь оно особенно нужно: заявку правят как раз после заявки). Здесь оно особенно нужно: заявку правят как раз после
возврата на доработку, и уточнить причину логично не уходя с формы. возврата на доработку, и уточнить причину логично не уходя с формы.
Пока включено только для «Обходного листа» — см. isChatEnabledFor. */} Включено не для всех типов заявок — см. isChatEnabledFor. */}
{isChatEnabledFor(formKey) && ( {isChatEnabledFor(formKey) && (
<RequestChat context={context} formKey={formKey} itemId={itemId} listId={listId} /> <RequestChat context={context} formKey={formKey} itemId={itemId} listId={listId} />
)} )}
@@ -143,7 +143,7 @@ interface ITableColumns {
assignee: boolean; assignee: boolean;
/** Колонка «Исполнено» — кто из исполнителей уже выполнил (см. showsExecuted). */ /** Колонка «Исполнено» — кто из исполнителей уже выполнил (см. showsExecuted). */
executed: boolean; executed: boolean;
/** Колонка «Дата выхода» — только «Все заявки» по организации рабочего места. */ /** Колонка «Дата выхода» — только таблица из заявок на организацию рабочего места (см. showsStartDate). */
startDate?: boolean; startDate?: boolean;
/** Заголовок колонки с комментарием; undefined — колонки нет. */ /** Заголовок колонки с комментарием; undefined — колонки нет. */
commentLabel?: string; commentLabel?: string;
@@ -178,9 +178,15 @@ function showsExecuted(view: ListView, statusFilter: string): boolean {
return true; return true;
} }
/** Дата выхода сотрудника есть только у заявки на организацию рабочего места, и показываем её во «Все заявки». */ /**
function showsStartDate(view: ListView, type: IRequestType | undefined): boolean { * Дата выхода сотрудника есть только у заявки на организацию рабочего места.
return view === 'all' && type?.pageUrl === WORKPLACE_SETUP_FORM_ID; * Показываем её, когда таблица целиком из таких заявок: «Все заявки» этого типа
* или любой раздел с фильтром по этому типу. Без фильтра типы вперемешку —
* колонка была бы почти пустой.
*/
function showsStartDate(view: ListView, allRequestsType: IRequestType | undefined, filteredType: IRequestType | undefined): boolean {
const tableType = view === 'all' ? allRequestsType : filteredType;
return tableType?.pageUrl === WORKPLACE_SETUP_FORM_ID;
} }
function columnsFor(view: ListView, statusFilter: string, startDate = false): ITableColumns { function columnsFor(view: ListView, statusFilter: string, startDate = false): ITableColumns {
@@ -194,15 +200,15 @@ function columnsFor(view: ListView, statusFilter: string, startDate = false): IT
? 'Причина возврата' ? 'Причина возврата'
: undefined; : undefined;
if (view === 'approvals' || view === 'execution') return { author: true, status: false, assignee, executed }; if (view === 'approvals' || view === 'execution') return { author: true, status: false, assignee, executed, startDate };
if (view === 'rework') return { author: false, status: false, assignee, executed, commentLabel }; if (view === 'rework') return { author: false, status: false, assignee, executed, startDate, commentLabel };
if (view === 'own:created') return { author: false, status: !statusFilter, assignee, executed, commentLabel }; if (view === 'own:created') return { author: false, status: !statusFilter, assignee, executed, startDate, commentLabel };
// 'all' — заявки всех авторов вперемешку, поэтому нужны и автор, и статус. // 'all' — заявки всех авторов вперемешку, поэтому нужны и автор, и статус.
if (view === 'all') return { author: true, status: true, assignee, executed, startDate }; if (view === 'all') return { author: true, status: true, assignee, executed, startDate };
// История решений: заявка чужая, поэтому нужен автор. Статус — только если он // История решений: заявка чужая, поэтому нужен автор. Статус — только если он
// не предопределён самой вкладкой («Отклонённые мной» — это всегда «Отклонено», // не предопределён самой вкладкой («Отклонённые мной» — это всегда «Отклонено»,
// а вот у согласованных/выполненных заявка могла уйти дальше по маршруту). // а вот у согласованных/выполненных заявка могла уйти дальше по маршруту).
return { author: true, status: !decisionStatus, assignee, executed, commentLabel }; return { author: true, status: !decisionStatus, assignee, executed, startDate, commentLabel };
} }
export interface IRequestsTableViewProps { export interface IRequestsTableViewProps {
@@ -494,6 +500,8 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
}); });
const { localFilters, sort, isRefined } = refinement; const { localFilters, sort, isRefined } = refinement;
const refinementSig = refinement.signature; const refinementSig = refinement.signature;
const filteredType = types?.find((t) => t.id === localFilters.typeId);
const withStartDate = showsStartDate(view, allRequestsType, filteredType);
// Полный список раздела для режима с фильтрами — последний загруженный. // Полный список раздела для режима с фильтрами — последний загруженный.
// Листание страниц и смена фильтра его переиспользуют (заново по сети не // Листание страниц и смена фильтра его переиспользуют (заново по сети не
@@ -664,7 +672,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
decisionStatus ? hydrateDecisionComments(context, types, result.rows, decisionStatus) : undefined, decisionStatus ? hydrateDecisionComments(context, types, result.rows, decisionStatus) : undefined,
showsAssignee(view, statusFilter) ? hydrateCurrentAssignees(context, types, result.rows) : undefined, showsAssignee(view, statusFilter) ? hydrateCurrentAssignees(context, types, result.rows) : undefined,
showsExecuted(view, statusFilter) ? hydrateExecutedSteps(context, types, result.rows) : undefined, showsExecuted(view, statusFilter) ? hydrateExecutedSteps(context, types, result.rows) : undefined,
showsStartDate(view, allRequestsType) ? hydrateStartDates(context, result.rows) : undefined, withStartDate ? hydrateStartDates(context, result.rows) : undefined,
]); ]);
return result; return result;
}; };
@@ -850,7 +858,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
rows={visible} rows={visible}
view={view} view={view}
statusFilter={statusFilter} statusFilter={statusFilter}
withStartDate={showsStartDate(view, allRequestsType)} withStartDate={withStartDate}
sort={sort} sort={sort}
onSort={refinement.onSort} onSort={refinement.onSort}
onOpen={onOpen} onOpen={onOpen}
@@ -33,6 +33,7 @@ function asDay(value: unknown): string {
} }
/** Разобрать сохранённое значение; всё, что не прошло проверку, — как «не задано». */ /** Разобрать сохранённое значение; всё, что не прошло проверку, — как «не задано». */
// eslint-disable-next-line @rushstack/no-new-null -- так отдаёт localStorage.getItem, если ключа нет
export function parseStoredRefinement(raw: string | null): IStoredRefinement | undefined { export function parseStoredRefinement(raw: string | null): IStoredRefinement | undefined {
if (!raw) return undefined; if (!raw) return undefined;
let parsed: { filters?: Record<string, unknown>; sort?: Record<string, unknown> }; let parsed: { filters?: Record<string, unknown>; sort?: Record<string, unknown> };
@@ -27,8 +27,10 @@ import {
IApproverToWrite, IApproverToWrite,
IUseMatrixEditor, IUseMatrixEditor,
unassignedStepMessage, unassignedStepMessage,
resolveWriteListId resolveWriteListId,
composeRemoveRowRule
} from '../../shared/approval'; } from '../../shared/approval';
import { effectiveUserEmail } from '../../shared/impersonation';
import { IRequestFormProps } from '../../models/types'; import { IRequestFormProps } from '../../models/types';
import { friendlyErrorMessage } from '../../shared/diagnostics'; import { friendlyErrorMessage } from '../../shared/diagnostics';
import { import {
@@ -244,7 +246,11 @@ const BusinessTripForm: React.FC<IRequestFormProps> = ({
<ObligationSection {...sectionProps} /> <ObligationSection {...sectionProps} />
</FormModeProvider> </FormModeProvider>
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} /> <ApprovalMatrix
editor={matrix}
search={(q) => approval.searchUsers(q)}
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
/>
{!executors.loading && executors.rows.length > 0 && ( {!executors.loading && executors.rows.length > 0 && (
<ApprovalMatrix <ApprovalMatrix
@@ -253,6 +259,7 @@ const BusinessTripForm: React.FC<IRequestFormProps> = ({
title="Исполнение (после согласования)" title="Исполнение (после согласования)"
roleLabel="Исполнитель" roleLabel="Исполнитель"
emptyText="Исполнители не заданы по маршруту." emptyText="Исполнители не заданы по маршруту."
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
/> />
)} )}
@@ -342,6 +342,7 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({
title="Исполнение (после согласования)" title="Исполнение (после согласования)"
roleLabel="Исполнитель" roleLabel="Исполнитель"
emptyText="Исполнители не заданы по маршруту." emptyText="Исполнители не заданы по маршруту."
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
/> />
)} )}
@@ -0,0 +1,20 @@
@import '../../../../styles/design-tokens';
.back {
display: inline-flex;
align-items: center;
gap: $sp-2;
margin-bottom: $sp-4;
padding: 0;
border: 0;
background: transparent;
color: $navy-700;
font-family: $font-sans;
font-size: $fs-sm;
cursor: pointer;
transition: color .18s;
&:hover {
color: $gold-600;
}
}
@@ -0,0 +1,339 @@
import * as React from 'react';
import { useState, useMemo, useCallback, useEffect } from 'react';
import styles from './PaymentRequestForm.module.scss';
import {
Alert,
CollapseSection,
FormCard,
InitiatorLine,
FormModeProvider,
Icon,
RequestNumber,
SubmitActions,
getDraftDeleteHandler,
useRequestSubmit
} from '../../shared/ui';
import {
composeRemoveRowRule,
ApprovalMatrix,
ApprovalHistory,
StatusBadge,
useMatrixEditor,
getPlannedMatrix,
loadRoutePhase,
isOpenedRouteReady,
notifyActiveStep,
useRequestTypeId,
REQUEST_STATUS,
isEditableStatus,
IApproverToWrite,
unassignedStepMessage,
resolveWriteListId,
useFormExecutors,
ExecutorsMatrix,
executorsToWrite,
initialSubmitStatus
} from '../../shared/approval';
import { effectiveUserEmail } from '../../shared/impersonation';
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import { todayIsoDate } from '../../shared/dateUtils';
import { IRequestFormProps } from '../../models/types';
import { approvalListTitle, approvalListTitleForListId, dataListTitle, dataListTitleForListId } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { savePaymentRequest, paymentRequestApprovalService } from './submit';
import { loadPaymentRequest } from './load';
import { loadPreviousPayments } from './services/previousPayments.service';
import { validatePayment } from './validation';
import { useBasisSelection } from './useBasisSelection';
import { useInvoiceFlow } from './useInvoiceFlow';
import { HeaderSection } from './sections/HeaderSection';
import { BasisSection } from './sections/BasisSection';
import { PaymentSection } from './sections/PaymentSection';
import { AmountsSection } from './sections/AmountsSection';
import { InvoiceSection } from './sections/InvoiceSection';
import { PositionsSection } from './sections/PositionsSection';
const PaymentRequestForm: 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();
const readOnly = !isEditableStatus(requestStatus);
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
const patch = useCallback((changes: Partial<IFormData>): void => setData((prev) => ({ ...prev, ...changes })), []);
const basis = useBasisSelection(context, patch, requestId);
const invoice = useInvoiceFlow(context, data, patch);
// Новая заявка — предзаполняем заявителя текущим пользователем и дату сегодняшней.
useEffect(() => {
if (editId) return undefined;
const requesterEmail = effectiveUserEmail(context);
setData((prev) => ({
...prev,
RequesterEmail: requesterEmail,
RequesterName: context.pageContext.user.displayName || '',
RequestDate: prev.RequestDate || todayIsoDate()
}));
let alive = true;
getEmployeeOrganizations(context, requesterEmail)
.then((records) => {
if (alive) {
setData((prev) => ({
...prev,
Organization: formatEmployeeOrganizations(records),
RequesterJobTitle: prev.RequesterJobTitle || records[0]?.position || '',
Department: prev.Department || records[0]?.section || '',
DepartmentId: prev.DepartmentId || records[0]?.departmentId || ''
}));
}
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [editId, context]);
// Выбор другого заявителя подтягивает его ФИО, должность, подразделение и организации.
const handleRequesterChange = (login: string): void => {
patch({ RequesterEmail: login });
if (!login) {
patch({ Organization: '' });
return;
}
getEmployeeOrganizations(context, login)
.then((records) =>
setData((prev) => ({
...prev,
RequesterName: records[0]?.fio || prev.RequesterName,
RequesterJobTitle: records[0]?.position || prev.RequesterJobTitle,
Department: records[0]?.section || prev.Department,
DepartmentId: records[0]?.departmentId || prev.DepartmentId,
Organization: formatEmployeeOrganizations(records)
}))
)
.catch(() => undefined);
};
// Открыта СВОЯ заявка на редактирование — подгружаем содержимое и статус.
useEffect(() => {
if (!editId) return undefined;
let alive = true;
loadPaymentRequest(editId, editListId)
.then((loaded) => {
if (!alive) return;
setData(loaded.data);
setRequestStatus(loaded.status);
setRequestCode(loaded.code || undefined);
if (loaded.code) onCodeLoaded?.(loaded.code);
// В элементе лежит снимок прежних оплат на момент сохранения. Пока заявку
// ещё правят (черновик, возврат), перечитываем их: по основанию могли
// подать новые, а лимит суммы должен считаться по актуальным.
if (isEditableStatus(loaded.status) && loaded.data.BasisKey) {
loadPreviousPayments(context, loaded.data.BasisKey, editId)
.then((previous) => {
if (alive) patch({ PreviousPayments: previous });
})
.catch(() => undefined); // остаётся снимок — лучше, чем пустая таблица
}
})
.catch((e) => {
if (alive) submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
});
return () => {
alive = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editId, editListId]);
const approval = useMemo(() => paymentRequestApprovalService(editListId), [editListId]);
const userEmail = data.RequesterEmail;
const requestTypeIdState = useRequestTypeId(context, requestType.id);
const requestTypeId = requestTypeIdState.id;
// Маршрут зависит от проекта — пока он не выбран, resolve вернёт 404.
const projectId = data.ProjectId || null;
const matrixReady =
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!userEmail && !!projectId && !!requestTypeId));
const loadMatrix = useCallback(
() =>
loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, undefined, projectId)
),
[context, userEmail, approval, editId, requestStatus, projectId, requestTypeId]
);
const matrix = useMatrixEditor(loadMatrix, `${userEmail}-${editId ?? 'new'}-${projectId}`, !readOnly, matrixReady, (login) =>
approval.getJobTitle(login)
);
const executors = useFormExecutors({
readOnly,
context,
approval,
requestTypeId,
userEmail: matrixReady ? userEmail : '',
editId,
requestStatus,
projectId
});
const formError = validatePayment(data);
const isValid = !formError;
const validate = (): string | undefined => {
if (formError) return formError;
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
return undefined;
};
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign, order: r.order }));
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
const rtId = requestTypeId;
if (!rtId) {
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
}
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
const { id, code } = await savePaymentRequest(requestId, rtId, data, toApprovers(matrix.rows), executorsToWrite(executors.rows), status, writeListId);
setRequestId(id);
setRequestStatus(status);
if (code) setRequestCode(code);
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 error = validate();
if (error) return error;
if (matrix.error) {
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
}
return (
unassignedStepMessage(
matrix.rows,
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
) ||
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 sectionProps = { data, set, patch, context, onRequesterChange: handleRequesterChange };
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}
fallback={{ name: data.RequesterName, email: data.RequesterEmail }}
/>
}
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}>
<HeaderSection {...sectionProps} />
<CollapseSection title="Основание" icon="document">
<BasisSection {...sectionProps} basis={basis} />
</CollapseSection>
<CollapseSection title="Вложения — счёт на оплату" icon="document">
<InvoiceSection data={data} invoice={invoice} />
</CollapseSection>
<CollapseSection title="Реквизиты платежа" icon="calculator">
<PaymentSection {...sectionProps} />
</CollapseSection>
<CollapseSection title="Суммы" icon="calculator">
<AmountsSection {...sectionProps} />
</CollapseSection>
<CollapseSection title="Позиции" icon="clipboard">
<PositionsSection rows={data.Positions} compared={!!data.InvoiceFileName} />
</CollapseSection>
</FormModeProvider>
<ApprovalMatrix
editor={matrix}
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
search={(q) => approval.searchUsers(q)}
waiting={!matrixReady}
waitingText="Укажите заявителя и проект, чтобы построить маршрут согласования."
/>
<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}
/>
</FormCard>
</div>
);
};
export default PaymentRequestForm;
@@ -0,0 +1,17 @@
@import '../../../../styles/design-tokens';
.state {
padding: $sp-8;
color: $gray-500;
font-size: $fs-sm;
text-align: center;
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: $sp-3;
margin-bottom: $sp-4;
}
@@ -0,0 +1,197 @@
import * as React from 'react';
import { useState, useEffect, useMemo } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './PaymentRequestView.module.scss';
import {
Alert,
Button,
CollapseSection,
FormCard,
InitiatorLine,
FormModeProvider,
PrintSplitButton,
OrgLetterhead,
Icon
} from '../../shared/ui';
import { navigate } from '../../shared/routing';
import {
ApproverService,
DecisionPanel,
StatusBadge,
ApprovalMatrix,
ApprovalHistory,
useMatrixEditor,
useViewExecutors,
ExecutorsMatrix,
approvalPhaseRows,
notifyActiveStep,
useRequestTypeId,
isEditableStatus
} from '../../shared/approval';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import { useStableCallback } from '../../shared/useStableCallback';
import { approvalListTitle, approvalListTitleForListId, dataListTitle, dataListTitleForListId } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { loadPaymentRequest } from './load';
import { paymentRequestApprovalService } from './submit';
import { HeaderSection } from './sections/HeaderSection';
import { PaymentSection } from './sections/PaymentSection';
import { AmountsSection } from './sections/AmountsSection';
import { InvoiceSection } from './sections/InvoiceSection';
import { PositionsSection } from './sections/PositionsSection';
import { IInvoiceFlow } from './useInvoiceFlow';
interface IPaymentRequestViewProps {
context: WebPartContext;
itemId: number;
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
formKey: string;
/** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */
listId?: string;
/** Уведомление о загруженном уникальном коде заявки — см. IApprovalViewProps.onCodeLoaded. */
onCodeLoaded?: (code: string) => void;
}
/** В просмотре счёт не загружается: поток нужен только секции как заглушка пропсов. */
const IDLE_INVOICE: IInvoiceFlow = { stage: 'idle', error: '', attach: () => Promise.resolve() };
// Заявка на оплату только для просмотра (режим согласующего).
const PaymentRequestView: React.FC<IPaymentRequestViewProps> = ({ 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 | null>(null);
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
const { id: requestTypeId } = useRequestTypeId(context, formKey);
const set: SetField = () => undefined;
const patch = (): void => undefined;
const approval = useMemo(() => paymentRequestApprovalService(listId), [listId]);
const dataListRef = listId || dataListTitle(FORM_ID);
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
const [decisionSeq, setDecisionSeq] = useState<number>(0);
const loadApprovers = React.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);
loadPaymentRequest(itemId, listId)
.then((loaded) => {
if (!alive) return;
setData(loaded.data);
setStatus(loaded.status);
setCode(loaded.code);
notifyCode(loaded.code);
setError(null);
setLoading(false);
})
.catch((e) => {
if (alive) {
setError(`Не удалось загрузить заявку №${itemId}: ${friendlyErrorMessage(e)}`);
setLoading(false);
}
});
return () => {
alive = false;
};
}, [itemId, listId, notifyCode]);
if (loading) {
return <div className={styles.state}>Загрузка заявки…</div>;
}
if (error) {
return (
<div className={styles.state}>
<Alert variant="err">{error}</Alert>
</div>
);
}
const sectionProps = { data, set, patch, context, printOrgIndex };
const notify = (): void => {
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined);
};
return (
<div>
<div className={`${styles.toolbar} rp-no-print`}>
{isEditableStatus(status) && (
<Button variant="secondary" onClick={() => navigate({ mode: 'edit', nav: 'new', formKey, itemId, listId })}>
<Icon name="edit" size="sm" />
Редактировать
</Button>
)}
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
</div>
<div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard
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>
<HeaderSection {...sectionProps} />
<CollapseSection title="Основание" icon="document">
<div>
{data.BasisTitle} · {data.ProjectName} · {data.CompanyName}
</div>
</CollapseSection>
<CollapseSection title="Вложения — счёт на оплату" icon="document">
<InvoiceSection data={data} invoice={IDLE_INVOICE} />
</CollapseSection>
<CollapseSection title="Реквизиты платежа" icon="calculator">
<PaymentSection {...sectionProps} />
</CollapseSection>
<CollapseSection title="Суммы" icon="calculator">
<AmountsSection {...sectionProps} />
</CollapseSection>
<CollapseSection title="Позиции" icon="clipboard">
<PositionsSection rows={data.Positions} compared={!!data.InvoiceFileName} />
</CollapseSection>
</FormModeProvider>
</FormCard>
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
</div>
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
</div>
<div className="rp-no-print">
<DecisionPanel
service={approverService}
requestId={itemId}
search={(q) => approval.searchUsers(q)}
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
notify();
}}
onDelegated={() => {
setDecisionSeq((n) => n + 1);
notify();
}}
/>
</div>
</div>
);
};
export default PaymentRequestView;
@@ -0,0 +1,40 @@
# Заявка на оплату (`acc-payment-request`)
**Назначение:** оплата по согласованной СТ (сравнительной таблице) или по потребности — счёт, получатель, суммы и сверка позиций счёта с СТ.
**Категория:** Бухгалтерия
**Списки SP:** «Заявка на оплату — данные», «Заявка на оплату — согласование»; библиотека «Счета на оплату» (создаётся вручную, колонка `file_content` — многострочный текст)
**RequestTypeId (внешний API маршрутов):** резолвится в рантайме по `pageUrl` (см. `docs/forms.md`); заведён тип 24, раздел «Бухгалтерия», список данных `/sites/requests/Lists/120`
## Маршрут
- Фазы: согласование (исполнителей нет)
- Маршрут зависит от проекта (`ProjectId`) — как у «Заявки на услуги»: пока проект не выбран, матрица не строится.
- Маршрут 25 (`byProject: true`, без компании и подразделения — для любого проекта): один шаг согласования, фиксированный человек `gorbenko.pavel@tnsu.kz`. Форма всегда шлёт `projectId`, поэтому маршрута с `byProject: false` нет.
## Особое поведение
- **Основание** — согласованная СТ (`status = Согласовано` в списке «Сравнительная таблица» сайта СТ, `CT_SITE_URL`) либо потребность (`GET /Requests/365`, позиции — `/Requests/{oid}/positions`; тот же источник, что шаг 1 СТ). Выбор подставляет проект, компанию (если известна), получателя, раздел, вид работ, условия, общую сумму и позиции (`basisPatch.ts`); всё остаётся редактируемым.
- **Получатель** — выигравший поставщик СТ (`winSupplier` → «Поставщики»). Если в счёте указан другой контрагент (БИН, иначе название без формы собственности) — поле подсвечивается (`recipientMatchesInvoice`).
- **Раздел / вид работ** у СТ — из позиций СТ (`disciplineCode` / `SMR`), у потребности — `discipline` / `workType` заявки.
- **Условия оплаты, доставки и срок** — из «Критериев» СТ по победителю.
- **Общая сумма** СТ — сумма предложений победителя в «Оценке поставщиков» (смешанные валюты — в тенге). У потребности её нет, берётся из счёта.
- **Сумма оплаты** может быть меньше общей (транши). Сумма всех оплат по основанию (прежние + эта) не может превышать общую — при превышении поле подсвечивается, отправку это **не** блокирует.
- **Ранее поданные заявки на оплату** — по `BasisKey` (`ct:<Id СТ>` / `need:<oid>`) через approval-route `GET /api/basis-requests` по всем партициям, без черновиков, отклонённых и возвращённых. Сервер читает от имени приложения, поэтому видны и чужие заявки (права на элементы разорваны поштучно); номер становится ссылкой, только если у пользователя есть права открыть заявку. Снимок пишется в `PreviousPaymentsJson`; черновик и возвращённая при открытии перечитывают список заново.
- **Счёт (PDF)**: сначала сохраняется в библиотеку «Счета на оплату», затем `POST /Document/extractPDFText` (OCR записывает текст в `file_content` до ответа, колонка читается сразу), затем ИИ (`/Proxy/Gemini`) достаёт номер счёта, поставщика, БИН, IBAN, валюту, суммы и позиции и подставляет их (назначение платежа — «Оплата по счёту № …»). При сбое OCR/ИИ счёт остаётся приложенным, поля заполняются вручную.
- **Таблица позиций**: код, наименование, ед. изм. и количество — из СТ/потребности; позиция поставщика — из его КП; позиция из счёта. ИИ сопоставляет позиции по смыслу, а количество и единицы сверяет код (`positions.ts`). Статусы: совпало / расхождение / нет в счёте / нет в СТ. Результат пишется в `PositionsJson`.
- **Код позиции потребности** — `lineNumber` (номер строки табличной части 1С, уникален в пределах заявки).
- Справочники 1С (`api-tnsu-portal`, `DictionaryController`) — `DictionaryPicker`; недоступный справочник не блокирует заявку (поле становится текстовым):
- **Банковский счёт** — `GET /Dictionary/BankAccounts?search=&companyId=&projectId=` (постранично, поиск на сервере по названию, коду, номеру счёта, банку). Сначала по компании и проекту; если по проекту пусто — повтор только по компании с пояснением под полем (совпадение Id проекта из `/Project` с `bank_accounts.project_id` не гарантировано).
- **Договор** — `GET /Dictionary/Contracts?search=` (только действующие; фильтра по компании нет, поиск по названию, коду и контрагенту).
- **Группа статьи ДДС** — `GET /Dictionary/CashFlowItemGroups` (целиком); **статья ДДС** — `GET /Dictionary/CashFlowItems?id=<группа>`.
- Фильтры-Id передаются, только если это GUID: иначе API отвечает 400.
## Что не проверено на живом API
- Совпадают ли Id компании из `/Dictionary/Companies` и проекта из `/Project` с `company_id` / `project_id` в `bank_accounts` (на это — откат на фильтр по компании).
- Доступ пользователей портала на чтение списков сайта СТ.
## Секции формы
HeaderSection, BasisSection (BasisPicker), InvoiceSection, PaymentSection, AmountsSection, PositionsSection
## Точки входа
- rptype: `acc-payment-request`
- Режим согласующего (просмотр): есть (`PaymentRequestView`)
- «Создать на основе»: —
@@ -0,0 +1,66 @@
import { formatAmountInput, overLimitAmount, parseAmount, previousPaymentsTotal, recipientMatchesInvoice } from './amounts';
describe('parseAmount', () => {
it('понимает пробелы и запятую', () => {
expect(parseAmount('1 234,50')).toBe(1234.5);
});
it('мусор и пустая строка — 0', () => {
expect(parseAmount('abc')).toBe(0);
expect(parseAmount('')).toBe(0);
});
});
describe('overLimitAmount', () => {
const prev = [
{ code: 'A', amount: 600 },
{ code: 'B', amount: 100 }
];
it('не превышает, пока сумма всех оплат не больше общей', () => {
expect(overLimitAmount({ TotalAmount: 1000, PayAmount: 300, PreviousPayments: prev })).toBe(0);
});
it('возвращает величину превышения', () => {
expect(overLimitAmount({ TotalAmount: 1000, PayAmount: 400, PreviousPayments: prev })).toBe(100);
});
it('копейки округления не считаются превышением', () => {
expect(overLimitAmount({ TotalAmount: 1000, PayAmount: 300.004, PreviousPayments: prev })).toBe(0);
});
it('без общей суммы сравнивать не с чем', () => {
expect(overLimitAmount({ TotalAmount: 0, PayAmount: 5, PreviousPayments: prev })).toBe(0);
});
it('считает сумму прежних оплат', () => {
expect(previousPaymentsTotal(prev)).toBe(700);
});
});
describe('recipientMatchesInvoice', () => {
it('БИН решает, когда он есть у обеих сторон', () => {
expect(recipientMatchesInvoice({ name: 'А', bin: '1' }, { name: 'Б', bin: '1' })).toBe(true);
expect(recipientMatchesInvoice({ name: 'А', bin: '1' }, { name: 'А', bin: '2' })).toBe(false);
});
it('без БИН сравнивает названия без формы собственности и кавычек', () => {
expect(recipientMatchesInvoice({ name: 'ТОО «Альфа Строй»', bin: '' }, { name: 'Альфа Строй, ТОО', bin: '' })).toBe(true);
expect(recipientMatchesInvoice({ name: 'ТОО «Альфа»', bin: '' }, { name: 'ТОО «Бета»', bin: '' })).toBe(false);
});
it('нет данных по счёту — расхождения нет', () => {
expect(recipientMatchesInvoice({ name: 'ТОО «Альфа»', bin: '1' }, { name: '', bin: '' })).toBe(true);
});
});
describe('formatAmountInput', () => {
it('разбивает на разряды', () => {
expect(formatAmountInput('1234567')).toBe('1 234 567');
expect(formatAmountInput('1 528 642')).toBe('1 528 642');
});
it('держит недописанную дробную часть и режет её до двух знаков', () => {
expect(formatAmountInput('1234,')).toBe('1 234,');
expect(formatAmountInput('1234.567')).toBe('1 234,56');
expect(formatAmountInput(',5')).toBe('0,5');
});
it('выкидывает мусор и лишние разделители', () => {
expect(formatAmountInput('12a3,4,5')).toBe('123,45');
expect(formatAmountInput('')).toBe('');
});
it('согласован с parseAmount', () => {
expect(parseAmount(formatAmountInput('154100,5'))).toBe(154100.5);
});
});
@@ -0,0 +1,75 @@
import { IFormData, IPreviousPayment } from './model';
/** Допуск при сравнении денег: копейки округления не считаем превышением. */
const MONEY_EPSILON = 0.005;
/** Число из текста поля ввода: пробелы и запятая допустимы; мусор → 0. */
export function parseAmount(raw: string): number {
const value = Number(raw.replace(/\s/g, '').replace(',', '.'));
return Number.isFinite(value) ? value : 0;
}
/**
* Текст поля суммы с разрядами («1234567,5» → «1 234 567,5»), как его видно при
* вводе: остаются цифры и один десятичный разделитель (запятая), в дробной
* части не больше двух знаков. Недописанное «12,» сохраняется — иначе запятую
* нельзя было бы ввести.
*/
export function formatAmountInput(raw: string): string {
const cleaned = raw.replace(/[^\d.,]/g, '').replace('.', ',');
const comma = cleaned.indexOf(',');
const intPart = (comma < 0 ? cleaned : cleaned.slice(0, comma)).replace(/,/g, '').replace(/^0+(?=\d)/, '');
const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
if (comma < 0) return grouped;
const fraction = cleaned.slice(comma + 1).replace(/,/g, '').slice(0, 2);
return `${grouped || '0'},${fraction}`;
}
/** Сумма ранее поданных оплат по основанию. */
export function previousPaymentsTotal(payments: IPreviousPayment[]): number {
return payments.reduce((sum, p) => sum + p.amount, 0);
}
/**
* На сколько все оплаты по основанию (прежние + эта) превышают общую сумму СТ.
* 0 — не превышают; общая сумма не задана — сравнивать не с чем, тоже 0.
*/
export function overLimitAmount(data: Pick<IFormData, 'TotalAmount' | 'PayAmount' | 'PreviousPayments'>): number {
if (data.TotalAmount <= 0) return 0;
const over = previousPaymentsTotal(data.PreviousPayments) + data.PayAmount - data.TotalAmount;
return over > MONEY_EPSILON ? over : 0;
}
/** Сумма с разрядами для показа («1 234 567,5»). */
export function formatAmount(value: number): string {
return value.toLocaleString('ru-RU', { maximumFractionDigits: 2 });
}
const LEGAL_FORMS = /(^|\s)(тоо|ао|ип|llp|ооо|товарищество с ограниченной ответственностью)(?=\s|$)/gi;
/** Название контрагента для сравнения: без формы собственности, кавычек и регистра. */
export function normalizeCounterparty(name: string): string {
return name
.toLowerCase()
.replace(/[^a-zа-яё0-9]+/gi, ' ')
.replace(LEGAL_FORMS, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Совпадает ли получатель из СТ с поставщиком из счёта. БИН надёжнее названия:
* если он есть у обеих сторон — решает он. Нет данных по счёту — расхождения нет
* (подсвечивать нечего).
*/
export function recipientMatchesInvoice(
recipient: { name: string; bin: string },
invoice: { name: string; bin: string }
): boolean {
if (!invoice.name && !invoice.bin) return true;
if (recipient.bin && invoice.bin) return recipient.bin === invoice.bin;
const a = normalizeCounterparty(recipient.name);
const b = normalizeCounterparty(invoice.name);
if (!a || !b) return true;
return a === b || a.indexOf(b) !== -1 || b.indexOf(a) !== -1;
}
@@ -0,0 +1,17 @@
{
"dataListTitle": "Заявка на оплату — согласование",
"columns": [
{ "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
{ "internalName": "ApprovalStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] },
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Заявка на оплату — данные", "lookupShowField": "Title" },
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] },
{ "internalName": "SLAhours", "title": "SLA, часов", "type": "Number" },
{ "internalName": "AskOverdueReason", "title": "Опрос причины просрочки", "type": "Boolean" },
{ "internalName": "AutoApprove", "title": "Автосогласование", "type": "Boolean" },
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
]
}
@@ -0,0 +1,54 @@
import { ICompany, IProject } from '../../shared/dictionary';
import { normalizeCounterparty } from './amounts';
import { IFormData, IPreviousPayment } from './model';
import { rowsFromBasis } from './positions';
import { IBasisInfo } from './services/basis.types';
/** Проект из справочника по названию: в СТ и потребности проект хранится только текстом. */
function findProject(projects: IProject[], name: string): IProject | undefined {
const wanted = name.trim().toLowerCase();
return wanted ? projects.filter((p) => p.name.trim().toLowerCase() === wanted)[0] : undefined;
}
/** Компания из справочника по подсказке (организация потребности). */
function findCompany(companies: ICompany[], hint: string): ICompany | undefined {
const wanted = normalizeCounterparty(hint);
if (!wanted) return undefined;
return companies.filter((c) => normalizeCounterparty(c.Name) === wanted)[0];
}
/**
* Поля формы, которые подставляет выбранное основание. Всё подставленное остаётся
* редактируемым: справочники не всегда точны, а заявитель лучше знает, что платит.
* Счёт и сумма оплаты основанием не затрагиваются.
*/
export function basisPatch(
info: IBasisInfo,
previous: IPreviousPayment[],
projects: IProject[],
companies: ICompany[]
): Partial<IFormData> {
const project = findProject(projects, info.projectName);
const company = findCompany(companies, info.companyHint);
return {
BasisKind: info.kind,
BasisKey: info.key,
BasisTitle: info.title,
ProjectName: info.projectName,
ProjectId: project ? project.oid : '',
ProjectManager: project ? project.projectManager || '' : '',
CompanyId: company ? company.Id : '',
CompanyName: company ? company.Name : '',
RecipientName: info.recipientName,
RecipientBin: info.recipientBin,
Section: info.section,
WorkKind: info.workKind,
TotalAmount: info.total,
Currency: info.currency,
DeliveryTerms: info.deliveryTerms,
PaymentTerms: info.paymentTerms,
DeliveryTime: info.deliveryTime,
PreviousPayments: previous,
Positions: rowsFromBasis(info.positions)
};
}
@@ -0,0 +1,62 @@
import { IFormData, IPositionRow } from './model';
import { buildRows, IInvoiceItem, IPositionPair } from './positions';
import { IInvoiceData } from './services/invoiceAi.service';
import { IBasisPosition } from './services/basis.types';
/** Назначение платежа по номеру счёта. */
export function purposeForInvoice(invoiceNumber: string): string {
return invoiceNumber ? `Оплата по счёту № ${invoiceNumber}` : '';
}
/**
* Позиции основания из уже собранной таблицы (строки «нет в СТ» — это позиции
* счёта, к основанию они не относятся). Нужны, чтобы повторно сверить таблицу с
* другим счётом, не перезагружая СТ.
*/
export function basisPositionsFromRows(rows: IPositionRow[]): IBasisPosition[] {
return rows
.filter((row) => row.status !== 'extra')
.map((row) => ({
code: row.code,
name: row.name,
unit: row.unit,
qty: row.qty,
supplierName: row.supplierName,
supplierQty: row.supplierQty,
supplierPrice: row.supplierPrice
}));
}
/**
* Что счёт подставляет в форму. Пустое значение из счёта поле не затирает.
* Общая сумма берётся из счёта только когда основание её не дало (потребность):
* у СТ общая сумма — предел для всех оплат, и счёт его менять не должен.
* Получатель у потребности — поставщик из счёта; у СТ — победитель, и расхождение
* со счётом подсвечивается, а не затирается.
*/
export function invoicePatch(
data: IFormData,
invoice: IInvoiceData,
items: IInvoiceItem[],
pairs: IPositionPair[]
): Partial<IFormData> {
const patch: Partial<IFormData> = {
InvoiceNumber: invoice.invoiceNumber,
InvoiceSupplierName: invoice.supplierName,
InvoiceSupplierBin: invoice.supplierBin,
Positions: buildRows(basisPositionsFromRows(data.Positions), items, pairs)
};
if (invoice.recipientAccount) patch.RecipientAccount = invoice.recipientAccount;
if (invoice.currency) patch.Currency = invoice.currency;
if (invoice.invoiceNumber) patch.PaymentPurpose = purposeForInvoice(invoice.invoiceNumber);
if (invoice.vatAmount > 0) patch.VatAmount = invoice.vatAmount;
if (invoice.totalAmount > 0) {
patch.PayAmount = invoice.totalAmount;
if (data.TotalAmount <= 0) patch.TotalAmount = invoice.totalAmount;
}
if (!data.RecipientName && invoice.supplierName) {
patch.RecipientName = invoice.supplierName;
patch.RecipientBin = invoice.supplierBin;
}
return patch;
}
@@ -0,0 +1,110 @@
import { getSP } from '../../shared/pnp/pnpjs';
import { dataListTitle } from '../../shared/listTitles';
import { spDateOnlyToIso } from '../../shared/dateUtils';
import { EMPTY_DATA, FORM_ID, IFormData, IPositionRow, IPreviousPayment, BasisKind } from './model';
export interface ILoadedRequest {
data: IFormData;
status: string;
/** Уникальный код заявки; '' — если запись создана до появления поля. */
code: string;
}
/** Текстовые колонки, которые читаются как есть (имя поля формы = имя колонки). */
const TEXT_KEYS = [
'RequesterEmail',
'RequesterName',
'RequesterJobTitle',
'Organization',
'Department',
'DepartmentId',
'BasisKey',
'BasisTitle',
'ProjectName',
'ProjectId',
'ProjectManager',
'CompanyId',
'CompanyName',
'BankAccountId',
'BankAccountName',
'RecipientName',
'RecipientBin',
'RecipientAccount',
'Section',
'WorkKind',
'ContractId',
'ContractName',
'CashFlowGroupId',
'CashFlowGroupName',
'CashFlowItemId',
'CashFlowItemName',
'PaymentPurpose',
'DeliveryTerms',
'PaymentTerms',
'Currency',
'DeliveryTime',
'Comment',
'InvoiceFileName',
'InvoiceUrl',
'InvoiceNumber',
'InvoiceSupplierName',
'InvoiceSupplierBin'
] as const;
const NUMBER_KEYS = ['TotalAmount', 'VatAmount', 'PayAmount'] as const;
const SELECT_FIELDS = [
'Id',
'RequestDate',
'BasisKind',
'PreviousPaymentsJson',
'PositionsJson',
'RequestStatus',
'RequestUniqueCode',
...TEXT_KEYS,
...NUMBER_KEYS
];
/**
* Безопасный разбор JSON-колонки табличной части: пустой массив при пустом
* значении или битом JSON — заявка должна открываться даже с испорченной
* таблицей, иначе её нельзя было бы починить через форму.
*/
function parseRows<T>(raw: unknown): T[] {
if (typeof raw !== 'string' || raw.trim() === '') return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as T[]) : [];
} catch {
return [];
}
}
/**
* Прочитать «Заявку на оплату» по id: модель формы + статус + код.
* listId — партиция, где лежит именно эта заявка; не задан — текущая.
*/
export async function loadPaymentRequest(itemId: number, listId?: string): Promise<ILoadedRequest> {
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const item = (await list.items.getById(itemId).select(...SELECT_FIELDS)()) as Record<string, unknown>;
const data: IFormData = {
...EMPTY_DATA,
RequestDate: spDateOnlyToIso(item.RequestDate),
BasisKind: item.BasisKind === 'need' ? ('need' as BasisKind) : 'ct',
PreviousPayments: parseRows<IPreviousPayment>(item.PreviousPaymentsJson),
Positions: parseRows<IPositionRow>(item.PositionsJson)
};
TEXT_KEYS.forEach((key) => {
data[key] = (item[key] as string) || '';
});
NUMBER_KEYS.forEach((key) => {
data[key] = Number(item[key] || 0);
});
return {
data,
status: (item.RequestStatus as string) || '',
code: (item.RequestUniqueCode as string) || ''
};
}
@@ -0,0 +1,189 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { RequestTypeId } from '../../models/types';
/**
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
* SharePoint выводятся из него, см. shared/listTitles.ts.
*/
export const FORM_ID: RequestTypeId = 'acc-payment-request';
/** На чём основана оплата: согласованная СТ либо потребность (когда СТ не было). */
export type BasisKind = 'ct' | 'need';
export const BASIS_OPTIONS: Array<{ value: BasisKind; label: string }> = [
{ value: 'ct', label: 'Сравнительная таблица (СТ)' },
{ value: 'need', label: 'Потребность' }
];
/** Ранее поданная заявка на оплату по той же СТ/потребности. */
export interface IPreviousPayment {
/** Уникальный код заявки на оплату. */
code: string;
amount: number;
/** Id элемента списка данных — для ссылки на заявку. Нет, если у пользователя нет прав её открыть (или в старом снимке). */
id?: number;
/** Список партиции (серверный относительный URL), где лежит заявка; '' — текущая. */
listId?: string;
/** Статус заявки на момент чтения. */
status?: string;
}
/**
* Результат сверки строки с позициями счёта:
* ok — совпало; diff — позиция та же, но отличается количество/ед. изм.;
* missing — есть в СТ/потребности, в счёте нет; extra — есть в счёте, в СТ нет.
*/
export type RowStatus = 'ok' | 'diff' | 'missing' | 'extra';
/** Строка таблицы позиций: СТ/потребность ↔ поставщик ↔ счёт. */
export interface IPositionRow {
/** Уникальный код позиции потребности. */
code: string;
/** Наименование и ед. изм. из потребности (в СТ они есть). */
name: string;
unit: string;
qty: number;
/** Позиция поставщика из его КП (только при основании «СТ»). */
supplierName: string;
supplierQty: number;
supplierPrice: number;
/** Позиция из счёта. */
invoiceName: string;
invoiceUnit: string;
invoiceQty: number;
invoicePrice: number;
invoiceSum: number;
status: RowStatus;
/** Пояснение к расхождению; '' — нет. */
note: string;
}
/**
* Данные формы «Заявка на оплату». Ключи = internalName колонок SP-списка
* «Заявка на оплату — данные». Табличные части уходят в JSON-колонки
* (PreviousPaymentsJson, PositionsJson), суммы — в числовые колонки (для отчётов).
*/
export interface IFormData {
// --- Заявитель ---
RequesterEmail: string;
RequesterName: string;
RequesterJobTitle: string;
Organization: string;
Department: string;
DepartmentId: string;
RequestDate: string;
// --- Основание ---
BasisKind: BasisKind;
/** Ключ основания для поиска прежних оплат: «ct:<Id СТ>» либо «need:<oid потребности>». */
BasisKey: string;
/** Подпись основания: «СТ №3 — Проект» либо «Потребность 081/26-04/0017». */
BasisTitle: string;
ProjectName: string;
/** oid проекта: по нему бэкенд резолвит маршрут согласования. */
ProjectId: string;
ProjectManager: string;
CompanyId: string;
CompanyName: string;
// --- Платёж ---
BankAccountId: string;
BankAccountName: string;
RecipientName: string;
RecipientBin: string;
/** Счёт получателя (IBAN) — из файла счёта. */
RecipientAccount: string;
Section: string;
WorkKind: string;
ContractId: string;
ContractName: string;
CashFlowGroupId: string;
CashFlowGroupName: string;
CashFlowItemId: string;
CashFlowItemName: string;
PaymentPurpose: string;
DeliveryTerms: string;
PaymentTerms: string;
// --- Суммы ---
/** Суммы хранятся числом: 0 — не заполнено. */
TotalAmount: number;
VatAmount: number;
Currency: string;
PayAmount: number;
DeliveryTime: string;
Comment: string;
// --- Счёт ---
InvoiceFileName: string;
/** Адрес файла в библиотеке счетов (ServerRelativeUrl). */
InvoiceUrl: string;
InvoiceNumber: string;
/** Поставщик, как он назван в самом счёте, — сверяется с получателем из СТ. */
InvoiceSupplierName: string;
InvoiceSupplierBin: string;
// --- Табличные части ---
PreviousPayments: IPreviousPayment[];
Positions: IPositionRow[];
}
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
/** Пропсы каждой секции формы. */
export interface ISectionProps {
data: IFormData;
set: SetField;
context: WebPartContext;
/** Подставить сразу несколько полей (выбор основания меняет их пачкой). */
patch: (changes: Partial<IFormData>) => void;
printOrgIndex?: number;
onRequesterChange?: (login: string) => void;
}
export const EMPTY_DATA: IFormData = {
RequesterEmail: '',
RequesterName: '',
RequesterJobTitle: '',
Organization: '',
Department: '',
DepartmentId: '',
RequestDate: '',
BasisKind: 'ct',
BasisKey: '',
BasisTitle: '',
ProjectName: '',
ProjectId: '',
ProjectManager: '',
CompanyId: '',
CompanyName: '',
BankAccountId: '',
BankAccountName: '',
RecipientName: '',
RecipientBin: '',
RecipientAccount: '',
Section: '',
WorkKind: '',
ContractId: '',
ContractName: '',
CashFlowGroupId: '',
CashFlowGroupName: '',
CashFlowItemId: '',
CashFlowItemName: '',
PaymentPurpose: '',
DeliveryTerms: '',
PaymentTerms: '',
TotalAmount: 0,
VatAmount: 0,
Currency: '',
PayAmount: 0,
DeliveryTime: '',
Comment: '',
InvoiceFileName: '',
InvoiceUrl: '',
InvoiceNumber: '',
InvoiceSupplierName: '',
InvoiceSupplierBin: '',
PreviousPayments: [],
Positions: []
};
@@ -0,0 +1,63 @@
import { buildRows, hasPositionIssues, IInvoiceItem } from './positions';
import { IBasisPosition } from './services/basis.types';
const base = (name: string, qty: number, unit = 'шт'): IBasisPosition => ({
code: `K-${name}`,
name,
unit,
qty,
supplierName: '',
supplierQty: 0,
supplierPrice: 0
});
const item = (name: string, quantity: number, unit = 'шт'): IInvoiceItem => ({
name,
unit,
quantity,
price: 10,
sum: 10 * quantity
});
describe('buildRows', () => {
it('совпавшая пара — ok', () => {
const rows = buildRows([base('Цемент', 5)], [item('Цемент М400', 5)], [{ baseIndex: 0, invoiceIndex: 0, note: '' }]);
expect(rows).toHaveLength(1);
expect(rows[0].status).toBe('ok');
expect(rows[0].invoiceName).toBe('Цемент М400');
});
it('разное количество — diff с пояснением', () => {
const rows = buildRows([base('Цемент', 5)], [item('Цемент', 7)], [{ baseIndex: 0, invoiceIndex: 0, note: '' }]);
expect(rows[0].status).toBe('diff');
expect(rows[0].note).toContain('количество');
});
it('единицы измерения сравниваются без регистра и точек', () => {
const rows = buildRows([base('Труба', 2, 'Шт.')], [item('Труба', 2, 'шт')], [{ baseIndex: 0, invoiceIndex: 0, note: '' }]);
expect(rows[0].status).toBe('ok');
});
it('позиция, которую ИИ не упомянул, не пропадает', () => {
const rows = buildRows([base('А', 1), base('Б', 1)], [item('А', 1), item('В', 1)], [{ baseIndex: 0, invoiceIndex: 0, note: '' }]);
expect(rows.map((r) => r.status)).toEqual(['ok', 'missing', 'extra']);
});
it('пары с неверными индексами и повторы отбрасываются', () => {
const rows = buildRows(
[base('А', 1)],
[item('А', 1), item('Б', 1)],
[
{ baseIndex: 0, invoiceIndex: 0, note: '' },
{ baseIndex: 0, invoiceIndex: 1, note: '' },
{ baseIndex: 9, invoiceIndex: 1, note: '' }
]
);
expect(rows.map((r) => r.status)).toEqual(['ok', 'extra']);
});
it('hasPositionIssues видит любое расхождение', () => {
expect(hasPositionIssues(buildRows([base('А', 1)], [], []))).toBe(true);
expect(hasPositionIssues(buildRows([base('А', 1)], [item('А', 1)], [{ baseIndex: 0, invoiceIndex: 0, note: '' }]))).toBe(false);
});
});
@@ -0,0 +1,129 @@
import { IPositionRow } from './model';
import { IBasisPosition } from './services/basis.types';
/** Позиция, распознанная в счёте. */
export interface IInvoiceItem {
name: string;
unit: string;
quantity: number;
price: number;
sum: number;
}
/**
* Пара, которую вернула ИИ: какой позиции основания (baseIndex) соответствует
* какая позиция счёта (invoiceIndex). −1 с одной из сторон — пары нет.
*/
export interface IPositionPair {
baseIndex: number;
invoiceIndex: number;
note: string;
}
const QTY_EPSILON = 0.0001;
function emptyInvoicePart(): Pick<IPositionRow, 'invoiceName' | 'invoiceUnit' | 'invoiceQty' | 'invoicePrice' | 'invoiceSum'> {
return { invoiceName: '', invoiceUnit: '', invoiceQty: 0, invoicePrice: 0, invoiceSum: 0 };
}
function invoicePart(item: IInvoiceItem): ReturnType<typeof emptyInvoicePart> {
return {
invoiceName: item.name,
invoiceUnit: item.unit,
invoiceQty: item.quantity,
invoicePrice: item.price,
invoiceSum: item.sum
};
}
/** Ед. изм. для сравнения: «шт.», «Шт» и «шт» — одно и то же. */
function normalizeUnit(unit: string): string {
return unit.toLowerCase().replace(/[.\s]/g, '');
}
function basisPart(position: IBasisPosition): Pick<IPositionRow, 'code' | 'name' | 'unit' | 'qty' | 'supplierName' | 'supplierQty' | 'supplierPrice'> {
return {
code: position.code,
name: position.name,
unit: position.unit,
qty: position.qty,
supplierName: position.supplierName,
supplierQty: position.supplierQty,
supplierPrice: position.supplierPrice
};
}
/** Строки таблицы до загрузки счёта: только основание, сверять пока не с чем. */
export function rowsFromBasis(base: IBasisPosition[]): IPositionRow[] {
return base.map((position) => ({
...basisPart(position),
...emptyInvoicePart(),
status: 'ok',
note: ''
}));
}
/** Расхождения парной строки: ед. изм. и количество сравниваем сами, не полагаясь на ИИ. */
function diffNote(position: IBasisPosition, item: IInvoiceItem, aiNote: string): string {
const notes: string[] = [];
if (position.unit && item.unit && normalizeUnit(position.unit) !== normalizeUnit(item.unit)) {
notes.push(`ед. изм.: ${position.unit} → ${item.unit}`);
}
if (Math.abs(position.qty - item.quantity) > QTY_EPSILON) {
notes.push(`количество: ${position.qty} → ${item.quantity}`);
}
// Смысловое отличие названия (другая марка, размер) видит только ИИ — его пометка сама по себе расхождение.
if (aiNote) notes.push(aiNote);
return notes.join('; ');
}
/**
* Собрать таблицу сверки из ответа ИИ. Пары с неверными индексами и повторы
* отбрасываются, а всё, что ИИ не упомянул, попадает в таблицу как «нет в счёте» /
* «нет в основании»: ни одна позиция не должна пропасть молча.
*/
export function buildRows(base: IBasisPosition[], invoice: IInvoiceItem[], pairs: IPositionPair[]): IPositionRow[] {
const invoiceByBase = new Map<number, { index: number; note: string }>();
const usedInvoice = new Set<number>();
pairs.forEach((pair) => {
const validBase = pair.baseIndex >= 0 && pair.baseIndex < base.length;
const validInvoice = pair.invoiceIndex >= 0 && pair.invoiceIndex < invoice.length;
if (!validBase || !validInvoice) return;
if (invoiceByBase.has(pair.baseIndex) || usedInvoice.has(pair.invoiceIndex)) return;
invoiceByBase.set(pair.baseIndex, { index: pair.invoiceIndex, note: pair.note });
usedInvoice.add(pair.invoiceIndex);
});
const rows: IPositionRow[] = base.map((position, baseIndex) => {
const match = invoiceByBase.get(baseIndex);
if (!match) {
return { ...basisPart(position), ...emptyInvoicePart(), status: 'missing', note: 'Нет в счёте' };
}
const item = invoice[match.index];
const note = diffNote(position, item, match.note);
return { ...basisPart(position), ...invoicePart(item), status: note ? 'diff' : 'ok', note };
});
invoice.forEach((item, index) => {
if (usedInvoice.has(index)) return;
rows.push({
code: '',
name: '',
unit: '',
qty: 0,
supplierName: '',
supplierQty: 0,
supplierPrice: 0,
...invoicePart(item),
status: 'extra',
note: 'Нет в СТ / потребности'
});
});
return rows;
}
/** Есть ли в таблице хоть одно расхождение со счётом. */
export function hasPositionIssues(rows: IPositionRow[]): boolean {
return rows.some((row) => row.status !== 'ok');
}
@@ -0,0 +1,57 @@
{
"dataListTitle": "Заявка на оплату — данные",
"columns": [
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
{ "internalName": "RequesterEmail", "title": "Заявитель (email)", "type": "Text" },
{ "internalName": "RequesterName", "title": "Заявитель (Ф.И.О.)", "type": "Text" },
{ "internalName": "RequesterJobTitle", "title": "Должность заявителя", "type": "Text" },
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
{ "internalName": "Department", "title": "Подразделение", "type": "Text" },
{ "internalName": "DepartmentId", "title": "Id подразделения (справочник)", "type": "Text" },
{ "internalName": "RequestDate", "title": "Дата заявки", "type": "DateTime", "dateOnly": true },
{ "internalName": "BasisKind", "title": "Основание (ct / need)", "type": "Text" },
{ "internalName": "BasisKey", "title": "Ключ основания (ct:Id / need:oid)", "type": "Text" },
{ "internalName": "BasisTitle", "title": "Основание", "type": "Text" },
{ "internalName": "ProjectName", "title": "Проект", "type": "Text" },
{ "internalName": "ProjectId", "title": "Id проекта", "type": "Text" },
{ "internalName": "ProjectManager", "title": "Руководитель проекта (email)", "type": "Text" },
{ "internalName": "CompanyId", "title": "Id компании", "type": "Text" },
{ "internalName": "CompanyName", "title": "Компания", "type": "Text" },
{ "internalName": "BankAccountId", "title": "Id банковского счёта (1С)", "type": "Text" },
{ "internalName": "BankAccountName", "title": "Банковский счёт", "type": "Text" },
{ "internalName": "RecipientName", "title": "Получатель", "type": "Text" },
{ "internalName": "RecipientBin", "title": "БИН получателя", "type": "Text" },
{ "internalName": "RecipientAccount", "title": "Счёт получателя", "type": "Text" },
{ "internalName": "Section", "title": "Раздел", "type": "Text" },
{ "internalName": "WorkKind", "title": "Вид работ", "type": "Text" },
{ "internalName": "ContractId", "title": "Id договора (1С)", "type": "Text" },
{ "internalName": "ContractName", "title": "Договор", "type": "Text" },
{ "internalName": "CashFlowGroupId", "title": "Id группы статьи ДДС", "type": "Text" },
{ "internalName": "CashFlowGroupName", "title": "Группа статьи ДДС", "type": "Text" },
{ "internalName": "CashFlowItemId", "title": "Id статьи ДДС", "type": "Text" },
{ "internalName": "CashFlowItemName", "title": "Статья ДДС", "type": "Text" },
{ "internalName": "PaymentPurpose", "title": "Назначение платежа", "type": "Note" },
{ "internalName": "DeliveryTerms", "title": "Условия доставки", "type": "Note" },
{ "internalName": "PaymentTerms", "title": "Условия оплаты", "type": "Note" },
{ "internalName": "TotalAmount", "title": "Общая сумма", "type": "Number" },
{ "internalName": "VatAmount", "title": "НДС", "type": "Number" },
{ "internalName": "Currency", "title": "Валюта", "type": "Text" },
{ "internalName": "PayAmount", "title": "Сумма оплаты", "type": "Number" },
{ "internalName": "DeliveryTime", "title": "Общий срок поставки", "type": "Text" },
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
{ "internalName": "InvoiceFileName", "title": "Файл счёта", "type": "Text" },
{ "internalName": "InvoiceUrl", "title": "Адрес файла счёта", "type": "Text" },
{ "internalName": "InvoiceNumber", "title": "Номер счёта", "type": "Text" },
{ "internalName": "InvoiceSupplierName", "title": "Поставщик по счёту", "type": "Text" },
{ "internalName": "InvoiceSupplierBin", "title": "БИН поставщика по счёту", "type": "Text" },
{ "internalName": "PreviousPaymentsJson", "title": "Ранее поданные оплаты (JSON)", "type": "Note" },
{ "internalName": "PositionsJson", "title": "Позиции: СТ, поставщик, счёт (JSON)", "type": "Note" }
]
}
@@ -0,0 +1,37 @@
import * as React from 'react';
import { TextInput } from '../../../shared/ui';
import { formatAmountInput, parseAmount } from '../amounts';
interface IAmountInputProps {
id?: string;
value: number;
onChange: (value: number) => void;
}
const toText = (value: number): string => (value ? formatAmountInput(String(value)) : '');
/**
* Поле суммы с десятичной частью и разрядами («1 234 567,50»). Хранит значение числом, но пока пользователь
* печатает, держит собственный текст: иначе «12,» превращалось бы в «12» на
* каждом нажатии и дробную часть ввести было бы нельзя.
*/
export const AmountInput: React.FC<IAmountInputProps> = ({ id, value, onChange }) => {
const [text, setText] = React.useState<string>(toText(value));
// Значение изменили снаружи (подставил счёт, загрузилась заявка) — показываем его.
React.useEffect(() => {
setText((current) => (parseAmount(current) === value ? current : toText(value)));
}, [value]);
return (
<TextInput
id={id}
value={text}
onChange={(next) => {
const formatted = formatAmountInput(next);
setText(formatted);
onChange(parseAmount(formatted));
}}
/>
);
};
@@ -0,0 +1,93 @@
import * as React from 'react';
import styles from './PaymentSections.module.scss';
import { FormField, TextInput, TextArea } from '../../../shared/ui';
import { formatAmount, overLimitAmount, previousPaymentsTotal } from '../amounts';
import { IPreviousPayment, ISectionProps } from '../model';
import { AmountInput } from './AmountInput';
import { RequestDrawer } from './RequestDrawer';
/**
* Суммы платежа. Сумма всех оплат по СТ (прежние заявки + эта) не может превышать
* общую сумму СТ — при превышении сумма оплаты подсвечивается, отправку это не блокирует.
*/
export const AmountsSection: React.FC<ISectionProps> = ({ data, set, context }) => {
// Прежняя заявка, открытая в панели справа; undefined — панель закрыта.
const [opened, setOpened] = React.useState<IPreviousPayment | undefined>(undefined);
const close = React.useCallback(() => setOpened(undefined), []);
const over = overLimitAmount(data);
const previousTotal = previousPaymentsTotal(data.PreviousPayments);
return (
<>
<FormField label="Общая сумма" htmlFor="TotalAmount">
<AmountInput id="TotalAmount" value={data.TotalAmount} onChange={(v) => set('TotalAmount', v)} />
</FormField>
<FormField label="Валюта" htmlFor="Currency">
<TextInput id="Currency" value={data.Currency} onChange={(v) => set('Currency', v.toUpperCase())} placeholder="KZT" />
</FormField>
<FormField label="НДС" htmlFor="VatAmount">
<AmountInput id="VatAmount" value={data.VatAmount} onChange={(v) => set('VatAmount', v)} />
</FormField>
<FormField label="Сумма оплаты" htmlFor="PayAmount" required>
<div className={over > 0 ? styles.warnBox : undefined}>
<AmountInput id="PayAmount" value={data.PayAmount} onChange={(v) => set('PayAmount', v)} />
{over > 0 && (
<div className={styles.warnText}>
Все оплаты по основанию превышают общую сумму на {formatAmount(over)} {data.Currency}.
</div>
)}
</div>
</FormField>
<FormField label="Общий срок поставки" htmlFor="DeliveryTime">
<TextInput id="DeliveryTime" value={data.DeliveryTime} onChange={(v) => set('DeliveryTime', v)} />
</FormField>
<FormField label="Комментарий" htmlFor="Comment" full>
<TextArea id="Comment" value={data.Comment} onChange={(v) => set('Comment', v)} rows={2} />
</FormField>
<h4 className={styles.fullRow}>Заявки на оплату по этому основанию</h4>
{data.PreviousPayments.length === 0 ? (
<div className={`${styles.empty} ${styles.fullRow}`}>Ранее заявок на оплату по этому основанию не было.</div>
) : (
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th>Номер заявки на оплату</th>
<th>Статус</th>
<th className={styles.num}>Сумма оплаты</th>
</tr>
</thead>
<tbody>
{data.PreviousPayments.map((p) => (
<tr key={p.code}>
<td>
{p.id ? (
<button type="button" className={styles.link} onClick={() => setOpened(p)}>
{p.code}
</button>
) : (
p.code
)}
</td>
<td>{p.status || ''}</td>
<td className={styles.num}>{formatAmount(p.amount)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={2}>Итого ранее</td>
<td className={styles.num}>{formatAmount(previousTotal)}</td>
</tr>
</tfoot>
</table>
</div>
)}
{opened && opened.id && (
<RequestDrawer context={context} itemId={opened.id} listId={opened.listId || ''} code={opened.code} onClose={close} />
)}
</>
);
};
@@ -0,0 +1,65 @@
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { IDictionaryItem, ItemPicker } from '../../../shared/dictionary';
import { friendlyErrorMessage } from '../../../shared/diagnostics';
import { BasisKind } from '../model';
import { IBasisOption } from '../services/basis.types';
import { listApprovedCts } from '../services/ctBasis.service';
import { listNeeds } from '../services/needBasis.service';
interface IBasisPickerProps {
context: WebPartContext;
kind: BasisKind;
/** Подпись выбранного основания. */
value: string;
onPick: (id: string) => void;
readOnly?: boolean;
}
function toPickerItem(option: IBasisOption): IDictionaryItem {
return { id: option.id, label: option.title, note: option.note };
}
/**
* Поиск основания: согласованные СТ либо потребности (по выбранному виду основания).
* Выбор запускает загрузку СТ/потребности целиком (см. useBasisSelection), поэтому
* ручной ввод текста основанием не считается — onPick зовётся только с id записи.
*/
export const BasisPicker: React.FC<IBasisPickerProps> = ({ context, kind, value, onPick, readOnly }) => {
const [items, setItems] = React.useState<IDictionaryItem[]>([]);
const [error, setError] = React.useState<string>('');
// null — показываем выбранное основание; строка — пользователь печатает запрос (в т.ч. пустой).
const [query, setQuery] = React.useState<string | null>(null);
React.useEffect(() => {
if (readOnly) return undefined;
let alive = true;
setError('');
setItems([]);
(kind === 'ct' ? listApprovedCts(context) : listNeeds(context))
.then((options) => {
if (alive) setItems(options.map(toPickerItem));
})
.catch((e) => {
if (alive) setError(`Не удалось загрузить список: ${friendlyErrorMessage(e)}`);
});
return () => {
alive = false;
};
}, [context, kind, readOnly]);
return (
<ItemPicker
id="BasisTitle"
items={items}
value={query ?? value}
readOnly={readOnly}
placeholder={kind === 'ct' ? 'Найдите согласованную СТ по номеру или проекту…' : 'Найдите потребность по номеру или проекту…'}
hint={error}
onChange={(label, id) => {
setQuery(id ? null : label);
if (id) onPick(id);
}}
/>
);
};
@@ -0,0 +1,87 @@
import * as React from 'react';
import { Alert, FormField, Select, ISelectOption } from '../../../shared/ui';
import styles from './PaymentSections.module.scss';
import { ICompany, ProjectPicker, getCompanies } from '../../../shared/dictionary';
import { BASIS_OPTIONS, BasisKind, ISectionProps } from '../model';
import { IBasisSelection } from '../useBasisSelection';
import { BasisPicker } from './BasisPicker';
interface IBasisSectionProps extends ISectionProps {
basis: IBasisSelection;
}
const KIND_OPTIONS: ISelectOption[] = BASIS_OPTIONS.map((o) => ({ value: o.value, label: o.label }));
/**
* Основание оплаты: согласованная СТ либо потребность. Выбор основания подставляет
* проект, раздел, вид работ, условия, суммы и позиции (см. basisPatch) — всё это
* остаётся редактируемым. Проект определяет маршрут согласования.
*/
export const BasisSection: React.FC<IBasisSectionProps> = ({ data, patch, context, basis }) => {
const [companies, setCompanies] = React.useState<ICompany[]>([]);
React.useEffect(() => {
let alive = true;
getCompanies(context)
.then((list) => {
if (alive) setCompanies(list);
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [context]);
const companyOptions: ISelectOption[] = companies.map((c) => ({ value: c.Id, label: c.Name }));
// Компания из заявки может отсутствовать в справочнике (его не удалось загрузить) — показываем её как есть.
if (data.CompanyId && !companies.some((c) => c.Id === data.CompanyId)) {
companyOptions.push({ value: data.CompanyId, label: data.CompanyName });
}
const handleKindChange = (value: string): void => {
patch({ BasisKind: value as BasisKind, BasisKey: '', BasisTitle: '', PreviousPayments: [], Positions: [] });
};
return (
<>
<FormField label="Основание" htmlFor="BasisKind" required>
<Select id="BasisKind" value={data.BasisKind} onChange={handleKindChange} options={KIND_OPTIONS} />
</FormField>
<FormField label={data.BasisKind === 'ct' ? 'Согласованная СТ' : 'Потребность'} htmlFor="BasisTitle" required>
<BasisPicker
context={context}
kind={data.BasisKind}
value={data.BasisTitle}
onPick={(id) => {
basis.select(data.BasisKind, id).catch(() => undefined);
}}
/>
</FormField>
<FormField label="Проект" htmlFor="ProjectName" required>
<ProjectPicker
id="ProjectName"
context={context}
value={data.ProjectName}
placeholder="Начните вводить название проекта…"
onChange={(name, id, project) => {
patch({ ProjectName: name, ProjectId: id, ProjectManager: project ? project.projectManager || '' : '' });
}}
/>
</FormField>
<FormField label="Компания" htmlFor="CompanyId" required>
<Select
id="CompanyId"
value={data.CompanyId}
onChange={(id) => {
const company = companies.filter((c) => c.Id === id)[0];
patch({ CompanyId: id, CompanyName: company ? company.Name : '' });
}}
options={companyOptions}
placeholder="Выберите компанию…"
/>
</FormField>
{basis.loading && <div className={styles.fullRow}><Alert variant="info">Загружаем данные основания…</Alert></div>}
{basis.error && <div className={styles.fullRow}><Alert variant="err">{basis.error}</Alert></div>}
</>
);
};
@@ -0,0 +1,37 @@
import * as React from 'react';
import { FormGrid, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui';
import { DepartmentPicker } from '../../../shared/dictionary';
import { ISectionProps } from '../model';
/** Шапка заявки: кто подаёт (по умолчанию текущий пользователь), должность, подразделение, дата. */
export const HeaderSection: React.FC<ISectionProps> = ({ data, set, context, printOrgIndex, onRequesterChange }) => (
<FormGrid>
<FormField label="Заявитель" htmlFor="RequesterEmail" required full>
<PeopleField
context={context}
value={data.RequesterEmail}
onChange={onRequesterChange ?? ((v) => set('RequesterEmail', v))}
placeholder="Начните вводить Ф.И.О. заявителя…"
/>
</FormField>
<OrganizationField value={data.Organization} printIndex={printOrgIndex} />
<FormField label="Должность заявителя" htmlFor="RequesterJobTitle">
<TextInput id="RequesterJobTitle" value={data.RequesterJobTitle} onChange={(v) => set('RequesterJobTitle', v)} />
</FormField>
<FormField label="Подразделение" htmlFor="Department">
<DepartmentPicker
id="Department"
context={context}
value={data.Department}
onChange={(name, id) => {
set('Department', name);
set('DepartmentId', id);
}}
/>
</FormField>
<FormField label="Дата заявки" htmlFor="RequestDate">
<TextInput id="RequestDate" type="date" value={data.RequestDate} onChange={(v) => set('RequestDate', v)} />
</FormField>
</FormGrid>
);
@@ -0,0 +1,70 @@
import * as React from 'react';
import styles from './PaymentSections.module.scss';
import { Alert, Button, Icon, useFormReadOnly } from '../../../shared/ui';
import { DATA_SITE_URL } from '../../../shared/config';
import { IFormData } from '../model';
import { INVOICE_STAGE_TEXT, IInvoiceFlow } from '../useInvoiceFlow';
interface IInvoiceSectionProps {
data: IFormData;
invoice: IInvoiceFlow;
}
/** Ссылка на файл счёта: в данных лежит серверный относительный адрес. */
function invoiceHref(serverRelativeUrl: string): string {
return new URL(serverRelativeUrl, DATA_SITE_URL).href;
}
/**
* Вложение — счёт на оплату (PDF). Файл сохраняется в библиотеку и уходит на OCR;
* ИИ достаёт из текста реквизиты, суммы и позиции и подставляет их в форму.
* Если распознать не вышло, счёт всё равно остаётся приложенным — поля заполняются руками.
*/
export const InvoiceSection: React.FC<IInvoiceSectionProps> = ({ data, invoice }) => {
const readOnly = useFormReadOnly();
const inputRef = React.useRef<HTMLInputElement>(null);
const busy = invoice.stage !== 'idle';
return (
<div>
<div className={styles.invoiceRow}>
{data.InvoiceFileName ? (
<span>
<Icon name="document" size="sm" />{' '}
{data.InvoiceUrl ? (
<a href={invoiceHref(data.InvoiceUrl)} target="_blank" rel="noreferrer">
{data.InvoiceFileName}
</a>
) : (
data.InvoiceFileName
)}
{data.InvoiceNumber && ` — счёт № ${data.InvoiceNumber}`}
</span>
) : (
<span className={styles.hint}>Счёт ещё не приложен.</span>
)}
{!readOnly && (
<>
<input
ref={inputRef}
className={styles.fileInput}
type="file"
accept="application/pdf,.pdf"
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (file) invoice.attach(file).catch(() => undefined);
}}
/>
<Button variant="secondary" disabled={busy} onClick={() => inputRef.current?.click()}>
<Icon name="upload" size="sm" />
{data.InvoiceFileName ? 'Заменить счёт' : 'Приложить счёт (PDF)'}
</Button>
</>
)}
{busy && <span className={styles.stage}>{INVOICE_STAGE_TEXT[invoice.stage]}</span>}
</div>
{invoice.error && <Alert variant="warn">{invoice.error}</Alert>}
</div>
);
};
@@ -0,0 +1,104 @@
import * as React from 'react';
import styles from './PaymentSections.module.scss';
import { FormField, TextInput, TextArea } from '../../../shared/ui';
import {
DictionaryPicker,
getCashFlowGroups,
getCashFlowItems,
searchBankAccounts,
searchContracts
} from '../../../shared/dictionary';
import { recipientMatchesInvoice } from '../amounts';
import { ISectionProps } from '../model';
/**
* Реквизиты платежа. Банковский счёт, договор и статьи ДДС — из справочников 1С
* (счета фильтруются по компании и проекту, договоры ищутся по названию, коду и
* контрагенту — фильтра по компании у API договоров нет). Получатель — победитель
* СТ; если в самом счёте указан другой контрагент, поле подсвечивается.
*/
export const PaymentSection: React.FC<ISectionProps> = ({ data, set, patch, context }) => {
const recipientMatches = recipientMatchesInvoice(
{ name: data.RecipientName, bin: data.RecipientBin },
{ name: data.InvoiceSupplierName, bin: data.InvoiceSupplierBin }
);
const accountFilter = { companyId: data.CompanyId, projectId: data.ProjectId };
return (
<>
<FormField label="Банковский счёт" htmlFor="BankAccountName" required>
<DictionaryPicker
id="BankAccountName"
load={(search) => searchBankAccounts(context, accountFilter, search)}
loadKey={`${data.CompanyId}|${data.ProjectId}`}
serverSearch
value={data.BankAccountName}
placeholder="Счёт компании в 1С…"
onChange={(label, id) => patch({ BankAccountName: label, BankAccountId: id })}
/>
</FormField>
<FormField label="Договор" htmlFor="ContractName">
<DictionaryPicker
id="ContractName"
load={(search) => searchContracts(context, search)}
loadKey="contracts"
serverSearch
value={data.ContractName}
placeholder="Название, код договора или контрагент…"
onChange={(label, id) => patch({ ContractName: label, ContractId: id })}
/>
</FormField>
<FormField label="Получатель" htmlFor="RecipientName" required>
<div className={recipientMatches ? undefined : styles.warnBox}>
<TextInput id="RecipientName" value={data.RecipientName} onChange={(v) => set('RecipientName', v)} />
{!recipientMatches && (
<div className={styles.warnText}>
В счёте указан другой контрагент: {data.InvoiceSupplierName || '—'}
{data.InvoiceSupplierBin ? ` (БИН ${data.InvoiceSupplierBin})` : ''}. Проверьте получателя.
</div>
)}
</div>
</FormField>
<FormField label="Счёт получателя (IBAN)" htmlFor="RecipientAccount" required>
<TextInput id="RecipientAccount" value={data.RecipientAccount} onChange={(v) => set('RecipientAccount', v)} />
</FormField>
<FormField label="Раздел" htmlFor="Section">
<TextInput id="Section" value={data.Section} onChange={(v) => set('Section', v)} />
</FormField>
<FormField label="Вид работ" htmlFor="WorkKind">
<TextInput id="WorkKind" value={data.WorkKind} onChange={(v) => set('WorkKind', v)} />
</FormField>
<FormField label="Группа статьи ДДС" htmlFor="CashFlowGroupName" required>
<DictionaryPicker
id="CashFlowGroupName"
load={() => getCashFlowGroups(context)}
loadKey="cash-flow-groups"
value={data.CashFlowGroupName}
onChange={(label, id) => patch({ CashFlowGroupName: label, CashFlowGroupId: id, CashFlowItemName: '', CashFlowItemId: '' })}
/>
</FormField>
<FormField label="Статья ДДС" htmlFor="CashFlowItemName" required>
<DictionaryPicker
id="CashFlowItemName"
load={() => getCashFlowItems(context, data.CashFlowGroupId)}
loadKey={data.CashFlowGroupId}
value={data.CashFlowItemName}
onChange={(label, id) => patch({ CashFlowItemName: label, CashFlowItemId: id })}
/>
</FormField>
<FormField label="Назначение платежа" htmlFor="PaymentPurpose" required full>
<TextArea id="PaymentPurpose" value={data.PaymentPurpose} onChange={(v) => set('PaymentPurpose', v)} rows={2} />
</FormField>
<FormField label="Условия доставки" htmlFor="DeliveryTerms">
<TextArea id="DeliveryTerms" value={data.DeliveryTerms} onChange={(v) => set('DeliveryTerms', v)} rows={2} />
</FormField>
<FormField label="Условия оплаты" htmlFor="PaymentTerms">
<TextArea id="PaymentTerms" value={data.PaymentTerms} onChange={(v) => set('PaymentTerms', v)} rows={2} />
</FormField>
</>
);
};
@@ -0,0 +1,125 @@
@import '../../../../../styles/design-tokens';
// Подсветка расхождений: поле, значение которого стоит перепроверить, обводится
// красным, а под ним — пояснение. Подсветка не блокирует отправку: решает человек.
.warnBox {
padding: $sp-1;
border: 1px solid $red-500;
border-radius: $r-sm;
background: $red-50;
}
.warnText {
margin-top: $sp-1;
font-size: $fs-caption;
color: $red-500;
}
.hint {
margin-top: $sp-1;
font-size: $fs-caption;
color: $gray-500;
}
.invoiceRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: $sp-4;
}
.fileInput {
display: none;
}
.stage {
font-size: $fs-sm;
color: $navy-700;
}
.tableWrap {
// Тело секции — flex/grid из ui-kit: без явной ширины блок ужимается по содержимому.
width: 100%;
min-width: 0;
box-sizing: border-box;
grid-column: 1 / -1;
overflow-x: auto;
border: 1px solid $gray-200;
border-radius: $r-sm;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: $fs-sm;
th,
td {
padding: 8px 10px;
border-bottom: 1px solid $gray-200;
text-align: left;
vertical-align: top;
}
th {
background: $gray-50;
font-size: $fs-caption;
font-weight: 600;
color: $gray-700;
white-space: nowrap;
}
tfoot td {
font-weight: 600;
background: $gray-50;
}
}
.num {
text-align: right !important;
white-space: nowrap;
}
.rowDiff {
background: $gold-50;
}
.rowBad {
background: $red-50;
}
.rowOk {
color: $green-600;
}
.statusBad {
color: $red-500;
font-weight: 600;
}
.empty {
padding: $sp-4;
color: $gray-500;
font-size: $fs-sm;
}
// Тело CollapseSection из ui-kit — сетка в две колонки; всё не-поле растягиваем на обе.
.fullRow {
grid-column: 1 / -1;
min-width: 0;
}
.link {
padding: 0;
border: 0;
background: none;
font: inherit;
cursor: pointer;
color: $navy-700;
font-weight: 600;
text-decoration: underline;
&:hover {
color: $gold-600;
}
}
@@ -0,0 +1,85 @@
import * as React from 'react';
import styles from './PaymentSections.module.scss';
import { Alert } from '../../../shared/ui';
import { formatAmount } from '../amounts';
import { IPositionRow, RowStatus } from '../model';
import { hasPositionIssues } from '../positions';
interface IPositionsSectionProps {
rows: IPositionRow[];
/** Счёт приложен и сверка выполнялась — только тогда статусы строк что-то значат. */
compared: boolean;
}
const STATUS_LABEL: Record<RowStatus, string> = {
ok: 'Совпадает',
diff: 'Расхождение',
missing: 'Нет в счёте',
extra: 'Нет в СТ'
};
function rowClass(row: IPositionRow, compared: boolean): string | undefined {
if (!compared) return undefined;
if (row.status === 'diff') return styles.rowDiff;
if (row.status === 'missing' || row.status === 'extra') return styles.rowBad;
return undefined;
}
const cell = (value: number): string => (value ? formatAmount(value) : '');
/**
* Позиции: из СТ/потребности (код, наименование, ед. изм.), позиции поставщика и
* позиции из счёта. Строки с расхождениями подсвечены; сверку выполняет ИИ при
* загрузке счёта, количество и единицы сверяются кодом.
*/
export const PositionsSection: React.FC<IPositionsSectionProps> = ({ rows, compared }) => {
if (rows.length === 0) {
return <div className={styles.empty}>Позиции появятся после выбора основания.</div>;
}
return (
<>
{compared && hasPositionIssues(rows) && (
<Alert variant="warn">Позиции счёта отличаются от СТ/потребности — расхождения подсвечены.</Alert>
)}
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th>Код</th>
<th>Наименование (потребность)</th>
<th>Ед. изм.</th>
<th className={styles.num}>Кол-во</th>
<th>Позиция поставщика</th>
<th>Позиция из счёта</th>
<th className={styles.num}>Кол-во в счёте</th>
<th className={styles.num}>Цена</th>
<th className={styles.num}>Сумма</th>
{compared && <th>Сверка</th>}
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={`${row.code}-${index}`} className={rowClass(row, compared)}>
<td>{row.code}</td>
<td>{row.name}</td>
<td>{row.unit}</td>
<td className={styles.num}>{cell(row.qty)}</td>
<td>{row.supplierName}</td>
<td>{row.invoiceName}</td>
<td className={styles.num}>{cell(row.invoiceQty)}</td>
<td className={styles.num}>{cell(row.invoicePrice)}</td>
<td className={styles.num}>{cell(row.invoiceSum)}</td>
{compared && (
<td className={row.status === 'ok' ? styles.rowOk : styles.statusBad}>
{STATUS_LABEL[row.status]}
{row.note && row.status !== 'ok' && <div>{row.note}</div>}
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</>
);
};
@@ -0,0 +1,41 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { Drawer } from '../../../components/Drawer/Drawer';
import { Spinner } from '@tnsu/ui-kit';
import portalStyles from '../../../components/RequestPortal.module.scss';
import { FORM_ID } from '../model';
// Лениво: ApproveFeature через реестр просмотров тянет PaymentRequestView, а тот —
// эту же секцию. Статический импорт замкнул бы цикл модулей.
const ApproveFeature = React.lazy(() => import(/* webpackChunkName: 'approve-feature' */ '../../../features/approve/ApproveFeature'));
interface IRequestDrawerProps {
context: WebPartContext;
itemId: number;
/** Партиция, где лежит заявка; '' — текущая. */
listId: string;
code: string;
onClose: () => void;
}
/**
* Прежняя заявка на оплату в выезжающей панели — той же, что у таблиц портала.
* Открываем её здесь, а не переходом на rpmode=approve: смена маршрута
* пересоздала бы открытую форму, и всё введённое пропало бы.
*
* Через портал в body: форма сама бывает открыта в панели (черновик), а
* fixed-слой внутри неё ограничивался бы её рамками. Корневой класс портала
* возвращает шрифт и цвет, которые в body не наследуются.
*/
export const RequestDrawer: React.FC<IRequestDrawerProps> = ({ context, itemId, listId, code, onClose }) =>
ReactDOM.createPortal(
<div className={portalStyles.requestPortal}>
<Drawer title={`Заявка №${code}`} onClose={onClose}>
<React.Suspense fallback={<Spinner block label="Загружаем заявку…" />}>
<ApproveFeature context={context} formKey={FORM_ID} itemId={itemId} listId={listId || undefined} />
</React.Suspense>
</Drawer>
</div>,
document.body
);
@@ -0,0 +1,47 @@
import { BasisKind } from '../model';
/** Позиция основания: из СТ (с предложением победителя) либо из потребности. */
export interface IBasisPosition {
/** Уникальный код позиции потребности; '' — у потребности его нет. */
code: string;
name: string;
unit: string;
qty: number;
/** Позиция поставщика из его КП; '' / 0 — у потребности КП нет. */
supplierName: string;
supplierQty: number;
supplierPrice: number;
}
/** Всё, что форма берёт из выбранной СТ или потребности. */
export interface IBasisInfo {
kind: BasisKind;
/** «ct:<Id СТ>» / «need:<oid потребности>» — по нему ищем прежние оплаты. */
key: string;
title: string;
projectName: string;
/** Раздел и вид работ. */
section: string;
workKind: string;
/** Компания-заказчик, если известна (подбирается по справочнику компаний). */
companyHint: string;
/** Выигравший поставщик СТ; у потребности пустой. */
recipientName: string;
recipientBin: string;
/** Общая сумма основания и её валюта; 0 — не определена. */
total: number;
currency: string;
deliveryTerms: string;
paymentTerms: string;
deliveryTime: string;
positions: IBasisPosition[];
}
/** Строка выпадающего списка выбора основания. */
export interface IBasisOption {
/** Id СТ либо oid потребности. */
id: string;
title: string;
/** Пояснение: проект, дата, число позиций. */
note: string;
}
@@ -0,0 +1,210 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { CT_SITE_URL } from '../../../shared/config';
import { spForSite } from '../../../shared/pnp/pnpjs';
import { IBasisInfo, IBasisOption, IBasisPosition } from './basis.types';
// Списки сайта СТ (см. ct-forms-portal, CtService.LISTS) — имена те же.
const CT_LISTS = {
ct: 'Сравнительная таблица',
positions: 'Позиции СТ',
offers: 'Оценка поставщиков',
suppliers: 'Поставщики',
criteria: 'Критерии',
rates: 'Курс Валют'
};
/** Значение Choice «Статус» у согласованной СТ. */
const CT_STATUS_APPROVED = 'Согласовано';
/** Валюта предложений без подстановки «Валюта» — в СТ это тенге. */
const DEFAULT_CURRENCY = 'KZT';
const MAX_CTS = 500;
const MAX_POSITIONS = 500;
/** Сколько позиций кладём в один OR-фильтр: длинная строка запроса SharePoint не принимает. */
const FILTER_CHUNK = 20;
interface ICtRow {
Id: number;
number?: number | string;
project?: string;
winSupplierId?: number;
}
interface IPositionRow {
Id: number;
Title?: string;
unit?: string;
quantity?: number;
disciplineCode?: string;
SMR?: string;
uniqueCode?: string;
}
interface IOfferRow {
positionId: number;
supplierPosition?: string;
quantity?: number;
price?: number;
summa?: number;
summaTenge?: number;
valutaId?: number;
}
interface ICriteriaRow {
paymentType?: string;
deliveryType?: string;
deadline?: string;
criteriaLabel?: string;
}
function ctWeb(context: WebPartContext): ReturnType<typeof spForSite>['web'] {
return spForSite(CT_SITE_URL, context).web;
}
function chunk<T>(items: T[], size: number): T[][] {
const result: T[][] = [];
for (let i = 0; i < items.length; i += size) result.push(items.slice(i, i + size));
return result;
}
function unique(values: Array<string | undefined>): string[] {
const seen: string[] = [];
values.forEach((v) => {
const value = (v || '').trim();
if (value && seen.indexOf(value) === -1) seen.push(value);
});
return seen;
}
function ctTitle(row: ICtRow): string {
return `СТ №${row.number ?? row.Id}${row.project ? ` — ${row.project}` : ''}`;
}
/** Согласованные СТ для выбора основания, новые сверху. */
export async function listApprovedCts(context: WebPartContext): Promise<IBasisOption[]> {
const rows = (await ctWeb(context)
.lists.getByTitle(CT_LISTS.ct)
.items.select('Id', 'number', 'project', 'winSupplierId')
.filter(`status eq '${CT_STATUS_APPROVED}'`)
.orderBy('Id', false)
.top(MAX_CTS)()) as ICtRow[];
return rows.map((row) => ({
id: String(row.Id),
title: ctTitle(row),
note: row.winSupplierId ? '' : 'победитель не выбран'
}));
}
/** Предложения победителя по позициям СТ (по чанкам — см. FILTER_CHUNK). */
async function loadWinnerOffers(
context: WebPartContext,
supplierId: number,
positionIds: number[]
): Promise<IOfferRow[]> {
const lists = ctWeb(context).lists;
const parts = await Promise.all(
chunk(positionIds, FILTER_CHUNK).map((ids) =>
lists
.getByTitle(CT_LISTS.offers)
.items.select('Id', 'positionId', 'supplierPosition', 'quantity', 'price', 'summa', 'summaTenge', 'valutaId')
.filter(`supplierId eq ${supplierId} and (${ids.map((id) => `positionId eq ${id}`).join(' or ')})`)
.top(MAX_POSITIONS)()
)
);
return ([] as IOfferRow[]).concat(...(parts as IOfferRow[][]));
}
/** Код валюты по Id записи «Курс Валют»; пустая подстановка — тенге. */
async function loadCurrencyCodes(context: WebPartContext, ids: number[]): Promise<Map<number, string>> {
const codes = new Map<number, string>();
await Promise.all(
ids.map(async (id) => {
const rate = (await ctWeb(context).lists.getByTitle(CT_LISTS.rates).items.getById(id).select('code')()) as { code?: string };
codes.set(id, rate.code || DEFAULT_CURRENCY);
})
);
return codes;
}
/** Условия победителя из «Критериев» СТ: оплата, доставка, срок. */
async function loadWinnerTerms(context: WebPartContext, ctId: number, supplierId: number): Promise<ICriteriaRow> {
const rows = (await ctWeb(context)
.lists.getByTitle(CT_LISTS.criteria)
.items.select('Id', 'paymentType', 'deliveryType', 'deadline', 'criteriaLabel')
.filter(`CTId eq ${ctId} and supplierId eq ${supplierId}`)
.top(MAX_POSITIONS)()) as ICriteriaRow[];
// Строки с criteriaLabel — произвольные критерии, а не условия поставки.
return rows.filter((r) => !r.criteriaLabel && (r.paymentType || r.deliveryType || r.deadline))[0] || {};
}
function toBasisPosition(position: IPositionRow, offers: IOfferRow[]): IBasisPosition {
return {
code: position.uniqueCode || '',
name: position.Title || '',
unit: position.unit || '',
qty: Number(position.quantity || 0),
supplierName: unique(offers.map((o) => o.supplierPosition)).join(' / '),
supplierQty: offers.reduce((sum, o) => sum + Number(o.quantity || 0), 0),
supplierPrice: Number(offers[0]?.price || 0)
};
}
/**
* Общая сумма предложений победителя. Все в одной валюте — суммируем «Сумма» в
* этой валюте; смешанные — берём «Сумма в тенге», иначе числа несопоставимы.
*/
function offersTotal(offers: IOfferRow[], codes: Map<number, string>): { total: number; currency: string } {
const currencies = unique(offers.map((o) => (o.valutaId ? codes.get(o.valutaId) : DEFAULT_CURRENCY)));
if (currencies.length > 1) {
return { total: offers.reduce((s, o) => s + Number(o.summaTenge || 0), 0), currency: DEFAULT_CURRENCY };
}
return { total: offers.reduce((s, o) => s + Number(o.summa || 0), 0), currency: currencies[0] || DEFAULT_CURRENCY };
}
/** Всё, что форма берёт из согласованной СТ: победитель, позиции, суммы, условия. */
export async function loadCtBasis(context: WebPartContext, ctId: string): Promise<IBasisInfo> {
const web = ctWeb(context);
const id = parseInt(ctId, 10);
const [ct, positions] = await Promise.all([
web.lists.getByTitle(CT_LISTS.ct).items.getById(id).select('Id', 'number', 'project', 'winSupplierId')() as Promise<ICtRow>,
web.lists
.getByTitle(CT_LISTS.positions)
.items.select('Id', 'Title', 'unit', 'quantity', 'disciplineCode', 'SMR', 'uniqueCode')
.filter(`CTId eq ${id}`)
.top(MAX_POSITIONS)() as Promise<IPositionRow[]>
]);
const winnerId = ct.winSupplierId || 0;
const [winner, offers, terms] = await Promise.all([
winnerId
? (web.lists.getByTitle(CT_LISTS.suppliers).items.getById(winnerId).select('name', 'bin')() as Promise<{ name?: string; bin?: string }>)
: Promise.resolve({} as { name?: string; bin?: string }),
winnerId && positions.length > 0 ? loadWinnerOffers(context, winnerId, positions.map((p) => p.Id)) : Promise.resolve([] as IOfferRow[]),
winnerId ? loadWinnerTerms(context, id, winnerId) : Promise.resolve({} as ICriteriaRow)
]);
const currencyIds = unique(offers.map((o) => (o.valutaId ? String(o.valutaId) : ''))).map(Number);
const codes = await loadCurrencyCodes(context, currencyIds);
const { total, currency } = offersTotal(offers, codes);
return {
kind: 'ct',
key: `ct:${ct.Id}`,
title: ctTitle(ct),
projectName: ct.project || '',
section: unique(positions.map((p) => p.disciplineCode)).join(', '),
workKind: unique(positions.map((p) => p.SMR)).join(', '),
companyHint: '',
recipientName: winner.name || '',
recipientBin: winner.bin || '',
total,
currency,
deliveryTerms: terms.deliveryType || '',
paymentTerms: terms.paymentType || '',
deliveryTime: terms.deadline || '',
positions: positions.map((p) =>
toBasisPosition(
p,
offers.filter((o) => o.positionId === p.Id)
)
)
};
}
@@ -0,0 +1,92 @@
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/items';
import '@pnp/sp/folders';
import '@pnp/sp/files';
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { APPROVAL_API_CLIENT_ID, DATA_SITE_URL, DICTIONARY_API_BASE_URL } from '../../../shared/config';
import { PAYMENT_INVOICES_LIBRARY_TITLE } from '../../../shared/listTitles';
import { getSP } from '../../../shared/pnp/pnpjs';
import { fileStamp } from '../../../shared/chat/chatFileNames';
/** Колонка библиотеки, куда OCR кладёт распознанный текст счёта. */
const FILE_CONTENT_FIELD = 'file_content';
export const MAX_INVOICE_SIZE_BYTES = 15 * 1024 * 1024;
export interface IStoredInvoice {
/** Id элемента библиотеки — по нему сервис OCR находит файл. */
itemId: number;
/** Имя файла, как он назван в библиотеке (с отметкой времени). */
storedName: string;
serverRelativeUrl: string;
}
/** Проверка файла счёта до загрузки. Возвращает текст ошибки или undefined. */
export function validateInvoiceFile(file: File): string | undefined {
if (!/\.pdf$/i.test(file.name)) {
return 'Счёт должен быть в формате PDF — другие форматы сервис распознавания не читает.';
}
if (file.size > MAX_INVOICE_SIZE_BYTES) {
return `Файл больше ${Math.round(MAX_INVOICE_SIZE_BYTES / (1024 * 1024))} МБ.`;
}
return undefined;
}
/**
* Сохранить файл счёта в библиотеку. Делаем это сразу при выборе файла, до
* сохранения самой заявки: OCR работает с элементом библиотеки, а не с вложением.
* Имя в библиотеке — только латиница и цифры: OCR-сервис получает имя в multipart и
* на кириллице теряет расширение («unsupported file type ''»). Исходное имя
* хранится в данных заявки (InvoiceFileName), поэтому в библиотеке оно не нужно.
* Метка времени и случайный хвост исключают перезапись счёта другой заявки.
*/
export async function storeInvoice(file: File): Promise<IStoredInvoice> {
const storedName = `invoice-${fileStamp(new Date())}-${Math.random().toString(36).slice(2, 8)}.pdf`;
const root = getSP().web.lists.getByTitle(PAYMENT_INVOICES_LIBRARY_TITLE).rootFolder;
const added = await root.files.addUsingPath(storedName, file);
// Id элемента библиотеки нужен OCR, а addUsingPath отдаёт только сведения о файле.
const item = (await getSP()
.web.getFileByServerRelativePath(added.ServerRelativeUrl)
.listItemAllFields.select('Id')()) as { Id: number };
return { itemId: item.Id, storedName, serverRelativeUrl: added.ServerRelativeUrl };
}
async function readFileContent(itemId: number): Promise<string> {
const item = (await getSP()
.web.lists.getByTitle(PAYMENT_INVOICES_LIBRARY_TITLE)
.items.getById(itemId)
.select(FILE_CONTENT_FIELD)()) as Record<string, unknown>;
return typeof item[FILE_CONTENT_FIELD] === 'string' ? (item[FILE_CONTENT_FIELD] as string).trim() : '';
}
/**
* Отправить сохранённый счёт на OCR (POST /Document/extractPDFText) и вернуть
* распознанный текст из колонки file_content. Сервис отвечает только после того,
* как записал текст в колонку, поэтому читаем её сразу, без опроса.
*/
export async function recognizeInvoice(context: WebPartContext, itemId: number): Promise<string> {
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
const response: HttpClientResponse = await client.post(
`${DICTIONARY_API_BASE_URL}/Document/extractPDFText`,
AadHttpClient.configurations.v1,
{
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
siteUrl: DATA_SITE_URL,
libraryName: PAYMENT_INVOICES_LIBRARY_TITLE,
itemId: String(itemId)
})
}
);
if (!response.ok) {
throw new Error(`Не удалось распознать счёт (Document/extractPDFText): ${response.status} ${response.statusText}`);
}
const text = await readFileContent(itemId);
if (!text) {
throw new Error('Сервис распознавания не нашёл в счёте текста.');
}
return text;
}
@@ -0,0 +1,115 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { askGemini, IGeminiSchema } from '../../../shared/ai/gemini.service';
import { IInvoiceItem, IPositionPair } from '../positions';
import { IBasisPosition } from './basis.types';
/** Текст счёта режем: прокси ИИ ограничен по размеру запроса, а хвост счёта — реквизиты и подписи. */
const MAX_INVOICE_TEXT_CHARS = 30000;
/** Что ИИ достаёт из текста счёта. */
export interface IInvoiceData {
invoiceNumber: string;
supplierName: string;
supplierBin: string;
/** Счёт получателя (IBAN). */
recipientAccount: string;
currency: string;
totalAmount: number;
vatAmount: number;
items: IInvoiceItem[];
}
const STRING: IGeminiSchema = { type: 'string' };
const NUMBER: IGeminiSchema = { type: 'number' };
const INVOICE_SCHEMA: IGeminiSchema = {
type: 'object',
properties: {
invoiceNumber: STRING,
supplierName: STRING,
supplierBin: STRING,
recipientAccount: STRING,
currency: STRING,
totalAmount: NUMBER,
vatAmount: NUMBER,
items: {
type: 'array',
items: {
type: 'object',
properties: { name: STRING, unit: STRING, quantity: NUMBER, price: NUMBER, sum: NUMBER },
required: ['name', 'quantity']
}
}
},
required: ['invoiceNumber', 'totalAmount', 'items']
};
const PAIRS_SCHEMA: IGeminiSchema = {
type: 'object',
properties: {
pairs: {
type: 'array',
items: {
type: 'object',
properties: { baseIndex: { type: 'integer' }, invoiceIndex: { type: 'integer' }, note: STRING },
required: ['baseIndex', 'invoiceIndex']
}
}
},
required: ['pairs']
};
/** Разобрать текст счёта: номер, поставщик, реквизиты, суммы, позиции. */
export async function parseInvoiceText(context: WebPartContext, text: string): Promise<IInvoiceData> {
const prompt =
'Ниже распознанный текст счёта на оплату. Извлеки: номер счёта, наименование поставщика (продавца), ' +
'его БИН/ИИН (12 цифр, пустая строка если нет), счёт получателя платежа (IBAN, например KZ…), ' +
'валюту (код: KZT, USD, EUR, RUB, CNY), итоговую сумму к оплате, сумму НДС (0 если нет) и все позиции ' +
'таблицы (наименование, единица измерения, количество, цена за единицу, сумма по строке). ' +
'Числа — без пробелов и валютных знаков. Ничего не выдумывай: чего нет в тексте — пустая строка или 0.\n\n' +
`Текст счёта:\n${text.slice(0, MAX_INVOICE_TEXT_CHARS)}`;
const data = await askGemini<Partial<IInvoiceData>>(context, prompt, INVOICE_SCHEMA);
return {
invoiceNumber: data.invoiceNumber || '',
supplierName: data.supplierName || '',
supplierBin: (data.supplierBin || '').replace(/\D/g, ''),
recipientAccount: (data.recipientAccount || '').replace(/\s/g, ''),
currency: (data.currency || '').toUpperCase(),
totalAmount: Number(data.totalAmount || 0),
vatAmount: Number(data.vatAmount || 0),
items: (data.items || []).map((item) => ({
name: item.name || '',
unit: item.unit || '',
quantity: Number(item.quantity || 0),
price: Number(item.price || 0),
sum: Number(item.sum || 0)
}))
};
}
/**
* Попросить ИИ сопоставить позиции основания (СТ/потребности) с позициями счёта.
* Названия в счёте поставщик пишет по-своему, поэтому сопоставляем по смыслу;
* количества и единицы потом сверяет код (см. buildRows), а не ИИ.
*/
export async function matchPositions(
context: WebPartContext,
base: IBasisPosition[],
invoice: IInvoiceItem[]
): Promise<IPositionPair[]> {
if (base.length === 0 || invoice.length === 0) return [];
const baseLines = base.map((p, i) => `${i}. ${p.name} (${p.unit}, ${p.qty})${p.supplierName ? ` — у поставщика: ${p.supplierName}` : ''}`);
const invoiceLines = invoice.map((p, i) => `${i}. ${p.name} (${p.unit}, ${p.quantity})`);
const prompt =
'Сопоставь позиции заявки с позициями счёта поставщика. Одна позиция заявки соответствует не более чем ' +
'одной позиции счёта и наоборот. Для каждой найденной пары верни baseIndex и invoiceIndex. ' +
'Позиции без пары верни с индексом −1 с отсутствующей стороны. В note коротко (до 10 слов) опиши отличие ' +
'в названии, если оно смысловое (другая марка, размер, материал); если названия по смыслу совпадают — пустая строка.\n\n' +
`Позиции заявки:\n${baseLines.join('\n')}\n\nПозиции счёта:\n${invoiceLines.join('\n')}`;
const result = await askGemini<{ pairs?: IPositionPair[] }>(context, prompt, PAIRS_SCHEMA);
return (result.pairs || []).map((pair) => ({
baseIndex: Number(pair.baseIndex),
invoiceIndex: Number(pair.invoiceIndex),
note: pair.note || ''
}));
}
@@ -0,0 +1,106 @@
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../../../shared/config';
import { IBasisInfo, IBasisOption, IBasisPosition } from './basis.types';
/** За сколько дней показываем потребности — как шаг выбора позиций в СТ. */
const NEEDS_LOOKBACK_DAYS = 365;
type RawRecord = Record<string, unknown>;
interface INeed {
id: string;
requestNumber: string;
project: string;
section: string;
workKind: string;
organization: string;
createdAt: string;
positionsCount: number;
}
async function apiGet(context: WebPartContext, path: string): Promise<unknown> {
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
const response: HttpClientResponse = await client.get(`${DICTIONARY_API_BASE_URL}${path}`, AadHttpClient.configurations.v1);
if (!response.ok) {
throw new Error(`${path}: ${response.status} ${response.statusText}`);
}
return response.json();
}
function mapNeed(item: RawRecord): INeed {
return {
id: String(item.id ?? ''),
requestNumber: String(item.requestNumber ?? ''),
// В API нет отдельной сущности «проект» — description и есть название проекта (как в шаге 1 СТ).
project: String(item.description ?? ''),
section: String(item.discipline ?? ''),
workKind: String(item.workType ?? ''),
organization: String(item.organization ?? ''),
createdAt: String(item.requestDate ?? ''),
positionsCount: Number(item.positionsCount ?? 0)
};
}
// Список потребностей большой и в рамках сессии не меняется — грузим один раз.
let needsCache: Promise<INeed[]> | undefined;
function getNeeds(context: WebPartContext): Promise<INeed[]> {
if (!needsCache) {
needsCache = (apiGet(context, `/Requests/${NEEDS_LOOKBACK_DAYS}`) as Promise<RawRecord[]>)
.then((rows) => rows.map(mapNeed))
.catch((e) => {
needsCache = undefined;
throw e;
});
}
return needsCache;
}
/** Потребности для выбора основания. */
export async function listNeeds(context: WebPartContext): Promise<IBasisOption[]> {
const needs = await getNeeds(context);
return needs.map((need) => ({
id: need.id,
title: `Потребность ${need.requestNumber}`,
note: [need.project, need.organization].filter(Boolean).join(' · ')
}));
}
/** Основание «Потребность»: проект, раздел, вид работ и позиции без КП и победителя. */
export async function loadNeedBasis(context: WebPartContext, needId: string): Promise<IBasisInfo> {
const needs = await getNeeds(context);
const need = needs.filter((n) => n.id === needId)[0];
if (!need) {
throw new Error('Потребность не найдена в списке — обновите страницу.');
}
const rows = (await apiGet(context, `/Requests/${encodeURIComponent(needId)}/positions`)) as RawRecord[];
const positions: IBasisPosition[] = rows.map((row) => ({
// Отдельного кода у позиции потребности нет; номер строки табличной части 1С
// (lineNumber) уникален в пределах заявки — он и служит кодом позиции.
code: row.lineNumber !== undefined && row.lineNumber !== null ? String(row.lineNumber) : '',
name: String(row.materialName ?? ''),
unit: String(row.unit ?? ''),
qty: Number(row.quantity ?? 0),
supplierName: '',
supplierQty: 0,
supplierPrice: 0
}));
return {
kind: 'need',
key: `need:${need.id}`,
title: `Потребность ${need.requestNumber}`,
projectName: need.project,
section: need.section,
workKind: need.workKind,
companyHint: need.organization,
recipientName: '',
recipientBin: '',
total: 0,
currency: '',
deliveryTerms: '',
paymentTerms: '',
deliveryTime: '',
positions
};
}
@@ -0,0 +1,41 @@
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { REQUEST_STATUS, getBasisRequests, getRequestTypeIdByPageUrl } from '../../../shared/approval';
import { FORM_ID, IPreviousPayment } from '../model';
/**
* Статусы, которые в «уже оплачено/заявлено» не входят: черновик ещё не подан,
* отклонённая и возвращённая заявки платёж не создают.
*/
const NOT_COUNTED_STATUSES: string[] = [REQUEST_STATUS.draft, REQUEST_STATUS.rejected, REQUEST_STATUS.returned];
/**
* Прежние заявки на оплату по тому же основанию (СТ или потребности) — по всем
* партициям списка.
*
* Читаем не список напрямую, а через approval-route (GET /api/basis-requests):
* права на элементы заявок разорваны поштучно, и под своей учёткой заявитель
* видел бы только свои оплаты — сумма по СТ занижалась бы и проверка превышения
* пропускала переплату. Сервер отдаёт все заявки, а canOpen говорит, можно ли
* текущему пользователю открыть каждую (без прав ссылки в таблице нет).
*
* excludeId — сама редактируемая заявка: себя в «ранее поданных» показывать нельзя.
*/
export async function loadPreviousPayments(
context: WebPartContext,
basisKey: string,
excludeId?: number
): Promise<IPreviousPayment[]> {
if (!basisKey) return [];
const requestTypeId = await getRequestTypeIdByPageUrl(context, FORM_ID);
const rows = await getBasisRequests(context, requestTypeId, basisKey);
return rows
.filter((row) => row.id !== excludeId && NOT_COUNTED_STATUSES.indexOf(row.status) === -1)
.map((row) => ({
code: row.uniqueCode || `№${row.id}`,
amount: Number(row.amount || 0),
id: row.canOpen ? row.id : undefined,
listId: row.listId,
status: row.status
}));
}
@@ -0,0 +1,55 @@
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
import { FORM_ID, IFormData } from './model';
/** Экземпляр сервиса списка согласования этой формы (см. approvalServiceFactory). */
export function paymentRequestApprovalService(listId?: string): ApprovalListService {
return approvalServiceFor(FORM_ID, listId);
}
/** Осмысленный заголовок элемента (системная колонка Title). */
function buildTitle(data: IFormData): string {
const subject = data.RecipientName.trim() || data.BasisTitle;
return ['Заявка на оплату', [subject, data.InvoiceNumber ? `счёт ${data.InvoiceNumber}` : ''].filter(Boolean).join(', ')]
.filter(Boolean)
.join(': ');
}
/** Колонки, которые не копируются как есть: табличные части идут в JSON. */
const JSON_KEYS: Array<keyof IFormData> = ['PreviousPayments', 'Positions'];
/** Поля элемента списка данных из модели формы. Пустая строка → null, чтобы не хранить пустые значения. */
function buildItem(data: IFormData, status: string): Record<string, unknown> {
const item: Record<string, unknown> = { Title: buildTitle(data), RequestStatus: status };
(Object.keys(data) as Array<keyof IFormData>).forEach((key) => {
if (JSON_KEYS.indexOf(key) !== -1) return;
const value = data[key];
item[key] = value === '' ? null : value;
});
item.PreviousPaymentsJson = data.PreviousPayments.length > 0 ? JSON.stringify(data.PreviousPayments) : null;
item.PositionsJson = data.Positions.length > 0 ? JSON.stringify(data.Positions) : null;
return item;
}
export type ISaveResult = ISaveItemResult;
/**
* Сохранить «Заявку на оплату»: контент + маршрут согласования. existingId задан →
* обновляем ту же запись (черновик/доработка), иначе создаём новую.
* listId — партиция, в которую пишем (см. resolveWriteListId).
*/
export async function savePaymentRequest(
existingId: number | undefined,
requestTypeId: number,
data: IFormData,
approvers: IApproverToWrite[],
executors: IApproverToWrite[],
status: string,
listId?: string
): Promise<ISaveResult> {
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, buildItem(data, status));
await writeRoute(paymentRequestApprovalService(listId), result.id, approvers, executors, status);
return result;
}
@@ -0,0 +1,53 @@
import { useCallback, useState } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { getCompanies, getProjects } from '../../shared/dictionary';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import { basisPatch } from './basisPatch';
import { BasisKind, IFormData } from './model';
import { loadCtBasis } from './services/ctBasis.service';
import { loadNeedBasis } from './services/needBasis.service';
import { loadPreviousPayments } from './services/previousPayments.service';
export interface IBasisSelection {
loading: boolean;
error: string;
/** Загрузить выбранную СТ/потребность и подставить её данные в форму. */
select: (kind: BasisKind, id: string) => Promise<void>;
}
/**
* Выбор основания оплаты. Справочники проектов и компаний нужны только для
* подбора oid/Id по тексту (в СТ проект — строка), поэтому их сбой не мешает:
* без них проект/компания просто выбираются руками.
*/
export function useBasisSelection(
context: WebPartContext,
patch: (changes: Partial<IFormData>) => void,
editId: number | undefined
): IBasisSelection {
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>('');
const select = useCallback(
async (kind: BasisKind, id: string): Promise<void> => {
setLoading(true);
setError('');
try {
const info = kind === 'ct' ? await loadCtBasis(context, id) : await loadNeedBasis(context, id);
const [previous, projects, companies] = await Promise.all([
loadPreviousPayments(context, info.key, editId),
getProjects(context).catch(() => []),
getCompanies(context).catch(() => [])
]);
patch(basisPatch(info, previous, projects, companies));
} catch (e) {
setError(`Не удалось загрузить основание: ${friendlyErrorMessage(e)}`);
} finally {
setLoading(false);
}
},
[context, patch, editId]
);
return { loading, error, select };
}
@@ -0,0 +1,68 @@
import { useCallback, useState } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import { IFormData } from './model';
import { basisPositionsFromRows, invoicePatch } from './invoicePatch';
import { recognizeInvoice, storeInvoice, validateInvoiceFile } from './services/invoice.service';
import { matchPositions, parseInvoiceText } from './services/invoiceAi.service';
export type InvoiceStage = 'idle' | 'upload' | 'ocr' | 'analyze';
export const INVOICE_STAGE_TEXT: Record<InvoiceStage, string> = {
idle: '',
upload: 'Сохраняем счёт в библиотеку…',
ocr: 'Распознаём текст счёта (OCR)…',
analyze: 'ИИ разбирает счёт и сверяет позиции…'
};
export interface IInvoiceFlow {
stage: InvoiceStage;
/** Ошибка последнего шага; файл при этом уже сохранён, форму можно заполнить руками. */
error: string;
attach: (file: File) => Promise<void>;
}
/**
* Конвейер счёта: библиотека → OCR → ИИ (реквизиты и позиции) → сверка с основанием.
* Каждый шаг после сохранения файла необязателен: при сбое OCR или ИИ счёт остаётся
* приложенным, а поля заполняются вручную — заявку из-за внешнего сервиса не блокируем.
*/
export function useInvoiceFlow(
context: WebPartContext,
data: IFormData,
patch: (changes: Partial<IFormData>) => void
): IInvoiceFlow {
const [stage, setStage] = useState<InvoiceStage>('idle');
const [error, setError] = useState<string>('');
const attach = useCallback(
async (file: File): Promise<void> => {
const invalid = validateInvoiceFile(file);
if (invalid) {
setError(invalid);
return;
}
setError('');
try {
setStage('upload');
const stored = await storeInvoice(file);
patch({ InvoiceFileName: file.name, InvoiceUrl: stored.serverRelativeUrl });
setStage('ocr');
const text = await recognizeInvoice(context, stored.itemId);
setStage('analyze');
const invoice = await parseInvoiceText(context, text);
const pairs = await matchPositions(context, basisPositionsFromRows(data.Positions), invoice.items);
patch(invoicePatch(data, invoice, invoice.items, pairs));
} catch (e) {
setError(`${friendlyErrorMessage(e)} Заполните поля по счёту вручную.`);
} finally {
setStage('idle');
}
},
[context, data, patch]
);
return { stage, error, attach };
}
@@ -0,0 +1,23 @@
import { IFormData } from './model';
/**
* Обязательные поля заявки на оплату. Возвращает текст ошибки или undefined.
* Превышение общей суммы и расхождения со счётом сюда НЕ входят: они только
* подсвечиваются, решение — за согласующими.
*/
export function validatePayment(data: IFormData): string | undefined {
const missing: string[] = [];
if (!data.RequesterEmail) missing.push('заявитель');
if (!data.BasisKey) missing.push('основание (СТ или потребность)');
if (!data.ProjectName.trim()) missing.push('проект');
if (!data.CompanyName.trim()) missing.push('компания');
if (!data.BankAccountName.trim()) missing.push('банковский счёт');
if (!data.RecipientName.trim()) missing.push('получатель');
if (!data.RecipientAccount.trim()) missing.push('счёт получателя');
if (!data.CashFlowGroupName.trim()) missing.push('группа статьи ДДС');
if (!data.CashFlowItemName.trim()) missing.push('статья ДДС');
if (!data.PaymentPurpose.trim()) missing.push('назначение платежа');
if (data.PayAmount <= 0) missing.push('сумма оплаты');
if (!data.InvoiceFileName) missing.push('счёт на оплату');
return missing.length > 0 ? `Заполните обязательные поля: ${missing.join(', ')}.` : undefined;
}
@@ -17,6 +17,8 @@
(`CompanyPickerMulti`), хранится JSON-массивом `{Id,Name}` в Note-колонке `CompaniesJson`. (`CompanyPickerMulti`), хранится JSON-массивом `{Id,Name}` в Note-колонке `CompaniesJson`.
- **График работы (WorkSchedule)** — выпадающий список (набор совпадает с «Заявкой - **График работы (WorkSchedule)** — выпадающий список (набор совпадает с «Заявкой
на подбор»); при выборе «Другое» появляется ручной ввод `WorkScheduleOther`. на подбор»); при выборе «Другое» появляется ручной ввод `WorkScheduleOther`.
- **Время работы (WorkHours)** — так же, как график: набор и колонка сайта общие
с «Заявкой на подбор», оттуда подставляется; «Другое» → `WorkHoursOther`.
- **Испытательный срок (ProbationPeriod)** — выпадающий список частых сроков. - **Испытательный срок (ProbationPeriod)** — выпадающий список частых сроков.
- **Рекрутинговое агентство (условный блок, последняя секция)** — `Toggle` - **Рекрутинговое агентство (условный блок, последняя секция)** — `Toggle`
`RecruitingAgency`; при включении показываются «Название компании» (`AgencyName`) `RecruitingAgency`; при включении показываются «Название компании» (`AgencyName`)
@@ -25,6 +25,7 @@ const SCALAR_FIELDS: (keyof IFormData)[] = [
'HireCandidateName', 'HireHireDate', 'Position', 'HireCandidateName', 'HireHireDate', 'Position',
'Department', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod', 'ProbationPeriodOther', 'Department', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod', 'ProbationPeriodOther',
'SalaryProbation', 'SalaryAfterProbation', 'WorkSchedule', 'WorkScheduleOther', 'SalaryProbation', 'SalaryAfterProbation', 'WorkSchedule', 'WorkScheduleOther',
'WorkHours', 'WorkHoursOther',
'AgencyName', 'AgencyTerms', 'HireSecurityNote', 'HireApproverNote', 'AgencyName', 'AgencyTerms', 'HireSecurityNote', 'HireApproverNote',
'SelectionRequestId', 'SelectionRequestListId', 'SelectionRequestCode' 'SelectionRequestId', 'SelectionRequestListId', 'SelectionRequestCode'
]; ];
@@ -75,6 +75,12 @@ export interface IFormData {
SalaryAfterProbation: string; SalaryAfterProbation: string;
WorkSchedule: string; WorkSchedule: string;
WorkScheduleOther: string; WorkScheduleOther: string;
/**
* Время работы — та же колонка сайта WorkHours (Choice), что в заявке на подбор:
* оттуда и подставляется (см. selectionPrefill).
*/
WorkHours: string;
WorkHoursOther: string;
/** /**
* Сдельная оплата труда — информационная отметка, на обязательность окладов не * Сдельная оплата труда — информационная отметка, на обязательность окладов не
* влияет. Та же колонка сайта PieceworkPay (Boolean), что в заявке на подбор: * влияет. Та же колонка сайта PieceworkPay (Boolean), что в заявке на подбор:
@@ -86,7 +92,7 @@ export interface IFormData {
AgencyName: string; AgencyName: string;
AgencyTerms: string; AgencyTerms: string;
// Служба безопасности: инициатор при подаче отмечает, что кандидат согласован // Служба безопасности: инициатор при подаче отмечает, что кандидат согласован
// с СБ; при отметке обязательно примечание (см. validation.ts). Информационное — // с СБ; примечание при отметке — по желанию. Информационное —
// на маршрут и решения не влияет. // на маршрут и решения не влияет.
HireSecurityApproved: boolean; HireSecurityApproved: boolean;
HireSecurityNote: string; HireSecurityNote: string;
@@ -132,6 +138,8 @@ export const EMPTY_DATA: IFormData = {
SalaryAfterProbation: '', SalaryAfterProbation: '',
WorkSchedule: '', WorkSchedule: '',
WorkScheduleOther: '', WorkScheduleOther: '',
WorkHours: '',
WorkHoursOther: '',
PieceworkPay: false, PieceworkPay: false,
RecruitingAgency: false, RecruitingAgency: false,
AgencyName: '', AgencyName: '',
@@ -201,5 +209,19 @@ export const WORK_SCHEDULE_OPTIONS: ISelectOption[] = [
{ value: OTHER_VALUE, label: OTHER_VALUE } { value: OTHER_VALUE, label: OTHER_VALUE }
]; ];
/**
* Варианты «Время работы» (24-часовой формат) — совпадают с набором в форме
* «Заявка на подбор» (WORK_HOURS_OPTIONS) и с choices колонки WorkHours:
* формы независимы, значения продублированы намеренно.
*/
export const WORK_HOURS_OPTIONS: ISelectOption[] = [
{ value: '08:00-17:00', label: '08:00-17:00' },
{ value: '09:00-18:00', label: '09:00-18:00' },
{ value: '07:00-19:00', label: '07:00-19:00' },
{ value: '08:00-20:00', label: '08:00-20:00' },
{ value: '09:00-21:00', label: '09:00-21:00' },
{ value: OTHER_VALUE, label: OTHER_VALUE }
];
/** Плейсхолдер для полей-людей. */ /** Плейсхолдер для полей-людей. */
export const PERSON_PLACEHOLDER = 'Начните вводить имя сотрудника…'; export const PERSON_PLACEHOLDER = 'Начните вводить имя сотрудника…';
@@ -41,6 +41,8 @@
{ "internalName": "SalaryAfterProbation", "title": "Оклад по завершении ИС", "type": "Number", "decimals": 2 }, { "internalName": "SalaryAfterProbation", "title": "Оклад по завершении ИС", "type": "Number", "decimals": 2 },
{ "internalName": "WorkSchedule", "title": "График работы", "type": "Text" }, { "internalName": "WorkSchedule", "title": "График работы", "type": "Text" },
{ "internalName": "WorkScheduleOther", "title": "График работы (другое)", "type": "Text" }, { "internalName": "WorkScheduleOther", "title": "График работы (другое)", "type": "Text" },
{ "internalName": "WorkHours", "title": "Время работы", "type": "Choice", "choices": ["08:00-17:00", "09:00-18:00", "07:00-19:00", "08:00-20:00", "09:00-21:00", "Другое"] },
{ "internalName": "WorkHoursOther", "title": "Время работы (другое)", "type": "Text" },
{ "internalName": "PieceworkPay", "title": "Сдельная оплата труда", "type": "Boolean" }, { "internalName": "PieceworkPay", "title": "Сдельная оплата труда", "type": "Boolean" },
{ "internalName": "RecruitingAgency", "title": "Рекрутинговое агентство", "type": "Boolean" }, { "internalName": "RecruitingAgency", "title": "Рекрутинговое агентство", "type": "Boolean" },
@@ -1,6 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { CollapseSection, FormField, TextInput, MoneyInput, Select, Toggle } from '../../../shared/ui'; import { CollapseSection, FormField, TextInput, MoneyInput, Select, Toggle } from '../../../shared/ui';
import { ISectionProps, OTHER_VALUE, WORK_SCHEDULE_OPTIONS, PROBATION_PERIOD_OPTIONS } from '../model'; import { ISectionProps, OTHER_VALUE, WORK_SCHEDULE_OPTIONS, WORK_HOURS_OPTIONS, PROBATION_PERIOD_OPTIONS } from '../model';
interface IConditionsSectionProps extends ISectionProps { interface IConditionsSectionProps extends ISectionProps {
/** Начальник расчётной группы правит оклады даже в режиме просмотра (см. approverRights.ts). */ /** Начальник расчётной группы правит оклады даже в режиме просмотра (см. approverRights.ts). */
@@ -67,8 +67,29 @@ export const ConditionsSection: React.FC<IConditionsSectionProps> = ({ data, set
/> />
</FormField> </FormField>
)} )}
<FormField label="4.5 Время работы" htmlFor="WorkHours" required>
<Select
id="WorkHours"
value={data.WorkHours}
onChange={(v) => {
set('WorkHours', v);
if (v !== OTHER_VALUE) set('WorkHoursOther', '');
}}
options={WORK_HOURS_OPTIONS}
placeholder="— выберите —"
/>
</FormField>
{data.WorkHours === OTHER_VALUE && (
<FormField label="Время работы (другое)" htmlFor="WorkHoursOther">
<TextInput
id="WorkHoursOther"
value={data.WorkHoursOther}
onChange={(v) => set('WorkHoursOther', v)}
/>
</FormField>
)}
{/* Просто отметка: обязательность окладов (4.2, 4.3) от неё не зависит. */} {/* Просто отметка: обязательность окладов (4.2, 4.3) от неё не зависит. */}
<FormField label="4.5 Сдельная оплата труда" fieldKey="PieceworkPay"> <FormField label="4.6 Сдельная оплата труда" fieldKey="PieceworkPay">
<Toggle checked={data.PieceworkPay} onChange={(v) => set('PieceworkPay', v)} onLabel="Да" /> <Toggle checked={data.PieceworkPay} onChange={(v) => set('PieceworkPay', v)} onLabel="Да" />
</FormField> </FormField>
</CollapseSection> </CollapseSection>
@@ -4,7 +4,7 @@ import { ISectionProps } from '../model';
/** /**
* 6. Служба безопасности: инициатор отмечает, что кандидат согласован с СБ, и * 6. Служба безопасности: инициатор отмечает, что кандидат согласован с СБ, и
* тогда обязательно пишет примечание (см. validation.ts). Поле информационное — * при желании пишет примечание (необязательно). Поле информационное —
* на маршрут и решения согласующих не влияет. При снятии отметки примечание * на маршрут и решения согласующих не влияет. При снятии отметки примечание
* очищается, чтобы в заявку не ушёл текст скрытого поля (как в AgencySection). * очищается, чтобы в заявку не ушёл текст скрытого поля (как в AgencySection).
*/ */
@@ -20,7 +20,7 @@ export const SecuritySection: React.FC<ISectionProps> = ({ data, set }) => {
<Toggle checked={data.HireSecurityApproved} onChange={handleToggle} onLabel="Да" /> <Toggle checked={data.HireSecurityApproved} onChange={handleToggle} onLabel="Да" />
</FormField> </FormField>
{data.HireSecurityApproved && ( {data.HireSecurityApproved && (
<FormField label="6.2 Примечание СБ" htmlFor="HireSecurityNote" required full> <FormField label="6.2 Примечание СБ" htmlFor="HireSecurityNote" full>
<TextArea <TextArea
id="HireSecurityNote" id="HireSecurityNote"
value={data.HireSecurityNote} value={data.HireSecurityNote}
@@ -34,6 +34,8 @@ export function selectionPrefill(link: ISelectionLink, selection: ISelectionData
Department: selection.Department, Department: selection.Department,
WorkSchedule: selection.WorkSchedule, WorkSchedule: selection.WorkSchedule,
WorkScheduleOther: selection.WorkScheduleOther, WorkScheduleOther: selection.WorkScheduleOther,
WorkHours: selection.WorkHours,
WorkHoursOther: selection.WorkHoursOther,
ProbationPeriod: selection.SelectionProbationPeriod, ProbationPeriod: selection.SelectionProbationPeriod,
ProbationPeriodOther: selection.SelectionProbationPeriodOther, ProbationPeriodOther: selection.SelectionProbationPeriodOther,
SalaryProbation: selection.RateProbation, SalaryProbation: selection.RateProbation,
@@ -25,7 +25,8 @@ describe('RecruitingHire validation', () => {
'4.1 Испытательный срок', '4.1 Испытательный срок',
'4.2 Оклад при испытательном сроке', '4.2 Оклад при испытательном сроке',
'4.3 Оклад по завершении ИС', '4.3 Оклад по завершении ИС',
'4.4 График работы' '4.4 График работы',
'4.5 Время работы'
]); ]);
}); });
@@ -38,13 +39,8 @@ describe('RecruitingHire validation', () => {
}); });
}); });
it('«Согласован СБ» требует примечание СБ, без отметки — не требует', () => { it('примечание СБ необязательно, даже при отметке «Согласован СБ»', () => {
expect(labels(submitIssues(DRAFT_READY))).not.toContain('6.2 Примечание СБ'); expect(labels(submitIssues({ ...DRAFT_READY, HireSecurityApproved: true }))).not.toContain('6.2 Примечание СБ');
expect(labels(submitIssues({ ...DRAFT_READY, HireSecurityApproved: true }))).toContain('6.2 Примечание СБ');
expect(
labels(submitIssues({ ...DRAFT_READY, HireSecurityApproved: true, HireSecurityNote: 'Проверка пройдена' }))
).not.toContain('6.2 Примечание СБ');
// Черновик без примечания сохранить можно.
expect(draftIssues({ ...DRAFT_READY, HireSecurityApproved: true })).toEqual([]); expect(draftIssues({ ...DRAFT_READY, HireSecurityApproved: true })).toEqual([]);
}); });
@@ -19,7 +19,7 @@ interface IRequiredRule extends IFieldIssue {
* агентство» не проверяется — у булевых нет пустого состояния. * агентство» не проверяется — у булевых нет пустого состояния.
* 2.2 «Дата приёма» не нужна, если отмечено «По приезду на объект» * 2.2 «Дата приёма» не нужна, если отмечено «По приезду на объект»
* (HireOnArrival) — тогда дату проставляет исполнитель на своём шаге. * (HireOnArrival) — тогда дату проставляет исполнитель на своём шаге.
* 6.2 «Примечание СБ» обязательно только при отметке 6.1 «Согласован СБ». * 6.2 «Примечание СБ» необязательно (по просьбе заказчика) — даже при отметке 6.1.
* *
* 1.3 «Проект» — хотя бы один проект из справочника: первый уходит в resolve * 1.3 «Проект» — хотя бы один проект из справочника: первый уходит в resolve
* маршрута (projectId), а без него approval-route отвечал 404 и заявка ушла бы * маршрута (projectId), а без него approval-route отвечал 404 и заявка ушла бы
@@ -50,12 +50,7 @@ const REQUIRED_RULES: ReadonlyArray<IRequiredRule> = [
{ field: 'SalaryProbation', label: '4.2 Оклад при испытательном сроке', ok: (d) => !!d.SalaryProbation.trim() }, { field: 'SalaryProbation', label: '4.2 Оклад при испытательном сроке', ok: (d) => !!d.SalaryProbation.trim() },
{ field: 'SalaryAfterProbation', label: '4.3 Оклад по завершении ИС', ok: (d) => !!d.SalaryAfterProbation.trim() }, { field: 'SalaryAfterProbation', label: '4.3 Оклад по завершении ИС', ok: (d) => !!d.SalaryAfterProbation.trim() },
{ field: 'WorkSchedule', label: '4.4 График работы', ok: (d) => !!d.WorkSchedule }, { field: 'WorkSchedule', label: '4.4 График работы', ok: (d) => !!d.WorkSchedule },
{ { field: 'WorkHours', label: '4.5 Время работы', ok: (d) => !!d.WorkHours }
field: 'HireSecurityNote',
label: '6.2 Примечание СБ',
message: 'отмечено «Согласован СБ» — укажите примечание',
ok: (d) => !d.HireSecurityApproved || !!d.HireSecurityNote.trim()
}
]; ];
function toIssue({ field, label, message }: IRequiredRule): IFieldIssue { function toIssue({ field, label, message }: IRequiredRule): IFieldIssue {
@@ -12,11 +12,13 @@
Если для вида работ согласование не требуется — заявка сразу «Выполнение». Если для вида работ согласование не требуется — заявка сразу «Выполнение».
Работу отдел 1С ведёт в карточке Microsoft Planner (см. «Особое поведение»). Работу отдел 1С ведёт в карточке Microsoft Planner (см. «Особое поведение»).
- Особенности: маршрут условный — набор шагов зависит от полей **«Виды работ»** - Особенности: маршрут условный — набор шагов зависит от полей **«Виды работ»**
(`WorkType`) и **«Отдел»** (`Department`, свободный текст из профиля AAD, см. (`WorkType`), **«База 1С»** (`DB1C`) и **«Отдел»** (`Department`, свободный текст
«Особое поведение»), которые передаются в `resolveRoute`/`getPlannedMatrix` через из профиля AAD, см. «Особое поведение»), которые передаются в
параметр `fieldValues: [{ fieldName: 'WorkType', value: data.WorkType }, `resolveRoute`/`getPlannedMatrix` через параметр `fieldValues` (см.
{ fieldName: 'Department', value: data.Department }]` (см. `IResolveFieldValue` в `IResolveFieldValue` в `shared/approval/models/IApprovalRouteApi.ts`). «База 1С» —
`shared/approval/models/IApprovalRouteApi.ts`). В админке маршрутов условие по мультивыбор, а `value` у API только строка, поэтому все выбранные базы уходят
одной записью через пробел; условия по ней в админке — только «содержит»
(напр. «База 1С содержит ЗУП»). В админке маршрутов условие по
отделу настроено оператором «содержит» (напр. «Department содержит Бухгалтерия»), отделу настроено оператором «содержит» (напр. «Department содержит Бухгалтерия»),
поэтому важно фактическое значение поля, а не Id из справочника — отдельного поэтому важно фактическое значение поля, а не Id из справочника — отдельного
DepartmentId у этой формы нет. Для части видов работ маршрут пуст — согласование DepartmentId у этой формы нет. Для части видов работ маршрут пуст — согласование
@@ -3,6 +3,8 @@ import { useState, useMemo, useCallback, useEffect } from 'react';
import styles from './Ticket1CForm.module.scss'; import styles from './Ticket1CForm.module.scss';
import { import {
Alert, Alert,
FieldIssuesProvider,
issueLabels,
FormCard, FormCard,
InitiatorLine, InitiatorLine,
FormModeProvider, FormModeProvider,
@@ -21,7 +23,6 @@ import {
ApprovalHistory, ApprovalHistory,
StatusBadge, StatusBadge,
useMatrixEditor, useMatrixEditor,
getPlannedMatrix,
loadRoutePhase, loadRoutePhase,
isOpenedRouteReady, isOpenedRouteReady,
resolveWriteListId, resolveWriteListId,
@@ -31,7 +32,8 @@ import {
isEditableStatus, isEditableStatus,
IApproverToWrite, IApproverToWrite,
unassignedStepMessage, unassignedStepMessage,
executorsToWrite executorsToWrite,
composeRemoveRowRule
} from '../../shared/approval'; } from '../../shared/approval';
import { import {
uploadAttachments, uploadAttachments,
@@ -49,7 +51,8 @@ import {
dataListTitle, dataListTitle,
dataListTitleForListId dataListTitleForListId
} from '../../shared/listTitles'; } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField, hasOtherDatabase } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { formIssues } from './validation';
import { saveTicketRequest, ticket1cApprovalService } from './submit'; import { saveTicketRequest, ticket1cApprovalService } from './submit';
import { loadTicketRequest } from './load'; import { loadTicketRequest } from './load';
import { getApplicantProfile } from './services/profile.service'; import { getApplicantProfile } from './services/profile.service';
@@ -57,6 +60,7 @@ import { RequestSection } from './sections/RequestSection';
import { ContactSection } from './sections/ContactSection'; import { ContactSection } from './sections/ContactSection';
import { useStableCallback } from '../../shared/useStableCallback'; import { useStableCallback } from '../../shared/useStableCallback';
import { applyManagerOverride, managerEmail } from './managerOverride'; import { applyManagerOverride, managerEmail } from './managerOverride';
import { planTicket1CPhase, ticket1cRouteFieldValues } from './planRoute';
import { handoffToPlanner } from './plannerHandoff'; import { handoffToPlanner } from './plannerHandoff';
import { submitStatus } from './submitStatus'; import { submitStatus } from './submitStatus';
@@ -168,26 +172,24 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
const requestTypeIdState = useRequestTypeId(context, requestType.id); const requestTypeIdState = useRequestTypeId(context, requestType.id);
const requestTypeId = requestTypeIdState.id; const requestTypeId = requestTypeIdState.id;
// Маршрут полностью и однозначно определяется видом работ и отделом — настроен // Маршрут полностью и однозначно определяется видом работ, базой 1С и отделом —
// в админке маршрутов (условные шаги по WorkType и Department, см. fieldValues), // настроен в админке маршрутов (условные шаги по WorkType, DB1C и Department,
// а не выбирается заявителем. Поэтому матрица здесь ВСЕГДА нередактируема // см. fieldValues), а не выбирается заявителем. Поэтому матрица здесь ВСЕГДА
// (editable=false) — заявитель только видит, кто согласует, без возможности // нередактируема (editable=false) — заявитель только видит, кто согласует, без
// добавить/убрать/переставить согласующего. // возможности добавить/убрать/переставить согласующего.
// Условия шагов (и согласования, и исполнения) — по виду работ и отделу. // Расчёт маршрута вынесен в planRoute.ts — им же пользуется страница
// «Проверка маршрутов», чтобы проверять ровно то, что видит заявитель.
const fieldValues = useMemo( const fieldValues = useMemo(
() => [ () => ticket1cRouteFieldValues({ WorkType: data.WorkType, DB1C: data.DB1C, Department: data.Department }),
{ fieldName: 'WorkType', value: data.WorkType }, [data.WorkType, data.DB1C, data.Department]
{ fieldName: 'Department', value: data.Department }
],
[data.WorkType, data.Department]
); );
const matrixKey = `${userEmail}-${editId ?? 'new'}-${data.WorkType}-${data.Department}`; const matrixKey = [userEmail, editId ?? 'new', ...fieldValues.map((f) => f.value)].join('-');
const matrixReady = const matrixReady =
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!data.WorkType && !!requestTypeId)); isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!data.WorkType && !!requestTypeId));
const loadMatrix = useCallback( const loadMatrix = useCallback(
() => () =>
loadRoutePhase(approval, editId, requestStatus, 'Approval', () => loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, fieldValues) planTicket1CPhase(context, requestTypeId as number, userEmail, approval, 'Approval', fieldValues)
), ),
[context, userEmail, approval, editId, requestStatus, fieldValues, requestTypeId] [context, userEmail, approval, editId, requestStatus, fieldValues, requestTypeId]
); );
@@ -198,7 +200,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
const loadExecutors = useCallback( const loadExecutors = useCallback(
() => () =>
loadRoutePhase(approval, editId, requestStatus, 'Execution', () => loadRoutePhase(approval, editId, requestStatus, 'Execution', () =>
getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Execution', null, fieldValues) planTicket1CPhase(context, requestTypeId as number, userEmail, approval, 'Execution', fieldValues)
), ),
[context, userEmail, approval, editId, requestStatus, fieldValues, requestTypeId] [context, userEmail, approval, editId, requestStatus, fieldValues, requestTypeId]
); );
@@ -241,20 +243,11 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
); );
const matrixEditor = useMemo(() => ({ ...matrix, rows: matrixRows }), [matrix, matrixRows]); const matrixEditor = useMemo(() => ({ ...matrix, rows: matrixRows }), [matrix, matrixRows]);
const isOtherDatabaseMissing = hasOtherDatabase(data) && !data.DB1COther.trim(); // Проблемы полей (см. validation.ts) подсвечиваются под полями через FieldIssuesProvider.
const areRequiredFilled = !!( const issues = formIssues(data);
data.TaskName.trim() && const isValid = issues.length === 0;
data.WorkType &&
data.Organization &&
data.DB1C.length > 0 &&
data.Question.trim()
);
const isValid = areRequiredFilled && !isOtherDatabaseMissing;
const validate = (): string | undefined => { const validate = (): string | undefined => {
if (!areRequiredFilled) { if (!isValid) return `Заполните или исправьте поля: ${issueLabels(issues)}.`;
return 'Заполните обязательные поля: наименование задачи, вид работ, организация, база 1С, вопрос.';
}
if (isOtherDatabaseMissing) return 'Выбрана база 1С «Другое» — укажите её название.';
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.'; if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
return undefined; return undefined;
@@ -392,7 +385,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
}); });
return ( return (
<div> <FieldIssuesProvider draftIssues={issues} submitIssues={issues}>
<button type="button" className={styles.back} onClick={onCancel}> <button type="button" className={styles.back} onClick={onCancel}>
<Icon name="arrow-left" size="sm" /> <Icon name="arrow-left" size="sm" />
<span>К списку заявок</span> <span>К списку заявок</span>
@@ -444,6 +437,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
<ApprovalMatrix <ApprovalMatrix
editor={matrixEditor} editor={matrixEditor}
search={(q) => approval.searchUsers(q)} search={(q) => approval.searchUsers(q)}
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
// Пустой маршрут — штатная ситуация: для части видов работ согласование // Пустой маршрут — штатная ситуация: для части видов работ согласование
// не нужно, заявка сразу уходит в Planner. Сбой загрузки маршрута // не нужно, заявка сразу уходит в Planner. Сбой загрузки маршрута
// показывается отдельно (matrix.error) и блокирует отправку. // показывается отдельно (matrix.error) и блокирует отправку.
@@ -457,6 +451,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
title="Исполнение" title="Исполнение"
roleLabel="Исполнитель" roleLabel="Исполнитель"
emptyText="Исполнители не назначены." emptyText="Исполнители не назначены."
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
/> />
)} )}
@@ -485,7 +480,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
submitLabel="Отправить" submitLabel="Отправить"
/> />
</FormCard> </FormCard>
</div> </FieldIssuesProvider>
); );
}; };
@@ -10,6 +10,13 @@ export const CHOICES_LOADING_PLACEHOLDER = 'Загрузка вариантов
export const CHOICES_EMPTY_PLACEHOLDER = 'Нет вариантов — обратитесь к администратору'; export const CHOICES_EMPTY_PLACEHOLDER = 'Нет вариантов — обратитесь к администратору';
export const CHOICES_ERROR_PREFIX = 'Не удалось загрузить варианты полей'; export const CHOICES_ERROR_PREFIX = 'Не удалось загрузить варианты полей';
// Подсказка в поле «База 1С», когда сработало правило «дата запрета по АТП».
export const DB1C_ATP_ONLY_HINT = 'Для даты запрета по АТП другие базы не выбираются';
// Предупреждение над полем «База 1С» при виде работ «Дата запрета».
export const DB1C_ATP_ONLY_WARNING =
'Для даты запрета (открытие/закрытие) по базе АТП можно выбрать только АТП — ' +
'дополнительные базы недоступны.';
// Значения из property pane старой web part (см. живую страницу SharePoint). // Значения из property pane старой web part (см. живую страницу SharePoint).
export const PLANNER_PLAN_ID = '168WqDV8CEKkW3cL6olCRZgACwYa'; export const PLANNER_PLAN_ID = '168WqDV8CEKkW3cL6olCRZgACwYa';
export const PLANNER_BUCKET_NAME = 'Входящие неотсортированные'; export const PLANNER_BUCKET_NAME = 'Входящие неотсортированные';
@@ -0,0 +1,28 @@
import { IMatrixRow } from '../../shared/approval';
import { dedupeRouteRows } from './dedupeRoute';
function row(order: number, email: string): IMatrixRow {
return { order, who: email, email, position: '', status: '—', date: '', comment: '', allowReassign: false };
}
describe('dedupeRouteRows', () => {
it('схлопывает одного человека на одном шаге (ЗУП + АТП от финдепа)', () => {
const rows = [row(1, 'boss@tnsu.kz'), row(2, 'gulzhan@tnsu.kz'), row(2, 'Gulzhan@tnsu.kz')];
expect(dedupeRouteRows(rows).map((r) => r.email)).toEqual(['boss@tnsu.kz', 'gulzhan@tnsu.kz']);
});
it('оставляет разных людей на одном шаге', () => {
const rows = [row(2, 'gulzhan@tnsu.kz'), row(2, 'zhanat@tnsu.kz')];
expect(dedupeRouteRows(rows)).toHaveLength(2);
});
it('оставляет одного человека на разных шагах', () => {
const rows = [row(1, 'gulzhan@tnsu.kz'), row(2, 'gulzhan@tnsu.kz')];
expect(dedupeRouteRows(rows)).toHaveLength(2);
});
it('не трогает неназначенные строки', () => {
const rows = [row(2, ''), row(2, '')];
expect(dedupeRouteRows(rows)).toHaveLength(2);
});
});
@@ -0,0 +1,23 @@
import { IMatrixRow } from '../../shared/approval';
/**
* Убирает повторы одного и того же человека внутри одного шага маршрута.
*
* Зачем: «База 1С» — мультивыбор, и при нескольких базах на одном шаге могут
* сработать несколько строк матрицы с одним исполнителем (напр. Гульжан и за
* ЗУП, и за АТП от финдепа). Без схлопывания он получил бы две строки
* параллельного шага — два уведомления и два решения.
*
* Один человек на РАЗНЫХ шагах не схлопывается — это разные согласования.
* Строки без email (неназначенный шаг) не трогаем.
*/
export function dedupeRouteRows(rows: IMatrixRow[]): IMatrixRow[] {
const seen = new Set<string>();
return rows.filter((row) => {
if (!row.email) return true;
const key = `${row.order}|${row.email.toLowerCase()}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
@@ -0,0 +1,17 @@
import { DB1C_ATP_OPTION, WORK_TYPE_BAN_DATE, restrictDatabases } from './model';
const ZUP = '1С: Зарплата и управление персоналом (ЗУП)';
describe('Ticket1C restrictDatabases', () => {
it('дата запрета + АТП — остаётся только АТП', () => {
expect(restrictDatabases(WORK_TYPE_BAN_DATE, [ZUP, DB1C_ATP_OPTION])).toEqual([DB1C_ATP_OPTION]);
});
it('дата запрета без АТП — базы не трогаем', () => {
expect(restrictDatabases(WORK_TYPE_BAN_DATE, [ZUP])).toEqual([ZUP]);
});
it('другой вид работ — АТП можно сочетать с другими базами', () => {
expect(restrictDatabases('Прочее', [ZUP, DB1C_ATP_OPTION])).toEqual([ZUP, DB1C_ATP_OPTION]);
});
});
@@ -44,6 +44,27 @@ export function hasOtherDatabase(data: IFormData): boolean {
return data.DB1C.indexOf(DB1C_OTHER_OPTION) !== -1; return data.DB1C.indexOf(DB1C_OTHER_OPTION) !== -1;
} }
/**
* Варианты колонок SP, для которых действует правило «дата запрета по АТП —
* только одна база». Должны дословно совпадать с вариантами колонок
* WorkType и DB1C в SharePoint (варианты читаются из SP, см. useFieldChoices).
*/
export const WORK_TYPE_BAN_DATE = 'Дата запрета (открытие/закрытие)';
export const DB1C_ATP_OPTION = '1С: Автотранспортное предприятие (АТП)';
/**
* Дата запрета в АТП открывается/закрывается отдельно от остальных баз,
* поэтому в такой заявке база АТП может быть только одна.
*/
export function isAtpOnlyBanDate(workType: string, databases: string[]): boolean {
return workType === WORK_TYPE_BAN_DATE && databases.indexOf(DB1C_ATP_OPTION) !== -1;
}
/** Применяет правило isAtpOnlyBanDate: при его срабатывании оставляет только АТП. */
export function restrictDatabases(workType: string, databases: string[]): string[] {
return isAtpOnlyBanDate(workType, databases) ? [DB1C_ATP_OPTION] : databases;
}
/** Обновление одного поля формы с сохранением типа значения. */ /** Обновление одного поля формы с сохранением типа значения. */
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void; export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
@@ -0,0 +1,45 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import {
ApprovalListService,
ApprovalStatus,
getPlannedMatrix,
IMatrixRow,
IResolveFieldValue
} from '../../shared/approval';
import { IFormData } from './model';
import { dedupeRouteRows } from './dedupeRoute';
/** Поля заявки, от которых зависит маршрут 1С (условия шагов в админке маршрута #8). */
export type Ticket1CRouteInput = Pick<IFormData, 'WorkType' | 'DB1C' | 'Department'>;
/**
* Значения полей для условных шагов маршрута (fieldValues при resolve).
*
* Базы 1С — мультивыбор, а approval-route принимает value только строкой:
* все выбранные базы уходят одной записью через пробел (как CompaniesJson в
* RecruitingHire), условия в админке — «DB1C содержит ЗУП/АТП/ATS».
*/
export function ticket1cRouteFieldValues(input: Ticket1CRouteInput): IResolveFieldValue[] {
return [
{ fieldName: 'WorkType', value: input.WorkType },
{ fieldName: 'DB1C', value: input.DB1C.slice().sort().join(' ') },
{ fieldName: 'Department', value: input.Department }
];
}
/**
* Запланированный маршрут одной фазы заявки в 1С. Один источник для формы и для
* страницы «Проверка маршрутов» — проверка должна видеть ровно то, что увидит
* пользователь, включая схлопывание повторов на шаге.
*/
export async function planTicket1CPhase(
context: WebPartContext,
requestTypeId: number,
userEmail: string,
approval: ApprovalListService,
phase: ApprovalStatus,
fieldValues: IResolveFieldValue[]
): Promise<IMatrixRow[]> {
const rows = await getPlannedMatrix(context, requestTypeId, userEmail, approval, phase, null, fieldValues);
return dedupeRouteRows(rows);
}
@@ -0,0 +1,4 @@
// Строка на обе колонки сетки секции — как FormField с full.
.fullRow {
grid-column: 1 / -1;
}
@@ -1,10 +1,21 @@
import * as React from 'react'; import * as React from 'react';
import styles from './RequestSection.module.scss';
import { Alert, CollapseSection, FormField, MultiChoiceField, Select, TextArea, TextInput, ISelectOption } from '../../../shared/ui'; import { Alert, CollapseSection, FormField, MultiChoiceField, Select, TextArea, TextInput, ISelectOption } from '../../../shared/ui';
import { useFieldChoices, IFieldChoicesState } from '../../../shared/fieldChoices'; import { useFieldChoices, IFieldChoicesState } from '../../../shared/fieldChoices';
import { dataListTitle } from '../../../shared/listTitles'; import { dataListTitle } from '../../../shared/listTitles';
import { DB1C_OTHER_OPTION, FORM_ID, ISectionProps, hasOtherDatabase } from '../model'; import {
DB1C_OTHER_OPTION,
FORM_ID,
WORK_TYPE_BAN_DATE,
ISectionProps,
hasOtherDatabase,
isAtpOnlyBanDate,
restrictDatabases
} from '../model';
import { import {
QUESTION_MAX, QUESTION_MAX,
DB1C_ATP_ONLY_HINT,
DB1C_ATP_ONLY_WARNING,
CHOICES_LOADING_PLACEHOLDER, CHOICES_LOADING_PLACEHOLDER,
CHOICES_EMPTY_PLACEHOLDER, CHOICES_EMPTY_PLACEHOLDER,
CHOICES_ERROR_PREFIX CHOICES_ERROR_PREFIX
@@ -30,6 +41,16 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set }) => {
const organizations = useFieldChoices(listTitle, 'Organization'); const organizations = useFieldChoices(listTitle, 'Organization');
const databases = useFieldChoices(listTitle, 'DB1C'); const databases = useFieldChoices(listTitle, 'DB1C');
const choicesError = workTypes.error || organizations.error || databases.error; const choicesError = workTypes.error || organizations.error || databases.error;
const isAtpOnly = isAtpOnlyBanDate(data.WorkType, data.DB1C);
// Правило «дата запрета по АТП» зависит и от вида работ, и от баз — поэтому
// применяется при смене любого из двух полей.
const setDatabases = (workType: string, values: string[]): void => {
const restricted = restrictDatabases(workType, values);
set('DB1C', restricted);
// Сняли «Другое» — вписанное название больше не относится к заявке.
if (restricted.indexOf(DB1C_OTHER_OPTION) === -1) set('DB1COther', '');
};
return ( return (
<CollapseSection title="Заявка" icon="clipboard"> <CollapseSection title="Заявка" icon="clipboard">
@@ -41,7 +62,10 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set }) => {
<Select <Select
id="WorkType" id="WorkType"
value={data.WorkType} value={data.WorkType}
onChange={(v) => set('WorkType', v)} onChange={(v) => {
set('WorkType', v);
setDatabases(v, data.DB1C);
}}
options={withCurrentValue(workTypes.options, data.WorkType)} options={withCurrentValue(workTypes.options, data.WorkType)}
placeholder={choicesPlaceholder(workTypes, '— выберите —')} placeholder={choicesPlaceholder(workTypes, '— выберите —')}
/> />
@@ -55,18 +79,22 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set }) => {
placeholder={choicesPlaceholder(organizations, '— выберите —')} placeholder={choicesPlaceholder(organizations, '— выберите —')}
/> />
</FormField> </FormField>
{/* Предупреждаем заранее, ещё до выбора баз, чтобы правило не стало сюрпризом. */}
{data.WorkType === WORK_TYPE_BAN_DATE && (
<div className={styles.fullRow}>
<Alert variant="warn">{DB1C_ATP_ONLY_WARNING}</Alert>
</div>
)}
{/* На всю ширину: чипы выбранных баз растят поле по высоте, и соседнее {/* На всю ширину: чипы выбранных баз растят поле по высоте, и соседнее
поле в той же строке сетки съезжало бы относительно него. */} поле в той же строке сетки съезжало бы относительно него. */}
<FormField label="База 1С" fieldKey="DB1C" required full> <FormField label="База 1С" fieldKey="DB1C" required full>
<MultiChoiceField <MultiChoiceField
value={data.DB1C} value={data.DB1C}
onChange={(v) => { onChange={(v) => setDatabases(data.WorkType, v)}
set('DB1C', v); // Пустой список вариантов прячет выпадашку — добавить другую базу нельзя,
// Сняли «Другое» — вписанное название больше не относится к заявке. // пока не уберут АТП или не сменят вид работ.
if (v.indexOf(DB1C_OTHER_OPTION) === -1) set('DB1COther', ''); options={isAtpOnly ? [] : databases.options}
}} placeholder={isAtpOnly ? DB1C_ATP_ONLY_HINT : choicesPlaceholder(databases, 'Выберите одну или несколько баз')}
options={databases.options}
placeholder={choicesPlaceholder(databases, 'Выберите одну или несколько баз')}
/> />
</FormField> </FormField>
{hasOtherDatabase(data) && ( {hasOtherDatabase(data) && (
@@ -0,0 +1,25 @@
import type { IFieldIssue } from '../../shared/ui';
import { IFormData, hasOtherDatabase } from './model';
/** Название базы вручную обязательно, только если в «База 1С» отмечено «Другое». */
function otherDatabaseIssue(data: IFormData): IFieldIssue | undefined {
if (!hasOtherDatabase(data) || data.DB1COther.trim()) return undefined;
return { field: 'DB1COther', label: 'Другая база 1С', message: 'выбрано «Другое» — укажите название базы' };
}
/**
* Всё, что мешает сохранить или отправить заявку, в порядке формы (пусто — можно).
* field — ключ FormField (htmlFor/fieldKey): по нему поле подсвечивается и к нему
* прокручивается форма.
*/
export function formIssues(data: IFormData): IFieldIssue[] {
const issues: Array<IFieldIssue | undefined> = [
data.TaskName.trim() ? undefined : { field: 'TaskName', label: 'Наименование задачи' },
data.WorkType ? undefined : { field: 'WorkType', label: 'Виды работ' },
data.Organization ? undefined : { field: 'Organization', label: 'Организация' },
data.DB1C.length > 0 ? undefined : { field: 'DB1C', label: 'База 1С' },
otherDatabaseIssue(data),
data.Question.trim() ? undefined : { field: 'Question', label: 'Вопрос' }
];
return issues.filter((issue): issue is IFieldIssue => issue !== undefined);
}
@@ -22,10 +22,11 @@
- **Период вводится двумя датами** (с / по), обе обязательны, конец ≥ начала. - **Период вводится двумя датами** (с / по), обе обязательны, конец ≥ начала.
`DaysCount` считается при сохранении (включительно) для отчётности — в UI не показывается. `DaysCount` считается при сохранении (включительно) для отчётности — в UI не показывается.
- **Причина** (`UnpaidLeaveReasonType`, Choice) — обязательна: брак / рождение ребёнка / - **Причина** (`UnpaidLeaveReasonType`, Choice) — обязательна: брак / рождение ребёнка /
смерть близких / «В иных случаях». Только при «иных случаях» появляется обязательный смерть близких / «В иных случаях» (в форме подписано «Иная причина», значение в SP прежнее). Только при «иных случаях» появляется обязательный
текст причины (`Reason`). У заявок до появления колонки причины нет — их прежний текст причины (`Reason`). У заявок до появления колонки причины нет — их прежний
текст из `Reason` по-прежнему показывается (`isReasonTextShown`). текст из `Reason` по-прежнему показывается (`isReasonTextShown`).
- **Не более 5 календарных дней** (`MAX_DAYS`) — при любой причине. - **Количество дней без верхнего предела** — 5 дней работодатель обязан дать при браке /
рождении / смерти, остальное — по договорённости, поэтому форма лимит не ставит.
- **Правило «не позднее чем за 14 дней»** (`MIN_NOTICE_DAYS`) — только для «иных случаев» - **Правило «не позднее чем за 14 дней»** (`MIN_NOTICE_DAYS`) — только для «иных случаев»
(`requiresAdvanceNotice`): брак, рождение и смерть заранее не планируются. (`requiresAdvanceNotice`): брак, рождение и смерть заранее не планируются.
- **Подсветка проблемных полей** — `FieldIssuesProvider` + правила в `validation.ts` - **Подсветка проблемных полей** — `FieldIssuesProvider` + правила в `validation.ts`
@@ -12,7 +12,8 @@ import {
RequestNumber, RequestNumber,
SubmitActions, SubmitActions,
getDraftDeleteHandler, getDraftDeleteHandler,
useRequestSubmit useRequestSubmit,
LeaveNotice
} from '../../shared/ui'; } from '../../shared/ui';
import { import {
composeRemoveRowRule, composeRemoveRowRule,
@@ -45,7 +46,8 @@ import {
dataListTitle, dataListTitle,
dataListTitleForListId dataListTitleForListId
} from '../../shared/listTitles'; } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField, NOTICE_TITLE, NOTICE_ITEMS } from './model';
import { organizationRouteFieldValues } from '../../shared/dictionary/organizationRouteFields';
import { formIssues } from './validation'; import { formIssues } from './validation';
import { saveUnpaidLeaveRequest, unpaidLeaveApprovalService } from './submit'; import { saveUnpaidLeaveRequest, unpaidLeaveApprovalService } from './submit';
import { loadUnpaidLeaveRequest } from './load'; import { loadUnpaidLeaveRequest } from './load';
@@ -161,16 +163,22 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
// Планируемый маршрут резолвится по e-mail ВЫБРАННОГО сотрудника (не текущего // Планируемый маршрут резолвится по e-mail ВЫБРАННОГО сотрудника (не текущего
// пользователя) — так работает подача за другого. У заявки на редактировании // пользователя) — так работает подача за другого. У заявки на редактировании
// маршрут читается из уже записанных строк, чтобы не затереть правки инициатора. // маршрут читается из уже записанных строк, чтобы не затереть правки инициатора.
//
// Кадровый сотрудник и исполнитель зависят от организации сотрудника (условия
// «Organization содержит …» в админке) — см. shared/dictionary/organizationRouteFields.ts.
// Организация приходит позже выбора сотрудника, поэтому она в ключе перезагрузки.
const routeFieldValues = useMemo(() => organizationRouteFieldValues(data.Organization), [data.Organization]);
const routeOrgKey = routeFieldValues.map((f) => f.value).join('|');
const loadMatrix = useCallback( const loadMatrix = useCallback(
() => () =>
loadRoutePhase(approval, editId, requestStatus, 'Approval', () => loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval') getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval', null, routeFieldValues)
), ),
[context, approval, editId, requestStatus, data.Employee, requestTypeId] [context, approval, editId, requestStatus, data.Employee, requestTypeId, routeFieldValues]
); );
const matrix = useMatrixEditor( const matrix = useMatrixEditor(
loadMatrix, loadMatrix,
`${data.Employee}-${editId ?? 'new'}`, `${data.Employee}-${editId ?? 'new'}-${routeOrgKey}`,
!readOnly, !readOnly,
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!data.Employee && !!requestTypeId)), isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!data.Employee && !!requestTypeId)),
(login) => approval.getJobTitle(login) (login) => approval.getJobTitle(login)
@@ -184,7 +192,8 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
requestTypeId, requestTypeId,
userEmail: data.Employee, userEmail: data.Employee,
editId, editId,
requestStatus requestStatus,
fieldValues: routeFieldValues
}); });
// Проблемы полей (см. validation.ts) подсвечиваются под полями через FieldIssuesProvider. // Проблемы полей (см. validation.ts) подсвечиваются под полями через FieldIssuesProvider.
@@ -326,6 +335,8 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
)} )}
{submit.error && <Alert variant="err">{submit.error}</Alert>} {submit.error && <Alert variant="err">{submit.error}</Alert>}
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
<FormModeProvider readOnly={readOnly}> <FormModeProvider readOnly={readOnly}>
<RequestSection {...sectionProps} /> <RequestSection {...sectionProps} />
</FormModeProvider> </FormModeProvider>
@@ -355,6 +366,7 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
onDelete={handleDelete} onDelete={handleDelete}
submit={submit} submit={submit}
isValid={isValid} isValid={isValid}
formId={FORM_ID}
readOnly={readOnly} readOnly={readOnly}
onCancel={onCancel} onCancel={onCancel}
onSaveDraft={handleSaveDraft} onSaveDraft={handleSaveDraft}
@@ -2,7 +2,15 @@ import * as React from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base'; import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './UnpaidLeaveView.module.scss'; import styles from './UnpaidLeaveView.module.scss';
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui'; import {
Alert,
FormCard,
InitiatorLine,
FormModeProvider,
PrintSplitButton,
OrgLetterhead,
LeaveNotice
} from '../../shared/ui';
import { import {
ApproverService, ApproverService,
DecisionPanel, DecisionPanel,
@@ -23,7 +31,7 @@ import {
dataListTitle, dataListTitle,
dataListTitleForListId dataListTitleForListId
} from '../../shared/listTitles'; } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField, NOTICE_TITLE, NOTICE_ITEMS } from './model';
import { loadUnpaidLeaveRequest } from './load'; import { loadUnpaidLeaveRequest } from './load';
import { unpaidLeaveApprovalService } from './submit'; import { unpaidLeaveApprovalService } from './submit';
import { RequestSection } from './sections/RequestSection'; import { RequestSection } from './sections/RequestSection';
@@ -148,6 +156,7 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
} }
headerRight={status ? <StatusBadge status={status} /> : undefined} headerRight={status ? <StatusBadge status={status} /> : undefined}
> >
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
<FormModeProvider readOnly> <FormModeProvider readOnly>
<RequestSection {...sectionProps} /> <RequestSection {...sectionProps} />
</FormModeProvider> </FormModeProvider>
@@ -94,8 +94,13 @@ export const SUBSTITUTE_PLACEHOLDER = 'Начните вводить Ф.И.О.
* Действует только для «иных случаев» — см. requiresAdvanceNotice. * Действует только для «иных случаев» — см. requiresAdvanceNotice.
*/ */
export const MIN_NOTICE_DAYS = 14; export const MIN_NOTICE_DAYS = 14;
/** Отпуск без сохранения заработной платы — не более стольких календарных дней (по любой причине). */
export const MAX_DAYS = 5; /** Памятка вверху формы и просмотра (см. shared/ui/LeaveNotice). Текст — по просьбе HR. */
export const NOTICE_TITLE = 'Памятка';
export const NOTICE_ITEMS: string[] = [
'После согласования заявления его необходимо распечатать с портала (кнопка «Печать» на странице заявки), ' +
'собственноручно подписать и передать специалисту отдела кадров.'
];
/** Причины отпуска. Значения совпадают с choices колонки UnpaidLeaveReasonType. */ /** Причины отпуска. Значения совпадают с choices колонки UnpaidLeaveReasonType. */
export const REASON_MARRIAGE = 'Регистрация брака'; export const REASON_MARRIAGE = 'Регистрация брака';
@@ -108,7 +113,8 @@ export const UNPAID_LEAVE_REASONS: ISelectOption[] = [
{ value: REASON_MARRIAGE, label: REASON_MARRIAGE }, { value: REASON_MARRIAGE, label: REASON_MARRIAGE },
{ value: REASON_CHILD_BIRTH, label: REASON_CHILD_BIRTH }, { value: REASON_CHILD_BIRTH, label: REASON_CHILD_BIRTH },
{ value: REASON_RELATIVE_DEATH, label: REASON_RELATIVE_DEATH }, { value: REASON_RELATIVE_DEATH, label: REASON_RELATIVE_DEATH },
{ value: REASON_OTHER, label: REASON_OTHER } // Значение в SP остаётся прежним (choices колонки и старые заявки), меняется только подпись.
{ value: REASON_OTHER, label: 'Иная причина' }
]; ];
/** /**
@@ -6,7 +6,6 @@ import {
EMPLOYEE_PLACEHOLDER, EMPLOYEE_PLACEHOLDER,
SUBSTITUTE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER,
MIN_NOTICE_DAYS, MIN_NOTICE_DAYS,
MAX_DAYS,
REASON_OTHER, REASON_OTHER,
UNPAID_LEAVE_REASONS, UNPAID_LEAVE_REASONS,
requiresAdvanceNotice, requiresAdvanceNotice,
@@ -61,14 +60,13 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
min={requiresAdvanceNotice(data.ReasonType) ? isoDatePlusDays(MIN_NOTICE_DAYS) : undefined} min={requiresAdvanceNotice(data.ReasonType) ? isoDatePlusDays(MIN_NOTICE_DAYS) : undefined}
/> />
</FormField> </FormField>
<FormField label={`Количество дней (не более ${MAX_DAYS})`} htmlFor="DaysCount" required> <FormField label="Количество дней" htmlFor="DaysCount" required>
<TextInput <TextInput
id="DaysCount" id="DaysCount"
type="number" type="number"
value={data.DaysCount} value={data.DaysCount}
onChange={(v) => set('DaysCount', v)} onChange={(v) => set('DaysCount', v)}
min={1} min={1}
max={MAX_DAYS}
/> />
</FormField> </FormField>
<FormField label="Причина" htmlFor="UnpaidLeaveReasonType" required full> <FormField label="Причина" htmlFor="UnpaidLeaveReasonType" required full>
@@ -1,6 +1,6 @@
import type { IFieldIssue } from '../../shared/ui'; import type { IFieldIssue } from '../../shared/ui';
import { isoDatePlusDays, formatIsoDate } from '../../shared/dateUtils'; import { isoDatePlusDays, formatIsoDate } from '../../shared/dateUtils';
import { IFormData, MAX_DAYS, MIN_NOTICE_DAYS, REASON_OTHER, requiresAdvanceNotice } from './model'; import { IFormData, MIN_NOTICE_DAYS, REASON_OTHER, requiresAdvanceNotice } from './model';
/** Дата начала: обязательна, а в «иных случаях» — не раньше чем через MIN_NOTICE_DAYS дней. */ /** Дата начала: обязательна, а в «иных случаях» — не раньше чем через MIN_NOTICE_DAYS дней. */
function startDateIssue(data: IFormData): IFieldIssue | undefined { function startDateIssue(data: IFormData): IFieldIssue | undefined {
@@ -12,17 +12,20 @@ function startDateIssue(data: IFormData): IFieldIssue | undefined {
return { return {
field: 'StartDate', field: 'StartDate',
label: 'Дата начала', label: 'Дата начала',
message: `в иных случаях заявление подают не позднее чем за ${MIN_NOTICE_DAYS} дней — не раньше ${formatIsoDate(minStartDate)}` message: `при иной причине заявление подают не позднее чем за ${MIN_NOTICE_DAYS} дней — не раньше ${formatIsoDate(minStartDate)}`
}; };
} }
/** Количество дней: обязательно, от 1 до MAX_DAYS. */ /**
* Количество дней: обязательно, целое и больше нуля. Верхнего предела нет:
* сверх 5 дней (обязательных при браке/рождении/смерти) — по договорённости с работодателем.
*/
function daysCountIssue(data: IFormData): IFieldIssue | undefined { function daysCountIssue(data: IFormData): IFieldIssue | undefined {
if (!data.DaysCount.trim()) return { field: 'DaysCount', label: 'Количество дней' }; if (!data.DaysCount.trim()) return { field: 'DaysCount', label: 'Количество дней' };
const days = Number(data.DaysCount); const days = Number(data.DaysCount);
if (Number.isFinite(days) && days > 0 && days <= MAX_DAYS) return undefined; if (Number.isInteger(days) && days > 0) return undefined;
return { field: 'DaysCount', label: 'Количество дней', message: `от 1 до ${MAX_DAYS} календарных дней` }; return { field: 'DaysCount', label: 'Количество дней', message: 'целое число больше нуля' };
} }
/** /**
@@ -30,6 +30,9 @@
- **Заголовок элемента** (`Title`): `Отпуск — {ФИО} — дд.мм.гггг`. - **Заголовок элемента** (`Title`): `Отпуск — {ФИО} — дд.мм.гггг`.
## Подписание в ЭДО ## Подписание в ЭДО
> **Сейчас выключено** (`VACATION_EDO_SIGNING_ENABLED = false` в `shared/config.ts`): решение последнего
> согласующего применяется сразу, в ЭДО ничего не уходит. Описанное ниже работает, когда флаг `true`.
Когда **последний согласующий** нажимает «Согласовать», заявление сначала уходит на подпись в TANSU ЭДО Когда **последний согласующий** нажимает «Согласовать», заявление сначала уходит на подпись в TANSU ЭДО
(`VacationView` → `onBeforeDecide`), и только потом применяется решение. Последний — значит заявка (`VacationView` → `onBeforeDecide`), и только потом применяется решение. Последний — значит заявка
«На согласовании» и несогласованной осталась одна строка фазы согласования (`completesApprovalPhase`; «На согласовании» и несогласованной осталась одна строка фазы согласования (`completesApprovalPhase`;
@@ -3,6 +3,8 @@ import { useState, useMemo, useCallback, useEffect } from 'react';
import styles from './VacationForm.module.scss'; import styles from './VacationForm.module.scss';
import { import {
Alert, Alert,
FieldIssuesProvider,
issueLabels,
FormCard, FormCard,
InitiatorLine, InitiatorLine,
FormModeProvider, FormModeProvider,
@@ -10,7 +12,8 @@ import {
RequestNumber, RequestNumber,
SubmitActions, SubmitActions,
getDraftDeleteHandler, getDraftDeleteHandler,
useRequestSubmit useRequestSubmit,
LeaveNotice
} from '../../shared/ui'; } from '../../shared/ui';
import { import {
composeRemoveRowRule, composeRemoveRowRule,
@@ -37,14 +40,15 @@ import { effectiveUserEmail } from '../../shared/impersonation';
import { IRequestFormProps } from '../../models/types'; import { IRequestFormProps } from '../../models/types';
import { getEmployeeOrganizations, formatEmployeeOrganizationsWithDepartment, IEmployeeOrganization } from '../../shared/dictionary'; import { getEmployeeOrganizations, formatEmployeeOrganizationsWithDepartment, IEmployeeOrganization } from '../../shared/dictionary';
import { friendlyErrorMessage } from '../../shared/diagnostics'; import { friendlyErrorMessage } from '../../shared/diagnostics';
import { isoDatePlusDays, formatIsoDate } from '../../shared/dateUtils';
import { import {
approvalListTitle, approvalListTitle,
approvalListTitleForListId, approvalListTitleForListId,
dataListTitle, dataListTitle,
dataListTitleForListId dataListTitleForListId
} from '../../shared/listTitles'; } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField, MIN_NOTICE_DAYS } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField, NOTICE_TITLE, NOTICE_ITEMS } from './model';
import { formIssues } from './validation';
import { organizationRouteFieldValues } from '../../shared/dictionary/organizationRouteFields';
import { saveVacationRequest, vacationApprovalService } from './submit'; import { saveVacationRequest, vacationApprovalService } from './submit';
import { loadVacationRequest } from './load'; import { loadVacationRequest } from './load';
import { RequestSection } from './sections/RequestSection'; import { RequestSection } from './sections/RequestSection';
@@ -169,16 +173,24 @@ const VacationForm: React.FC<IRequestFormProps> = ({
// Планируемый маршрут резолвится по e-mail ВЫБРАННОГО сотрудника (не текущего // Планируемый маршрут резолвится по e-mail ВЫБРАННОГО сотрудника (не текущего
// пользователя) — так работает подача за другого. У заявки на редактировании // пользователя) — так работает подача за другого. У заявки на редактировании
// маршрут читается из уже записанных строк, чтобы не затереть правки инициатора. // маршрут читается из уже записанных строк, чтобы не затереть правки инициатора.
//
// Кадровый сотрудник и исполнитель в маршруте зависят от организации
// сотрудника (условия «Organization содержит …» в админке) — см.
// shared/dictionary/organizationRouteFields.ts. Организация приходит позже
// выбора сотрудника (Employee/ByEmail), поэтому она в ключе перезагрузки:
// маршрут пересчитается, когда придёт, а не останется рассчитанным без условий.
const routeFieldValues = useMemo(() => organizationRouteFieldValues(data.Organization), [data.Organization]);
const routeOrgKey = routeFieldValues.map((f) => f.value).join('|');
const loadMatrix = useCallback( const loadMatrix = useCallback(
() => () =>
loadRoutePhase(approval, editId, requestStatus, 'Approval', () => loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval') getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval', null, routeFieldValues)
), ),
[context, approval, editId, requestStatus, data.Employee, requestTypeId] [context, approval, editId, requestStatus, data.Employee, requestTypeId, routeFieldValues]
); );
const matrix = useMatrixEditor( const matrix = useMatrixEditor(
loadMatrix, loadMatrix,
`${data.Employee}-${editId ?? 'new'}`, `${data.Employee}-${editId ?? 'new'}-${routeOrgKey}`,
!readOnly, !readOnly,
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!data.Employee && !!requestTypeId)), isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!data.Employee && !!requestTypeId)),
(login) => approval.getJobTitle(login) (login) => approval.getJobTitle(login)
@@ -193,27 +205,15 @@ const VacationForm: React.FC<IRequestFormProps> = ({
requestTypeId, requestTypeId,
userEmail: data.Employee, userEmail: data.Employee,
editId, editId,
requestStatus requestStatus,
fieldValues: routeFieldValues
}); });
const daysNum = Number(data.DaysCount); // Проблемы полей (см. validation.ts) подсвечиваются под полями через FieldIssuesProvider.
// Заявку нужно подавать не позднее чем за MIN_NOTICE_DAYS дней до даты начала const issues = formIssues(data);
// отпуска — календарь (min у input type=date, см. RequestSection) не даёт const isValid = issues.length === 0;
// выбрать более раннюю дату, но проверяем и здесь на случай обхода UI.
const minStartDate = isoDatePlusDays(MIN_NOTICE_DAYS);
const isStartDateValid = !data.StartDate || data.StartDate >= minStartDate;
const isValid = !!(
data.Employee &&
data.StartDate &&
isStartDateValid &&
Number.isFinite(daysNum) &&
daysNum > 0
);
const validate = (): string | undefined => { const validate = (): string | undefined => {
if (data.StartDate && !isStartDateValid) { if (!isValid) return `Заполните или исправьте поля: ${issueLabels(issues)}.`;
return `Заявку на отпуск нужно подать не позднее чем за ${MIN_NOTICE_DAYS} дней до даты начала — минимально доступная дата: ${formatIsoDate(minStartDate)}.`;
}
if (!isValid) return 'Заполните обязательные поля: сотрудник, дата начала, количество дней (> 0).';
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.'; if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
return undefined; return undefined;
@@ -323,7 +323,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
}); });
return ( return (
<div> <FieldIssuesProvider draftIssues={issues} submitIssues={issues}>
<button type="button" className={styles.back} onClick={onCancel}> <button type="button" className={styles.back} onClick={onCancel}>
<Icon name="arrow-left" size="sm" /> <Icon name="arrow-left" size="sm" />
<span>К списку заявок</span> <span>К списку заявок</span>
@@ -354,6 +354,8 @@ const VacationForm: React.FC<IRequestFormProps> = ({
)} )}
{submit.error && <Alert variant="err">{submit.error}</Alert>} {submit.error && <Alert variant="err">{submit.error}</Alert>}
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
<FormModeProvider readOnly={readOnly}> <FormModeProvider readOnly={readOnly}>
<RequestSection {...sectionProps} /> <RequestSection {...sectionProps} />
</FormModeProvider> </FormModeProvider>
@@ -383,6 +385,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
onDelete={handleDelete} onDelete={handleDelete}
submit={submit} submit={submit}
isValid={isValid} isValid={isValid}
formId={FORM_ID}
readOnly={readOnly} readOnly={readOnly}
onCancel={onCancel} onCancel={onCancel}
onSaveDraft={handleSaveDraft} onSaveDraft={handleSaveDraft}
@@ -390,7 +393,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
submitLabel="Отправить на согласование" submitLabel="Отправить на согласование"
/> />
</FormCard> </FormCard>
</div> </FieldIssuesProvider>
); );
}; };
@@ -2,7 +2,16 @@ import * as React from 'react';
import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base'; import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './VacationView.module.scss'; import styles from './VacationView.module.scss';
import { Alert, Button, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui'; import {
Alert,
Button,
FormCard,
InitiatorLine,
FormModeProvider,
PrintSplitButton,
OrgLetterhead,
LeaveNotice
} from '../../shared/ui';
import { import {
ApproverService, ApproverService,
DecisionPanel, DecisionPanel,
@@ -21,13 +30,14 @@ import type { ApproverDecision } from '../../shared/approval';
import { isAlreadyAccepted } from '../../shared/edo/edoErrors'; import { isAlreadyAccepted } from '../../shared/edo/edoErrors';
import { executionPhaseRows } from '../../shared/approval/useRouteExecutors'; import { executionPhaseRows } from '../../shared/approval/useRouteExecutors';
import { friendlyErrorMessage } from '../../shared/diagnostics'; import { friendlyErrorMessage } from '../../shared/diagnostics';
import { VACATION_EDO_SIGNING_ENABLED } from '../../shared/config';
import { import {
approvalListTitle, approvalListTitle,
approvalListTitleForListId, approvalListTitleForListId,
dataListTitle, dataListTitle,
dataListTitleForListId dataListTitleForListId
} from '../../shared/listTitles'; } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField, NOTICE_TITLE, NOTICE_ITEMS } from './model';
import { loadVacationRequest } from './load'; import { loadVacationRequest } from './load';
import { vacationApprovalService } from './submit'; import { vacationApprovalService } from './submit';
import { IVacationSigningResult, sendVacationForSigning } from './sendToSign'; import { IVacationSigningResult, sendVacationForSigning } from './sendToSign';
@@ -170,7 +180,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
// заявка остаётся на согласовании, и ошибку видно в панели решения; согласовать можно ещё раз. // заявка остаётся на согласовании, и ошибку видно в панели решения; согласовать можно ещё раз.
const sendBeforeFinalApproval = async (decision: ApproverDecision): Promise<void> => { const sendBeforeFinalApproval = async (decision: ApproverDecision): Promise<void> => {
sentBeforeDecision.current = false; sentBeforeDecision.current = false;
if (decision !== 'approve') return; if (!VACATION_EDO_SIGNING_ENABLED || decision !== 'approve') return;
if (!completesApprovalPhase(status, await approval.getMatrixForRequest(itemId))) return; if (!completesApprovalPhase(status, await approval.getMatrixForRequest(itemId))) return;
@@ -208,6 +218,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
} }
headerRight={status ? <StatusBadge status={status} /> : undefined} headerRight={status ? <StatusBadge status={status} /> : undefined}
> >
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
<FormModeProvider readOnly> <FormModeProvider readOnly>
<RequestSection {...sectionProps} /> <RequestSection {...sectionProps} />
</FormModeProvider> </FormModeProvider>
@@ -225,18 +236,23 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
</div> </div>
<div className="rp-no-print"> <div className="rp-no-print">
{signingBusy && <Alert variant="ok">Отправляем заявление на подпись в ЭДО…</Alert>} {/* Пока отправка в ЭДО выключена — ни одного сообщения о ней. */}
{!signingBusy && signingResult && signingResult.skipped.length > 0 && ( {VACATION_EDO_SIGNING_ENABLED && (
<Alert variant="err">
{`Не отправлено по компаниям: ${signingResult.skipped.map((c) => `${c.name} — ${c.reason}`).join('; ')}.`}
</Alert>
)}
{!signingBusy && signingError && (
<> <>
<Alert variant="err">{signingError}</Alert> {signingBusy && <Alert variant="ok">Отправляем заявление на подпись в ЭДО…</Alert>}
<Button variant="secondary" onClick={retrySigning}> {!signingBusy && signingResult && signingResult.skipped.length > 0 && (
Повторить отправку на подпись <Alert variant="err">
</Button> {`Не отправлено по компаниям: ${signingResult.skipped.map((c) => `${c.name} — ${c.reason}`).join('; ')}.`}
</Alert>
)}
{!signingBusy && signingError && (
<>
<Alert variant="err">{signingError}</Alert>
<Button variant="secondary" onClick={retrySigning}>
Повторить отправку на подпись
</Button>
</>
)}
</> </>
)} )}
<DecisionPanel <DecisionPanel
@@ -248,7 +264,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
setStatus(newStatus); setStatus(newStatus);
setDecisionSeq((n) => n + 1); setDecisionSeq((n) => n + 1);
notify(); notify();
return sentBeforeDecision.current ? 'Документы отправлены на подписание в ЭДО.' : undefined; return VACATION_EDO_SIGNING_ENABLED && sentBeforeDecision.current ?'Документы отправлены на подписание в ЭДО.' : undefined;
}} }}
onDelegated={() => { onDelegated={() => {
setDecisionSeq((n) => n + 1); setDecisionSeq((n) => n + 1);
@@ -79,6 +79,25 @@ export const EMPTY_DATA: IFormData = {
/** Плейсхолдер для поля выбора сотрудника. */ /** Плейсхолдер для поля выбора сотрудника. */
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…'; export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
/** Заявку нужно подавать не позднее чем за столько дней до даты начала отпуска. */ /** Заявку нужно подавать не позднее чем за столько дней до даты начала отпуска. */
export const MIN_NOTICE_DAYS = 14; export const MIN_NOTICE_DAYS = 7;
/** Ежегодный трудовой отпуск — не более стольких календарных дней в одной заявке (требование HR). */
export const MAX_DAYS = 24;
/**
* Примечание вверху формы и просмотра (см. shared/ui/LeaveNotice). Текст — от HR,
* дословно. «Одну календарную неделю» — это MIN_NOTICE_DAYS: поменяли срок — поправьте и текст.
* Правило про часть не менее 14 дней только показываем, а не проверяем: форма не знает,
* брал ли сотрудник уже часть отпуска.
*/
export const NOTICE_TITLE = 'Примечание';
export const NOTICE_ITEMS: string[] = [
'Согласно пункту 3 статьи 92 Трудового кодекса Республики Казахстан, оплачиваемый ежегодный трудовой отпуск ' +
'может быть разделен на части по соглашению сторон, но одна из частей должна составлять не менее 14 календарных дней.',
'Заявление на оплачиваемый ежегодный трудовой отпуск направляется не менее чем за одну календарную неделю ' +
'до начала отпуска.',
'После согласования заявления на оплачиваемый ежегодный трудовой отпуск по маршруту на портале сотрудник ' +
'должен подойти в отдел кадров для подписания заявления на бумажном носителе.'
];
/** Плейсхолдер для поля выбора замещающего. */ /** Плейсхолдер для поля выбора замещающего. */
export const SUBSTITUTE_PLACEHOLDER = 'Начните вводить Ф.И.О. замещающего…'; export const SUBSTITUTE_PLACEHOLDER = 'Начните вводить Ф.И.О. замещающего…';
@@ -1,7 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import { FormGrid, FormField, TextInput, PeopleField, OrganizationTable } from '../../../shared/ui'; import { FormGrid, FormField, TextInput, PeopleField, OrganizationTable } from '../../../shared/ui';
import { ProjectPicker } from '../../../shared/dictionary'; import { ProjectPicker } from '../../../shared/dictionary';
import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS } from '../model'; import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS, MAX_DAYS } from '../model';
import { isoDatePlusDays } from '../../../shared/dateUtils'; import { isoDatePlusDays } from '../../../shared/dateUtils';
/** Единственная секция формы «Заявка на отпуск»: сотрудник (должность и подразделение подтягиваются из справочника), даты. */ /** Единственная секция формы «Заявка на отпуск»: сотрудник (должность и подразделение подтягиваются из справочника), даты. */
@@ -38,7 +38,15 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
/> />
</FormField> </FormField>
<FormField label="Количество дней" htmlFor="DaysCount" required> <FormField label="Количество дней" htmlFor="DaysCount" required>
<TextInput id="DaysCount" type="number" value={data.DaysCount} onChange={(v) => set('DaysCount', v)} /> <TextInput
id="DaysCount"
type="number"
value={data.DaysCount}
onChange={(v) => set('DaysCount', v)}
min={1}
max={MAX_DAYS}
placeholder={`от 1 до ${MAX_DAYS}`}
/>
</FormField> </FormField>
<FormField label="Лицо, замещающее на время отсутствия" htmlFor="Substitute" full> <FormField label="Лицо, замещающее на время отсутствия" htmlFor="Substitute" full>
<PeopleField <PeopleField
@@ -0,0 +1,43 @@
import type { IFieldIssue } from '../../shared/ui';
import { isoDatePlusDays, formatIsoDate } from '../../shared/dateUtils';
import { IFormData, MAX_DAYS, MIN_NOTICE_DAYS } from './model';
/**
* Дата начала: обязательна и не раньше чем через MIN_NOTICE_DAYS дней. Календарь
* (min у input type=date, см. RequestSection) раннюю дату выбрать не даст,
* но её можно вписать с клавиатуры — поэтому проверяем и здесь.
*/
function startDateIssue(data: IFormData): IFieldIssue | undefined {
if (!data.StartDate) return { field: 'StartDate', label: 'Дата начала' };
const minStartDate = isoDatePlusDays(MIN_NOTICE_DAYS);
if (data.StartDate >= minStartDate) return undefined;
return {
field: 'StartDate',
label: 'Дата начала',
message: `заявление подают не позднее чем за ${MIN_NOTICE_DAYS} дней — не раньше ${formatIsoDate(minStartDate)}`
};
}
/** Количество дней: обязательно, целое, от 1 до MAX_DAYS. */
function daysCountIssue(data: IFormData): IFieldIssue | undefined {
if (!data.DaysCount.trim()) return { field: 'DaysCount', label: 'Количество дней' };
const days = Number(data.DaysCount);
if (Number.isInteger(days) && days >= 1 && days <= MAX_DAYS) return undefined;
return { field: 'DaysCount', label: 'Количество дней', message: `от 1 до ${MAX_DAYS} календарных дней` };
}
/**
* Всё, что мешает сохранить или отправить заявку, в порядке формы (пусто — можно).
* field — ключ FormField (htmlFor): по нему поле подсвечивается и к нему
* прокручивается форма.
*/
export function formIssues(data: IFormData): IFieldIssue[] {
const issues: Array<IFieldIssue | undefined> = [
data.Employee ? undefined : { field: 'Employee', label: 'Сотрудник' },
startDateIssue(data),
daysCountIssue(data)
];
return issues.filter((issue): issue is IFieldIssue => issue !== undefined);
}
@@ -55,6 +55,9 @@ const formRegistry: Record<string, React.LazyExoticComponent<FormComponent>> = {
'hr-transfer': React.lazy( 'hr-transfer': React.lazy(
() => import(/* webpackChunkName: 'form-employee-transfer' */ './EmployeeTransfer/EmployeeTransferForm') () => import(/* webpackChunkName: 'form-employee-transfer' */ './EmployeeTransfer/EmployeeTransferForm')
), ),
'acc-payment-request': React.lazy(
() => import(/* webpackChunkName: 'form-payment-request' */ './PaymentRequest/PaymentRequestForm')
),
'wh-concrete-request': React.lazy( 'wh-concrete-request': React.lazy(
() => import(/* webpackChunkName: 'form-concrete-request' */ './ConcreteRequest/ConcreteRequestForm') () => import(/* webpackChunkName: 'form-concrete-request' */ './ConcreteRequest/ConcreteRequestForm')
) )
@@ -74,6 +74,9 @@ const viewRegistry: Record<string, React.LazyExoticComponent<ViewComponent>> = {
'hr-transfer': React.lazy( 'hr-transfer': React.lazy(
() => import(/* webpackChunkName: 'view-employee-transfer' */ './EmployeeTransfer/EmployeeTransferView') () => import(/* webpackChunkName: 'view-employee-transfer' */ './EmployeeTransfer/EmployeeTransferView')
), ),
'acc-payment-request': React.lazy(
() => import(/* webpackChunkName: 'view-payment-request' */ './PaymentRequest/PaymentRequestView')
),
'wh-concrete-request': React.lazy( 'wh-concrete-request': React.lazy(
() => import(/* webpackChunkName: 'view-concrete-request' */ './ConcreteRequest/ConcreteRequestView') () => import(/* webpackChunkName: 'view-concrete-request' */ './ConcreteRequest/ConcreteRequestView')
) )
@@ -0,0 +1,37 @@
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../config';
/** JSON-схема ответа в формате, который принимает прокси Gemini (как в СТ). */
export interface IGeminiSchema {
type: 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean';
properties?: Record<string, IGeminiSchema>;
items?: IGeminiSchema;
required?: string[];
}
/**
* POST /Proxy/Gemini { prompt, responseSchema } → объект, подогнанный под схему.
* Тот же вызов делает СТ (CtService.scorePaymentTerms); ответ прокси — уже
* разобранный JSON, без обёртки.
*/
export async function askGemini<T>(context: WebPartContext, prompt: string, responseSchema: IGeminiSchema): Promise<T> {
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
const response: HttpClientResponse = await client.post(
`${DICTIONARY_API_BASE_URL}/Proxy/Gemini`,
AadHttpClient.configurations.v1,
{
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, responseSchema })
}
);
if (!response.ok) {
throw new Error(`Proxy/Gemini: ${response.status} ${response.statusText}`);
}
const body = (await response.json()) as T & { error?: unknown };
// Пустой ответ модели прокси отдаёт как 200 с { error } — это сбой, а не данные.
if (body && typeof body.error === 'string') {
throw new Error(`Proxy/Gemini: ${body.error}`);
}
return body;
}
@@ -5,6 +5,7 @@ import {
ApprovalStatus, ApprovalStatus,
ICurrentListPartition, ICurrentListPartition,
IApiResponse, IApiResponse,
IBasisRequest,
IRequestType, IRequestType,
IRequestTypeListSeries, IRequestTypeListSeries,
IResolvedRoute, IResolvedRoute,
@@ -147,6 +148,32 @@ export async function getRequestTypeIdByPageUrl(context: WebPartContext, pageUrl
return found.id; return found.id;
} }
/**
* GET /api/basis-requests — заявки типа requestTypeId, поданные по основанию
* basisKey («ct:<Id>», «need:<oid>»), по всем партициям. Читаются на сервере от
* имени приложения: права на элементы заявок разорваны поштучно, и прямым
* чтением списка пользователь увидел бы только свои.
*/
export async function getBasisRequests(
context: WebPartContext,
requestTypeId: number,
basisKey: string
): Promise<IBasisRequest[]> {
return withNetworkRetry(async () => {
const c = await client(context);
const query = `requestTypeId=${requestTypeId}&basisKey=${encodeURIComponent(basisKey)}`;
const response: HttpClientResponse = await c.get(
`${getApprovalApiBaseUrl()}/api/basis-requests?${query}`,
AadHttpClient.configurations.v1
);
if (!response.ok) {
throw new Error(`basis-requests: ${response.status} ${response.statusText}`);
}
const body: IApiResponse<IBasisRequest[]> = await response.json();
return body.data ?? [];
});
}
// GET /api/request-type-access/{requestTypeId}/emails — email'ы, кому в // GET /api/request-type-access/{requestTypeId}/emails — email'ы, кому в
// админке approval-route выдан доступ видеть ВСЕ заявки этого типа (не только // админке approval-route выдан доступ видеть ВСЕ заявки этого типа (не только
// свои). Тот же эндпоинт дёргает и Power Automate — здесь просто ещё один // свои). Тот же эндпоинт дёргает и Power Automate — здесь просто ещё один
@@ -19,6 +19,7 @@ export type {
IListPartition, IListPartition,
ICurrentListPartition, ICurrentListPartition,
IRequestType, IRequestType,
IBasisRequest,
IRequestTypeListSeries, IRequestTypeListSeries,
IResolvedRoute, IResolvedRoute,
IResolvedRouteStep, IResolvedRouteStep,
@@ -32,9 +33,10 @@ export {
getListPartitions, getListPartitions,
getCurrentListPartition, getCurrentListPartition,
getRequestTypeAccessEmails, getRequestTypeAccessEmails,
getBasisRequests,
resolveRoute resolveRoute
} from './approvalRoute.service'; } from './approvalRoute.service';
export { hasRequestTypeAccess, accessibleAllRequestsTypes } from './requestTypeAccess'; export { hasRequestTypeAccess, accessibleAllRequestsTypes, emailsForType } from './requestTypeAccess';
export { useAccessibleAllRequestsFormKeys } from './useAccessibleAllRequestsFormKeys'; export { useAccessibleAllRequestsFormKeys } from './useAccessibleAllRequestsFormKeys';
export { export {
initListPartitions, initListPartitions,
@@ -59,7 +61,7 @@ export { approvalServiceFor } from './approvalServiceFactory';
export { ApproverService } from './approverDecision.service'; export { ApproverService } from './approverDecision.service';
export type { ApproverDecision, IPendingRow } from './approverDecision.service'; export type { ApproverDecision, IPendingRow } from './approverDecision.service';
export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service'; export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service';
export { notifyActiveStep } from './notify.service'; export { notifyActiveStep, postToFlow } from './notify.service';
export type { INotifyTarget } from './notify.service'; export type { INotifyTarget } from './notify.service';
export { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor'; export { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor';
export type { IUseMatrixEditor } from './useMatrixEditor'; export type { IUseMatrixEditor } from './useMatrixEditor';
@@ -126,3 +126,19 @@ export interface IResolvedRouteStep {
*/ */
positionCode?: string; positionCode?: string;
} }
/**
* Заявка, поданная по тому же основанию (СТ, потребность) — GET /api/basis-requests.
* Сервер читает их от имени приложения, поэтому видны все, а не только свои;
* canOpen — есть ли у текущего пользователя права открыть саму заявку.
*/
export interface IBasisRequest {
id: number;
/** Серверный относительный URL списка партиции, где лежит заявка. */
listId: string;
/** Номер заявки; пока не присвоен — пусто (сервер шлёт null). */
uniqueCode?: string;
status: string;
amount: number;
canOpen: boolean;
}
@@ -12,14 +12,15 @@ export interface INotifyTarget {
approvalListName: string; // ApproveListName (список согласования) approvalListName: string; // ApproveListName (список согласования)
} }
// POST в поток оповещения (Entra-триггер) через AadHttpClient — обычный fetch → 401, // POST в поток Power Automate (Entra-триггер) через AadHttpClient — обычный fetch → 401,
// AadHttpClient даёт токен Flow Service (и снимает вопрос CORS). // AadHttpClient даёт токен Flow Service (и снимает вопрос CORS). Общий для всех
async function postNotify(context: WebPartContext, body: Record<string, unknown>): Promise<void> { // потоков оповещения портала (согласование, чат заявки — shared/chat/chatNotify.service.ts).
if (!FLOW_NOTIFY_URL) { export async function postToFlow(context: WebPartContext, flowUrl: string, body: object): Promise<void> {
if (!flowUrl) {
return; return;
} }
const client = await context.aadHttpClientFactory.getClient(FLOW_RESOURCE); const client = await context.aadHttpClientFactory.getClient(FLOW_RESOURCE);
const res = await client.post(FLOW_NOTIFY_URL, AadHttpClient.configurations.v1, { const res = await client.post(flowUrl, AadHttpClient.configurations.v1, {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
@@ -68,7 +69,7 @@ async function notifyRow(
if (!(await isNotifyAllowed(approval, row.approverId))) { if (!(await isNotifyAllowed(approval, row.approverId))) {
return; return;
} }
await postNotify(context, { await postToFlow(context, FLOW_NOTIFY_URL, {
RequestTypeId: target.requestTypeId, RequestTypeId: target.requestTypeId,
RowID: row.id, RowID: row.id,
RequestID: requestId, RequestID: requestId,
@@ -9,7 +9,12 @@ import { effectiveUserEmail } from '../impersonation';
// кешируется, чтобы временный сбой сети не «залип» на весь сеанс. // кешируется, чтобы временный сбой сети не «залип» на весь сеанс.
const cache = new Map<number, Promise<string[]>>(); const cache = new Map<number, Promise<string[]>>();
function emailsForType(context: WebPartContext, requestTypeId: number): Promise<string[]> { /**
* Кому выдан доступ видеть ВСЕ заявки этого типа (раздел «Все заявки»). Кешируется
* на сеанс. Нужен не только для проверки доступа: эти люди могут писать в чат
* любой заявки типа, значит, их можно и отмечать в нём (см. shared/chat).
*/
export function emailsForType(context: WebPartContext, requestTypeId: number): Promise<string[]> {
let pending = cache.get(requestTypeId); let pending = cache.get(requestTypeId);
if (!pending) { if (!pending) {
pending = getRequestTypeAccessEmails(context, requestTypeId).catch((e) => { pending = getRequestTypeAccessEmails(context, requestTypeId).catch((e) => {
@@ -23,6 +23,7 @@ describe('linkify', () => {
}); });
it('не-http схемы ссылкой не становятся', () => { it('не-http схемы ссылкой не становятся', () => {
// eslint-disable-next-line no-script-url -- проверяем как раз то, что такая строка ссылкой не станет
expect(linkify('javascript:alert(1)')).toEqual([{ text: 'javascript:alert(1)' }]); expect(linkify('javascript:alert(1)')).toEqual([{ text: 'javascript:alert(1)' }]);
}); });
@@ -1,14 +1,16 @@
import { isChatEnabledFor } from './chatEnabled'; import { isChatEnabledFor } from './chatEnabled';
describe('isChatEnabledFor', () => { describe('isChatEnabledFor', () => {
it('включён для «Обходного листа»', () => { it('включён для Обходного листа, подбора, приёма и заявки в 1С', () => {
expect(isChatEnabledFor('clearance-form')).toBe(true); expect(isChatEnabledFor('clearance-form')).toBe(true);
expect(isChatEnabledFor('recruiting-selection')).toBe(true);
expect(isChatEnabledFor('recruiting-hire')).toBe(true);
expect(isChatEnabledFor('it-1c-ticket')).toBe(true);
}); });
it('выключен для остальных типов заявок', () => { it('выключен для остальных типов заявок', () => {
expect(isChatEnabledFor('hr-vacation')).toBe(false); expect(isChatEnabledFor('hr-vacation')).toBe(false);
expect(isChatEnabledFor('it-1c-ticket')).toBe(false); expect(isChatEnabledFor('recruiting-referral')).toBe(false);
expect(isChatEnabledFor('recruiting-hire')).toBe(false);
}); });
it('незнакомый ключ — тоже выключен, а не бросает', () => { it('незнакомый ключ — тоже выключен, а не бросает', () => {
@@ -3,9 +3,9 @@ import { RequestTypeId } from '../../models/types';
/** /**
* Типы заявок, для которых показывается чат (shared/chat). * Типы заявок, для которых показывается чат (shared/chat).
* *
* Пилот: только «Обходной лист» (forms/Clearance, FORM_ID = 'clearance-form'). * Включаем по одному типу, а не сразу на все: «Обходной лист» — первый пилот,
* Остальным формам чат пока не нужен — решение сузить область до одного типа, * с 2026-10-14 добавлены «Заявка на подбор», «Заявка на приём» и «Заявка в
* а не включать сразу на все девять. * отдел 1С».
* *
* Ключ — строковый литерал, а не импорт FORM_ID из forms/Clearance/model: * Ключ — строковый литерал, а не импорт FORM_ID из forms/Clearance/model:
* shared/* не должен зависеть от forms/* (зависимость идёт в обратную сторону * shared/* не должен зависеть от forms/* (зависимость идёт в обратную сторону
@@ -13,7 +13,12 @@ import { RequestTypeId } from '../../models/types';
* так что литерал здесь не менее надёжен, чем в LIST_TITLE_BASE * так что литерал здесь не менее надёжен, чем в LIST_TITLE_BASE
* (shared/listTitles.ts), где тем же приёмом перечислены все типы заявок. * (shared/listTitles.ts), где тем же приёмом перечислены все типы заявок.
*/ */
const CHAT_ENABLED_FORM_IDS: ReadonlySet<RequestTypeId> = new Set<RequestTypeId>(['clearance-form']); const CHAT_ENABLED_FORM_IDS: ReadonlySet<RequestTypeId> = new Set<RequestTypeId>([
'clearance-form',
'recruiting-selection',
'recruiting-hire',
'it-1c-ticket'
]);
/** Показывать ли чат по заявке для этого типа. Проверяется ДО монтирования /** Показывать ли чат по заявке для этого типа. Проверяется ДО монтирования
* RequestChat — иначе хук useRequestChat опрашивал бы SharePoint (загрузка + * RequestChat — иначе хук useRequestChat опрашивал бы SharePoint (загрузка +
@@ -0,0 +1,70 @@
import {
buildParticipants,
extractMentionedEmails,
filterParticipants,
findMentionQuery,
insertMention
} from './chatMentions';
const ivanov = { name: 'Иван Иванов', email: 'ivanov@tnsu.kz' };
const petrova = { name: 'Анна Петрова', email: 'petrova@tnsu.kz' };
describe('buildParticipants', () => {
it('убирает повторы, пустые адреса и самого пишущего', () => {
const result = buildParticipants(
[ivanov, { name: 'Иван Иванов', email: 'IVANOV@tnsu.kz' }, { name: 'Без почты', email: '' }, undefined, petrova],
'petrova@tnsu.kz'
);
expect(result).toEqual([ivanov]);
});
it('без ФИО показывает почту', () => {
expect(buildParticipants([{ email: 'x@tnsu.kz' }], '')).toEqual([{ name: 'x@tnsu.kz', email: 'x@tnsu.kz' }]);
});
});
describe('findMentionQuery', () => {
it('находит отметку в начале и после пробела', () => {
expect(findMentionQuery('@Ив', 3)).toEqual({ start: 0, query: 'Ив' });
expect(findMentionQuery('Привет @Иван Ив', 15)).toEqual({ start: 7, query: 'Иван Ив' });
});
it('не считает отметкой адрес почты и перенос строки', () => {
expect(findMentionQuery('mail@tnsu', 9)).toBeUndefined();
expect(findMentionQuery('@Иван\nдалее', 11)).toBeUndefined();
expect(findMentionQuery('без отметки', 5)).toBeUndefined();
});
});
describe('filterParticipants', () => {
it('ищет по началу слов ФИО и по почте', () => {
expect(filterParticipants([ivanov, petrova], 'пет')).toEqual([petrova]);
expect(filterParticipants([ivanov, petrova], 'иван ив')).toEqual([ivanov]);
expect(filterParticipants([ivanov, petrova], 'petrova@')).toEqual([petrova]);
expect(filterParticipants([ivanov, petrova], '')).toEqual([ivanov, petrova]);
});
});
describe('insertMention', () => {
it('заменяет набранное на ФИО и ставит курсор за пробелом', () => {
const text = 'Привет @Ив, глянь';
const result = insertMention(text, { start: 7, query: 'Ив' }, 10, ivanov);
expect(result.text).toBe('Привет @Иван Иванов , глянь');
expect(result.caret).toBe('Привет @Иван Иванов '.length);
});
});
describe('extractMentionedEmails', () => {
it('берёт отметки из текста без повторов', () => {
expect(extractMentionedEmails('@Иван Иванов и @Анна Петрова, @Иван Иванов', [ivanov, petrova])).toEqual([
'ivanov@tnsu.kz',
'petrova@tnsu.kz'
]);
});
it('стёртая отметка не считается, короткое ФИО не цепляется за длинное', () => {
const ivan = { name: 'Иван', email: 'ivan@tnsu.kz' };
expect(extractMentionedEmails('@Иван Иванов, привет', [ivan, ivanov])).toEqual(['ivanov@tnsu.kz']);
expect(extractMentionedEmails('Иван Иванов, привет', [ivanov])).toEqual([]);
});
});
@@ -0,0 +1,116 @@
/**
* Отметки участников заявки в чате через «@».
*
* Отметка — ОБЫЧНЫЙ текст «@ФИО» в сообщении, а не штатное упоминание
* SharePoint (параметр mentions у комментария): штатное упоминание SharePoint
* сопровождает своим письмом, а письма по чату шлёт наш поток (chatNotify.ts) —
* с номером заявки и ссылкой на портал, а не на строку списка. Два письма об
* одном сообщении — лишнее.
*
* Модуль чистый (без React и PnPjs) — покрыт тестами.
*/
/** Участник заявки, которого можно отметить в чате. */
export interface IChatParticipant {
name: string;
email: string;
}
/** Активная отметка, которую пользователь сейчас набирает: «@Ива|». */
export interface IMentionQuery {
/** Позиция символа «@» в тексте. */
start: number;
/** Набранное после «@» до курсора. */
query: string;
}
/**
* Предел длины набираемой отметки: ФИО с отчеством укладывается, а «@» в
* середине обычного текста (адрес почты, «@всем, смотрите…») не держит
* подсказку открытой до конца абзаца.
*/
const MAX_QUERY_LENGTH = 40;
const sameEmail = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase();
/**
* Список тех, кого можно отметить: инициатор и все из маршрута согласования
* (согласующие и исполнители), без повторов и без самого пишущего — отмечать
* себя незачем. Порядок — как в маршруте, инициатор первым.
*/
export function buildParticipants(
people: Array<Partial<IChatParticipant> | undefined>,
currentUserEmail: string
): IChatParticipant[] {
const result: IChatParticipant[] = [];
for (const p of people) {
const email = (p?.email || '').trim();
if (!email) continue;
if (currentUserEmail && sameEmail(email, currentUserEmail)) continue;
if (result.some((r) => sameEmail(r.email, email))) continue;
result.push({ name: (p?.name || '').trim() || email, email });
}
return result;
}
/**
* Набирается ли сейчас отметка: «@» в начале текста или после пробела/переноса,
* и между ним и курсором нет переноса строки. Пробелы внутри допустимы — ФИО
* из нескольких слов.
*/
export function findMentionQuery(text: string, caret: number): IMentionQuery | undefined {
const before = text.slice(0, caret);
const start = before.lastIndexOf('@');
if (start < 0) return undefined;
if (start > 0 && !/\s/.test(before[start - 1])) return undefined;
const query = before.slice(start + 1);
if (query.length > MAX_QUERY_LENGTH || /[\r\n@]/.test(query)) return undefined;
return { start, query };
}
/** Участники, подходящие под набранное: каждое слово запроса — начало слова в ФИО или часть почты. */
export function filterParticipants(participants: IChatParticipant[], query: string): IChatParticipant[] {
const words = query.toLowerCase().split(/\s+/).filter((w) => !!w);
if (words.length === 0) return participants;
return participants.filter((p) => {
const nameWords = p.name.toLowerCase().split(/\s+/);
const email = p.email.toLowerCase();
return words.every((w) => nameWords.some((n) => n.startsWith(w)) || email.includes(w));
});
}
/** Заменить набранное «@запрос» на «@ФИО » и вернуть новый текст и позицию курсора. */
export function insertMention(
text: string,
mention: IMentionQuery,
caret: number,
participant: IChatParticipant
): { text: string; caret: number } {
const inserted = `@${participant.name} `;
const head = text.slice(0, mention.start);
// Пробел сразу за курсором уже есть — второй не нужен.
const tail = text.slice(caret).replace(/^ /, '');
return { text: head + inserted + tail, caret: head.length + inserted.length };
}
/**
* Кого в итоге отметили — по тексту, а не по истории выбора в подсказке:
* отметку могли стереть или вписать руками целиком. Длинные ФИО проверяются
* первыми, чтобы «@Иван Иванов» не засчитал заодно участника «Иван».
*/
export function extractMentionedEmails(text: string, participants: IChatParticipant[]): string[] {
// Найденную отметку затираем пробелами той же длины — позиции остальных не
// сдвигаются, и результат идёт в порядке появления в тексте.
let rest = text;
const found: Array<{ email: string; at: number }> = [];
const byLength = [...participants].sort((a, b) => b.name.length - a.name.length);
for (const p of byLength) {
const tag = `@${p.name}`;
const at = rest.indexOf(tag);
if (at < 0) continue;
rest = rest.split(tag).join(' '.repeat(tag.length));
if (!found.some((f) => sameEmail(f.email, p.email))) found.push({ email: p.email, at });
}
return found.sort((a, b) => a.at - b.at).map((f) => f.email);
}
@@ -0,0 +1,17 @@
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { FLOW_CHAT_NOTIFY_URL } from '../config';
import { postToFlow } from '../approval/notify.service';
import { IChatNotifyBody } from './chatNotify';
/**
* Отправить оповещение о сообщении в чате. Некритично: сообщение уже записано,
* и сбой письма не должен выглядеть как сбой отправки — ошибку только пишем в
* консоль, как и прочие оповещения портала (approval/notify.service.ts).
*/
export async function notifyChatMessage(context: WebPartContext, body: IChatNotifyBody): Promise<void> {
try {
await postToFlow(context, FLOW_CHAT_NOTIFY_URL, body);
} catch (e) {
console.warn('[Чат] Оповещение о сообщении не отправлено', e);
}
}
@@ -0,0 +1,65 @@
import { buildChatNotifyBody, IChatNotifyInput } from './chatNotify';
const initiator = { name: 'Инициатор Заявки', email: 'init@tnsu.kz' };
const approver = { name: 'Анна Петрова', email: 'petrova@tnsu.kz' };
const base: IChatNotifyInput = {
initiatorEmail: initiator.email,
participants: [initiator, approver],
authorName: 'Анна Петрова',
authorEmail: approver.email,
messageText: 'Текст',
requestCode: '13-2608-42',
requestUrl: 'https://portal/x',
requestTitle: 'Обходной лист',
selfNotify: false
};
describe('buildChatNotifyBody', () => {
it('пишет согласующий без отметок — письмо инициатору, отмеченных нет', () => {
expect(buildChatNotifyBody(base)).toEqual({
initiatorEmail: 'init@tnsu.kz',
mentionedEmails: [],
authorName: 'Анна Петрова',
messageText: 'Текст',
RequestUniqueCode: '13-2608-42',
requestUrl: 'https://portal/x',
requestTitle: 'Обходной лист'
});
});
it('инициатор пишет без отметок — поток не вызываем', () => {
expect(buildChatNotifyBody({ ...base, authorEmail: 'INIT@tnsu.kz' })).toBeUndefined();
});
it('инициатор отметил согласующего — инициатор подменён отмеченным, чтобы не писать самому себе', () => {
const body = buildChatNotifyBody({ ...base, authorEmail: initiator.email, messageText: '@Анна Петрова глянь' });
expect(body?.initiatorEmail).toBe('petrova@tnsu.kz');
expect(body?.mentionedEmails).toEqual(['petrova@tnsu.kz']);
});
it('отметка самого себя без режима проверки не считается', () => {
const body = buildChatNotifyBody({ ...base, messageText: '@Анна Петрова @Инициатор Заявки' });
expect(body?.mentionedEmails).toEqual(['init@tnsu.kz']);
});
it('в режиме проверки письма и о своих сообщениях, и себе по отметке', () => {
const own = buildChatNotifyBody({ ...base, authorEmail: initiator.email, selfNotify: true });
expect(own?.initiatorEmail).toBe('init@tnsu.kz');
const self = buildChatNotifyBody({ ...base, messageText: '@Анна Петрова', selfNotify: true });
expect(self?.mentionedEmails).toEqual(['petrova@tnsu.kz']);
});
it('файлы — строкой в тексте письма, и при пустом сообщении тоже', () => {
expect(buildChatNotifyBody({ ...base, fileNames: ['акт.pdf', 'скан.jpg'] })?.messageText).toBe(
'Текст\n\nПрикреплены файлы: акт.pdf, скан.jpg'
);
expect(buildChatNotifyBody({ ...base, messageText: '', fileNames: ['акт.pdf'] })?.messageText).toBe(
'Прикреплены файлы: акт.pdf'
);
});
it('нет ни инициатора, ни отметок — поток не вызываем', () => {
expect(buildChatNotifyBody({ ...base, initiatorEmail: '' })).toBeUndefined();
});
});
@@ -0,0 +1,83 @@
import { IChatParticipant, extractMentionedEmails } from './chatMentions';
/**
* Тело запроса к потоку оповещения о сообщении в чате (FLOW_CHAT_NOTIFY_URL).
* Контракт потока: все поля — строки, кроме mentionedEmails; никого не
* отметили — пустой массив, а не null и не пропущенное поле.
*/
export interface IChatNotifyBody {
initiatorEmail: string;
mentionedEmails: string[];
authorName: string;
messageText: string;
RequestUniqueCode: string;
requestUrl: string;
requestTitle: string;
}
export interface IChatNotifyInput {
/** Инициатор заявки (автор строки списка данных). */
initiatorEmail: string;
/** Кого можно было отметить — по ним ищутся «@ФИО» в тексте. */
participants: IChatParticipant[];
authorName: string;
authorEmail: string;
messageText: string;
/** Имена приложенных файлов, как их выбрал пользователь (без складского префикса). */
fileNames?: string[];
requestCode: string;
requestUrl: string;
requestTitle: string;
/** Режим проверки (свойство веб-части chatSelfNotify): письма и о своих сообщениях. */
selfNotify: boolean;
}
const sameEmail = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase();
/**
* Текст для письма: сообщение и строка о файлах. Без неё сообщение из одного
* файла пришло бы письмом с пустым текстом. Сами файлы в письмо не кладём —
* они в библиотеке, открываются по ссылке на заявку.
*/
function letterText(text: string, fileNames: string[]): string {
if (fileNames.length === 0) return text;
const files = `Прикреплены файлы: ${fileNames.join(', ')}`;
return text ? `${text}\n\n${files}` : files;
}
/**
* Собрать тело запроса к потоку или undefined, если писать некому.
*
* Поток шлёт инициатору письмо ВСЕГДА, когда его нет среди отмеченных. Значит,
* когда пишет сам инициатор (а режим проверки выключен), письмо «новое сообщение
* по вашей заявке» ушло бы ему же. Отключить его в контракте потока нечем,
* поэтому:
* — никого не отметил → поток не вызываем вовсе;
* — кого-то отметил → в initiatorEmail кладём первого отмеченного: по
* контракту отмеченный инициатор получает только «Вас упомянули», и
* лишнего письма никому не уходит.
*/
export function buildChatNotifyBody(input: IChatNotifyInput): IChatNotifyBody | undefined {
const mentioned = extractMentionedEmails(input.messageText, input.participants).filter(
(email) => input.selfNotify || !sameEmail(email, input.authorEmail)
);
let initiatorEmail = input.initiatorEmail;
const authorIsInitiator =
!!initiatorEmail && !!input.authorEmail && sameEmail(initiatorEmail, input.authorEmail);
if (authorIsInitiator && !input.selfNotify) {
if (mentioned.length === 0) return undefined;
initiatorEmail = mentioned[0];
}
if (!initiatorEmail && mentioned.length === 0) return undefined;
return {
initiatorEmail,
mentionedEmails: mentioned,
authorName: input.authorName,
messageText: letterText(input.messageText, input.fileNames || []),
RequestUniqueCode: input.requestCode,
requestUrl: input.requestUrl,
requestTitle: input.requestTitle
};
}
@@ -3,7 +3,19 @@ export { RequestChat } from './ui/RequestChat';
export { isChatEnabledFor } from './chatEnabled'; export { isChatEnabledFor } from './chatEnabled';
export { useRequestChat } from './useRequestChat'; export { useRequestChat } from './useRequestChat';
export type { IRequestChat } from './useRequestChat'; export type { IRequestChat } from './useRequestChat';
export { getChatMessages, addChatMessage } from './requestChat.service'; export { getChatMessages, addChatMessage, getChatRequestInfo } from './requestChat.service';
export type { IChatRequestInfo } from './requestChat.service';
export {
buildParticipants,
findMentionQuery,
filterParticipants,
insertMention,
extractMentionedEmails
} from './chatMentions';
export type { IChatParticipant, IMentionQuery } from './chatMentions';
export { buildChatNotifyBody } from './chatNotify';
export type { IChatNotifyBody } from './chatNotify';
export { notifyChatMessage } from './chatNotify.service';
export { export {
uploadChatFiles, uploadChatFiles,
chatFilesFolderUrl, chatFilesFolderUrl,
@@ -6,6 +6,12 @@ import { RequestTypeId } from '../../models/types';
import { IChatMessage } from './models/IChatMessage'; import { IChatMessage } from './models/IChatMessage';
import { buildCommentText, hasAttachments, toChatMessages } from './chatMessages'; import { buildCommentText, hasAttachments, toChatMessages } from './chatMessages';
import { chatFilesFolderUrl, uploadChatFiles } from './chatFiles.service'; import { chatFilesFolderUrl, uploadChatFiles } from './chatFiles.service';
import { IChatParticipant } from './chatMentions';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { approvalServiceFor } from '../approval/approvalServiceFactory';
import { getRequestTypeIdByPageUrl } from '../approval/approvalRoute.service';
import { emailsForType } from '../approval/requestTypeAccess';
import { getEmployeeOrganizations } from '../dictionary';
/** /**
* Чат по заявке ПОВЕРХ штатных комментариев к строке списка. * Чат по заявке ПОВЕРХ штатных комментариев к строке списка.
@@ -76,3 +82,68 @@ export async function addChatMessage(
const storedNames = await uploadChatFiles(formKey, itemId, listId, files); const storedNames = await uploadChatFiles(formKey, itemId, listId, files);
await commentsOf(formKey, itemId, listId).add(buildCommentText(text, storedNames)); await commentsOf(formKey, itemId, listId).add(buildCommentText(text, storedNames));
} }
/** Сведения о заявке, нужные чату помимо переписки: кого отмечать и что писать в письме. */
export interface IChatRequestInfo {
code: string;
title: string;
initiator: IChatParticipant;
/**
* Инициатор, все из маршрута (согласующие и исполнители) и те, кому открыты
* «Все заявки» этого типа, — как есть, повторы убирает buildParticipants.
*/
people: IChatParticipant[];
}
/**
* Люди с доступом ко «Всем заявкам» этого типа (админка approval-route): они
* открывают любую заявку типа и пишут в её чат, значит, их можно и отметить.
* ФИО — из кадрового справочника, не нашлось — показываем почту. Любой сбой —
* просто без них: отметки участников маршрута от этого не страдают.
*/
async function allRequestsViewers(context: WebPartContext, formKey: RequestTypeId): Promise<IChatParticipant[]> {
try {
const typeId = await getRequestTypeIdByPageUrl(context, formKey);
const emails = await emailsForType(context, typeId);
return await Promise.all(
emails.map(async (email) => {
const records = await getEmployeeOrganizations(context, email).catch(() => []);
return { name: records[0]?.fio || email, email };
})
);
} catch {
return [];
}
}
/**
* Инициатор — автор строки списка данных: заявку создаёт он сам (подмена
* личности на автора записи не влияет, см. RequestChat). Участники — строки
* маршрута; если список согласования не прочитался, чат работает и так —
* просто отметить можно только инициатора.
*/
export async function getChatRequestInfo(
context: WebPartContext,
formKey: RequestTypeId,
itemId: number,
listId: string | undefined
): Promise<IChatRequestInfo> {
const [item, matrix, viewers] = await Promise.all([
resolveDataList(formKey, listId)
.items.getById(itemId)
.select('Title', 'RequestUniqueCode', 'Author/Title', 'Author/EMail')
.expand('Author')() as Promise<Record<string, unknown>>,
approvalServiceFor(formKey, listId)
.getMatrixForRequest(itemId)
.catch(() => []),
allRequestsViewers(context, formKey)
]);
const author = item.Author as { Title?: string; EMail?: string } | undefined;
const initiator = { name: author?.Title || '', email: author?.EMail || '' };
return {
code: (item.RequestUniqueCode as string) || '',
title: (item.Title as string) || '',
initiator,
people: [initiator, ...matrix.map((r) => ({ name: r.who, email: r.email })), ...viewers]
};
}
@@ -2,22 +2,33 @@ import * as React from 'react';
import styles from './RequestChat.module.scss'; import styles from './RequestChat.module.scss';
import { Button, FileDropzone, Icon } from '../../ui'; import { Button, FileDropzone, Icon } from '../../ui';
import { MAX_FILES_PER_MESSAGE, MAX_FILE_SIZE_BYTES } from '../chatFiles.service'; import { MAX_FILES_PER_MESSAGE, MAX_FILE_SIZE_BYTES } from '../chatFiles.service';
import { filterParticipants, findMentionQuery, IChatParticipant, insertMention } from '../chatMentions';
interface IChatComposerProps { interface IChatComposerProps {
sending: boolean; sending: boolean;
/** Кого можно отметить через «@» (см. chatMentions.ts). */
participants: IChatParticipant[];
/** Возвращает true, если сообщение записано — только тогда очищаем поле. */ /** Возвращает true, если сообщение записано — только тогда очищаем поле. */
onSend: (text: string, files: File[]) => Promise<boolean>; onSend: (text: string, files: File[]) => Promise<boolean>;
} }
const MAX_FILE_SIZE_MB = Math.round(MAX_FILE_SIZE_BYTES / (1024 * 1024)); const MAX_FILE_SIZE_MB = Math.round(MAX_FILE_SIZE_BYTES / (1024 * 1024));
/** Сколько вариантов показывать в подсказке «@» — дальше пусть уточняют набором. */
const MAX_SUGGESTIONS = 8;
/** /**
* Поле ввода сообщения и выбор файлов. Здесь НЕ ui-kit TextArea, а собственный * Поле ввода сообщения и выбор файлов. Здесь НЕ ui-kit TextArea, а собственный
* textarea: чату нужен Enter → отправка, а TextArea из ui-kit обработчик клавиш * textarea: чату нужен Enter → отправка, а TextArea из ui-kit обработчик клавиш
* не пробрасывает. * не пробрасывает.
*/ */
export const ChatComposer: React.FC<IChatComposerProps> = ({ sending, onSend }) => { export const ChatComposer: React.FC<IChatComposerProps> = ({ sending, participants, onSend }) => {
const [text, setText] = React.useState<string>(''); const [text, setText] = React.useState<string>('');
const [caret, setCaret] = React.useState<number>(0);
const [highlighted, setHighlighted] = React.useState<number>(0);
// Esc закрывает подсказку до следующего набора — иначе её не убрать, не стерев «@».
const [mentionDismissed, setMentionDismissed] = React.useState<boolean>(false);
const inputRef = React.useRef<HTMLTextAreaElement>(null);
const [files, setFiles] = React.useState<File[]>([]); const [files, setFiles] = React.useState<File[]>([]);
const [filesOpen, setFilesOpen] = React.useState<boolean>(false); const [filesOpen, setFilesOpen] = React.useState<boolean>(false);
@@ -25,6 +36,30 @@ export const ChatComposer: React.FC<IChatComposerProps> = ({ sending, onSend })
// текст отправку не блокирует. // текст отправку не блокирует.
const canSend = (text.trim().length > 0 || files.length > 0) && !sending; const canSend = (text.trim().length > 0 || files.length > 0) && !sending;
const mention = mentionDismissed ? undefined : findMentionQuery(text, caret);
const suggestions = mention ? filterParticipants(participants, mention.query).slice(0, MAX_SUGGESTIONS) : [];
const suggestOpen = suggestions.length > 0;
const active = Math.min(highlighted, suggestions.length - 1);
const pickMention = (participant: IChatParticipant): void => {
if (!mention) return;
const next = insertMention(text, mention, caret, participant);
setText(next.text);
setCaret(next.caret);
setHighlighted(0);
// Курсор ставим за вставленным ФИО — после перерисовки, когда текст уже в поле.
window.setTimeout(() => {
const input = inputRef.current;
if (!input) return;
input.focus();
input.setSelectionRange(next.caret, next.caret);
}, 0);
};
const trackCaret = (e: React.SyntheticEvent<HTMLTextAreaElement>): void => {
setCaret(e.currentTarget.selectionStart || 0);
};
const send = (): void => { const send = (): void => {
if (!canSend) return; if (!canSend) return;
onSend(text, files) onSend(text, files)
@@ -38,7 +73,26 @@ export const ChatComposer: React.FC<IChatComposerProps> = ({ sending, onSend })
}; };
// Enter отправляет, Shift+Enter переносит строку — привычное поведение чата. // Enter отправляет, Shift+Enter переносит строку — привычное поведение чата.
// Пока открыта подсказка «@», стрелки ходят по ней, а Enter/Tab выбирают.
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => { const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
if (suggestOpen) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const step = e.key === 'ArrowDown' ? 1 : -1;
setHighlighted((active + step + suggestions.length) % suggestions.length);
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
pickMention(suggestions[active]);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
setMentionDismissed(true);
return;
}
}
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
send(); send();
@@ -57,16 +111,49 @@ export const ChatComposer: React.FC<IChatComposerProps> = ({ sending, onSend })
)} )}
<div className={styles.composer}> <div className={styles.composer}>
<textarea <div className={styles.inputWrap}>
className={styles.input} <textarea
value={text} ref={inputRef}
rows={2} className={styles.input}
disabled={sending} value={text}
placeholder="Сообщение… (Enter — отправить, Shift+Enter — новая строка)" rows={2}
aria-label="Сообщение в обсуждение заявки" disabled={sending}
onChange={(e) => setText(e.target.value)} placeholder="Сообщение… (@ — отметить участника, Enter — отправить, Shift+Enter — новая строка)"
onKeyDown={handleKeyDown} aria-label="Сообщение в обсуждение заявки"
/> aria-autocomplete="list"
aria-expanded={suggestOpen}
onChange={(e) => {
setText(e.target.value);
setCaret(e.target.selectionStart || 0);
setHighlighted(0);
setMentionDismissed(false);
}}
onSelect={trackCaret}
onClick={trackCaret}
onKeyDown={handleKeyDown}
/>
{suggestOpen && (
<ul className={styles.mentions} role="listbox" aria-label="Участники заявки">
{suggestions.map((p, i) => (
<li
key={p.email}
role="option"
aria-selected={i === active}
className={`${styles.mentionItem} ${i === active ? styles.mentionActive : ''}`}
// mousedown, а не click: иначе поле теряет фокус раньше, чем выбор сработает.
onMouseDown={(e) => {
e.preventDefault();
pickMention(p);
}}
onMouseEnter={() => setHighlighted(i)}
>
<span className={styles.mentionName}>{p.name}</span>
<span className={styles.mentionEmail}>{p.email}</span>
</li>
))}
</ul>
)}
</div>
<div className={styles.composerActions}> <div className={styles.composerActions}>
{/* Пока файлы выбраны, панель не сворачиваем: иначе прикреплённое {/* Пока файлы выбраны, панель не сворачиваем: иначе прикреплённое
пропадает с глаз, и легко отправить не то, что собирался. */} пропадает с глаз, и легко отправить не то, что собирался. */}
@@ -167,6 +167,59 @@
flex-shrink: 0; flex-shrink: 0;
} }
.inputWrap {
position: relative;
flex: 1 1 auto;
min-width: 0;
display: flex;
}
// Подсказка «@» — НАД полем: панель заявки прокручивается, и под полем список
// уходил бы за нижний край.
.mentions {
position: absolute;
left: 0;
right: 0;
bottom: calc(100% + 4px);
z-index: 10;
list-style: none;
margin: 0;
padding: 4px;
max-height: 240px;
overflow-y: auto;
background: $white;
border: 1px solid $gray-200;
border-radius: $r-md;
box-shadow: $sh-1;
}
.mentionItem {
display: flex;
align-items: baseline;
gap: 8px;
padding: 6px 10px;
border-radius: $r-sm;
cursor: pointer;
}
.mentionActive {
background: $navy-100;
}
.mentionName {
font-size: $fs-sm;
font-weight: 600;
color: $navy-800;
}
.mentionEmail {
font-size: $fs-caption;
color: $gray-500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.input { .input {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
@@ -27,11 +27,7 @@ interface IRequestChatProps {
* Сообщения — штатные комментарии к строке списка (см. requestChat.service.ts). * Сообщения — штатные комментарии к строке списка (см. requestChat.service.ts).
*/ */
export const RequestChat: React.FC<IRequestChatProps> = ({ context, formKey, itemId, listId }) => { export const RequestChat: React.FC<IRequestChatProps> = ({ context, formKey, itemId, listId }) => {
// Автора комментария SharePoint подписывает РЕАЛЬНОЙ учёткой — отладочная const chat = useRequestChat(context, formKey, itemId, listId);
// подмена личности (shared/impersonation.ts) права доступа и автора записи не
// меняет. Поэтому «свои» сообщения ищем по настоящему пользователю.
const currentUserEmail = context.pageContext.user.email || '';
const chat = useRequestChat(formKey, itemId, listId, currentUserEmail);
const impersonated = getImpersonatedEmail(); const impersonated = getImpersonatedEmail();
return ( return (
@@ -50,7 +46,7 @@ export const RequestChat: React.FC<IRequestChatProps> = ({ context, formKey, ite
{chat.error && <Alert variant="err">{chat.error}</Alert>} {chat.error && <Alert variant="err">{chat.error}</Alert>}
<ChatMessageList messages={chat.messages} loading={chat.loading} /> <ChatMessageList messages={chat.messages} loading={chat.loading} />
<ChatComposer sending={chat.sending} onSend={chat.send} /> <ChatComposer sending={chat.sending} participants={chat.participants} onSend={chat.send} />
{/* Подмена личности не распространяется на комментарии — предупреждаем, {/* Подмена личности не распространяется на комментарии — предупреждаем,
иначе при отладке непонятно, почему сообщение подписано не тем. */} иначе при отладке непонятно, почему сообщение подписано не тем. */}

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