From 64f53b781439c25abcfcbed8fb392c656fe59080 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Tue, 22 Sep 2026 12:38:40 +0500 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=B2=D0=BD=D0=B5=D1=81=20=D0=B8=D0=B7?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=20=D0=B7?= =?UTF-8?q?=D0=B0=D1=8F=D0=B2=D0=BA=D0=B0=D1=85=20=D1=80=D0=B5=D0=BA=D1=80?= =?UTF-8?q?=D1=83=D1=82=D0=B8=D0=BD=D0=B3=D0=B0=20=D0=BE=D1=82=2022.09.202?= =?UTF-8?q?6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/webparts/requestPortal/data/requests.ts | 12 +- .../RecruitingHire/RecruitingHireForm.tsx | 24 +++- .../RecruitingHireView.module.scss | 32 +++++ .../RecruitingHire/RecruitingHireView.tsx | 120 ++++++++++++++++-- .../forms/RecruitingHire/approverRights.ts | 42 ++++++ .../forms/RecruitingHire/load.ts | 2 +- .../forms/RecruitingHire/model.ts | 19 +-- .../forms/RecruitingHire/provisioning.json | 3 +- .../sections/ApplicationSection.tsx | 20 +-- .../sections/CandidateSection.tsx | 63 +++++++-- .../sections/ConditionsSection.tsx | 31 ++++- .../sections/SelectionRequestField.tsx | 23 +++- .../forms/RecruitingHire/submit.ts | 34 ++++- .../forms/RecruitingHire/validation.ts | 27 ++++ .../RecruitingSelectionForm.tsx | 21 ++- .../RecruitingSelectionView.tsx | 32 ++++- .../sections/ApplicationSection.tsx | 12 +- .../sections/ConditionsSection.tsx | 16 +-- .../sections/DepartmentSection.tsx | 4 +- .../sections/VacancySection.tsx | 12 +- .../forms/RecruitingSelection/validation.ts | 39 ++++++ .../forms/Referral/ReferralForm.tsx | 22 +++- .../forms/Referral/ReferralView.tsx | 33 ++++- .../shared/approval/approvalList.service.ts | 9 ++ .../approval/approverDecision.service.ts | 17 +++ .../shared/approval/approverOrgInfo.ts | 26 ++++ .../requestPortal/shared/approval/index.ts | 3 + .../shared/approval/models/IMatrixRow.ts | 17 +++ .../shared/approval/routeEditRights.ts | 21 +++ .../shared/approval/ui/ApprovalMatrix.tsx | 82 +++++++++--- .../shared/approval/useMatrixEditor.ts | 32 ++++- .../requestPortal/shared/pnp/pnpjs.ts | 1 + .../shared/reports/reportsAccess.ts | 1 + .../shared/ui/SubmitActions/SubmitActions.tsx | 77 ++++++++--- .../ui/SubmitActions/testerGroupAccess.ts | 31 +++++ 35 files changed, 836 insertions(+), 124 deletions(-) create mode 100644 src/webparts/requestPortal/forms/RecruitingHire/approverRights.ts create mode 100644 src/webparts/requestPortal/forms/RecruitingHire/validation.ts create mode 100644 src/webparts/requestPortal/forms/RecruitingSelection/validation.ts create mode 100644 src/webparts/requestPortal/shared/approval/approverOrgInfo.ts create mode 100644 src/webparts/requestPortal/shared/approval/routeEditRights.ts create mode 100644 src/webparts/requestPortal/shared/ui/SubmitActions/testerGroupAccess.ts diff --git a/src/webparts/requestPortal/data/requests.ts b/src/webparts/requestPortal/data/requests.ts index adfdccb..af0e41f 100644 --- a/src/webparts/requestPortal/data/requests.ts +++ b/src/webparts/requestPortal/data/requests.ts @@ -27,21 +27,21 @@ export const REQUEST_TYPES: IRequestType[] = [ comingSoon: true }, - // Склад / Снабжение - { id: 'wh-tmc-transfer', categoryId: 'warehouse', title: 'СЗ на перемещение ТМЦ', icon: 'send', comingSoon: true }, + // Склад / Снабжение — формы открыты для ознакомления, но приём заявок ещё не + // запущен (маршруты/списки не готовы): кнопки сохранения/отправки в самих + // формах задизейблены (см. SubmitActions в каждой из них). + { id: 'wh-tmc-transfer', categoryId: 'warehouse', title: 'СЗ на перемещение ТМЦ', icon: 'send' }, { id: 'wh-service-request', categoryId: 'warehouse', title: 'Заявка на услуги', - icon: 'document', - comingSoon: true + icon: 'document' }, { id: 'wh-concrete-request', categoryId: 'warehouse', title: 'Заявка на бетон', - icon: 'vehicle', - comingSoon: true + icon: 'vehicle' } ]; diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx index 5af07e9..501c697 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx @@ -27,7 +27,9 @@ import { isEditableStatus, IApproverToWrite, APPROVAL_PHASE, - unassignedStepMessage + unassignedStepMessage, + resolveApproverOrgInfo, + canEditRoute } from '../../shared/approval'; import { uploadAttachments, @@ -45,6 +47,7 @@ import { dataListTitleForListId } from '../../shared/listTitles'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; +import { missingRequiredFields } from './validation'; import { saveHireRequest, hireApprovalService } from './submit'; import { loadHireRequest } from './load'; import { @@ -133,6 +136,9 @@ const RecruitingHireForm: React.FC = ({ // hireApprovalService можно звать не опасаясь исключения. const approval = useMemo(() => hireApprovalService(editListId), [editListId]); const userEmail = effectiveUserEmail(context); + // Право удалять согласующих из маршрута ещё на этапе составления заявки — + // см. shared/approval/routeEditRights.ts (сейчас — Алтынай Бултурукова). + const canRemoveApprover = canEditRoute(userEmail); // pageUrl формы (requestType.id совпадает с ним по конвенции портала, см. // data/requests.ts) → числовой RequestTypeId, нужный approval-route API. const requestTypeIdState = useRequestTypeId(context, requestType.id); @@ -173,7 +179,8 @@ const RecruitingHireForm: React.FC = ({ `${userEmail}-${editId ?? 'new'}-${projectId}-${companiesFieldValue}`, !readOnly, matrixReady, - (login) => approval.getJobTitle(login) + (login) => approval.getJobTitle(login), + resolveApproverOrgInfo(context) ); // Фаза исполнения: исполнители из маршрута (status='Execution'), только для чтения. @@ -191,7 +198,8 @@ const RecruitingHireForm: React.FC = ({ `${userEmail}-${editId ?? 'new'}-${projectId}-${companiesFieldValue}`, false, matrixReady, - (login) => approval.getJobTitle(login) + (login) => approval.getJobTitle(login), + resolveApproverOrgInfo(context) ); // При совмещении (2+ компании в «Организация») нужно явно указать, какая из них основная. @@ -215,6 +223,11 @@ const RecruitingHireForm: React.FC = ({ return undefined; }; + // Остальные поля формы обязательны только для ОТПРАВКИ на согласование — + // черновик по-прежнему можно сохранить не заполнив их (см. validation.ts). + const missingFields = missingRequiredFields(data); + const isSubmitValid = isValid && missingFields.length === 0; + const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] => // order обязателен: параллельные шаги делят один номер, по позиции в // массиве его не восстановить (см. useMatrixEditor/withOrders). @@ -306,6 +319,9 @@ const RecruitingHireForm: React.FC = ({ const validateSubmit = (): string | undefined => { const formError = validate(); if (formError) return formError; + if (missingFields.length > 0) { + return `Заполните обязательные поля: ${missingFields.join(', ')}.`; + } if (matrix.error) { return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`; } @@ -419,6 +435,7 @@ const RecruitingHireForm: React.FC = ({ search={(q) => approval.searchUsers(q)} waiting={!matrixReady} waitingText="Выберите «Проект», чтобы построить маршрут согласования." + canRemoveRow={canRemoveApprover ? () => true : undefined} /> {!executors.loading && executors.rows.length > 0 && ( @@ -434,6 +451,7 @@ const RecruitingHireForm: React.FC = ({ undefined; +type SaveState = 'idle' | 'saving' | 'saved' | 'error'; -// Заявка «на прием» только для просмотра (режим согласующего). -// Переиспользует те же секции, что и форма подачи; редактирование выключено -// через — поля сами становятся неизменяемыми. +// Заявка «на прием» только для просмотра (режим согласующего). Переиспользует +// те же секции, что и форма подачи; редактирование выключено через +// , КРОМЕ окладов (4.2/4.3) и отдела/участка (2.5) — +// их на своём шаге правит начальник расчётной группы (см. approverRights.ts). const RecruitingHireView: React.FC = ({ context, itemId, formKey, listId, onCodeLoaded }) => { // onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка // может меняться на каждом рендере, поэтому в зависимости эффекта загрузки @@ -68,6 +74,17 @@ const RecruitingHireView: React.FC = ({ context, itemI const [attachments, setAttachments] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [payrollEditable, setPayrollEditable] = useState(false); + const [saveState, setSaveState] = useState('idle'); + const [saveErrorMessage, setSaveErrorMessage] = useState(''); + // Право удалять согласующих из уже поданного маршрута — см. + // shared/approval/routeEditRights.ts (сейчас — Алтынай Бултурукова). Не + // привязано к активному шагу: в отличие от payrollEditable, это право не + // зависит от того, чья сейчас очередь согласовывать. + const canRemoveApprover = canEditRoute(effectiveUserEmail(context)); + const [routeError, setRouteError] = useState(undefined); + + const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); const { id: requestTypeId } = useRequestTypeId(context, formKey); @@ -100,7 +117,7 @@ const RecruitingHireView: React.FC = ({ context, itemI [approval, itemId] ); const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) => - approval.getJobTitle(l) + approval.getJobTitle(l), resolveApproverOrgInfo(context) ); const loadExecutors = useCallback( () => @@ -110,9 +127,66 @@ const RecruitingHireView: React.FC = ({ context, itemI [approval, itemId] ); const executors = useMatrixEditor(loadExecutors, `e-${itemId}-${decisionSeq}`, false, true, (l) => - approval.getJobTitle(l) + approval.getJobTitle(l), resolveApproverOrgInfo(context) ); + // Права на оклады и отдел/участок — по должности ТЕКУЩЕГО активного шага + // пользователя. isDelegator намеренно не даёт прав: делегировавший не решает + // за делегата (см. DecisionPanel), значит и эти поля за него править не должен. + useEffect(() => { + let alive = true; + approverService + .getPendingRowForCurrentUser(itemId) + .then((pending) => { + if (!alive) return; + const active = !!pending && !pending.isDelegator && pending.phase !== APPROVAL_PHASE.execution; + setPayrollEditable(active && canEditPayrollFields(pending?.position)); + }) + .catch(() => { + if (alive) setPayrollEditable(false); + }); + return () => { + alive = false; + }; + // decisionSeq: после решения/делегирования активный шаг меняется — права пересчитываем. + }, [itemId, approverService, decisionSeq]); + + // Пишем ТОЛЬКО эти поля: иначе согласующий затёр бы остальную заявку + // устаревшим снимком формы, загруженным при открытии экрана. + const saveApproverFields = useCallback(async (): Promise => { + if (!payrollEditable) return; + const fields: Partial = { + SalaryProbation: data.SalaryProbation, + SalaryAfterProbation: data.SalaryAfterProbation, + Department: data.Department + }; + await updateApproverFields(itemId, fields, listId); + }, [itemId, listId, payrollEditable, data.SalaryProbation, data.SalaryAfterProbation, data.Department]); + + const handleSaveFields = async (): Promise => { + setSaveState('saving'); + setSaveErrorMessage(''); + try { + await saveApproverFields(); + setSaveState('saved'); + } catch (e) { + setSaveErrorMessage(friendlyErrorMessage(e)); + setSaveState('error'); + } + }; + + // Удалить ещё не решённый шаг маршрута (см. ApproverService.deleteQueuedStep) — + // строка без id (не должно случиться для canRemoveRow, но на всякий случай) + // просто игнорируется. + const handleRemoveApprover = (row: IMatrixRow): void => { + if (!row.id) return; + setRouteError(undefined); + approverService + .deleteQueuedStep(itemId, row.id) + .then(() => setDecisionSeq((n) => n + 1)) + .catch((e) => setRouteError(friendlyErrorMessage(e))); + }; + useEffect(() => { let alive = true; setLoading(true); @@ -150,7 +224,7 @@ const RecruitingHireView: React.FC = ({ context, itemI ); } - const sectionProps = { data, set: NOOP_SET, context }; + const sectionProps = { data, set, context, payrollEditable }; const notify = (): void => { if (!requestTypeId) return; @@ -182,6 +256,21 @@ const RecruitingHireView: React.FC = ({ context, itemI {isApprovedStatus(status) && } + {payrollEditable && ( +
+ + Как согласующий вы можете указать оклады (п. 4.2, 4.3) и отдел/участок (п. 2.5). + + + {saveState === 'saved' && Сохранено} + {saveState === 'error' && ( + {saveErrorMessage || 'Ошибка сохранения'} + )} +
+ )} +
= ({ context, itemI
- approval.searchUsers(q)} /> + {routeError && {routeError}} + approval.searchUsers(q)} + canRemoveRow={canRemoveApprover ? (row) => row.status === REQUEST_STATUS.queued : undefined} + onRemoveRow={handleRemoveApprover} + /> {!executors.loading && executors.rows.length > 0 && ( = ({ context, itemI service={approverService} requestId={itemId} search={(q) => approval.searchUsers(q)} + // Оклады сохраняются вместе с решением — иначе согласующий, забывший + // нажать «Сохранить», согласовал бы заявку со старыми значениями. + onBeforeDecide={payrollEditable ? saveApproverFields : undefined} onDecided={(newStatus) => { setStatus(newStatus); setDecisionSeq((n) => n + 1); diff --git a/src/webparts/requestPortal/forms/RecruitingHire/approverRights.ts b/src/webparts/requestPortal/forms/RecruitingHire/approverRights.ts new file mode 100644 index 0000000..6e50db4 --- /dev/null +++ b/src/webparts/requestPortal/forms/RecruitingHire/approverRights.ts @@ -0,0 +1,42 @@ +/** + * Кто из согласующих какие поля заявки «на прием» может править на своём шаге. + * + * Инициатор указывает оклады предварительно — фактические суммы (после + * калькуляции по грейду/ставке) утверждает начальник расчётной группы на + * своём шаге согласования (см. ClearanceView.approverRights.ts — тот же приём). + * + * Должность берётся из строки согласования (колонка Title, см. + * IApprovalRow.position), то есть та, что подставил маршрут при планировании. + * Сравнение — по ключевым словам, а не по точной строке: формулировки в + * справочнике различаются («Начальник…», «Руководитель…»). + * + * ВАЖНО про требование управленческого маркера (начальник/руководитель): без + * него право получили бы и рядовые сотрудники расчётной группы, оказавшиеся + * согласующими на любом другом шаге. + */ + +const MANAGER_MARKERS = ['начальник', 'руководитель']; + +/** Расчётная группа (бухгалтерия/payroll) — правит оклады на ИС/после ИС и отдел/участок. */ +const PAYROLL_MARKERS = ['расчет']; + +function normalize(position: string | undefined): string { + return (position || '').toLowerCase(); +} + +function matchesAll(position: string, markers: string[]): boolean { + return markers.every((m) => position.indexOf(m) !== -1); +} + +function isManagerOf(position: string | undefined, departmentMarkers: string[]): boolean { + const normalized = normalize(position); + if (!normalized) return false; + return ( + MANAGER_MARKERS.some((m) => normalized.indexOf(m) !== -1) && matchesAll(normalized, departmentMarkers) + ); +} + +/** Оклад на ИС, оклад после ИС, отдел/участок — начальник расчётной группы. */ +export function canEditPayrollFields(position: string | undefined): boolean { + return isManagerOf(position, PAYROLL_MARKERS); +} diff --git a/src/webparts/requestPortal/forms/RecruitingHire/load.ts b/src/webparts/requestPortal/forms/RecruitingHire/load.ts index befc41c..ed918b7 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/load.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/load.ts @@ -19,7 +19,7 @@ const SCALAR_FIELDS: (keyof IFormData)[] = [ 'MainCompanyId', 'Phone', 'HireType', 'HireReplacedEmployee', 'Project', 'ProjectId', 'ProjectCode', 'HireCandidateName', 'HireHireDate', 'Position', 'WorkKind', - 'Department', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod', + 'Department', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod', 'ProbationPeriodOther', 'SalaryProbation', 'SalaryAfterProbation', 'WorkSchedule', 'WorkScheduleOther', 'AgencyName', 'AgencyTerms', 'SelectionRequestId', 'SelectionRequestListId', 'SelectionRequestCode' diff --git a/src/webparts/requestPortal/forms/RecruitingHire/model.ts b/src/webparts/requestPortal/forms/RecruitingHire/model.ts index 600e4ec..4449efd 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/model.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/model.ts @@ -62,6 +62,7 @@ export interface IFormData { PpeShoeSize: string; // Условия оплаты и оформления ProbationPeriod: string; + ProbationPeriodOther: string; SalaryProbation: string; SalaryAfterProbation: string; WorkSchedule: string; @@ -105,6 +106,7 @@ export const EMPTY_DATA: IFormData = { PpeClothingSize: '', PpeShoeSize: '', ProbationPeriod: '', + ProbationPeriodOther: '', SalaryProbation: '', SalaryAfterProbation: '', WorkSchedule: '', @@ -123,17 +125,18 @@ export const HIRE_TYPE_OPTIONS: ISelectOption[] = [ { value: HIRE_TYPE_REPLACEMENT, label: HIRE_TYPE_REPLACEMENT } ]; +/** Значение-триггер «Другое»: при нём показываем поле ручного ввода. */ +export const OTHER_VALUE = 'Другое'; + /** Варианты «Испытательный срок» — самые частые сроки (совпадают с choices колонки ProbationPeriod). */ export const PROBATION_PERIOD_OPTIONS: ISelectOption[] = [ { value: '1 месяц', label: '1 месяц' }, { value: '2 месяца', label: '2 месяца' }, { value: '3 месяца', label: '3 месяца' }, - { value: '6 месяцев', label: '6 месяцев' } + { value: '6 месяцев', label: '6 месяцев' }, + { value: OTHER_VALUE, label: OTHER_VALUE } ]; -/** Значение-триггер «Другое»: при нём показываем поле ручного ввода графика. */ -export const OTHER_VALUE = 'Другое'; - /** * Варианты «График работы» — совпадают с набором в форме «Заявка на подбор» * (WORK_SCHEDULE_OPTIONS): формы независимы, значения продублированы намеренно. @@ -141,10 +144,10 @@ export const OTHER_VALUE = 'Другое'; export const WORK_SCHEDULE_OPTIONS: ISelectOption[] = [ { value: '5/2', label: '5/2' }, { value: 'Вахтовый 30/30', label: 'Вахтовый 30/30' }, - { value: '20/10', label: '20/10' }, - { value: '30/15', label: '30/15' }, - { value: '22/8', label: '22/8' }, - { value: '15/15', label: '15/15' }, + { value: 'Вахтовый 20/10', label: 'Вахтовый 20/10' }, + { value: 'Вахтовый 30/15', label: 'Вахтовый 30/15' }, + { value: 'Вахтовый 22/8', label: 'Вахтовый 22/8' }, + { value: 'Вахтовый 15/15', label: 'Вахтовый 15/15' }, { value: '6/1', label: '6/1' }, { value: '1/2', label: '1/2' }, { value: '2/2', label: '2/2' }, diff --git a/src/webparts/requestPortal/forms/RecruitingHire/provisioning.json b/src/webparts/requestPortal/forms/RecruitingHire/provisioning.json index 9fe463d..4fffb58 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/provisioning.json +++ b/src/webparts/requestPortal/forms/RecruitingHire/provisioning.json @@ -33,7 +33,8 @@ { "internalName": "PpeClothingSize", "title": "СИЗ: размер одежды", "type": "Text" }, { "internalName": "PpeShoeSize", "title": "СИЗ: размер обуви", "type": "Number" }, - { "internalName": "ProbationPeriod", "title": "Испытательный срок", "type": "Choice", "choices": ["1 месяц", "2 месяца", "3 месяца", "6 месяцев"] }, + { "internalName": "ProbationPeriod", "title": "Испытательный срок", "type": "Choice", "choices": ["1 месяц", "2 месяца", "3 месяца", "6 месяцев", "Другое"] }, + { "internalName": "ProbationPeriodOther", "title": "Испытательный срок (другое)", "type": "Text" }, { "internalName": "SalaryProbation", "title": "Оклад при испытательном сроке", "type": "Number", "decimals": 2 }, { "internalName": "SalaryAfterProbation", "title": "Оклад по завершении ИС", "type": "Number", "decimals": 2 }, { "internalName": "WorkSchedule", "title": "График работы", "type": "Text" }, diff --git a/src/webparts/requestPortal/forms/RecruitingHire/sections/ApplicationSection.tsx b/src/webparts/requestPortal/forms/RecruitingHire/sections/ApplicationSection.tsx index 4b4a817..717a1f0 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/sections/ApplicationSection.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/sections/ApplicationSection.tsx @@ -33,7 +33,10 @@ export const ApplicationSection: React.FC = ({ data, set, context return ( - + + + + = ({ data, set, context shares={data.CompanyShares} onSharesChange={(v) => set('CompanyShares', v)} /> - + = ({ data, set, context placeholder="Начните вводить название или код проекта…" /> - - - - + set('Phone', v)} /> - + set('ProbationPeriod', v)} + onChange={(v) => { + set('ProbationPeriod', v); + if (v !== OTHER_VALUE) set('ProbationPeriodOther', ''); + }} options={PROBATION_PERIOD_OPTIONS} placeholder="— выберите —" /> - + {data.ProbationPeriod === OTHER_VALUE && ( + + set('ProbationPeriodOther', v)} + /> + + )} + set('SalaryProbation', v)} + readOnly={payrollEditable ? false : undefined} /> - + set('SalaryAfterProbation', v)} + readOnly={payrollEditable ? false : undefined} /> - + { setQuery(e.target.value); ensureLoaded(); diff --git a/src/webparts/requestPortal/forms/RecruitingHire/submit.ts b/src/webparts/requestPortal/forms/RecruitingHire/submit.ts index 7fc7a52..46b63e3 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/submit.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/submit.ts @@ -1,5 +1,5 @@ import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval'; -import { ISaveItemResult, saveRequestItem } from '../../shared/dataList'; +import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList'; import { FORM_ID, IFormData } from './model'; // Числовые поля: пустая строка → null, иначе Number(). @@ -47,6 +47,38 @@ function buildItem(data: IFormData, status: string): Record { export type ISaveResult = ISaveItemResult; +/** Поля, которые начальник расчётной группы правит на своём шаге (см. approverRights.ts). */ +export interface IApproverEditableFields { + SalaryProbation: string; + SalaryAfterProbation: string; + Department: string; +} + +/** + * Обновить только поля, что заполняет согласующий расчётной группы на своём + * шаге. Пишутся ровно переданные поля — как в Clearance.updateApproverFields, + * чтобы не затирать остальную заявку значениями из уже устаревшего снимка формы. + */ +export async function updateApproverFields( + id: number, + fields: Partial, + listId?: string +): Promise { + const item: Record = {}; + if (fields.SalaryProbation !== undefined) { + item.SalaryProbation = fields.SalaryProbation === '' ? null : Number(fields.SalaryProbation); + } + if (fields.SalaryAfterProbation !== undefined) { + item.SalaryAfterProbation = fields.SalaryAfterProbation === '' ? null : Number(fields.SalaryAfterProbation); + } + if (fields.Department !== undefined) { + item.Department = fields.Department === '' ? null : fields.Department; + } + if (Object.keys(item).length === 0) return; + const list = resolveDataList(FORM_ID, listId); + await list.items.getById(id).update(item); +} + /** * Сохранить заявку «на прием»: контент + маршрут (согласующие + исполнители) со статусом. * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. diff --git a/src/webparts/requestPortal/forms/RecruitingHire/validation.ts b/src/webparts/requestPortal/forms/RecruitingHire/validation.ts new file mode 100644 index 0000000..2958afa --- /dev/null +++ b/src/webparts/requestPortal/forms/RecruitingHire/validation.ts @@ -0,0 +1,27 @@ +import { IFormData } from './model'; + +/** + * Поля формы, обязательные для ОТПРАВКИ на согласование (черновик по-прежнему + * можно сохранить не заполнив их). Исключения по требованию заказчика: 2.4 + * «Вид работ» (WorkKind), 2.5 «Отдел/Участок» (Department), 2.6 + * «Местоположение» (SiteLocation), 3.1–3.3 СИЗ (PpeHeight/PpeClothingSize/ + * PpeShoeSize). 1.1 «Заявка на подбор» оставлена опциональной — приём иногда + * идёт через рекрутинговое агентство (см. AgencySection), без внутреннего + * подбора. Toggle-поле 5.1 «Рекрутинговое агентство» в проверку не входит — у + * булевых нет пустого состояния. + */ +const REQUIRED_FIELDS: ReadonlyArray<{ label: string; ok: (d: IFormData) => boolean }> = [ + { label: '1.2 организация', ok: (d) => d.Companies.length > 0 }, + { label: '1.4 телефон', ok: (d) => !!d.Phone.trim() }, + { label: '1.5 тип приёма', ok: (d) => !!d.HireType }, + { label: '2.2 дата приёма', ok: (d) => !!d.HireHireDate }, + { label: '4.1 испытательный срок', ok: (d) => !!d.ProbationPeriod }, + { label: '4.2 оклад при испытательном сроке', ok: (d) => !!d.SalaryProbation.trim() }, + { label: '4.3 оклад по завершении ИС', ok: (d) => !!d.SalaryAfterProbation.trim() }, + { label: '4.4 график работы', ok: (d) => !!d.WorkSchedule } +]; + +/** Подписи незаполненных обязательных-для-отправки полей (пусто — всё заполнено). */ +export function missingRequiredFields(data: IFormData): string[] { + return REQUIRED_FIELDS.filter((f) => !f.ok(data)).map((f) => f.label); +} diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx index 7c22e33..327f616 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx @@ -26,7 +26,9 @@ import { REQUEST_STATUS, isEditableStatus, IApproverToWrite, - unassignedStepMessage + unassignedStepMessage, + resolveApproverOrgInfo, + canEditRoute } from '../../shared/approval'; import { uploadAttachments, @@ -45,6 +47,7 @@ import { dataListTitleForListId } from '../../shared/listTitles'; import { FORM_ID, IFormData, EMPTY_DATA, SetField, todayIso } from './model'; +import { missingRequiredFields } from './validation'; import { saveSelectionRequest, selectionApprovalService } from './submit'; import { loadSelectionRequest } from './load'; import { ApplicationSection } from './sections/ApplicationSection'; @@ -169,6 +172,9 @@ const RecruitingSelectionForm: React.FC = ({ // selectionApprovalService можно звать не опасаясь исключения. const approval = useMemo(() => selectionApprovalService(editListId), [editListId]); const userEmail = effectiveUserEmail(context); + // Право удалять согласующих из маршрута ещё на этапе составления заявки — + // см. shared/approval/routeEditRights.ts (сейчас — Алтынай Бултурукова). + const canRemoveApprover = canEditRoute(userEmail); // pageUrl формы (requestType.id совпадает с ним по конвенции портала, см. // data/requests.ts) → числовой RequestTypeId, нужный approval-route API. const requestTypeIdState = useRequestTypeId(context, requestType.id); @@ -204,7 +210,8 @@ const RecruitingSelectionForm: React.FC = ({ `${userEmail}-${editId ?? 'new'}-${projectId}-${companiesFieldValue}`, !readOnly, matrixReady, - (login) => approval.getJobTitle(login) + (login) => approval.getJobTitle(login), + resolveApproverOrgInfo(context) ); // ObjectProjectId обязателен (а не просто текст ObjectProject): маршрут @@ -229,6 +236,11 @@ const RecruitingSelectionForm: React.FC = ({ return undefined; }; + // Остальные поля формы обязательны только для ОТПРАВКИ на согласование — + // черновик по-прежнему можно сохранить не заполнив их (см. validation.ts). + const missingFields = missingRequiredFields(data); + const isSubmitValid = isValid && missingFields.length === 0; + const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] => rows.map((r) => ({ email: r.email, @@ -306,6 +318,9 @@ const RecruitingSelectionForm: React.FC = ({ const validateSubmit = (): string | undefined => { const formError = validate(); if (formError) return formError; + if (missingFields.length > 0) { + return `Заполните обязательные поля: ${missingFields.join(', ')}.`; + } if (matrix.error) { return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`; } @@ -389,11 +404,13 @@ const RecruitingSelectionForm: React.FC = ({ search={(q) => approval.searchUsers(q)} waiting={!matrixReady} waitingText="Выберите «Объект/Проект» (п. 1.7), чтобы построить маршрут согласования." + canRemoveRow={canRemoveApprover ? () => true : undefined} /> = ({ cont const [attachments, setAttachments] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // Право удалять согласующих из уже поданного маршрута — см. + // shared/approval/routeEditRights.ts (сейчас — Алтынай Бултурукова). Не + // привязано к активному шагу — не зависит от того, чья сейчас очередь. + const canRemoveApprover = canEditRoute(effectiveUserEmail(context)); + const [routeError, setRouteError] = useState(undefined); // listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) — // selectionApprovalService можно звать не опасаясь исключения. @@ -74,9 +84,19 @@ const RecruitingSelectionView: React.FC = ({ cont const [decisionSeq, setDecisionSeq] = useState(0); const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]); const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) => - approval.getJobTitle(l) + approval.getJobTitle(l), resolveApproverOrgInfo(context) ); + // Удалить ещё не решённый шаг маршрута (см. ApproverService.deleteQueuedStep). + const handleRemoveApprover = (row: IMatrixRow): void => { + if (!row.id) return; + setRouteError(undefined); + approverService + .deleteQueuedStep(itemId, row.id) + .then(() => setDecisionSeq((n) => n + 1)) + .catch((e) => setRouteError(friendlyErrorMessage(e))); + }; + useEffect(() => { let alive = true; setLoading(true); @@ -160,7 +180,13 @@ const RecruitingSelectionView: React.FC = ({ cont - approval.searchUsers(q)} /> + {routeError && {routeError}} + approval.searchUsers(q)} + canRemoveRow={canRemoveApprover ? (row) => row.status === REQUEST_STATUS.queued : undefined} + onRemoveRow={handleRemoveApprover} + /> {/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/sections/ApplicationSection.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/sections/ApplicationSection.tsx index 8f1a63b..663d51e 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/sections/ApplicationSection.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/sections/ApplicationSection.tsx @@ -14,7 +14,9 @@ export const ApplicationSection: React.FC = ({ data, set, context const reason = data.VacancyOpenReason; const showReasonDetails = reason !== '' && reason !== OPEN_REASON_NEW; const reasonDetailsLabel = - reason === OPEN_REASON_REPLACE || reason === OPEN_REASON_TRANSFER ? 'ФИО сотрудника' : 'Уточните причину'; + reason === OPEN_REASON_REPLACE || reason === OPEN_REASON_TRANSFER + ? 'ФИО сотрудника (Полностью, пример: Иванов Иван Иванович)' + : 'Уточните причину'; return ( @@ -34,10 +36,10 @@ export const ApplicationSection: React.FC = ({ data, set, context placeholder="Подставится автоматически по выбранному ответственному" /> - + set('SubmitDate', v)} readOnly /> - + = ({ data, set, context min={data.SubmitDate || undefined} /> - + = ({ /> )} - + = ({ data, set, context }) placeholder="— выберите —" /> - +