fix: внес изменения в заявках рекрутинга от 22.09.2026
Tests / frontend-test (push) Successful in 1m19s

This commit is contained in:
Тимур Городецкий
2026-09-22 12:38:40 +05:00
parent b6708a7e72
commit 64f53b7814
35 changed files with 836 additions and 124 deletions
@@ -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<IRequestFormProps> = ({
// 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<IRequestFormProps> = ({
`${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<IRequestFormProps> = ({
`${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<IRequestFormProps> = ({
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<IRequestFormProps> = ({
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<IRequestFormProps> = ({
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<IRequestFormProps> = ({
<SubmitActions
submit={submit}
isValid={isValid}
submitValid={isSubmitValid}
readOnly={readOnly}
onCancel={onCancel}
onSaveDraft={handleSaveDraft}
@@ -18,3 +18,35 @@
font-size: 13.5px;
color: $gray-500;
}
// Полоса «вы можете заполнить оклады» для начальника расчётной группы —
// см. approverRights.ts (тот же приём, что и в Clearance/ClearanceView).
.reviewBar {
display: flex;
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;
}
@@ -12,9 +12,14 @@ import {
notifyActiveStep,
useRequestTypeId,
APPROVAL_PHASE,
isApprovedStatus
isApprovedStatus,
resolveApproverOrgInfo,
canEditRoute,
REQUEST_STATUS,
IMatrixRow
} from '../../shared/approval';
import { navigate } from '../../shared/routing';
import { effectiveUserEmail } from '../../shared/impersonation';
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import {
@@ -27,7 +32,8 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { loadHireRequest } from './load';
import { ATT_CATEGORY_ID, ATT_CATEGORY_RESUME, attachmentsOfCategory, uncategorizedAttachments } from './attachmentCategories';
import { confirmDiscardFormChanges } from '../../shared/unsavedChanges';
import { hireApprovalService } from './submit';
import { hireApprovalService, updateApproverFields, IApproverEditableFields } from './submit';
import { canEditPayrollFields } from './approverRights';
import { ApplicationSection } from './sections/ApplicationSection';
import { CandidateSection } from './sections/CandidateSection';
import { PpeSection } from './sections/PpeSection';
@@ -50,12 +56,12 @@ interface IRecruitingHireViewProps {
// производной заявки «на основе прием».
const WORKPLACE_FORM_KEY = 'it-workplace-setup';
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
const NOOP_SET: SetField = () => undefined;
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
// Заявка «на прием» только для просмотра (режим согласующего).
// Переиспользует те же секции, что и форма подачи; редактирование выключено
// через <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
// Заявка «на прием» только для просмотра (режим согласующего). Переиспользует
// те же секции, что и форма подачи; редактирование выключено через
// <FormModeProvider readOnly>, КРОМЕ окладов (4.2/4.3) и отдела/участка (2.5) —
// их на своём шаге правит начальник расчётной группы (см. approverRights.ts).
const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
@@ -68,6 +74,17 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
const [attachments, setAttachments] = useState<IAttachmentInfo[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [payrollEditable, setPayrollEditable] = useState<boolean>(false);
const [saveState, setSaveState] = useState<SaveState>('idle');
const [saveErrorMessage, setSaveErrorMessage] = useState<string>('');
// Право удалять согласующих из уже поданного маршрута — см.
// shared/approval/routeEditRights.ts (сейчас — Алтынай Бултурукова). Не
// привязано к активному шагу: в отличие от payrollEditable, это право не
// зависит от того, чья сейчас очередь согласовывать.
const canRemoveApprover = canEditRoute(effectiveUserEmail(context));
const [routeError, setRouteError] = useState<string | undefined>(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<IRecruitingHireViewProps> = ({ 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<IRecruitingHireViewProps> = ({ 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<void> => {
if (!payrollEditable) return;
const fields: Partial<IApproverEditableFields> = {
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<void> => {
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<IRecruitingHireViewProps> = ({ 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<IRecruitingHireViewProps> = ({ context, itemI
{isApprovedStatus(status) && <PrintButton />}
</div>
{payrollEditable && (
<div className={`${styles.reviewBar} rp-no-print`}>
<span className={styles.reviewHint}>
Как согласующий вы можете указать оклады (п. 4.2, 4.3) и отдел/участок (п. 2.5).
</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">
<FormCard
title="Заявка на прием"
@@ -227,7 +316,13 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
</div>
</FormCard>
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
{routeError && <Alert variant="err">{routeError}</Alert>}
<ApprovalMatrix
editor={approvers}
search={(q) => approval.searchUsers(q)}
canRemoveRow={canRemoveApprover ? (row) => row.status === REQUEST_STATUS.queued : undefined}
onRemoveRow={handleRemoveApprover}
/>
{!executors.loading && executors.rows.length > 0 && (
<ApprovalMatrix
@@ -246,6 +341,9 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
service={approverService}
requestId={itemId}
search={(q) => approval.searchUsers(q)}
// Оклады сохраняются вместе с решением — иначе согласующий, забывший
// нажать «Сохранить», согласовал бы заявку со старыми значениями.
onBeforeDecide={payrollEditable ? saveApproverFields : undefined}
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
@@ -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);
}
@@ -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'
@@ -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' },
@@ -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" },
@@ -33,7 +33,10 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
return (
<CollapseSection title="1. Заявка" icon="clipboard">
<FormField label="1.1 Организация" htmlFor="Companies" full>
<FormField label="1.1 Заявка на подбор" full>
<SelectionRequestField data={data} set={set} />
</FormField>
<FormField label="1.2 Организация" htmlFor="Companies" required full>
<CompanyPickerMulti
id="Companies"
context={context}
@@ -49,7 +52,7 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
shares={data.CompanyShares}
onSharesChange={(v) => set('CompanyShares', v)}
/>
<FormField label="1.2 Проект" htmlFor="Project" required full>
<FormField label="1.3 Проект" htmlFor="Project" required full>
<ProjectPicker
context={context}
id="Project"
@@ -62,13 +65,10 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
placeholder="Начните вводить название или код проекта…"
/>
</FormField>
<FormField label="1.3 Заявка на подбор" full>
<SelectionRequestField data={data} set={set} />
</FormField>
<FormField label="1.4 Телефон" htmlFor="Phone">
<FormField label="1.4 Телефон" htmlFor="Phone" required>
<PhoneInput id="Phone" value={data.Phone} onChange={(v) => set('Phone', v)} />
</FormField>
<FormField label="1.5 Тип приёма" htmlFor="HireType">
<FormField label="1.5 Тип приёма" htmlFor="HireType" required>
<Select
id="HireType"
value={data.HireType}
@@ -78,7 +78,11 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
/>
</FormField>
{isReplacement && (
<FormField label="Взамен кого (Ф.И.О. уволенного)" htmlFor="HireReplacedEmployee" full>
<FormField
label="Взамен кого (Ф.И.О. уволенного, полностью, пример: Иванов Иван Иванович)"
htmlFor="HireReplacedEmployee"
full
>
<TextInput
id="HireReplacedEmployee"
value={data.HireReplacedEmployee}
@@ -1,33 +1,70 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput } from '../../../shared/ui';
import { PositionPicker } from '../../../shared/dictionary';
import { ISectionProps } from '../model';
import * as React from "react";
import { CollapseSection, FormField, TextInput } from "../../../shared/ui";
import { PositionPicker } from "../../../shared/dictionary";
import { ISectionProps } from "../model";
export const CandidateSection: React.FC<ISectionProps> = ({ data, set, context }) => (
interface ICandidateSectionProps extends ISectionProps {
/** Начальник расчётной группы правит отдел/участок даже в режиме просмотра (см. approverRights.ts). */
payrollEditable?: boolean;
}
export const CandidateSection: React.FC<ICandidateSectionProps> = ({
data,
set,
context,
payrollEditable,
}) => (
<CollapseSection title="2. Кандидат" icon="people">
<FormField label="2.1 Ф.И.О. кандидата" htmlFor="HireCandidateName" required full>
<TextInput id="HireCandidateName" value={data.HireCandidateName} onChange={(v) => set('HireCandidateName', v)} />
<FormField
label="2.1 Ф.И.О. кандидата (Полностью, пример: Иванов Иван Иванович)"
htmlFor="HireCandidateName"
required
full
>
<TextInput
id="HireCandidateName"
value={data.HireCandidateName}
onChange={(v) => set("HireCandidateName", v)}
/>
</FormField>
<FormField label="2.2 Дата приёма" htmlFor="HireHireDate">
<TextInput id="HireHireDate" type="date" value={data.HireHireDate} onChange={(v) => set('HireHireDate', v)} />
<FormField label="2.2 Дата приёма" htmlFor="HireHireDate" required>
<TextInput
id="HireHireDate"
type="date"
value={data.HireHireDate}
onChange={(v) => set("HireHireDate", v)}
/>
</FormField>
<FormField label="2.3 Должность" htmlFor="Position" required>
<PositionPicker
context={context}
id="Position"
value={data.Position}
onChange={(v) => set('Position', v)}
onChange={(v) => set("Position", v)}
placeholder="Начните вводить название должности…"
/>
</FormField>
<FormField label="2.4 Вид работ" htmlFor="WorkKind">
<TextInput id="WorkKind" value={data.WorkKind} onChange={(v) => set('WorkKind', v)} />
<TextInput
id="WorkKind"
value={data.WorkKind}
onChange={(v) => set("WorkKind", v)}
/>
</FormField>
<FormField label="2.5 Отдел/Участок" htmlFor="Department">
<TextInput id="Department" value={data.Department} onChange={(v) => set('Department', v)} />
<TextInput
id="Department"
value={data.Department}
onChange={(v) => set("Department", v)}
readOnly={payrollEditable ? false : undefined}
/>
</FormField>
<FormField label="2.6 Местоположение" htmlFor="SiteLocation">
<TextInput id="SiteLocation" value={data.SiteLocation} onChange={(v) => set('SiteLocation', v)} />
<TextInput
id="SiteLocation"
value={data.SiteLocation}
onChange={(v) => set("SiteLocation", v)}
/>
</FormField>
</CollapseSection>
);
@@ -2,32 +2,51 @@ import * as React from 'react';
import { CollapseSection, FormField, TextInput, MoneyInput, Select } from '../../../shared/ui';
import { ISectionProps, OTHER_VALUE, WORK_SCHEDULE_OPTIONS, PROBATION_PERIOD_OPTIONS } from '../model';
export const ConditionsSection: React.FC<ISectionProps> = ({ data, set }) => (
interface IConditionsSectionProps extends ISectionProps {
/** Начальник расчётной группы правит оклады даже в режиме просмотра (см. approverRights.ts). */
payrollEditable?: boolean;
}
export const ConditionsSection: React.FC<IConditionsSectionProps> = ({ data, set, payrollEditable }) => (
<CollapseSection title="4. Условия оплаты и оформления" icon="calculator">
<FormField label="4.1 Испытательный срок" htmlFor="ProbationPeriod">
<FormField label="4.1 Испытательный срок" htmlFor="ProbationPeriod" required>
<Select
id="ProbationPeriod"
value={data.ProbationPeriod}
onChange={(v) => set('ProbationPeriod', v)}
onChange={(v) => {
set('ProbationPeriod', v);
if (v !== OTHER_VALUE) set('ProbationPeriodOther', '');
}}
options={PROBATION_PERIOD_OPTIONS}
placeholder="— выберите —"
/>
</FormField>
<FormField label="4.2 Оклад при испытательном сроке" htmlFor="SalaryProbation">
{data.ProbationPeriod === OTHER_VALUE && (
<FormField label="Испытательный срок (другое)" htmlFor="ProbationPeriodOther">
<TextInput
id="ProbationPeriodOther"
value={data.ProbationPeriodOther}
onChange={(v) => set('ProbationPeriodOther', v)}
/>
</FormField>
)}
<FormField label="4.2 Оклад при испытательном сроке" htmlFor="SalaryProbation" required>
<MoneyInput
id="SalaryProbation"
value={data.SalaryProbation}
onChange={(v) => set('SalaryProbation', v)}
readOnly={payrollEditable ? false : undefined}
/>
</FormField>
<FormField label="4.3 Оклад по завершении ИС" htmlFor="SalaryAfterProbation">
<FormField label="4.3 Оклад по завершении ИС" htmlFor="SalaryAfterProbation" required>
<MoneyInput
id="SalaryAfterProbation"
value={data.SalaryAfterProbation}
onChange={(v) => set('SalaryAfterProbation', v)}
readOnly={payrollEditable ? false : undefined}
/>
</FormField>
<FormField label="4.4 График работы" htmlFor="WorkSchedule">
<FormField label="4.4 График работы" htmlFor="WorkSchedule" required>
<Select
id="WorkSchedule"
value={data.WorkSchedule}
@@ -3,6 +3,7 @@ import styles from './SelectionRequestField.module.scss';
import { useFormReadOnly } from '../../../shared/ui';
import { routeUrl } from '../../../shared/routing';
import { getApprovedSelectionRequests, ISelectionRequestOption } from '../selectionLookup';
import { loadSelectionRequest } from '../../RecruitingSelection/load';
import { IFormData, SetField } from '../model';
/** Слаг формы подбора в реестре форм — цель ссылки (rptype). */
@@ -83,6 +84,26 @@ export const SelectionRequestField: React.FC<ISelectionRequestFieldProps> = ({ d
setEditing(false);
setOpen(false);
setQuery('');
// Максимум полей, общих с заявкой на подбор, подтягиваем из неё — перезаписываем
// даже уже заполненные вручную (пользователь осознанно выбрал/сменил связь, см.
// обсуждение с Тимуром: перезапись — ожидаемое поведение при смене связанной заявки).
loadSelectionRequest(o.id, o.listId || undefined)
.then(({ data: selection }) => {
set('Companies', selection.Companies);
set('MainCompanyId', ''); // компании заменены целиком — зависимые поля пересчитывать не от чего
set('CompanyShares', []);
set('Project', selection.ObjectProject);
set('ProjectId', selection.ObjectProjectId);
set('ProjectCode', selection.ObjectProjectCode);
set('Position', selection.Position);
set('Department', selection.Department);
set('WorkSchedule', selection.WorkSchedule);
set('WorkScheduleOther', selection.WorkScheduleOther);
set('SalaryProbation', selection.RateProbation);
set('SalaryAfterProbation', selection.RateAfterProbation);
})
.catch(() => undefined); // связь уже сохранена по id/коду — сбой обогащения не критичен
};
const handleClear = (): void => {
@@ -144,7 +165,7 @@ export const SelectionRequestField: React.FC<ISelectionRequestFieldProps> = ({ d
<input
className={styles.input}
value={query}
placeholder="Начните вводить код, должность или название…"
placeholder="Начните вводить номер, должность или название…"
onChange={(e) => {
setQuery(e.target.value);
ensureLoaded();
@@ -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<string, unknown> {
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<IApproverEditableFields>,
listId?: string
): Promise<void> {
const item: Record<string, unknown> = {};
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 задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
@@ -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);
}
@@ -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<IRequestFormProps> = ({
// 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<IRequestFormProps> = ({
`${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<IRequestFormProps> = ({
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<IRequestFormProps> = ({
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<IRequestFormProps> = ({
search={(q) => approval.searchUsers(q)}
waiting={!matrixReady}
waitingText="Выберите «Объект/Проект» (п. 1.7), чтобы построить маршрут согласования."
canRemoveRow={canRemoveApprover ? () => true : undefined}
/>
<SubmitActions
submit={submit}
isValid={isValid}
submitValid={isSubmitValid}
readOnly={readOnly}
onCancel={onCancel}
onSaveDraft={handleSaveDraft}
@@ -11,9 +11,14 @@ import {
useMatrixEditor,
notifyActiveStep,
useRequestTypeId,
isApprovedStatus
isApprovedStatus,
resolveApproverOrgInfo,
canEditRoute,
REQUEST_STATUS,
IMatrixRow
} from '../../shared/approval';
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
import { effectiveUserEmail } from '../../shared/impersonation';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import {
approvalListTitle,
@@ -59,6 +64,11 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
const [attachments, setAttachments] = useState<IAttachmentInfo[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Право удалять согласующих из уже поданного маршрута — см.
// shared/approval/routeEditRights.ts (сейчас — Алтынай Бултурукова). Не
// привязано к активному шагу — не зависит от того, чья сейчас очередь.
const canRemoveApprover = canEditRoute(effectiveUserEmail(context));
const [routeError, setRouteError] = useState<string | undefined>(undefined);
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
// selectionApprovalService можно звать не опасаясь исключения.
@@ -74,9 +84,19 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
const [decisionSeq, setDecisionSeq] = useState<number>(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<IRecruitingSelectionViewProps> = ({ cont
</div>
</FormCard>
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
{routeError && <Alert variant="err">{routeError}</Alert>}
<ApprovalMatrix
editor={approvers}
search={(q) => approval.searchUsers(q)}
canRemoveRow={canRemoveApprover ? (row) => row.status === REQUEST_STATUS.queued : undefined}
onRemoveRow={handleRemoveApprover}
/>
</div>
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
@@ -14,7 +14,9 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ 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 (
<CollapseSection title="1. Заявка" icon="clipboard">
@@ -34,10 +36,10 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
placeholder="Подставится автоматически по выбранному ответственному"
/>
</FormField>
<FormField label="1.3 Дата подачи заявки" htmlFor="SubmitDate">
<FormField label="1.3 Дата подачи заявки" htmlFor="SubmitDate" required>
<TextInput id="SubmitDate" type="date" value={data.SubmitDate} onChange={(v) => set('SubmitDate', v)} readOnly />
</FormField>
<FormField label="1.4 Крайний срок закрытия" htmlFor="ClosingDeadline">
<FormField label="1.4 Крайний срок закрытия" htmlFor="ClosingDeadline" required>
<TextInput
id="ClosingDeadline"
type="date"
@@ -46,7 +48,7 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
min={data.SubmitDate || undefined}
/>
</FormField>
<FormField label="1.5 Причина открытия вакансии" htmlFor="VacancyOpenReason">
<FormField label="1.5 Причина открытия вакансии" htmlFor="VacancyOpenReason" required>
<Select
id="VacancyOpenReason"
value={data.VacancyOpenReason}
@@ -67,7 +69,7 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
/>
</FormField>
)}
<FormField label="1.6 Организация" htmlFor="Companies" full>
<FormField label="1.6 Организация" htmlFor="Companies" required full>
<CompanyPickerMulti
context={context}
id="Companies"
@@ -25,14 +25,14 @@ export const ConditionsSection: React.FC<ISectionProps> = ({
context,
}) => (
<CollapseSection title="5. Условия работы" icon="chart">
<FormField label="5.1 Ставка на ИС" htmlFor="RateProbation">
<FormField label="5.1 Ставка на ИС" htmlFor="RateProbation" required>
<MoneyInput
id="RateProbation"
value={data.RateProbation}
onChange={(v) => set("RateProbation", v)}
/>
</FormField>
<FormField label="5.2 Ставка после ИС" htmlFor="RateAfterProbation">
<FormField label="5.2 Ставка после ИС" htmlFor="RateAfterProbation" required>
<MoneyInput
id="RateAfterProbation"
value={data.RateAfterProbation}
@@ -46,7 +46,7 @@ export const ConditionsSection: React.FC<ISectionProps> = ({
onLabel="Да"
/>
</FormField>
<FormField label="5.4 График работы" htmlFor="WorkSchedule">
<FormField label="5.4 График работы" htmlFor="WorkSchedule" required>
<Select
id="WorkSchedule"
value={data.WorkSchedule}
@@ -67,7 +67,7 @@ export const ConditionsSection: React.FC<ISectionProps> = ({
/>
</FormField>
)}
<FormField label="5.5 Время работы" htmlFor="WorkHours">
<FormField label="5.5 Время работы" htmlFor="WorkHours" required>
<Select
id="WorkHours"
value={data.WorkHours}
@@ -95,7 +95,7 @@ export const ConditionsSection: React.FC<ISectionProps> = ({
onLabel="Да"
/>
</FormField>
<FormField label="5.7 Обеспечение рабочего места" full>
<FormField label="5.7 Обеспечение рабочего места" required full>
<MultiChoiceField
value={data.WorkplaceProvision}
onChange={(v) => set("WorkplaceProvision", v)}
@@ -111,7 +111,7 @@ export const ConditionsSection: React.FC<ISectionProps> = ({
onLabel="Да"
/>
</FormField>
<FormField label="5.9 Предполагаемый наставник">
<FormField label="5.9 Предполагаемый наставник" required>
<PeopleField
context={context}
value={data.ProposedMentor}
@@ -119,7 +119,7 @@ export const ConditionsSection: React.FC<ISectionProps> = ({
placeholder={PERSON_PLACEHOLDER}
/>
</FormField>
<FormField label="5.10 Непосредственный руководитель">
<FormField label="5.10 Непосредственный руководитель" required>
<PeopleField
context={context}
value={data.DirectManager}
@@ -127,7 +127,7 @@ export const ConditionsSection: React.FC<ISectionProps> = ({
placeholder={PERSON_PLACEHOLDER}
/>
</FormField>
<FormField label="5.11 Кто принимает решение о приеме (собеседования)" full>
<FormField label="5.11 Кто принимает решение о приеме (собеседования)" required full>
<PeopleFieldMulti
context={context}
value={data.HiringDecisionMaker}
@@ -5,7 +5,7 @@ import { ISectionProps } from '../model';
export const DepartmentSection: React.FC<ISectionProps> = ({ data, set, context }) => (
<CollapseSection title="2. Отдел / Подразделение" icon="building">
<FormField label="2.1 Подразделение" htmlFor="Department" full>
<FormField label="2.1 Подразделение" htmlFor="Department" required full>
<DepartmentPicker
id="Department"
context={context}
@@ -16,7 +16,7 @@ export const DepartmentSection: React.FC<ISectionProps> = ({ data, set, context
}}
/>
</FormField>
<FormField label="2.2 Количество открытых вакансий" htmlFor="OpenVacanciesCount">
<FormField label="2.2 Количество открытых вакансий" htmlFor="OpenVacanciesCount" required>
<TextInput
id="OpenVacanciesCount"
type="number"
@@ -24,7 +24,7 @@ export const VacancySection: React.FC<ISectionProps> = ({ data, set, context })
placeholder="Начните вводить название должности…"
/>
</FormField>
<FormField label="3.2 Характер занятости" htmlFor="EmploymentType">
<FormField label="3.2 Характер занятости" htmlFor="EmploymentType" required>
<Select
id="EmploymentType"
value={data.EmploymentType}
@@ -33,16 +33,16 @@ export const VacancySection: React.FC<ISectionProps> = ({ data, set, context })
placeholder="— выберите —"
/>
</FormField>
<FormField label="3.3 Функционал" htmlFor="JobFunction" full>
<FormField label="3.3 Функционал" htmlFor="JobFunction" required full>
<TextArea id="JobFunction" value={data.JobFunction} onChange={(v) => set('JobFunction', v)} />
</FormField>
<FormField label="3.4 Компетенции" htmlFor="Competencies" full>
<FormField label="3.4 Компетенции" htmlFor="Competencies" required full>
<TextArea id="Competencies" value={data.Competencies} onChange={(v) => set('Competencies', v)} />
</FormField>
<FormField label="3.5 Личностные характеристики" htmlFor="PersonalTraits" full>
<FormField label="3.5 Личностные характеристики" htmlFor="PersonalTraits" required full>
<TextArea id="PersonalTraits" value={data.PersonalTraits} onChange={(v) => set('PersonalTraits', v)} />
</FormField>
<FormField label="3.6 Образование" htmlFor="Education">
<FormField label="3.6 Образование" htmlFor="Education" required>
<Select
id="Education"
value={data.Education}
@@ -51,7 +51,7 @@ export const VacancySection: React.FC<ISectionProps> = ({ data, set, context })
placeholder="— выберите —"
/>
</FormField>
<FormField label="3.7 Опыт работы" htmlFor="WorkExperience">
<FormField label="3.7 Опыт работы" htmlFor="WorkExperience" required>
<Select
id="WorkExperience"
value={data.WorkExperience}
@@ -0,0 +1,39 @@
import { IFormData } from './model';
/**
* Поля формы, обязательные для ОТПРАВКИ на согласование (черновик по-прежнему
* можно сохранить не заполнив их). Исключения по требованию заказчика: 3.8
* «Необходимые знания в программах» (Programs), 3.9 «Иностранные языки»
* (Languages), 3.11 «Водительские права, стаж вождения» (DrivingLicense).
* Toggle-поля (BusinessTrips, PieceworkPay, MaterialResponsibility,
* ReferralProgram) и условные «другое»-подполя в проверку не входят — у
* булевых нет пустого состояния, а «другое» раскрывается только при выборе
* соответствующего варианта в уже обязательном родительском поле.
*/
const REQUIRED_FIELDS: ReadonlyArray<{ label: string; ok: (d: IFormData) => boolean }> = [
{ label: '1.3 дата подачи заявки', ok: (d) => !!d.SubmitDate },
{ label: '1.4 крайний срок закрытия', ok: (d) => !!d.ClosingDeadline },
{ label: '1.5 причина открытия вакансии', ok: (d) => !!d.VacancyOpenReason },
{ label: '1.6 организация', ok: (d) => d.Companies.length > 0 },
{ label: '2.1 подразделение', ok: (d) => !!d.Department.trim() },
{ label: '2.2 количество открытых вакансий', ok: (d) => !!d.OpenVacanciesCount.trim() },
{ label: '3.2 характер занятости', ok: (d) => !!d.EmploymentType },
{ label: '3.3 функционал', ok: (d) => !!d.JobFunction.trim() },
{ label: '3.4 компетенции', ok: (d) => !!d.Competencies.trim() },
{ label: '3.5 личностные характеристики', ok: (d) => !!d.PersonalTraits.trim() },
{ label: '3.6 образование', ok: (d) => !!d.Education },
{ label: '3.7 опыт работы', ok: (d) => !!d.WorkExperience },
{ label: '5.1 ставка на ИС', ok: (d) => !!d.RateProbation.trim() },
{ label: '5.2 ставка после ИС', ok: (d) => !!d.RateAfterProbation.trim() },
{ label: '5.4 график работы', ok: (d) => !!d.WorkSchedule },
{ label: '5.5 время работы', ok: (d) => !!d.WorkHours },
{ label: '5.7 обеспечение рабочего места', ok: (d) => d.WorkplaceProvision.length > 0 },
{ label: '5.9 предполагаемый наставник', ok: (d) => !!d.ProposedMentor },
{ label: '5.10 непосредственный руководитель', ok: (d) => !!d.DirectManager },
{ label: '5.11 кто принимает решение о приёме', ok: (d) => d.HiringDecisionMaker.length > 0 }
];
/** Подписи незаполненных обязательных-для-отправки полей (пусто — всё заполнено). */
export function missingRequiredFields(data: IFormData): string[] {
return REQUIRED_FIELDS.filter((f) => !f.ok(data)).map((f) => f.label);
}
@@ -28,7 +28,9 @@ import {
unassignedStepMessage,
resolveWriteListId,
APPROVAL_PHASE,
initialSubmitStatus
initialSubmitStatus,
resolveApproverOrgInfo,
canEditRoute
} from '../../shared/approval';
import {
uploadAttachments,
@@ -37,6 +39,7 @@ import {
IAttachmentInfo
} from '../../shared/attachments/attachments.service';
import { IRequestFormProps } from '../../models/types';
import { effectiveUserEmail } from '../../shared/impersonation';
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import {
@@ -82,6 +85,11 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
const readOnly = !isEditableStatus(requestStatus);
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
// Право удалять согласующих из маршрута ещё на этапе составления заявки —
// см. shared/approval/routeEditRights.ts (сейчас — Алтынай Бултурукова).
// Проверяем ТЕКУЩЕГО пользователя портала, а не выбранного «Рекомендателя»
// (data.Referrer) — это разные роли, заявку может составлять не сам рекомендатель.
const canRemoveApprover = canEditRoute(effectiveUserEmail(context));
// editListId уже проверен в EditFeature — если бы партиции не нашлось в реестре,
// форма сюда вообще не смонтировалась бы, поэтому referralApprovalService можно
@@ -210,7 +218,8 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
`${data.Referrer}-${editId ?? 'new'}`,
!readOnly,
!!editId || (!!data.Referrer && !!requestTypeId),
(login) => approval.getJobTitle(login)
(login) => approval.getJobTitle(login),
resolveApproverOrgInfo(context)
);
// Фаза исполнения: реальный исполнитель, назначаемый по маршруту (не
@@ -231,7 +240,8 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
`${data.Referrer}-${editId ?? 'new'}`,
false,
!!editId || (!!data.Referrer && !!requestTypeId),
(login) => approval.getJobTitle(login)
(login) => approval.getJobTitle(login),
resolveApproverOrgInfo(context)
);
// «Другое» в «откуда знаете» без уточнения — незаполненное поле по сути.
@@ -440,7 +450,11 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
{/* Блок остаётся видимым и пустым (не скрываем по noApprovalPhase) —
маршрут без согласующих не значит, что их нельзя добавить: матрица
редактируема, заявитель вправе вписать согласующего сам. */}
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
<ApprovalMatrix
editor={matrix}
search={(q) => approval.searchUsers(q)}
canRemoveRow={canRemoveApprover ? () => true : undefined}
/>
{!executors.loading && executors.rows.length > 0 && (
<ApprovalMatrix
@@ -22,9 +22,14 @@ import {
notifyActiveStep,
useRequestTypeId,
APPROVAL_PHASE,
isApprovedStatus
isApprovedStatus,
resolveApproverOrgInfo,
canEditRoute,
REQUEST_STATUS,
IMatrixRow
} from '../../shared/approval';
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
import { effectiveUserEmail } from '../../shared/impersonation';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import {
approvalListTitle,
@@ -74,6 +79,10 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
// Какая из организаций рекомендателя (см. OrganizationField) идёт в печать —
// выбор в тулбаре через PrintSplitButton, по умолчанию первая.
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
// Право удалять согласующих из уже поданного маршрута — см.
// shared/approval/routeEditRights.ts (сейчас — Алтынай Бултурукова).
const canRemoveApprover = canEditRoute(effectiveUserEmail(context));
const [routeError, setRouteError] = useState<string | undefined>(undefined);
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
// referralApprovalService можно звать не опасаясь исключения.
@@ -97,7 +106,7 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
[approval, itemId]
);
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
approval.getJobTitle(l)
approval.getJobTitle(l), resolveApproverOrgInfo(context)
);
const loadExecutors = useCallback(
() =>
@@ -107,9 +116,19 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
[approval, itemId]
);
const executors = useMatrixEditor(loadExecutors, `e-${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);
@@ -199,7 +218,13 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
</div>
</FormCard>
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
{routeError && <Alert variant="err">{routeError}</Alert>}
<ApprovalMatrix
editor={approvers}
search={(q) => approval.searchUsers(q)}
canRemoveRow={canRemoveApprover ? (row) => row.status === REQUEST_STATUS.queued : undefined}
onRemoveRow={handleRemoveApprover}
/>
{!executors.loading && executors.rows.length > 0 && (
<ApprovalMatrix