feat: Алтынай Бултурукова правит все поля заявок на подбор и приём на своём шаге
Tests / frontend-test (push) Successful in 1m14s
Tests / frontend-test (push) Successful in 1m14s
This commit is contained in:
@@ -15,8 +15,10 @@ import {
|
|||||||
isApprovedStatus,
|
isApprovedStatus,
|
||||||
resolveApproverOrgInfo,
|
resolveApproverOrgInfo,
|
||||||
canEditRoute,
|
canEditRoute,
|
||||||
|
canEditAllFields,
|
||||||
REQUEST_STATUS,
|
REQUEST_STATUS,
|
||||||
IMatrixRow
|
IMatrixRow,
|
||||||
|
ApproverDecision
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { navigate } from '../../shared/routing';
|
import { navigate } from '../../shared/routing';
|
||||||
import { effectiveUserEmail } from '../../shared/impersonation';
|
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||||
@@ -32,7 +34,8 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
|||||||
import { loadHireRequest } from './load';
|
import { loadHireRequest } from './load';
|
||||||
import { ATT_CATEGORY_ID, ATT_CATEGORY_RESUME, attachmentsOfCategory, uncategorizedAttachments } from './attachmentCategories';
|
import { ATT_CATEGORY_ID, ATT_CATEGORY_RESUME, attachmentsOfCategory, uncategorizedAttachments } from './attachmentCategories';
|
||||||
import { confirmDiscardFormChanges } from '../../shared/unsavedChanges';
|
import { confirmDiscardFormChanges } from '../../shared/unsavedChanges';
|
||||||
import { hireApprovalService, updateApproverFields, IApproverEditableFields } from './submit';
|
import { hireApprovalService, updateApproverFields, updateAllHireFields, IApproverEditableFields } from './submit';
|
||||||
|
import { missingRequiredFields } from './validation';
|
||||||
import { canEditPayrollFields } from './approverRights';
|
import { canEditPayrollFields } from './approverRights';
|
||||||
import { ApplicationSection } from './sections/ApplicationSection';
|
import { ApplicationSection } from './sections/ApplicationSection';
|
||||||
import { CandidateSection } from './sections/CandidateSection';
|
import { CandidateSection } from './sections/CandidateSection';
|
||||||
@@ -61,12 +64,14 @@ type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
|||||||
const HIRE_DATE_REQUIRED_MESSAGE = 'укажите дату приёма (п. 2.2) — без неё заявку нельзя отметить выполненной.';
|
const HIRE_DATE_REQUIRED_MESSAGE = 'укажите дату приёма (п. 2.2) — без неё заявку нельзя отметить выполненной.';
|
||||||
const PAYROLL_HINT = 'Как согласующий вы можете указать оклады (п. 4.2, 4.3) и отдел/участок (п. 2.4).';
|
const PAYROLL_HINT = 'Как согласующий вы можете указать оклады (п. 4.2, 4.3) и отдел/участок (п. 2.4).';
|
||||||
const HIRE_DATE_HINT = 'Как исполнитель укажите дату приёма (п. 2.2) — она обязательна для отметки «Выполнено».';
|
const HIRE_DATE_HINT = 'Как исполнитель укажите дату приёма (п. 2.2) — она обязательна для отметки «Выполнено».';
|
||||||
|
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля заявки (кроме вложений).';
|
||||||
|
|
||||||
// Заявка «на прием» только для просмотра (режим согласующего). Переиспользует
|
// Заявка «на прием» только для просмотра (режим согласующего). Переиспользует
|
||||||
// те же секции, что и форма подачи; редактирование выключено через
|
// те же секции, что и форма подачи; редактирование выключено через
|
||||||
// <FormModeProvider readOnly>, КРОМЕ окладов (4.2/4.3) и отдела/участка (2.4) —
|
// <FormModeProvider readOnly>, КРОМЕ окладов (4.2/4.3) и отдела/участка (2.4) —
|
||||||
// их на своём шаге правит начальник расчётной группы (см. approverRights.ts), —
|
// их на своём шаге правит начальник расчётной группы (см. approverRights.ts), —
|
||||||
// и даты приёма (2.2), которую проставляет активный исполнитель.
|
// и даты приёма (2.2), которую проставляет активный исполнитель. Согласующий
|
||||||
|
// из fullEditRights.ts на своём шаге правит все поля (вложения — нет).
|
||||||
const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||||
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
|
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
|
||||||
@@ -83,6 +88,9 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
// Дату приёма (2.2) проставляет АКТИВНЫЙ исполнитель — по роли в маршруте, а
|
// Дату приёма (2.2) проставляет АКТИВНЫЙ исполнитель — по роли в маршруте, а
|
||||||
// не по должности: кто сейчас на шаге исполнения, тот и заполняет.
|
// не по должности: кто сейчас на шаге исполнения, тот и заполняет.
|
||||||
const [hireDateEditable, setHireDateEditable] = useState<boolean>(false);
|
const [hireDateEditable, setHireDateEditable] = useState<boolean>(false);
|
||||||
|
// Все поля заявки правит на своём активном шаге согласующий из
|
||||||
|
// shared/approval/fullEditRights.ts (сейчас — Алтынай Бултурукова).
|
||||||
|
const [fullEditable, setFullEditable] = useState<boolean>(false);
|
||||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
const [saveState, setSaveState] = useState<SaveState>('idle');
|
||||||
const [saveErrorMessage, setSaveErrorMessage] = useState<string>('');
|
const [saveErrorMessage, setSaveErrorMessage] = useState<string>('');
|
||||||
// Право удалять согласующих из уже поданного маршрута — см.
|
// Право удалять согласующих из уже поданного маршрута — см.
|
||||||
@@ -139,7 +147,8 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Права на поля — по ТЕКУЩЕМУ активному шагу пользователя: оклады и
|
// Права на поля — по ТЕКУЩЕМУ активному шагу пользователя: оклады и
|
||||||
// отдел/участок — по должности согласующего, дата приёма — любому исполнителю.
|
// отдел/участок — по должности согласующего, дата приёма — любому исполнителю,
|
||||||
|
// все поля — согласующему из fullEditRights.ts (по email).
|
||||||
// isDelegator намеренно не даёт прав: делегировавший не решает за делегата
|
// isDelegator намеренно не даёт прав: делегировавший не решает за делегата
|
||||||
// (см. DecisionPanel), значит и эти поля за него править не должен.
|
// (см. DecisionPanel), значит и эти поля за него править не должен.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -152,21 +161,28 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
const isExecution = pending?.phase === APPROVAL_PHASE.execution;
|
const isExecution = pending?.phase === APPROVAL_PHASE.execution;
|
||||||
setPayrollEditable(isActive && !isExecution && canEditPayrollFields(pending?.position));
|
setPayrollEditable(isActive && !isExecution && canEditPayrollFields(pending?.position));
|
||||||
setHireDateEditable(isActive && isExecution);
|
setHireDateEditable(isActive && isExecution);
|
||||||
|
setFullEditable(isActive && !isExecution && canEditAllFields(effectiveUserEmail(context)));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
setPayrollEditable(false);
|
setPayrollEditable(false);
|
||||||
setHireDateEditable(false);
|
setHireDateEditable(false);
|
||||||
|
setFullEditable(false);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// decisionSeq: после решения/делегирования активный шаг меняется — права пересчитываем.
|
// decisionSeq: после решения/делегирования активный шаг меняется — права пересчитываем.
|
||||||
}, [itemId, approverService, decisionSeq]);
|
}, [itemId, approverService, decisionSeq, context]);
|
||||||
|
|
||||||
// Пишем ТОЛЬКО эти поля: иначе согласующий затёр бы остальную заявку
|
// Полное право — перезаписываем все поля заявки. Иначе пишем ТОЛЬКО свои
|
||||||
// устаревшим снимком формы, загруженным при открытии экрана.
|
// поля: согласующий не должен затирать остальную заявку устаревшим снимком
|
||||||
const saveApproverFields = useCallback(async (): Promise<void> => {
|
// формы, загруженным при открытии экрана.
|
||||||
|
const saveApproverFields = async (): Promise<void> => {
|
||||||
|
if (fullEditable) {
|
||||||
|
await updateAllHireFields(itemId, data, listId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const fields: Partial<IApproverEditableFields> = {};
|
const fields: Partial<IApproverEditableFields> = {};
|
||||||
if (payrollEditable) {
|
if (payrollEditable) {
|
||||||
fields.SalaryProbation = data.SalaryProbation;
|
fields.SalaryProbation = data.SalaryProbation;
|
||||||
@@ -177,27 +193,28 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
fields.HireHireDate = data.HireHireDate;
|
fields.HireHireDate = data.HireHireDate;
|
||||||
}
|
}
|
||||||
await updateApproverFields(itemId, fields, listId);
|
await updateApproverFields(itemId, fields, listId);
|
||||||
}, [
|
};
|
||||||
itemId,
|
|
||||||
listId,
|
|
||||||
payrollEditable,
|
|
||||||
hireDateEditable,
|
|
||||||
data.SalaryProbation,
|
|
||||||
data.SalaryAfterProbation,
|
|
||||||
data.Department,
|
|
||||||
data.HireHireDate
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Перед решением: исполнитель не может отметить «Выполнено» без даты приёма
|
// Перед решением (бросок отменяет решение, текст ошибки показывает DecisionPanel):
|
||||||
// (при подаче её могли не указать — «По приезду на объект»). Бросок отменяет
|
// • исполнитель не может отметить «Выполнено» без даты приёма (при подаче
|
||||||
// решение, текст ошибки показывает DecisionPanel.
|
// её могли не указать — «По приезду на объект»);
|
||||||
const handleBeforeDecide = async (): Promise<void> => {
|
// • с полным правом нельзя согласовать заявку с очищенным обязательным
|
||||||
|
// полем. Возврат/отклонение — можно: там заявка дальше не идёт.
|
||||||
|
const handleBeforeDecide = async (decision: ApproverDecision): Promise<void> => {
|
||||||
if (hireDateEditable && !data.HireHireDate) {
|
if (hireDateEditable && !data.HireHireDate) {
|
||||||
throw new Error(HIRE_DATE_REQUIRED_MESSAGE);
|
throw new Error(HIRE_DATE_REQUIRED_MESSAGE);
|
||||||
}
|
}
|
||||||
|
if (fullEditable && decision === 'approve') {
|
||||||
|
const missing = missingRequiredFields(data);
|
||||||
|
if (missing.length > 0) throw new Error(`заполните обязательные поля: ${missing.join(', ')}.`);
|
||||||
|
}
|
||||||
await saveApproverFields();
|
await saveApproverFields();
|
||||||
};
|
};
|
||||||
const hasEditableFields = payrollEditable || hireDateEditable;
|
const hasEditableFields = payrollEditable || hireDateEditable || fullEditable;
|
||||||
|
|
||||||
|
let editHint = PAYROLL_HINT;
|
||||||
|
if (fullEditable) editHint = FULL_EDIT_HINT;
|
||||||
|
else if (hireDateEditable) editHint = HIRE_DATE_HINT;
|
||||||
|
|
||||||
const handleSaveFields = async (): Promise<void> => {
|
const handleSaveFields = async (): Promise<void> => {
|
||||||
setSaveState('saving');
|
setSaveState('saving');
|
||||||
@@ -294,7 +311,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
|
|
||||||
{hasEditableFields && (
|
{hasEditableFields && (
|
||||||
<div className={`${styles.reviewBar} rp-no-print`}>
|
<div className={`${styles.reviewBar} rp-no-print`}>
|
||||||
<span className={styles.reviewHint}>{hireDateEditable ? HIRE_DATE_HINT : PAYROLL_HINT}</span>
|
<span className={styles.reviewHint}>{editHint}</span>
|
||||||
<Button variant="secondary" onClick={handleSaveFields} disabled={saveState === 'saving'}>
|
<Button variant="secondary" onClick={handleSaveFields} disabled={saveState === 'saving'}>
|
||||||
Сохранить
|
Сохранить
|
||||||
</Button>
|
</Button>
|
||||||
@@ -317,7 +334,8 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
}
|
}
|
||||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||||
>
|
>
|
||||||
<FormModeProvider readOnly>
|
{/* Вложения ниже — вне этого блока: полное право открывает только поля. */}
|
||||||
|
<FormModeProvider readOnly={!fullEditable}>
|
||||||
<ApplicationSection {...sectionProps} />
|
<ApplicationSection {...sectionProps} />
|
||||||
<CandidateSection {...sectionProps} />
|
<CandidateSection {...sectionProps} />
|
||||||
<PpeSection {...sectionProps} />
|
<PpeSection {...sectionProps} />
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ function buildTitle(data: IFormData): string {
|
|||||||
return ['Прием', data.HireCandidateName, data.Position].filter(Boolean).join(' — ');
|
return ['Прием', data.HireCandidateName, data.Position].filter(Boolean).join(' — ');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Поля элемента списка данных из модели формы (текст/выбор/даты/числа). */
|
/** Поля элемента списка данных из модели формы (текст/выбор/даты/числа), без статуса. */
|
||||||
function buildItem(data: IFormData, status: string): Record<string, unknown> {
|
function buildFields(data: IFormData): Record<string, unknown> {
|
||||||
const item: Record<string, unknown> = { Title: buildTitle(data), RequestStatus: status };
|
const item: Record<string, unknown> = { Title: buildTitle(data) };
|
||||||
|
|
||||||
(Object.keys(data) as (keyof IFormData)[]).forEach((key) => {
|
(Object.keys(data) as (keyof IFormData)[]).forEach((key) => {
|
||||||
if (key === 'Companies') {
|
if (key === 'Companies') {
|
||||||
@@ -45,8 +45,21 @@ function buildItem(data: IFormData, status: string): Record<string, unknown> {
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildItem(data: IFormData, status: string): Record<string, unknown> {
|
||||||
|
return { ...buildFields(data), RequestStatus: status };
|
||||||
|
}
|
||||||
|
|
||||||
export type ISaveResult = ISaveItemResult;
|
export type ISaveResult = ISaveItemResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Перезаписать ВСЕ поля заявки — для согласующего с полным правом правки
|
||||||
|
* (см. shared/approval/fullEditRights.ts). Статус, маршрут и вложения не
|
||||||
|
* трогаются: заявка остаётся на том же шаге согласования.
|
||||||
|
*/
|
||||||
|
export async function updateAllHireFields(id: number, data: IFormData, listId?: string): Promise<void> {
|
||||||
|
await resolveDataList(FORM_ID, listId).items.getById(id).update(buildFields(data));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Поля, которые участники маршрута правят на своём шаге: оклады и отдел —
|
* Поля, которые участники маршрута правят на своём шаге: оклады и отдел —
|
||||||
* начальник расчётной группы (см. approverRights.ts), дата приёма — активный
|
* начальник расчётной группы (см. approverRights.ts), дата приёма — активный
|
||||||
|
|||||||
+33
@@ -12,3 +12,36 @@
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-bottom: $sp-4;
|
margin-bottom: $sp-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Полоса «вы можете редактировать все поля» для согласующего с полным правом —
|
||||||
|
// см. shared/approval/fullEditRights.ts (тот же приём, что в RecruitingHireView).
|
||||||
|
.reviewBar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap; // на узком экране кнопка уходит под подсказку
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-3;
|
||||||
|
margin-bottom: $sp-4;
|
||||||
|
padding: $sp-3 $sp-4;
|
||||||
|
border: 1px solid $gold-300;
|
||||||
|
border-radius: $r-md;
|
||||||
|
background: $gold-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reviewHint {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $navy-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.savedNote {
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $green-600;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveError {
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $red-500;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import * as React from 'react';
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import styles from './RecruitingSelectionView.module.scss';
|
import styles from './RecruitingSelectionView.module.scss';
|
||||||
import { Alert, FormCard, InitiatorLine, FormModeProvider, CollapseSection, FormField, AttachmentList, PrintButton } from '../../shared/ui';
|
import { Alert, Button, FormCard, InitiatorLine, FormModeProvider, CollapseSection, FormField, AttachmentList, PrintButton } from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApproverService,
|
ApproverService,
|
||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
@@ -14,9 +14,11 @@ import {
|
|||||||
isApprovedStatus,
|
isApprovedStatus,
|
||||||
resolveApproverOrgInfo,
|
resolveApproverOrgInfo,
|
||||||
canEditRoute,
|
canEditRoute,
|
||||||
|
canEditAllFields,
|
||||||
REQUEST_STATUS,
|
REQUEST_STATUS,
|
||||||
IMatrixRow,
|
IMatrixRow,
|
||||||
APPROVAL_PHASE
|
APPROVAL_PHASE,
|
||||||
|
ApproverDecision
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
||||||
import { effectiveUserEmail } from '../../shared/impersonation';
|
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||||
@@ -29,7 +31,8 @@ import {
|
|||||||
} from '../../shared/listTitles';
|
} from '../../shared/listTitles';
|
||||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||||
import { loadSelectionRequest } from './load';
|
import { loadSelectionRequest } from './load';
|
||||||
import { selectionApprovalService } from './submit';
|
import { selectionApprovalService, updateAllSelectionFields } from './submit';
|
||||||
|
import { missingRequiredFields } from './validation';
|
||||||
import { ApplicationSection } from './sections/ApplicationSection';
|
import { ApplicationSection } from './sections/ApplicationSection';
|
||||||
import { DepartmentSection } from './sections/DepartmentSection';
|
import { DepartmentSection } from './sections/DepartmentSection';
|
||||||
import { VacancySection } from './sections/VacancySection';
|
import { VacancySection } from './sections/VacancySection';
|
||||||
@@ -48,11 +51,14 @@ interface IRecruitingSelectionViewProps {
|
|||||||
onCodeLoaded?: (code: string) => void;
|
onCodeLoaded?: (code: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||||
const NOOP_SET: SetField = () => undefined;
|
|
||||||
|
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля заявки (кроме вложений).';
|
||||||
|
|
||||||
// Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же
|
// Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же
|
||||||
// секции, что и форма подачи; редактирование выключено через <FormModeProvider readOnly>.
|
// секции, что и форма подачи; редактирование выключено через <FormModeProvider readOnly>,
|
||||||
|
// кроме согласующего из fullEditRights.ts на его активном шаге — он правит все поля
|
||||||
|
// (вложения — нет).
|
||||||
const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||||
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
|
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
|
||||||
@@ -70,6 +76,13 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
// привязано к активному шагу — не зависит от того, чья сейчас очередь.
|
// привязано к активному шагу — не зависит от того, чья сейчас очередь.
|
||||||
const canRemoveApprover = canEditRoute(effectiveUserEmail(context));
|
const canRemoveApprover = canEditRoute(effectiveUserEmail(context));
|
||||||
const [routeError, setRouteError] = useState<string | undefined>(undefined);
|
const [routeError, setRouteError] = useState<string | undefined>(undefined);
|
||||||
|
// Все поля заявки правит на своём АКТИВНОМ шаге согласующий из
|
||||||
|
// shared/approval/fullEditRights.ts (сейчас — Алтынай Бултурукова).
|
||||||
|
const [fullEditable, setFullEditable] = useState<boolean>(false);
|
||||||
|
const [saveState, setSaveState] = useState<SaveState>('idle');
|
||||||
|
const [saveErrorMessage, setSaveErrorMessage] = useState<string>('');
|
||||||
|
|
||||||
|
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||||
|
|
||||||
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
|
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
|
||||||
// selectionApprovalService можно звать не опасаясь исключения.
|
// selectionApprovalService можно звать не опасаясь исключения.
|
||||||
@@ -105,6 +118,55 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
approval.getJobTitle(l), resolveApproverOrgInfo(context)
|
approval.getJobTitle(l), resolveApproverOrgInfo(context)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Право на все поля — только на СВОЁМ активном шаге согласования. isDelegator
|
||||||
|
// прав не даёт: делегировавший не решает за делегата (см. DecisionPanel).
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
approverService
|
||||||
|
.getPendingRowForCurrentUser(itemId)
|
||||||
|
.then((pending) => {
|
||||||
|
if (!alive) return;
|
||||||
|
const isActiveApproval =
|
||||||
|
!!pending && !pending.isDelegator && pending.phase !== APPROVAL_PHASE.execution;
|
||||||
|
setFullEditable(isActiveApproval && canEditAllFields(effectiveUserEmail(context)));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (alive) setFullEditable(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
// decisionSeq: после решения/делегирования активный шаг меняется — права пересчитываем.
|
||||||
|
}, [itemId, approverService, decisionSeq, context]);
|
||||||
|
|
||||||
|
const saveAllFields = async (): Promise<void> => {
|
||||||
|
if (!fullEditable) return;
|
||||||
|
await updateAllSelectionFields(itemId, data, listId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveFields = async (): Promise<void> => {
|
||||||
|
setSaveState('saving');
|
||||||
|
setSaveErrorMessage('');
|
||||||
|
try {
|
||||||
|
await saveAllFields();
|
||||||
|
setSaveState('saved');
|
||||||
|
} catch (e) {
|
||||||
|
setSaveErrorMessage(friendlyErrorMessage(e));
|
||||||
|
setSaveState('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Правки сохраняются вместе с решением — иначе решение ушло бы со старыми
|
||||||
|
// данными. Согласовать с очищенным обязательным полем нельзя; вернуть/
|
||||||
|
// отклонить — можно (бросок отменяет решение, текст показывает DecisionPanel).
|
||||||
|
const handleBeforeDecide = async (decision: ApproverDecision): Promise<void> => {
|
||||||
|
if (decision === 'approve') {
|
||||||
|
const missing = missingRequiredFields(data);
|
||||||
|
if (missing.length > 0) throw new Error(`заполните обязательные поля: ${missing.join(', ')}.`);
|
||||||
|
}
|
||||||
|
await saveAllFields();
|
||||||
|
};
|
||||||
|
|
||||||
// Удалить ещё не решённый шаг маршрута (см. ApproverService.deleteQueuedStep).
|
// Удалить ещё не решённый шаг маршрута (см. ApproverService.deleteQueuedStep).
|
||||||
const handleRemoveApprover = (row: IMatrixRow): void => {
|
const handleRemoveApprover = (row: IMatrixRow): void => {
|
||||||
if (!row.id) return;
|
if (!row.id) return;
|
||||||
@@ -152,7 +214,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sectionProps = { data, set: NOOP_SET, context };
|
const sectionProps = { data, set, context };
|
||||||
|
|
||||||
const notify = (): void => {
|
const notify = (): void => {
|
||||||
if (!requestTypeId) return;
|
if (!requestTypeId) return;
|
||||||
@@ -169,6 +231,19 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
{isApprovedStatus(status) && <PrintButton />}
|
{isApprovedStatus(status) && <PrintButton />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{fullEditable && (
|
||||||
|
<div className={`${styles.reviewBar} rp-no-print`}>
|
||||||
|
<span className={styles.reviewHint}>{FULL_EDIT_HINT}</span>
|
||||||
|
<Button variant="secondary" onClick={handleSaveFields} disabled={saveState === 'saving'}>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
{saveState === 'saved' && <span className={styles.savedNote}>Сохранено</span>}
|
||||||
|
{saveState === 'error' && (
|
||||||
|
<span className={styles.saveError}>{saveErrorMessage || 'Ошибка сохранения'}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Заявка на подбор"
|
title="Заявка на подбор"
|
||||||
@@ -181,7 +256,8 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
}
|
}
|
||||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||||
>
|
>
|
||||||
<FormModeProvider readOnly>
|
{/* Вложения ниже — вне этого блока: полное право открывает только поля. */}
|
||||||
|
<FormModeProvider readOnly={!fullEditable}>
|
||||||
<ApplicationSection {...sectionProps} />
|
<ApplicationSection {...sectionProps} />
|
||||||
<DepartmentSection {...sectionProps} />
|
<DepartmentSection {...sectionProps} />
|
||||||
<VacancySection {...sectionProps} />
|
<VacancySection {...sectionProps} />
|
||||||
@@ -223,6 +299,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
service={approverService}
|
service={approverService}
|
||||||
requestId={itemId}
|
requestId={itemId}
|
||||||
search={(q) => approval.searchUsers(q)}
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
onBeforeDecide={fullEditable ? handleBeforeDecide : undefined}
|
||||||
onDecided={(newStatus) => {
|
onDecided={(newStatus) => {
|
||||||
setStatus(newStatus);
|
setStatus(newStatus);
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getSP } from '../../shared/pnp/pnpjs';
|
import { getSP } from '../../shared/pnp/pnpjs';
|
||||||
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
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';
|
import { FORM_ID, IFormData } from './model';
|
||||||
|
|
||||||
// Поля-люди (один человек): логин → ensureUser → Id в колонку <Field>Id.
|
// Поля-люди (один человек): логин → ensureUser → Id в колонку <Field>Id.
|
||||||
@@ -85,6 +85,17 @@ async function applyPeople(item: Record<string, unknown>, data: IFormData): Prom
|
|||||||
|
|
||||||
export type ISaveResult = ISaveItemResult;
|
export type ISaveResult = ISaveItemResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Перезаписать ВСЕ поля заявки — для согласующего с полным правом правки
|
||||||
|
* (см. shared/approval/fullEditRights.ts). Статус, маршрут и вложения не
|
||||||
|
* трогаются: заявка остаётся на том же шаге согласования.
|
||||||
|
*/
|
||||||
|
export async function updateAllSelectionFields(id: number, data: IFormData, listId?: string): Promise<void> {
|
||||||
|
const item = buildScalarBody(data);
|
||||||
|
await applyPeople(item, data);
|
||||||
|
await resolveDataList(FORM_ID, listId).items.getById(id).update(item);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сохранить заявку «на подбор»: содержимое + маршрут (согласующие + исполнители).
|
* Сохранить заявку «на подбор»: содержимое + маршрут (согласующие + исполнители).
|
||||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Кто на СВОЁМ АКТИВНОМ шаге согласования может править ВСЕ поля заявки
|
||||||
|
* (а не только отдельные, как расчётная группа в RecruitingHire).
|
||||||
|
*
|
||||||
|
* Пока фиксированный email одного человека (Алтынай Бултурукова, начальник
|
||||||
|
* отдела подбора и адаптации персонала) — группы SharePoint для этого ещё
|
||||||
|
* нет, как и в routeEditRights.ts. ВРЕМЕННО.
|
||||||
|
*
|
||||||
|
* Подключено только в «Заявке на подбор» и «Заявке на прием» (Реферальная
|
||||||
|
* рекомендация — нет, по решению Тимура 2026-09-24). Вложения этим правом не
|
||||||
|
* открываются — только поля.
|
||||||
|
*/
|
||||||
|
const FULL_FIELD_EDITOR_EMAILS: ReadonlySet<string> = new Set([
|
||||||
|
'a.bulturukova@tnsu.kz' // Алтынай Бултурукова
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Может ли этот email править все поля заявки на своём активном шаге (см. выше). */
|
||||||
|
export function canEditAllFields(email: string | undefined): boolean {
|
||||||
|
return !!email && FULL_FIELD_EDITOR_EMAILS.has(email.trim().toLowerCase());
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ export type { IUseMatrixEditor } from './useMatrixEditor';
|
|||||||
export { resolveApproverOrgInfo } from './approverOrgInfo';
|
export { resolveApproverOrgInfo } from './approverOrgInfo';
|
||||||
export type { IApproverOrgInfo } from './approverOrgInfo';
|
export type { IApproverOrgInfo } from './approverOrgInfo';
|
||||||
export { canEditRoute } from './routeEditRights';
|
export { canEditRoute } from './routeEditRights';
|
||||||
|
export { canEditAllFields } from './fullEditRights';
|
||||||
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
||||||
export { UserPicker } from './ui/UserPicker';
|
export { UserPicker } from './ui/UserPicker';
|
||||||
export { DecisionPanel } from './ui/DecisionPanel';
|
export { DecisionPanel } from './ui/DecisionPanel';
|
||||||
|
|||||||
@@ -26,8 +26,10 @@ interface IDecisionPanelProps {
|
|||||||
*/
|
*/
|
||||||
onDecided?: (status: string) => void | Promise<void>;
|
onDecided?: (status: string) => void | Promise<void>;
|
||||||
/** Выполнить ПЕРЕД применением решения (напр. сохранить поля, заполненные
|
/** Выполнить ПЕРЕД применением решения (напр. сохранить поля, заполненные
|
||||||
* согласующим). Если бросит — решение не применяется, показывается ошибка. */
|
* согласующим). Если бросит — решение не применяется, показывается ошибка.
|
||||||
onBeforeDecide?: () => Promise<void>;
|
* decision — какое решение применяется (проверки, нужные только при
|
||||||
|
* согласовании, не должны мешать возврату/отклонению). */
|
||||||
|
onBeforeDecide?: (decision: ApproverDecision) => Promise<void>;
|
||||||
/** Поиск пользователей — нужен для переназначения (делегирования). Без него
|
/** Поиск пользователей — нужен для переназначения (делегирования). Без него
|
||||||
* кнопка «Переназначить» не показывается. */
|
* кнопка «Переназначить» не показывается. */
|
||||||
search?: (query: string) => Promise<ISiteUser[]>;
|
search?: (query: string) => Promise<ISiteUser[]>;
|
||||||
@@ -110,7 +112,7 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
if (onBeforeDecide) {
|
if (onBeforeDecide) {
|
||||||
await onBeforeDecide();
|
await onBeforeDecide(decision);
|
||||||
}
|
}
|
||||||
const status = await service.decide(requestId, pendingRow.id, decision, comment.trim());
|
const status = await service.decide(requestId, pendingRow.id, decision, comment.trim());
|
||||||
// Сначала побочные действия решения, потом «сохранено»: пока onDecided не
|
// Сначала побочные действия решения, потом «сохранено»: пока onDecided не
|
||||||
|
|||||||
Reference in New Issue
Block a user