From 2d2194047be78a0f4c8f7ce23cc8da51526b499a Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 25 Aug 2026 16:46:41 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=81=D0=BE=D0=B2=D0=BC=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B8=D1=82=D0=B5=D0=BB=D0=B8=20=D0=B2=20=D0=B7=D0=B0?= =?UTF-8?q?=D1=8F=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B8=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=83=D0=B2=D0=BE=D0=BB=D1=8C=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5,?= =?UTF-8?q?=20=D0=BF=D0=B5=D1=87=D0=B0=D1=82=D1=8C=20=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D0=BB=D0=B5=20=D0=BE=D0=B1=D1=85=D0=BE=D0=B4=D0=BD=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=BB=D0=B8=D1=81=D1=82=D0=B0,=20=D0=BB=D0=B8=D1=86?= =?UTF-8?q?=D0=BE=20=D0=BF=D1=80=D0=B8=D0=BD=D0=B8=D0=BC=D0=B0=D1=8E=D1=89?= =?UTF-8?q?=D0=B5=D0=B5=20=D0=9E=D0=A1/=D0=9D=D0=9C=D0=90,=20=D1=81=D1=83?= =?UTF-8?q?=D0=BC=D0=BC=D1=8B=20=D1=81=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B2=20=D0=BE=D0=B1=D1=85=D0=BE=D0=B4=D0=BD=D0=BE?= =?UTF-8?q?=D0=BC=20=D0=BB=D0=B8=D1=81=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Четыре независимых доработки поверх связки «Заявление на увольнение» ↔ «Обходной лист»: 1. Совместители (несколько компаний у одного сотрудника): approval-route уже резолвит директора/должностного эквивалента КАЖДОЙ компании отдельным шагом маршрута (HeadType.CompanyDirectors, шаблон route 17: Chief → CompanyDirectors). Organization на форме подачи заявления теперь интерактивная таблица (OrganizationsTable) вместо read-only списка — строку компании можно убрать, если сотрудник реально не увольняется отовсюду сразу; убранная компания сразу снимается и из уже спланированной матрицы (matrix.remove) — искать нужную строку вручную не надо. Новый shared/dictionary/employeeLookup.service.ts зеркалит DirectorPositionByCompany бэкенда (KazpromService → Главный механик, СТ Global → Генеральный директор, иначе Директор) и Employee/Manager (managerType=chief) — используется и здесь, и в следующем пункте. 2. Печать заявления заблокирована, пока парный обходной лист не согласован (статус читается напрямую по ClearanceUrl, без отдельного поля-дублёра). 3. «Лицо, принимающее ОС/НМА» (AssetsAcceptor, People) — обязательное поле, которое заполняет НЕ заявитель, а непосредственный руководитель увольняемого (identity-check по Employee/Manager, а не по должности, как в approverRights «Обходного листа») прямо перед своим решением (DecisionPanel.onBeforeDecide); пустое поле блокирует решение. 4. В обходном листе — 4 независимые пары «сумма к списанию + обязательный комментарий» (ОС/СИЗ/подотчёт, обучение, НМА, ТМЦ), отдельно от уже существующего TrainingAmount. Заполнить может любой активный согласующий (не только профильный), но как только сумма введена — пара блокируется навсегда для всех (лок по снимку на момент загрузки, не реактивно от текущего ввода — иначе поле блокировалось бы прямо во время набора цифр). Списки на /sites/requests обновлены: AssetsAcceptor (Заявление на увольнение), 8 колонок WriteOff* (Обходной лист). tsc --noEmit и heft test (build+lint+179 тестов) чистые. Co-Authored-By: Claude Opus 5 --- docs/forms.md | 4 +- .../forms/Clearance/ClearanceView.tsx | 91 ++++++++++++--- .../requestPortal/forms/Clearance/README.md | 25 +++- .../requestPortal/forms/Clearance/load.ts | 23 +++- .../requestPortal/forms/Clearance/model.ts | 70 ++++++++++- .../forms/Clearance/provisioning.json | 11 +- .../Clearance/sections/RequestSection.tsx | 50 +++++++- .../requestPortal/forms/Clearance/submit.ts | 43 +++++++ .../forms/Dismissal/DismissalForm.tsx | 72 +++++++++++- .../forms/Dismissal/DismissalView.module.scss | 7 ++ .../forms/Dismissal/DismissalView.tsx | 109 +++++++++++++++++- .../requestPortal/forms/Dismissal/README.md | 72 +++++++++++- .../requestPortal/forms/Dismissal/load.ts | 9 +- .../requestPortal/forms/Dismissal/model.ts | 12 +- .../forms/Dismissal/provisioning.json | 4 +- .../sections/OrganizationsTable.module.scss | 53 +++++++++ .../Dismissal/sections/OrganizationsTable.tsx | 56 +++++++++ .../Dismissal/sections/RequestSection.tsx | 45 +++++++- .../requestPortal/forms/Dismissal/submit.ts | 26 ++++- .../dictionary/employeeLookup.service.ts | 99 ++++++++++++++++ .../requestPortal/shared/dictionary/index.ts | 6 + 21 files changed, 841 insertions(+), 46 deletions(-) create mode 100644 src/webparts/requestPortal/forms/Dismissal/sections/OrganizationsTable.module.scss create mode 100644 src/webparts/requestPortal/forms/Dismissal/sections/OrganizationsTable.tsx create mode 100644 src/webparts/requestPortal/shared/dictionary/employeeLookup.service.ts diff --git a/docs/forms.md b/docs/forms.md index c953d84..370d402 100644 --- a/docs/forms.md +++ b/docs/forms.md @@ -45,8 +45,8 @@ | [`recruiting-referral`](../src/webparts/requestPortal/forms/Referral/README.md) | Реферальная рекомендация | Рекрутинг | да | да | да | кандидат — внешний (текстовые поля, не People); маршрут по рекомендателю; исполнитель переназначает на реального; условное уточнение «откуда знаете» | ✅ | | [`it-workplace-setup`](../src/webparts/requestPortal/forms/WorkplaceSetup/README.md) | Организация рабочего места | IT | да | да | да | префилл из «прием», поля ПО/доступы правит руководитель | ✅ | | [`it-1c-ticket`](../src/webparts/requestPortal/forms/Ticket1C/README.md) | Заявка в отдел 1С | IT | да | нет | да | контакты предзаполняются из Graph (/me); вместо фазы исполнения — карточка в Microsoft Planner (согласовано/сразу без согласования — рабочий бакет, отклонено — отдельный бакет) | ✅ | -| [`clearance-form`](../src/webparts/requestPortal/forms/Clearance/README.md) | Обходной лист | Отдел кадров | да | нет | да | подаётся HR за увольняемого (People-picker); условие «IsManager» резолвится по должности из самой заявки; да/нет-поля (парковка, СКУД), сумма обучения | ✅ | -| [`hr-dismissal`](../src/webparts/requestPortal/forms/Dismissal/README.md) | Заявление на увольнение | Отдел кадров | да | нет | да | по умолчанию на себя, но можно за другого; подразделение из профиля (read-only), должность/организация из справочника; последний рабочий день не в прошлом; **после согласования сам создаёт обходной лист** и связывает пару ссылками | ⏳ тип заявки не заведён в админке approval-route | +| [`clearance-form`](../src/webparts/requestPortal/forms/Clearance/README.md) | Обходной лист | Отдел кадров | да | нет | да | подаётся HR за увольняемого (People-picker) либо создаётся сам по согласованному «Заявлению на увольнение»; условие «IsManager» резолвится по должности из самой заявки; да/нет-поля (парковка, СКУД); 4 независимые пары «сумма к списанию + комментарий» (ОС/СИЗ/подотчёт, обучение, НМА, ТМЦ) — вводит любой активный согласующий, блокируется навсегда после первого ввода | ✅ | +| [`hr-dismissal`](../src/webparts/requestPortal/forms/Dismissal/README.md) | Заявление на увольнение | Отдел кадров | да | нет | да | совместители — таблица компаний с удалением строки (снимает и согласующего из матрицы); должность/организация из справочника; **после согласования сам создаёт обходной лист**; печать заблокирована до согласования обходного листа; «Лицо, принимающее ОС/НМА» — заполняет только непосредственный руководитель | ✅ | | `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) | Легенда: **Согл.** — есть фаза согласования; **Исп.** — есть фаза исполнения; diff --git a/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx b/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx index 83f41c8..57b3cd5 100644 --- a/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx +++ b/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx @@ -20,12 +20,14 @@ import { dataListTitle, dataListTitleForListId } from '../../shared/listTitles'; -import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; +import { FORM_ID, IFormData, EMPTY_DATA, SetField, WRITE_OFF_CATEGORIES } from './model'; import { loadClearanceRequest } from './load'; import { clearanceApprovalService, updateApproverFields, - IApproverEditableFields + updateWriteOffFields, + IApproverEditableFields, + IWriteOffFields } from './submit'; import { canEditHrRecordsFields, canEditParkingField } from './approverRights'; import { RequestSection } from './sections/RequestSection'; @@ -52,7 +54,15 @@ const ClearanceView: React.FC = ({ context, itemId, formKey const [error, setError] = useState(null); const [hrRecordsEditable, setHrRecordsEditable] = useState(false); const [parkingEditable, setParkingEditable] = useState(false); + // Суммы к списанию, в отличие от отметок выше, не привязаны к должности — их + // может заполнить ЛЮБОЙ активный согласующий (см. эффект прав ниже). + const [canEditWriteOffs, setCanEditWriteOffs] = useState(false); + // Категории, УЖЕ заблокированные (сумма непустая) НА МОМЕНТ ЗАГРУЗКИ — не + // реактивно от текущего data, иначе поле блокировалось бы прямо во время + // ввода (набрал сумму → data стал непустым → поле сразу стало бы readOnly). + const [lockedWriteOffs, setLockedWriteOffs] = useState>(new Set()); const [saveState, setSaveState] = useState('idle'); + const [saveErrorMessage, setSaveErrorMessage] = useState(''); const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); @@ -102,6 +112,9 @@ const ClearanceView: React.FC = ({ context, itemId, formKey setCode(loaded.code); setError(null); setLoading(false); + setLockedWriteOffs( + new Set(WRITE_OFF_CATEGORIES.filter((cat) => !!loaded.data[cat.amountField]).map((cat) => cat.key)) + ); } }) .catch((e) => { @@ -127,11 +140,15 @@ const ClearanceView: React.FC = ({ context, itemId, formKey const active = !!pending && !pending.isDelegator && pending.phase !== APPROVAL_PHASE.execution; setHrRecordsEditable(active && canEditHrRecordsFields(pending?.position)); setParkingEditable(active && canEditParkingField(pending?.position)); + // Суммы к списанию — без проверки должности: их вводит тот, кто сейчас + // фактически согласует шаг, вне зависимости от отдела. + setCanEditWriteOffs(active); }) .catch(() => { if (alive) { setHrRecordsEditable(false); setParkingEditable(false); + setCanEditWriteOffs(false); } }); return () => { @@ -140,9 +157,24 @@ const ClearanceView: React.FC = ({ context, itemId, formKey // decisionSeq: после решения/делегирования активный шаг меняется — права пересчитываем. }, [itemId, approverService, decisionSeq]); + // Категории списания, которые может править ИМЕННО СЕЙЧАС этот пользователь: + // ещё не заблокированы (см. lockedWriteOffs) И у него сейчас активный шаг. + // Отдельная переменная, не «canEditWriteOffs && !lockedWriteOffs.has(...)» + // на месте использования — нужна и в саму секцию (RequestSection), и здесь. + const editableWriteOffKeys = useMemo( + () => + canEditWriteOffs + ? new Set(WRITE_OFF_CATEGORIES.filter((cat) => !lockedWriteOffs.has(cat.key)).map((cat) => cat.key)) + : new Set(), + [canEditWriteOffs, lockedWriteOffs] + ); + // Пишем ТОЛЬКО те отметки, на которые у согласующего есть права: иначе он - // затёр бы чужие поля значениями, которые видел у себя на экране. - const saveApproverFields = useCallback((): Promise => { + // затёр бы чужие поля значениями, которые видел у себя на экране. Пары сумм к + // списанию — только НЕПУСТЫЕ и только среди editableWriteOffKeys; частично + // заполненная пара (сумма без комментария или наоборот) бросает ДО записи + // чего бы то ни было — половинчатая запись хуже отказа сохранить. + const saveApproverFields = useCallback(async (): Promise => { const fields: Partial = {}; if (hrRecordsEditable) { fields.AccessCard = data.AccessCard; @@ -151,15 +183,36 @@ const ClearanceView: React.FC = ({ context, itemId, formKey if (parkingEditable) { fields.ParkingCard = data.ParkingCard; } - return updateApproverFields(itemId, fields, listId); - }, [itemId, listId, hrRecordsEditable, parkingEditable, data.AccessCard, data.TrainingAmount, data.ParkingCard]); + + const writeOffFields: Partial = {}; + for (const cat of WRITE_OFF_CATEGORIES) { + if (!editableWriteOffKeys.has(cat.key)) continue; + const amount = data[cat.amountField].trim(); + const comment = data[cat.commentField].trim(); + if (!amount && !comment) continue; // не трогали — не пишем + if (!amount || !comment) { + throw new Error( + `«${cat.label}»: заполните и сумму, и комментарий (за что конкретно) — или оставьте пару пустой.` + ); + } + writeOffFields[cat.amountField] = data[cat.amountField]; + writeOffFields[cat.commentField] = data[cat.commentField]; + } + + await updateApproverFields(itemId, fields, listId); + if (Object.keys(writeOffFields).length > 0) { + await updateWriteOffFields(itemId, writeOffFields, listId); + } + }, [itemId, listId, hrRecordsEditable, parkingEditable, editableWriteOffKeys, data]); const handleSaveFields = async (): Promise => { setSaveState('saving'); + setSaveErrorMessage(''); try { await saveApproverFields(); setSaveState('saved'); - } catch { + } catch (e) { + setSaveErrorMessage(friendlyErrorMessage(e)); setSaveState('error'); } }; @@ -175,8 +228,20 @@ const ClearanceView: React.FC = ({ context, itemId, formKey ); } - const canEditAnything = hrRecordsEditable || parkingEditable; - const sectionProps = { data, set, context, hrRecordsEditable, parkingEditable }; + const canEditAnything = hrRecordsEditable || parkingEditable || canEditWriteOffs; + const reviewHints = [ + hrRecordsEditable && 'отметить карту СКУД и указать сумму за обучение', + parkingEditable && 'отметить парковочную карту', + editableWriteOffKeys.size > 0 && 'указать суммы к списанию (если применимо)' + ].filter((hint): hint is string => !!hint); + const sectionProps = { + data, + set, + context, + hrRecordsEditable, + parkingEditable, + writeOffEditableKeys: editableWriteOffKeys + }; const notify = (): void => { // requestTypeId ещё не резолвился — решение уже применилось (строка списка @@ -199,15 +264,15 @@ const ClearanceView: React.FC = ({ context, itemId, formKey {canEditAnything && (
- {hrRecordsEditable - ? 'Как согласующий вы можете отметить карту СКУД и указать сумму за обучение.' - : 'Как согласующий вы можете отметить парковочную карту.'} + {reviewHints.length > 0 && `Как согласующий вы можете ${reviewHints.join('; ')}.`} {saveState === 'saved' && Сохранено} - {saveState === 'error' && Ошибка сохранения} + {saveState === 'error' && ( + {saveErrorMessage || 'Ошибка сохранения'} + )}
)} diff --git a/src/webparts/requestPortal/forms/Clearance/README.md b/src/webparts/requestPortal/forms/Clearance/README.md index 2a0f73d..351ddaa 100644 --- a/src/webparts/requestPortal/forms/Clearance/README.md +++ b/src/webparts/requestPortal/forms/Clearance/README.md @@ -54,8 +54,31 @@ RequestTypeId» в `docs/forms.md`). Тип уже зарегистрирова `LinkField` из `shared/ui`, read-only, открывается в той же вкладке; у листов, заведённых руками, ссылки нет и поле не рендерится. +## Суммы к списанию (согласование) +4 НЕЗАВИСИМЫЕ пары «сумма + обязательный комментарий (за что конкретно)», колонки +`WriteOff{Assets,Training,Nma,Tmc}{Amount,Comment}` (см. `model.ts +WRITE_OFF_CATEGORIES`): «ОС / СИЗ / подотчётная сумма», «Обучение», «НМА», «ТМЦ». + +- **Не привязаны к должности**, в отличие от отметок выше: заполнить может ЛЮБОЙ + согласующий, у которого сейчас активный шаг (`ClearanceView.canEditWriteOffs`, + фаза «Согласование» — исполнителям недоступно, как и остальным отметкам). +- **Каждая пара блокируется НАВСЕГДА после первого ввода** — как только сумма + непустая, поле становится read-only для всех, включая того, кто её ввёл. + Признак блокировки — сама непустая сумма на МОМЕНТ ЗАГРУЗКИ страницы + (`ClearanceView.lockedWriteOffs`, снимок при загрузке, а не реактивно от + текущего `data` — иначе поле блокировалось бы прямо во время набора цифр). +- **Комментарий обязателен вместе с суммой**: заполнить можно либо обе половины + пары, либо ни одной — `saveApproverFields` бросает ДО записи чего бы то ни + было, если половина пары осталась пустой. +- Сохраняются той же кнопкой «Сохранить» (`reviewBar`), что и остальные отметки, + и тем же `onBeforeDecide` — при решении несохранённые суммы уйдут вместе с ним. +- Это ОТДЕЛЬНЫЙ механизм от `TrainingAmount` («Обучение: сумма» выше, привязана к + КДП) — сознательно не объединялись, чтобы не менять уже установившееся + поведение существующего поля. + ## Секции формы -RequestSection (сотрудник · должность · подразделение · дата расторжения · сумма обучения · парковочная карта · карта СКУД) +RequestSection (сотрудник · должность · подразделение · дата расторжения · сумма +обучения · парковочная карта · карта СКУД · суммы к списанию · ссылка на заявление) ## Точки входа - rptype: `clearance-form` diff --git a/src/webparts/requestPortal/forms/Clearance/load.ts b/src/webparts/requestPortal/forms/Clearance/load.ts index 197fadc..f9a21d7 100644 --- a/src/webparts/requestPortal/forms/Clearance/load.ts +++ b/src/webparts/requestPortal/forms/Clearance/load.ts @@ -2,6 +2,11 @@ import { getSP } from '../../shared/pnp/pnpjs'; import { dataListTitle } from '../../shared/listTitles'; import { FORM_ID, IFormData, EMPTY_DATA } from './model'; +/** Число или '' (SharePoint отдаёт null для незаполненного Number). */ +function numberField(value: unknown): string { + return value === null || value === undefined ? '' : String(value); +} + export interface ILoadedRequest { data: IFormData; /** Ф.И.О. увольняемого сотрудника из поля Employee (для заголовка режима чтения). */ @@ -33,6 +38,14 @@ export async function loadClearanceRequest(itemId: number, listId?: string): Pro 'TrainingAmount', 'Organization', 'DismissalUrl', + 'WriteOffAssetsAmount', + 'WriteOffAssetsComment', + 'WriteOffTrainingAmount', + 'WriteOffTrainingComment', + 'WriteOffNmaAmount', + 'WriteOffNmaComment', + 'WriteOffTmcAmount', + 'WriteOffTmcComment', 'RequestStatus', 'RequestUniqueCode', 'Employee/Title', @@ -55,7 +68,15 @@ export async function loadClearanceRequest(itemId: number, listId?: string): Pro TrainingAmount: amount === null || amount === undefined ? '' : String(amount), Organization: (item.Organization as string) || '', // Гиперссылка приходит объектом {Url, Description} — модели нужен только адрес. - DismissalUrl: (item.DismissalUrl as { Url?: string } | undefined)?.Url || '' + DismissalUrl: (item.DismissalUrl as { Url?: string } | undefined)?.Url || '', + WriteOffAssetsAmount: numberField(item.WriteOffAssetsAmount), + WriteOffAssetsComment: (item.WriteOffAssetsComment as string) || '', + WriteOffTrainingAmount: numberField(item.WriteOffTrainingAmount), + WriteOffTrainingComment: (item.WriteOffTrainingComment as string) || '', + WriteOffNmaAmount: numberField(item.WriteOffNmaAmount), + WriteOffNmaComment: (item.WriteOffNmaComment as string) || '', + WriteOffTmcAmount: numberField(item.WriteOffTmcAmount), + WriteOffTmcComment: (item.WriteOffTmcComment as string) || '' }; return { diff --git a/src/webparts/requestPortal/forms/Clearance/model.ts b/src/webparts/requestPortal/forms/Clearance/model.ts index 359bd65..9a9f937 100644 --- a/src/webparts/requestPortal/forms/Clearance/model.ts +++ b/src/webparts/requestPortal/forms/Clearance/model.ts @@ -37,8 +37,68 @@ export interface IFormData { * forms/Dismissal/createClearance.ts) и не даёт править — только показывает. */ DismissalUrl: string; + + /** + * Суммы к списанию по обходному листу — 4 независимые пары (сумма + + * обязательный комментарий, «за что конкретно»), см. WRITE_OFF_CATEGORIES. + * «Сырые» цифры без пробелов, как TrainingAmount (см. MoneyInput). Пусто = + * не заполнено. КАЖДАЯ пара — независимая: заполнить может любой активный + * согласующий (см. ClearanceView.canEditWriteOffs), но как только сумма + * непустая — пара блокируется НАВСЕГДА, для всех (включая того, кто её ввёл). + * Признак блокировки — сама непустая сумма, отдельного флага в списке нет. + */ + WriteOffAssetsAmount: string; + WriteOffAssetsComment: string; + WriteOffTrainingAmount: string; + WriteOffTrainingComment: string; + WriteOffNmaAmount: string; + WriteOffNmaComment: string; + WriteOffTmcAmount: string; + WriteOffTmcComment: string; } +/** Имена всех 8 строковых полей сумм/комментариев к списанию — см. IFormData.WriteOff*. */ +export type WriteOffFieldKey = + | 'WriteOffAssetsAmount' + | 'WriteOffAssetsComment' + | 'WriteOffTrainingAmount' + | 'WriteOffTrainingComment' + | 'WriteOffNmaAmount' + | 'WriteOffNmaComment' + | 'WriteOffTmcAmount' + | 'WriteOffTmcComment'; + +/** + * Одна из 4 независимых категорий списания — см. IFormData.WriteOff*. + * amountField/commentField сужены до WriteOffFieldKey (а не keyof IFormData + * целиком): у IFormData есть и булевы поля (ParkingCard/AccessCard), и + * `data[cat.amountField]` должен типизироваться строго строкой — иначе + * `.trim()` в валидации и передача в MoneyInput/TextArea не собираются. + */ +export interface IWriteOffCategory { + key: string; + label: string; + amountField: WriteOffFieldKey; + commentField: WriteOffFieldKey; +} + +export const WRITE_OFF_CATEGORIES: IWriteOffCategory[] = [ + { + key: 'assets', + label: 'ОС / СИЗ / подотчётная сумма', + amountField: 'WriteOffAssetsAmount', + commentField: 'WriteOffAssetsComment' + }, + { + key: 'training', + label: 'Обучение', + amountField: 'WriteOffTrainingAmount', + commentField: 'WriteOffTrainingComment' + }, + { key: 'nma', label: 'НМА', amountField: 'WriteOffNmaAmount', commentField: 'WriteOffNmaComment' }, + { key: 'tmc', label: 'ТМЦ', amountField: 'WriteOffTmcAmount', commentField: 'WriteOffTmcComment' } +]; + /** Обновление одного поля формы с сохранением типа значения. */ export type SetField = (key: K, value: IFormData[K]) => void; @@ -63,7 +123,15 @@ export const EMPTY_DATA: IFormData = { AccessCard: false, TrainingAmount: '', Organization: '', - DismissalUrl: '' + DismissalUrl: '', + WriteOffAssetsAmount: '', + WriteOffAssetsComment: '', + WriteOffTrainingAmount: '', + WriteOffTrainingComment: '', + WriteOffNmaAmount: '', + WriteOffNmaComment: '', + WriteOffTmcAmount: '', + WriteOffTmcComment: '' }; /** Плейсхолдер для поля выбора сотрудника. */ diff --git a/src/webparts/requestPortal/forms/Clearance/provisioning.json b/src/webparts/requestPortal/forms/Clearance/provisioning.json index 3d36fa6..98d33de 100644 --- a/src/webparts/requestPortal/forms/Clearance/provisioning.json +++ b/src/webparts/requestPortal/forms/Clearance/provisioning.json @@ -14,6 +14,15 @@ { "internalName": "TrainingAmount", "title": "Обучение: сумма", "type": "Number", "decimals": 2 }, { "internalName": "Organization", "title": "Организация", "type": "Note" }, - { "internalName": "DismissalUrl", "title": "Ссылка на заявление на увольнение", "type": "Url" } + { "internalName": "DismissalUrl", "title": "Ссылка на заявление на увольнение", "type": "Url" }, + + { "internalName": "WriteOffAssetsAmount", "title": "Сумма к списанию: ОС/СИЗ/подотчёт", "type": "Number", "decimals": 2 }, + { "internalName": "WriteOffAssetsComment", "title": "Комментарий: ОС/СИЗ/подотчёт", "type": "Note" }, + { "internalName": "WriteOffTrainingAmount", "title": "Сумма к списанию: обучение", "type": "Number", "decimals": 2 }, + { "internalName": "WriteOffTrainingComment", "title": "Комментарий: обучение", "type": "Note" }, + { "internalName": "WriteOffNmaAmount", "title": "Сумма к списанию: НМА", "type": "Number", "decimals": 2 }, + { "internalName": "WriteOffNmaComment", "title": "Комментарий: НМА", "type": "Note" }, + { "internalName": "WriteOffTmcAmount", "title": "Сумма к списанию: ТМЦ", "type": "Number", "decimals": 2 }, + { "internalName": "WriteOffTmcComment", "title": "Комментарий: ТМЦ", "type": "Note" } ] } diff --git a/src/webparts/requestPortal/forms/Clearance/sections/RequestSection.tsx b/src/webparts/requestPortal/forms/Clearance/sections/RequestSection.tsx index a37caf9..cf44b89 100644 --- a/src/webparts/requestPortal/forms/Clearance/sections/RequestSection.tsx +++ b/src/webparts/requestPortal/forms/Clearance/sections/RequestSection.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import { CollapseSection, FormField, TextInput, MoneyInput, Toggle, PeopleField, OrganizationField, LinkField } from '../../../shared/ui'; -import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model'; +import { CollapseSection, FormField, TextArea, TextInput, MoneyInput, Toggle, PeopleField, OrganizationField, LinkField } from '../../../shared/ui'; +import { ISectionProps, EMPLOYEE_PLACEHOLDER, WRITE_OFF_CATEGORIES } from '../model'; // Подразделение подтягивается автоматически при выборе сотрудника (см. // ClearanceForm.handleEmployeeChange — SharePoint User Profile) — свободный @@ -12,6 +12,14 @@ interface IRequestSectionProps extends ISectionProps { hrRecordsEditable?: boolean; /** Согласующий из отдела подбора правит парковочную карту. */ parkingEditable?: boolean; + /** + * Ключи категорий списания (см. WRITE_OFF_CATEGORIES), которые ЭТОТ + * пользователь может сейчас заполнить — присутствие пропа (даже пустым + * Set'ом) включает блок сумм к списанию; не задан вовсе (форма подачи, см. + * ClearanceForm) — блок не показывается: до первого решения списывать + * нечего. См. ClearanceView.editableWriteOffKeys. + */ + writeOffEditableKeys?: Set; } /** Единственная секция формы «Обходной лист»: увольняемый, должность, подразделение, даты, карты, обучение. */ @@ -21,7 +29,8 @@ export const RequestSection: React.FC = ({ context, onEmployeeChange, hrRecordsEditable, - parkingEditable + parkingEditable, + writeOffEditableKeys }) => ( @@ -89,6 +98,41 @@ export const RequestSection: React.FC = ({ readOnly={hrRecordsEditable ? false : undefined} /> + {/* Суммы к списанию — 4 независимые пары (сумма + обязательный комментарий, + за что конкретно). Каждую может заполнить ЛЮБОЙ активный согласующий + (не только профильный, в отличие от отметок выше) — но как только сумма + указана, пара блокируется навсегда для всех, поэтому у незаблокированных + (writeOffEditableKeys.has(cat.key)) есть readOnly={false}, у остальных — + обычное поведение FormMode (просмотр — заблокировано и так). */} + {writeOffEditableKeys && + WRITE_OFF_CATEGORIES.map((cat) => { + const editable = writeOffEditableKeys.has(cat.key); + return ( + + + set(cat.amountField, v)} + readOnly={editable ? false : undefined} + /> + + +