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
@@ -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);
}