diff --git a/docs/forms.md b/docs/forms.md index d275456..d09ca26 100644 --- a/docs/forms.md +++ b/docs/forms.md @@ -45,7 +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» резолвится по должности из самой заявки; да/нет-поля (парковка, СКУД), сумма обучения | ✅ | +| [`clearance-form`](../src/webparts/requestPortal/forms/Clearance/README.md) | Обходной лист | Отдел кадров | да | нет | да | подаётся HR за увольняемого (People-picker) либо создаётся сам по согласованному «Заявлению на увольнение»; условие «IsManager» резолвится по должности из самой заявки; да/нет-поля (парковка, СКУД); 6 независимых пар «сумма к списанию + комментарий» (ОС, СИЗ, подотчётная сумма, обучение, НМА, ТМЦ) — вводит любой активный согласующий, блокируется навсегда после первого ввода | ✅ | +| [`hr-dismissal`](../src/webparts/requestPortal/forms/Dismissal/README.md) | Заявление на увольнение | Отдел кадров | да | нет | да | совместители — таблица компаний с удалением строки (снимает и согласующего из матрицы); должность/организация из справочника; **после согласования сам создаёт обходной лист**; печать заблокирована до согласования обходного листа; «Лицо, принимающее ОС/НМА» — заполняет только непосредственный руководитель | ✅ | | `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) | Легенда: **Согл.** — есть фаза согласования; **Исп.** — есть фаза исполнения; diff --git a/docs/routing.md b/docs/routing.md index 3e85312..9d1ea37 100644 --- a/docs/routing.md +++ b/docs/routing.md @@ -44,6 +44,8 @@ | `recruiting-hire` | Заявка на прием | Рекрутинг | да | да | — | | `it-workplace-setup` | Организация рабочего места | IT | да | да | из `recruiting-hire` | | `it-1c-ticket` | Заявка в отдел 1С | IT | да | да | — | +| `hr-dismissal` | Заявление на увольнение | Отдел кадров | да | да | — | +| `clearance-form` | Обходной лист | Отдел кадров | да | да | создаётся сам по согласованному `hr-dismissal` | | `hr-transfer` | На перемещение сотрудников | Отдел кадров | (в разработке) | — | — | --- diff --git a/provisioning/Provision-Lists.ps1 b/provisioning/Provision-Lists.ps1 index fc6b629..6046cb2 100644 --- a/provisioning/Provision-Lists.ps1 +++ b/provisioning/Provision-Lists.ps1 @@ -4,11 +4,14 @@ по манифестам provisioning.json (единый источник правды по колонкам). .DESCRIPTION - Обходит ВСЕ манифесты forms/**/provisioning.json (в т.ч. подпапки approval/) и для - каждого: - 1) создаёт список (если его ещё нет); - 2) при необходимости переименовывает колонку Title; - 3) обеспечивает колонки. + Обходит ВСЕ манифесты forms/**/provisioning.json (в т.ч. подпапки approval/) + ДВУМЯ проходами: + 1) создаёт ВСЕ списки (которых ещё нет); + 2) по каждому манифесту: при необходимости переименовывает колонку Title и + обеспечивает колонки. + Проходы разделены из-за Lookup'ов между формами: список-источник должен + существовать раньше колонки, а при перекрёстных ссылках (две формы ссылаются + друг на друга) одним проходом это недостижимо ни при каком порядке обхода. Имена списков НУМЕРОВАННЫЕ: «<Форма> — данные» / «<Форма> — согласование». Номер собирает сам скрипт из имени в манифесте (см. Get-NumberedListTitle) — в @@ -170,6 +173,7 @@ function Ensure-SiteColumn { } 'Choice' { Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type Choice -Choices $Col.choices -Group $g | Out-Null } 'Number' { Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type Number -Group $g | Out-Null } + 'Url' { Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type URL -Group $g | Out-Null } default { throw "Неизвестный тип столбца сайта '$($Col.type)'" } } Write-Host " [+эталон] $($Col.internalName) — «$($Col.title)» [$($Col.type)]" -ForegroundColor Green @@ -214,6 +218,7 @@ function Add-ListColumn { } } 'Choice' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Choice -Choices $Col.choices -AddToDefaultView | Out-Null } + 'Url' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type URL -AddToDefaultView | Out-Null } 'Number' { if ($null -ne $Col.decimals) { $xml = "" @@ -325,21 +330,35 @@ if ($ListNumber -eq 1 -and -not $SkipRename) { } } +# Проход 1: САМИ СПИСКИ — все до единого, ДО колонок. +# +# Раньше список и его колонки создавались одним проходом по манифесту, и Lookup на +# список ДРУГОЙ формы (WorkplaceSetup.SourceHire → «Прием на работу … — данные») +# резолвился только если та форма попадалась в обходе раньше — то есть держался на +# алфавитном везении, а для взаимных ссылок был бы невозможен в принципе. Поэтому +# сначала все списки, потом все колонки: к моменту разрешения любого Lookup'а +# список-источник уже существует. +Write-Host "`n=== Списки ===" -ForegroundColor Cyan +foreach ($manifest in $manifests) { + $def = Get-Content -Path $manifest.FullName -Raw -Encoding UTF8 | ConvertFrom-Json + $listTitle = Get-NumberedListTitle -Title $def.dataListTitle -Number $ListNumber + + if (-not (Get-PnPList -Identity $listTitle -ErrorAction SilentlyContinue)) { + New-PnPList -Title $listTitle -Template GenericList -EnableVersioning | Out-Null + Write-Host " [+] создан список «$listTitle»" -ForegroundColor Green + } + else { + Write-Host " [=] список уже существует: «$listTitle»" -ForegroundColor DarkGray + } +} + +# Проход 2: колонки (и переименование Title) — по одному манифесту за раз. foreach ($manifest in $manifests) { $def = Get-Content -Path $manifest.FullName -Raw -Encoding UTF8 | ConvertFrom-Json $listTitle = Get-NumberedListTitle -Title $def.dataListTitle -Number $ListNumber Write-Host "`n=== Форма: $($manifest.Directory.Name) → список «$listTitle» ===" -ForegroundColor Cyan - # 1) Список (идемпотентно) - if (-not (Get-PnPList -Identity $listTitle -ErrorAction SilentlyContinue)) { - New-PnPList -Title $listTitle -Template GenericList -EnableVersioning | Out-Null - Write-Host " [+] создан список" -ForegroundColor Green - } - else { - Write-Host " [=] список уже существует" -ForegroundColor DarkGray - } - - # 2) Переименование Title (если задано) + # 1) Переименование Title (если задано) if ($def.titleColumnName) { try { Set-PnPField -List $listTitle -Identity 'Title' -Values @{ Title = $def.titleColumnName } | Out-Null @@ -350,7 +369,7 @@ foreach ($manifest in $manifests) { } } - # 3) Колонки (идемпотентно): не-Lookup — через эталоны (столбцы сайта), Lookup — на список. + # 2) Колонки (идемпотентно): не-Lookup — через эталоны (столбцы сайта), Lookup — на список. foreach ($col in $def.columns) { Add-ColumnToList -List $listTitle -Col $col } diff --git a/provisioning/README.md b/provisioning/README.md index ceac1f4..52d1048 100644 --- a/provisioning/README.md +++ b/provisioning/README.md @@ -25,12 +25,15 @@ | `DateTime` | дата/время | — | | `Boolean` | да/нет | — | | `User` | пользователь | — | +| `Url` | гиперссылка (адрес + подпись) | — | | `Lookup` | подстановка | `lookupList` (заголовок источника), `lookupShowField` (по умолч. `Title`) | Плюс на уровне формы: `titleColumnName` — переименовать стандартную колонку `Title`. > **Порядок для Lookup:** список-источник должен существовать раньше, чем колонка, -> которая на него ссылается. Если источник в другой форме — сначала прогоните её манифест. +> которая на него ссылается — но следить за этим руками не нужно: скрипт сначала +> создаёт ВСЕ списки, и только потом колонки, поэтому порядок обхода манифестов +> не важен (и взаимные ссылки тоже разрешимы). ## Разовая подготовка diff --git a/src/webparts/requestPortal/data/requests.ts b/src/webparts/requestPortal/data/requests.ts index 1735966..cdfb6fb 100644 --- a/src/webparts/requestPortal/data/requests.ts +++ b/src/webparts/requestPortal/data/requests.ts @@ -17,6 +17,7 @@ export const REQUEST_TYPES: IRequestType[] = [ // Отдел кадров { id: 'hr-vacation', categoryId: 'hr', title: 'Заявка на трудовой отпуск', icon: 'calendar' }, { id: 'hr-unpaid-leave', categoryId: 'hr', title: 'Заявка на отпуск без содержания', icon: 'document' }, + { id: 'hr-dismissal', categoryId: 'hr', title: 'Заявление на увольнение', icon: 'document' }, { id: 'clearance-form', categoryId: 'hr', title: 'Обходной лист', icon: 'clipboard' }, { id: 'hr-transfer', categoryId: 'hr', title: 'Заявка на перемещение сотрудников', icon: 'people' }, diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx index a8a139c..bab5da9 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -421,6 +421,12 @@ export const RequestsTableView: React.FC = ({ ? undefined : (partial: IPage): void => { if (cancelled) return; + // ПУСТОЙ промежуточный результат не показываем: смысл onPartial — + // не держать экран пустым, пока отвечает более медленный источник, + // а пустая страница как раз пустой экран и даёт, только вместо + // «Загрузка…» на нём появляется «Вы ещё не создавали заявок» — + // и через мгновение исчезает, когда придёт второй источник. + if (partial.rows.length === 0) return; setRows(partial.rows); setTotalRows(partial.totalRows); }; @@ -477,6 +483,18 @@ export const RequestsTableView: React.FC = ({ const q = debouncedQuery.trim(); const total = totalRows; const visible = rows || []; + + /** + * Можно ли уже судить о содержимом таблицы. + * + * Пустоту («Вы ещё не создавали заявок») объявляем ТОЛЬКО по завершённой + * загрузке именно ЭТОЙ таблицы (loadedKey), а не по любому пустому rows: до + * этого rows может быть пустым по совершенно другой причине — часть источников + * ещё в пути (см. onPartial выше, «Мои заявки» собираются из Search + REST). + * Промежуточный НЕПУСТОЙ результат показывать можно и нужно — в нём уже есть + * что смотреть. + */ + const loaded = loadedKey === dataKey || visible.length > 0; const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); // Страница может оказаться за пределами набора: пришла из старой ссылки или @@ -527,8 +545,8 @@ export const RequestsTableView: React.FC = ({ {error &&
Не удалось загрузить заявки: {error}
} - {!error && rows === undefined &&
Загрузка…
} - {!error && rows && total === 0 && ( + {!error && !loaded &&
Загрузка…
} + {!error && loaded && total === 0 && (
{q ? `По запросу «${debouncedQuery}» ничего не найдено.` : EMPTY_TEXTS[view]}
diff --git a/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx b/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx index 65ee8cf..066210d 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,10 +54,18 @@ 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'); // Какая из организаций сотрудника (см. OrganizationField) идёт в печать — // выбор в тулбаре через PrintOrgSelect, по умолчанию первая. const [printOrgIndex, setPrintOrgIndex] = useState(0); + const [saveErrorMessage, setSaveErrorMessage] = useState(''); const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); @@ -105,6 +115,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) => { @@ -130,11 +143,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 () => { @@ -143,9 +160,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; @@ -154,15 +186,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'); } }; @@ -178,8 +231,21 @@ const ClearanceView: React.FC = ({ context, itemId, formKey ); } - const canEditAnything = hrRecordsEditable || parkingEditable; - const sectionProps = { data, set, context, hrRecordsEditable, parkingEditable, printOrgIndex }; + 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, + printOrgIndex + }; const notify = (): void => { // requestTypeId ещё не резолвился — решение уже применилось (строка списка @@ -203,15 +269,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 56a1f96..e943790 100644 --- a/src/webparts/requestPortal/forms/Clearance/README.md +++ b/src/webparts/requestPortal/forms/Clearance/README.md @@ -39,8 +39,46 @@ RequestTypeId» в `docs/forms.md`). Тип уже зарегистрирова (в отличие от `EndDate` в «Заявке на отпуск», которая считается). - **Заголовок элемента** (`Title`): `Обходной лист — {ФИО} — дд.мм.гггг`. +## Связь с заявлением на увольнение +Обходной лист может появиться **сам, без этой формы**: когда согласовано «Заявление +на увольнение» (`hr-dismissal`), портал создаёт лист по нему и сразу отправляет на +согласование — см. `forms/Dismissal/createClearance.ts` и README той формы. Такой +лист приходит уже с заполненными сотрудником, должностью, подразделением и датой +расторжения; отметки (парковка, СКУД, обучение) по-прежнему проставляют профильные +согласующие. Подача этой формы руками остаётся — для увольнений, оформленных без +заявления в портале. + +Пара связана колонками-гиперссылками: `DismissalUrl` здесь → на заявление, +`ClearanceUrl` в заявлении → на лист. Форма их не пишет (заполняет автосоздание либо +вручную в SharePoint), но **показывает**: поле «Заявление на увольнение» — +`LinkField` из `shared/ui`, read-only, открывается в той же вкладке; у листов, +заведённых руками, ссылки нет и поле не рендерится. + +## Суммы к списанию (согласование) +6 НЕЗАВИСИМЫХ пар «сумма + обязательный комментарий (за что конкретно)», колонки +`WriteOff{Assets,Ppe,Accountable,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 1692f53..0563fa5 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 (для заголовка режима чтения). */ @@ -34,6 +39,19 @@ export async function loadClearanceRequest(itemId: number, listId?: string): Pro 'Organization', 'Project', 'ProjectId', + 'DismissalUrl', + 'WriteOffAssetsAmount', + 'WriteOffAssetsComment', + 'WriteOffPpeAmount', + 'WriteOffPpeComment', + 'WriteOffAccountableAmount', + 'WriteOffAccountableComment', + 'WriteOffTrainingAmount', + 'WriteOffTrainingComment', + 'WriteOffNmaAmount', + 'WriteOffNmaComment', + 'WriteOffTmcAmount', + 'WriteOffTmcComment', 'RequestStatus', 'RequestUniqueCode', 'Employee/Title', @@ -56,7 +74,21 @@ export async function loadClearanceRequest(itemId: number, listId?: string): Pro TrainingAmount: amount === null || amount === undefined ? '' : String(amount), Organization: (item.Organization as string) || '', Project: (item.Project as string) || '', - ProjectId: (item.ProjectId as string) || '' + ProjectId: (item.ProjectId as string) || '', + // Гиперссылка приходит объектом {Url, Description} — модели нужен только адрес. + DismissalUrl: (item.DismissalUrl as { Url?: string } | undefined)?.Url || '', + WriteOffAssetsAmount: numberField(item.WriteOffAssetsAmount), + WriteOffAssetsComment: (item.WriteOffAssetsComment as string) || '', + WriteOffPpeAmount: numberField(item.WriteOffPpeAmount), + WriteOffPpeComment: (item.WriteOffPpeComment as string) || '', + WriteOffAccountableAmount: numberField(item.WriteOffAccountableAmount), + WriteOffAccountableComment: (item.WriteOffAccountableComment 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 2fc3a1b..9c84866 100644 --- a/src/webparts/requestPortal/forms/Clearance/model.ts +++ b/src/webparts/requestPortal/forms/Clearance/model.ts @@ -35,8 +35,84 @@ export interface IFormData { Project: string; /** oid проекта ('' — если введён вручную без выбора из списка). */ ProjectId: string; + /** + * Ссылка на заявление на увольнение, по которому создан этот лист — колонка- + * гиперссылка `DismissalUrl`. Форма её не пишет (заполняет автосоздание, см. + * forms/Dismissal/createClearance.ts) и не даёт править — только показывает. + */ + DismissalUrl: string; + + /** + * Суммы к списанию по обходному листу — 6 независимых пар (сумма + + * обязательный комментарий, «за что конкретно»), см. WRITE_OFF_CATEGORIES. + * «Сырые» цифры без пробелов, как TrainingAmount (см. MoneyInput). Пусто = + * не заполнено. КАЖДАЯ пара — независимая: заполнить может любой активный + * согласующий (см. ClearanceView.canEditWriteOffs), но как только сумма + * непустая — пара блокируется НАВСЕГДА, для всех (включая того, кто её ввёл). + * Признак блокировки — сама непустая сумма, отдельного флага в списке нет. + */ + WriteOffAssetsAmount: string; + WriteOffAssetsComment: string; + WriteOffPpeAmount: string; + WriteOffPpeComment: string; + WriteOffAccountableAmount: string; + WriteOffAccountableComment: string; + WriteOffTrainingAmount: string; + WriteOffTrainingComment: string; + WriteOffNmaAmount: string; + WriteOffNmaComment: string; + WriteOffTmcAmount: string; + WriteOffTmcComment: string; } +/** Имена всех 12 строковых полей сумм/комментариев к списанию — см. IFormData.WriteOff*. */ +export type WriteOffFieldKey = + | 'WriteOffAssetsAmount' + | 'WriteOffAssetsComment' + | 'WriteOffPpeAmount' + | 'WriteOffPpeComment' + | 'WriteOffAccountableAmount' + | 'WriteOffAccountableComment' + | 'WriteOffTrainingAmount' + | 'WriteOffTrainingComment' + | 'WriteOffNmaAmount' + | 'WriteOffNmaComment' + | 'WriteOffTmcAmount' + | 'WriteOffTmcComment'; + +/** + * Одна из 6 независимых категорий списания — см. 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: 'ppe', label: 'СИЗ', amountField: 'WriteOffPpeAmount', commentField: 'WriteOffPpeComment' }, + { + key: 'accountable', + label: 'Подотчётная сумма', + amountField: 'WriteOffAccountableAmount', + commentField: 'WriteOffAccountableComment' + }, + { + key: 'training', + label: 'Обучение', + amountField: 'WriteOffTrainingAmount', + commentField: 'WriteOffTrainingComment' + }, + { key: 'nma', label: 'НМА', amountField: 'WriteOffNmaAmount', commentField: 'WriteOffNmaComment' }, + { key: 'tmc', label: 'ТМЦ', amountField: 'WriteOffTmcAmount', commentField: 'WriteOffTmcComment' } +]; + /** Обновление одного поля формы с сохранением типа значения. */ export type SetField = (key: K, value: IFormData[K]) => void; @@ -64,7 +140,20 @@ export const EMPTY_DATA: IFormData = { TrainingAmount: '', Organization: '', Project: '', - ProjectId: '' + ProjectId: '', + DismissalUrl: '', + WriteOffAssetsAmount: '', + WriteOffAssetsComment: '', + WriteOffPpeAmount: '', + WriteOffPpeComment: '', + WriteOffAccountableAmount: '', + WriteOffAccountableComment: '', + 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 72be974..317b330 100644 --- a/src/webparts/requestPortal/forms/Clearance/provisioning.json +++ b/src/webparts/requestPortal/forms/Clearance/provisioning.json @@ -14,6 +14,21 @@ { "internalName": "TrainingAmount", "title": "Обучение: сумма", "type": "Number", "decimals": 2 }, { "internalName": "Organization", "title": "Организация", "type": "Note" }, { "internalName": "Project", "title": "Проект", "type": "Text" }, - { "internalName": "ProjectId", "title": "Id проекта (справочник)", "type": "Text" } + { "internalName": "ProjectId", "title": "Id проекта (справочник)", "type": "Text" }, + + { "internalName": "DismissalUrl", "title": "Ссылка на заявление на увольнение", "type": "Url" }, + + { "internalName": "WriteOffAssetsAmount", "title": "Сумма к списанию: ОС", "type": "Number", "decimals": 2 }, + { "internalName": "WriteOffAssetsComment", "title": "Комментарий: ОС", "type": "Note" }, + { "internalName": "WriteOffPpeAmount", "title": "Сумма к списанию: СИЗ", "type": "Number", "decimals": 2 }, + { "internalName": "WriteOffPpeComment", "title": "Комментарий: СИЗ", "type": "Note" }, + { "internalName": "WriteOffAccountableAmount", "title": "Сумма к списанию: подотчётная сумма", "type": "Number", "decimals": 2 }, + { "internalName": "WriteOffAccountableComment", "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 414ebaa..0796369 100644 --- a/src/webparts/requestPortal/forms/Clearance/sections/RequestSection.tsx +++ b/src/webparts/requestPortal/forms/Clearance/sections/RequestSection.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; -import { CollapseSection, FormField, TextInput, MoneyInput, Toggle, PeopleField, OrganizationField } from '../../../shared/ui'; +import { CollapseSection, FormField, TextArea, TextInput, MoneyInput, Toggle, PeopleField, OrganizationField, LinkField } from '../../../shared/ui'; import { ProjectPicker } from '../../../shared/dictionary'; -import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model'; +import { ISectionProps, EMPLOYEE_PLACEHOLDER, WRITE_OFF_CATEGORIES } from '../model'; // Подразделение подтягивается автоматически при выборе сотрудника (см. // ClearanceForm.handleEmployeeChange — SharePoint User Profile) — свободный @@ -13,6 +13,14 @@ interface IRequestSectionProps extends ISectionProps { hrRecordsEditable?: boolean; /** Согласующий из отдела подбора правит парковочную карту. */ parkingEditable?: boolean; + /** + * Ключи категорий списания (см. WRITE_OFF_CATEGORIES), которые ЭТОТ + * пользователь может сейчас заполнить — присутствие пропа (даже пустым + * Set'ом) включает блок сумм к списанию; не задан вовсе (форма подачи, см. + * ClearanceForm) — блок не показывается: до первого решения списывать + * нечего. См. ClearanceView.editableWriteOffKeys. + */ + writeOffEditableKeys?: Set; } /** Единственная секция формы «Обходной лист»: увольняемый, должность, подразделение, даты, карты, обучение. */ @@ -23,7 +31,8 @@ export const RequestSection: React.FC = ({ onEmployeeChange, hrRecordsEditable, parkingEditable, - printOrgIndex + printOrgIndex, + writeOffEditableKeys }) => ( @@ -103,5 +112,47 @@ export const RequestSection: React.FC = ({ readOnly={hrRecordsEditable ? false : undefined} /> + {/* Суммы к списанию — 6 независимых пар (сумма + обязательный комментарий, + за что конкретно). Каждую может заполнить ЛЮБОЙ активный согласующий + (не только профильный, в отличие от отметок выше) — но как только сумма + указана, пара блокируется навсегда для всех, поэтому у незаблокированных + (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} + /> + + +