diff --git a/src/webparts/requestPortal/forms/ContractChange/ContractChangeForm.tsx b/src/webparts/requestPortal/forms/ContractChange/ContractChangeForm.tsx index dec8cb2..4b357a9 100644 --- a/src/webparts/requestPortal/forms/ContractChange/ContractChangeForm.tsx +++ b/src/webparts/requestPortal/forms/ContractChange/ContractChangeForm.tsx @@ -48,6 +48,9 @@ import { loadContractChangeRequest } from './load'; import { useContractEmployee } from './useContractEmployee'; import { RequestSection } from './sections/RequestSection'; import { ChangeTypeSections } from './byType/ChangeTypeSections'; +import { missingRequiredAttachments, prepareFilesFor } from './byType/attachmentSlots'; +import { useAttachmentEditor } from '../../shared/attachments/useAttachmentEditor'; +import { getAttachments } from '../../shared/attachments/attachments.service'; import { useStableCallback } from '../../shared/useStableCallback'; const ContractChangeForm: React.FC = ({ @@ -76,17 +79,22 @@ const ContractChangeForm: React.FC = ({ const approval = useMemo(() => contractChangeApprovalService(editListId), [editListId]); const requestTypeIdState = useRequestTypeId(context, requestType.id); const requestTypeId = requestTypeIdState.id; + // Вложения (подписанные слоты по ТЗ «Что приложить», см. byType/attachmentSlots). + const dataListRef = editListId || dataListTitle(FORM_ID); + const attachments = useAttachmentEditor(dataListRef, requestId); + const { setItems: setAttachmentItems } = attachments; - // Открыта СВОЯ заявка на редактирование (черновик/возврат) — подгружаем содержимое и статус. + // Открыта СВОЯ заявка на редактирование (черновик/возврат) — подгружаем содержимое, статус и вложения. useEffect(() => { if (!editId) { return; } let alive = true; - loadContractChangeRequest(editId, editListId) - .then((loaded) => { + Promise.all([loadContractChangeRequest(editId, editListId), getAttachments(dataListRef, editId)]) + .then(([loaded, files]) => { if (!alive) return; setData(loaded.data); + setAttachmentItems(files); setRequestStatus(loaded.status); setRequestCode(loaded.code || undefined); if (loaded.code) notifyCode(loaded.code); @@ -99,7 +107,7 @@ const ContractChangeForm: React.FC = ({ return () => { alive = false; }; - }, [editId, editListId, notifyCode, setSubmitError]); + }, [editId, editListId, dataListRef, notifyCode, setSubmitError, setAttachmentItems]); // Сотрудник + его организация (как в командировке); новая заявка — по умолчанию на себя. const employeeControl = useContractEmployee(context, applicantEmail, !editId, data.Employee, (record) => @@ -170,9 +178,20 @@ const ContractChangeForm: React.FC = ({ status, writeListId ); + // id фиксируем ДО загрузки файлов: если она упадёт, повторное сохранение + // обновит эту же заявку, а не создаст вторую. setRequestId(id); setRequestStatus(status); if (code) setRequestCode(code); + // Файлы — после записи элемента (нужен его id), с подписью-префиксом слота. + try { + await attachments.uploadPending(prepareFilesFor(data), { + listRef: writeListId || dataListTitle(FORM_ID), + itemId: id + }); + } catch (e) { + throw new Error(`Заявка сохранена, но файлы не прикрепились: ${friendlyErrorMessage(e)}. Сохраните ещё раз.`); + } if (onSaved) onSaved(); // Отправка — уведомляем первый активный шаг; списки той партиции, куда записали. if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) { @@ -188,6 +207,9 @@ const ContractChangeForm: React.FC = ({ const validateSubmit = (): string | undefined => { const formError = validate(); if (formError) return formError; + // Обязательные документы — только на отправку: черновик можно сохранить и без файла. + const missingFiles = missingRequiredAttachments(data, attachments); + if (missingFiles.length > 0) return `Приложите обязательные документы: ${missingFiles.join(', ')}.`; if (matrix.error) { return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`; } @@ -258,7 +280,7 @@ const ContractChangeForm: React.FC = ({ - + = ({ context, itemI // listId (url) для архивной партиции, Title текущей — см. ApproverService.dataListRef. const dataListRef = listId || dataListTitle(FORM_ID); const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]); + // Вложения — только для показа (секции рисуют их под FormModeProvider readOnly). + const attachments = useAttachmentEditor(dataListRef, itemId); + const { setItems: setAttachmentItems } = attachments; // decisionSeq в reloadKey: после решения строки на сервере меняются — перечитываем матрицу. const [decisionSeq, setDecisionSeq] = useState(0); @@ -76,10 +81,11 @@ const ContractChangeView: React.FC = ({ context, itemI useEffect(() => { let alive = true; setLoading(true); - loadContractChangeRequest(itemId, listId) - .then((loaded) => { + Promise.all([loadContractChangeRequest(itemId, listId), getAttachments(dataListRef, itemId)]) + .then(([loaded, files]) => { if (!alive) return; setData(loaded.data); + setAttachmentItems(files); setStatus(loaded.status); setCode(loaded.code); notifyCode(loaded.code); @@ -94,7 +100,7 @@ const ContractChangeView: React.FC = ({ context, itemI return () => { alive = false; }; - }, [itemId, listId, notifyCode]); + }, [itemId, listId, dataListRef, notifyCode, setAttachmentItems]); if (loading) { return
Загрузка заявки…
; @@ -138,7 +144,7 @@ const ContractChangeView: React.FC = ({ context, itemI > - + diff --git a/src/webparts/requestPortal/forms/ContractChange/README.md b/src/webparts/requestPortal/forms/ContractChange/README.md index 6bd46da..8a1e35a 100644 --- a/src/webparts/requestPortal/forms/ContractChange/README.md +++ b/src/webparts/requestPortal/forms/ContractChange/README.md @@ -58,7 +58,7 @@ RequestTypeId» в `docs/forms.md`). Тип и маршрут в админке | Вид | Секция | Поля (все необязательные, без ограничений) | | --- | --- | --- | | Повышение должностного оклада | `SalaryIncrease` | текущий оклад, новый оклад (`MoneyInput`, Number), дата изменения, причина (испытательный срок / расширение функционала / иное), обоснование — при «иное» это и есть текст причины | -| Установление надбавки / доплаты | `Allowance` | основание (а–д: надбавка или доплата видно из пункта), дата начала, дата окончания или «бессрочно» (флажок прячет и очищает дату), размер + переключатель «% / ₸» (`UnitSwitch`, по умолчанию %), обоснование — в т.ч. объём доп. работы при совмещении и текст «иное». Письменное согласие работника на совмещение оформляет кадровик после согласования — не в форме. Вложение «Результаты аттестации» (обязательно при основании «г») — следующим шагом | +| Установление надбавки / доплаты | `Allowance` | основание (а–д: надбавка или доплата видно из пункта), дата начала, дата окончания или «бессрочно» (флажок прячет и очищает дату), размер + переключатель «% / ₸» (`UnitSwitch`, по умолчанию %), обоснование — в т.ч. объём доп. работы при совмещении и текст «иное». Письменное согласие работника на совмещение оформляет кадровик после согласования — не в форме. Вложение **«Результаты аттестации»** — только при основании «г», обязательно | | Остальные 8 | — | ещё не разобраны с кадрами | **Вложения по колонке ТЗ «Что приложить»** (решение 2026-10-28): фиксированные @@ -66,6 +66,14 @@ RequestTypeId» в `docs/forms.md`). Тип и маршрут в админке прямо требует документ (вид 2 «г» — аттестация, вид 3 — ДИ, вид 8 — новая ДИ; вид 9 — текст ИЛИ файл), «можно приложить» — необязательно (вид 7). +Как устроено: вложения элемента списка данных, подпись — префикс в имени файла +(`shared/attachments/attachmentCategories`, тот же механизм, что в «Приёме»), поле +слота — `AttachmentSlotField`. Какие слоты видны и обязательны — одна функция +`byType/attachmentSlots.ts` (`activeAttachmentSlots`); новый слот = строка там + +`AttachmentSlotField` в секции вида. Файлы грузятся после записи элемента; файлы +скрытого слота (сменили вид/основание) не грузятся. Обязательность проверяется +только при отправке — черновик сохраняется и без файла. + ## Открытые вопросы (проверить на тесте) - Выдаёт ли бэкенд (вебхуки) права на элемент по People-колонке `Employee`? Если да — сотрудник, за которого подали заявку, её не увидит (такой колонки здесь нет). diff --git a/src/webparts/requestPortal/forms/ContractChange/byType/Allowance/AllowanceSection.tsx b/src/webparts/requestPortal/forms/ContractChange/byType/Allowance/AllowanceSection.tsx index 155d547..8e4356f 100644 --- a/src/webparts/requestPortal/forms/ContractChange/byType/Allowance/AllowanceSection.tsx +++ b/src/webparts/requestPortal/forms/ContractChange/byType/Allowance/AllowanceSection.tsx @@ -1,8 +1,10 @@ import * as React from 'react'; import { CollapseSection, FormField, MoneyInput, TextInput, TextArea, Select, Checkbox } from '../../../../shared/ui'; +import { useFormReadOnly } from '@tnsu/ui-kit'; +import { AttachmentSlotField } from '../../../../shared/attachments/AttachmentSlotField'; import { CHANGE_TYPE_LABELS } from '../../changeTypes'; import { ISectionProps } from '../../model'; -import { ALLOWANCE_REASONS } from './options'; +import { ALLOWANCE_REASONS, ATTESTATION_ATTACHMENT, requiresAttestation } from './options'; import { UnitSwitch } from './UnitSwitch'; import { sanitizePercentInput, amountForUnit } from './amount'; import styles from './AllowanceSection.module.scss'; @@ -10,78 +12,93 @@ import styles from './AllowanceSection.module.scss'; /** * Поля вида «Установление надбавки / доплаты». Состав согласован с Тимуром по ТЗ * кадров: основание (а–д), срок с/по или бессрочно, размер в % или ₸, обоснование - * (в т.ч. объём доп. работы при совмещении). Все необязательные. + * (в т.ч. объём доп. работы при совмещении). Поля необязательные; вложение + * «Результаты аттестации» — только при основании «г» и там обязательно (по ТЗ). * * Поля — прямо в CollapseSection, без FormGrid (тело секции само сетка). */ -export const AllowanceSection: React.FC = ({ data, set }) => ( - - - set('AllowanceReason', v)} + options={ALLOWANCE_REASONS} + placeholder="— выберите —" + /> + + set('AllowanceEndDate', v)} + value={data.AllowanceStartDate} + onChange={(v) => set('AllowanceStartDate', v)} + /> + + + {/* «Бессрочно» прячет дату окончания — и очищает её, чтобы не сохранить скрытое значение. */} + {!data.AllowanceIndefinite && ( + set('AllowanceEndDate', v)} + /> + )} + { + set('AllowanceIndefinite', checked); + if (checked) set('AllowanceEndDate', ''); + }} + label="Бессрочно" + /> + + +
+
+ {/* Проценты бывают дробными (7,5 %), тенге — целые с разделителем тысяч. */} + {data.AllowanceUnit === '%' ? ( + set('AllowanceAmount', sanitizePercentInput(v))} + placeholder="например, 7.5" + /> + ) : ( + set('AllowanceAmount', v)} /> + )} +
+ { + set('AllowanceUnit', unit); + set('AllowanceAmount', amountForUnit(data.AllowanceAmount, unit)); + }} + /> +
+
+ +