feat: подсветка незаполненных полей и переход к ним в приёме и подборе

Пользователи не понимали, почему кнопка «Отправить на согласование» серая.
Теперь в заявках на приём и подбор кнопки активны: нажатие при незаполненных
или неверных полях ничего не сохраняет, а подсвечивает эти поля с подсказкой
под ними и прокручивает к первому. Над кнопками — список проблем, пункты ведут
к полю. Механизм общий (FieldIssuesProvider + FormField), остальные формы
пока работают по-старому и переводятся по тому же паттерну.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-09-30 17:15:16 +05:00
parent c95118a216
commit 5702939b52
14 changed files with 485 additions and 125 deletions
@@ -8,6 +8,8 @@ import {
FormModeProvider,
FieldChangesProvider,
FieldChangesBanner,
FieldIssuesProvider,
issueLabels,
FileDropzone,
AttachmentList,
CollapseSection,
@@ -52,7 +54,7 @@ import {
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { missingRequiredFields } from './validation';
import { draftIssues, submitIssues } from './validation';
import { saveHireRequest, hireApprovalService } from './submit';
import { loadHireRequest } from './load';
import {
@@ -210,32 +212,18 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
resolveApproverOrgInfo(context)
);
// При совмещении (2+ компании в «Организация») нужно явно указать, какая из них основная.
const mainCompanyMissing = data.Companies.length >= 2 && !data.MainCompanyId;
// ProjectId обязателен (а не просто текст Project): маршрут резолвится ПО
// ПРОЕКТУ (ByProject, см. projectId выше), и без выбранного из списка
// варианта (id) approval-route вернёт 404 — форма должна не пускать на
// отправку раньше, чем выяснится, что маршрут вообще некому строить (баг:
// раньше это не проверялось, и заявка молча уходила на согласование с
// пустой матрицей).
const isValid =
data.HireCandidateName.trim().length > 0 &&
data.Position.trim().length > 0 &&
!!data.ProjectId &&
!mainCompanyMissing;
// Черновику нужны лишь несколько полей, отправке — все обязательные (см. validation.ts).
const draftMissing = draftIssues(data);
const submitMissing = submitIssues(data);
const isValid = draftMissing.length === 0;
const isSubmitValid = submitMissing.length === 0;
const validate = (): string | undefined => {
if (mainCompanyMissing) return 'Выбрано несколько организаций — укажите, какая из них основное место работы.';
if (!isValid) return 'Заполните обязательные поля: Ф.И.О. кандидата, Должность, Проект (выберите вариант из списка).';
if (!isValid) return `Заполните обязательные поля: ${issueLabels(draftMissing)}.`;
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
return undefined;
};
// Остальные поля формы обязательны только для ОТПРАВКИ на согласование —
// черновик по-прежнему можно сохранить не заполнив их (см. validation.ts).
const missingFields = missingRequiredFields(data);
const isSubmitValid = isValid && missingFields.length === 0;
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
// order обязателен: параллельные шаги делят один номер, по позиции в
// массиве его не восстановить (см. useMatrixEditor/withOrders).
@@ -327,8 +315,8 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
const validateSubmit = (): string | undefined => {
const formError = validate();
if (formError) return formError;
if (missingFields.length > 0) {
return `Заполните обязательные поля: ${missingFields.join(', ')}.`;
if (!isSubmitValid) {
return `Заполните обязательные поля: ${issueLabels(submitMissing)}.`;
}
if (matrix.error) {
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
@@ -381,7 +369,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
});
return (
<div>
<FieldIssuesProvider draftIssues={draftMissing} submitIssues={submitMissing}>
<button type="button" className={styles.back} onClick={onCancel}>
<Icon name="arrow-left" size="sm" />
<span>К списку заявок</span>
@@ -495,7 +483,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
onSubmit={handleSubmit}
/>
</FormCard>
</div>
</FieldIssuesProvider>
);
};
@@ -14,7 +14,8 @@ import {
Icon,
PrintButton,
FieldChangesProvider,
FieldChangesBanner
FieldChangesBanner,
issueLabels
} from '../../shared/ui';
import { IFieldChange, mergeFieldChanges, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
import { HIRE_CHANGE_TRACKING } from './changeTracking';
@@ -51,7 +52,7 @@ import { loadHireRequest } from './load';
import { ATT_CATEGORY_ID, ATT_CATEGORY_RESUME, attachmentsOfCategory, uncategorizedAttachments } from './attachmentCategories';
import { confirmDiscardFormChanges } from '../../shared/unsavedChanges';
import { hireApprovalService, updateApproverFields, updateAllHireFields, IApproverEditableFields } from './submit';
import { missingRequiredFields } from './validation';
import { submitIssues } from './validation';
import { canEditPayrollFields } from './approverRights';
import { ApplicationSection } from './sections/ApplicationSection';
import { CandidateSection } from './sections/CandidateSection';
@@ -230,8 +231,8 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
throw new Error(HIRE_DATE_REQUIRED_MESSAGE);
}
if (fullEditable && decision === 'approve') {
const missing = missingRequiredFields(data);
if (missing.length > 0) throw new Error(`заполните обязательные поля: ${missing.join(', ')}.`);
const missing = submitIssues(data);
if (missing.length > 0) throw new Error(`заполните обязательные поля: ${issueLabels(missing)}.`);
}
await saveApproverFields();
};
@@ -0,0 +1,44 @@
import { EMPTY_DATA, IFormData } from './model';
import { draftIssues, submitIssues } from './validation';
const DRAFT_READY: IFormData = {
...EMPTY_DATA,
HireCandidateName: 'Иванов Иван Иванович',
Position: 'Инженер',
ProjectId: '42'
};
const labels = (issues: { label: string }[]): string[] => issues.map((i) => i.label);
describe('RecruitingHire validation', () => {
it('пустая форма: черновику не хватает проекта, ФИО и должности', () => {
expect(labels(draftIssues(EMPTY_DATA))).toEqual(['1.3 Проект', '2.1 Ф.И.О. кандидата', '2.3 Должность']);
});
it('черновик готов, но для отправки остаются поля — в порядке формы', () => {
expect(draftIssues(DRAFT_READY)).toEqual([]);
expect(labels(submitIssues(DRAFT_READY))).toEqual([
'1.2 Организация',
'1.4 Телефон',
'1.5 Тип приёма',
'2.2 Дата приёма',
'4.1 Испытательный срок',
'4.2 Оклад при испытательном сроке',
'4.3 Оклад по завершении ИС',
'4.4 График работы'
]);
});
it('ключ поля совпадает с htmlFor у FormField — по нему подсветка и прокрутка', () => {
expect(submitIssues(DRAFT_READY)[0]).toEqual({ field: 'Companies', label: '1.2 Организация', message: undefined });
expect(draftIssues(EMPTY_DATA)[0]).toEqual({
field: 'Project',
label: '1.3 Проект',
message: 'выберите вариант из списка'
});
});
it('«По приезду на объект» снимает обязательность даты приёма', () => {
expect(labels(submitIssues({ ...DRAFT_READY, HireOnArrival: true }))).not.toContain('2.2 Дата приёма');
});
});
@@ -1,29 +1,66 @@
import type { IFieldIssue } from '../../shared/ui';
import { IFormData } from './model';
interface IRequiredRule extends IFieldIssue {
ok: (d: IFormData) => boolean;
/** true — без этого поля не сохранить даже черновик. */
draft?: boolean;
}
/**
* Поля формы, обязательные для ОТПРАВКИ на согласование (черновик по-прежнему
* можно сохранить не заполнив их). Исключения по требованию заказчика: 2.4
* «Отдел/Участок» (Department), 2.5 «Местоположение» (SiteLocation), 3.1–3.3
* СИЗ (PpeHeight/PpeClothingSize/PpeShoeSize). 1.1 «Заявка на подбор»
* оставлена опциональной — приём иногда
* идёт через рекрутинговое агентство (см. AgencySection), без внутреннего
* подбора. Toggle-поле 5.1 «Рекрутинговое агентство» в проверку не входит — у
* булевых нет пустого состояния.
* Обязательные поля в порядке формы. field — ключ FormField (htmlFor/fieldKey):
* по нему поле подсвечивается и к нему прокручивается форма. Большинство полей
* нужны только для ОТПРАВКИ на согласование; draft — нужны уже черновику.
*
* Исключения по требованию заказчика: 2.4 «Отдел/Участок» (Department), 2.5
* «Местоположение» (SiteLocation), 3.1–3.3 СИЗ. 1.1 «Заявка на подбор»
* опциональна — приём иногда идёт через рекрутинговое агентство (см.
* AgencySection), без внутреннего подбора. Toggle 5.1 «Рекрутинговое
* агентство» не проверяется — у булевых нет пустого состояния.
* 2.2 «Дата приёма» не нужна, если отмечено «По приезду на объект»
* (HireOnArrival) — тогда дату проставляет исполнитель на своём шаге.
*
* 1.3 «Проект» проверяется по ProjectId, а не по тексту: маршрут резолвится
* ПО ПРОЕКТУ (ByProject), и без выбранного из списка варианта approval-route
* вернёт 404 — заявка ушла бы на согласование с пустой матрицей.
*/
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 || d.HireOnArrival },
{ 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 }
const REQUIRED_RULES: ReadonlyArray<IRequiredRule> = [
{ field: 'Companies', label: '1.2 Организация', ok: (d) => d.Companies.length > 0 },
{
field: 'MainCompanyId',
label: '1.2 Основное место работы',
message: 'выбрано несколько организаций — укажите основную',
ok: (d) => d.Companies.length < 2 || !!d.MainCompanyId,
draft: true
},
{
field: 'Project',
label: '1.3 Проект',
message: 'выберите вариант из списка',
ok: (d) => !!d.ProjectId,
draft: true
},
{ field: 'Phone', label: '1.4 Телефон', ok: (d) => !!d.Phone.trim() },
{ field: 'HireType', label: '1.5 Тип приёма', ok: (d) => !!d.HireType },
{ field: 'HireCandidateName', label: '2.1 Ф.И.О. кандидата', ok: (d) => !!d.HireCandidateName.trim(), draft: true },
{ field: 'HireHireDate', label: '2.2 Дата приёма', ok: (d) => !!d.HireHireDate || d.HireOnArrival },
{ field: 'Position', label: '2.3 Должность', ok: (d) => !!d.Position.trim(), draft: true },
{ field: 'ProbationPeriod', label: '4.1 Испытательный срок', ok: (d) => !!d.ProbationPeriod },
{ field: 'SalaryProbation', label: '4.2 Оклад при испытательном сроке', ok: (d) => !!d.SalaryProbation.trim() },
{ field: 'SalaryAfterProbation', label: '4.3 Оклад по завершении ИС', ok: (d) => !!d.SalaryAfterProbation.trim() },
{ field: 'WorkSchedule', 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);
function toIssue({ field, label, message }: IRequiredRule): IFieldIssue {
return { field, label, message };
}
/** Что мешает сохранить черновик (пусто — сохранить можно). */
export function draftIssues(data: IFormData): IFieldIssue[] {
return REQUIRED_RULES.filter((r) => r.draft && !r.ok(data)).map(toIssue);
}
/** Всё, что мешает отправке на согласование, в порядке формы (пусто — отправлять можно). */
export function submitIssues(data: IFormData): IFieldIssue[] {
return REQUIRED_RULES.filter((r) => !r.ok(data)).map(toIssue);
}
@@ -8,6 +8,8 @@ import {
FormModeProvider,
FieldChangesProvider,
FieldChangesBanner,
FieldIssuesProvider,
issueLabels,
FileDropzone,
AttachmentList,
CollapseSection,
@@ -53,7 +55,7 @@ import {
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField, todayIso } from './model';
import { submitBlocker } from './validation';
import { draftIssues, submitBlocker, submitIssues } from './validation';
import { saveSelectionRequest, selectionApprovalService } from './submit';
import { loadSelectionRequest } from './load';
import { ApplicationSection } from './sections/ApplicationSection';
@@ -239,33 +241,18 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
resolveApproverOrgInfo(context)
);
// ObjectProjectId обязателен (а не просто текст ObjectProject): маршрут
// согласования резолвится ПО ПРОЕКТУ (ByProject, см. projectId выше), и без
// выбранного из списка варианта (id) approval-route вернёт 404 — форма
// должна не пускать на отправку раньше, чем выяснится, что маршрут вообще
// некому строить (баг: раньше это не проверялось, и заявка молча уходила
// на согласование с пустой матрицей).
const isValid = !!(
data.Responsible &&
data.DepartmentName.trim() &&
data.Position.trim() &&
data.Residence &&
data.ObjectProjectId
);
// Черновику нужны лишь несколько полей, отправке — все обязательные (см. validation.ts).
const draftMissing = draftIssues(data);
const submitProblems = submitIssues(data);
const isValid = draftMissing.length === 0;
const isSubmitValid = submitProblems.length === 0;
const validate = (): string | undefined => {
if (!isValid) {
return 'Заполните обязательные поля: ответственный за подачу, название отдела, должность, проживание, объект/проект (выберите вариант из списка).';
}
if (!isValid) return `Заполните обязательные поля: ${issueLabels(draftMissing)}.`;
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
return undefined;
};
// Остальные поля формы обязательны только для ОТПРАВКИ на согласование —
// черновик по-прежнему можно сохранить не заполнив их (см. validation.ts).
const blocker = submitBlocker(data);
const isSubmitValid = isValid && !blocker;
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
rows.map((r) => ({
email: r.email,
@@ -344,6 +331,7 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
const validateSubmit = (): string | undefined => {
const formError = validate();
if (formError) return formError;
const blocker = submitBlocker(data);
if (blocker) return blocker;
if (matrix.error) {
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
@@ -387,7 +375,7 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
});
return (
<div>
<FieldIssuesProvider draftIssues={draftMissing} submitIssues={submitProblems}>
<button type="button" className={styles.back} onClick={onCancel}>
<Icon name="arrow-left" size="sm" />
<span>К списку заявок</span>
@@ -486,7 +474,7 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
submitLabel="Отправить на согласование"
/>
</FormCard>
</div>
</FieldIssuesProvider>
);
};
@@ -1,54 +1,107 @@
import type { IFieldIssue } from '../../shared/ui';
import { IFormData } from './model';
import { parseVacanciesCount, vacancyReasonsError } from './vacancyReasons';
interface IRequiredRule extends IFieldIssue {
ok: (d: IFormData) => boolean;
/** true — без этого поля не сохранить даже черновик. */
draft?: boolean;
}
/**
* Поля формы, обязательные для ОТПРАВКИ на согласование (черновик по-прежнему
* можно сохранить не заполнив их). Исключения по требованию заказчика: 3.8
* «Необходимые знания в программах» (Programs), 3.9 «Иностранные языки»
* (Languages), 3.11 «Водительские права, стаж вождения» (DrivingLicense).
* Toggle-поля (BusinessTrips, PieceworkPay, MaterialResponsibility,
* ReferralProgram) и условные «другое»-подполя в проверку не входят — у
* булевых нет пустого состояния, а «другое» раскрывается только при выборе
* соответствующего варианта в уже обязательном родительском поле.
* Обязательные поля в порядке формы. field — ключ FormField (htmlFor/fieldKey):
* по нему поле подсвечивается и к нему прокручивается форма. Большинство полей
* нужны только для ОТПРАВКИ на согласование; draft — нужны уже черновику.
*
* Исключения по требованию заказчика: 3.8 «Необходимые знания в программах»
* (Programs), 3.9 «Иностранные языки» (Languages), 3.11 «Водительские права,
* стаж вождения» (DrivingLicense). Toggle-поля (BusinessTrips, PieceworkPay,
* MaterialResponsibility, ReferralProgram) и условные «другое»-подполя в
* проверку не входят — у булевых нет пустого состояния, а «другое»
* раскрывается только при выборе соответствующего варианта в уже обязательном
* родительском поле.
* 5.10 «Предполагаемый наставник» (ProposedMentor) сделан необязательным
* по просьбе Тимура (2026-09-24) — не всегда есть кого назначить заранее.
* 5.1 «Испытательный срок» добавлен 2026-09-24 — нумерация раздела 5 сдвинута на 1.
*
* 1.6 «Объект/Проект» проверяется по ObjectProjectId, а не по тексту: маршрут
* резолвится ПО ПРОЕКТУ (ByProject), и без выбранного из списка варианта
* approval-route вернёт 404 — заявка ушла бы на согласование с пустой матрицей.
*/
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.Companies.length > 0 },
{ label: '2.1 подразделение', ok: (d) => !!d.Department.trim() },
{ label: '2.2 количество открытых вакансий', ok: (d) => !!d.OpenVacanciesCount.trim() },
{ label: '2.3 причина открытия вакансии', ok: (d) => d.VacancyReasons.length > 0 },
{ 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.SelectionProbationPeriod },
{ label: '5.2 ставка на ИС', ok: (d) => !!d.RateProbation.trim() },
{ label: '5.3 ставка после ИС', ok: (d) => !!d.RateAfterProbation.trim() },
{ label: '5.5 график работы', ok: (d) => !!d.WorkSchedule },
{ label: '5.6 время работы', ok: (d) => !!d.WorkHours },
{ label: '5.8 обеспечение рабочего места', ok: (d) => d.WorkplaceProvision.length > 0 },
{ label: '5.11 непосредственный руководитель', ok: (d) => !!d.DirectManager },
{ label: '5.12 кто принимает решение о приёме', ok: (d) => d.HiringDecisionMaker.length > 0 }
const REQUIRED_RULES: ReadonlyArray<IRequiredRule> = [
{ field: 'Responsible', label: '1.1 Ответственный за подачу заявки', ok: (d) => !!d.Responsible, draft: true },
{
field: 'DepartmentName',
label: '1.2 Подразделение ответственного за подачу',
ok: (d) => !!d.DepartmentName.trim(),
draft: true
},
{ field: 'SubmitDate', label: '1.3 Дата подачи заявки', ok: (d) => !!d.SubmitDate },
{ field: 'ClosingDeadline', label: '1.4 Крайний срок закрытия', ok: (d) => !!d.ClosingDeadline },
{ field: 'Companies', label: '1.5 Организация', ok: (d) => d.Companies.length > 0 },
{
field: 'ObjectProject',
label: '1.6 Объект/Проект',
message: 'выберите вариант из списка',
ok: (d) => !!d.ObjectProjectId,
draft: true
},
{ field: 'Department', label: '2.1 Подразделение', ok: (d) => !!d.Department.trim() },
{ field: 'OpenVacanciesCount', label: '2.2 Количество открытых вакансий', ok: (d) => !!d.OpenVacanciesCount.trim() },
{ field: 'VacancyReasons', label: '2.3 Причина открытия вакансии', ok: (d) => d.VacancyReasons.length > 0 },
{ field: 'Position', label: '3.1 Должность', ok: (d) => !!d.Position.trim(), draft: true },
{ field: 'EmploymentType', label: '3.2 Характер занятости', ok: (d) => !!d.EmploymentType },
{ field: 'JobFunction', label: '3.3 Функционал', ok: (d) => !!d.JobFunction.trim() },
{ field: 'Competencies', label: '3.4 Компетенции', ok: (d) => !!d.Competencies.trim() },
{ field: 'PersonalTraits', label: '3.5 Личностные характеристики', ok: (d) => !!d.PersonalTraits.trim() },
{ field: 'Education', label: '3.6 Образование', ok: (d) => !!d.Education },
{ field: 'WorkExperience', label: '3.7 Опыт работы', ok: (d) => !!d.WorkExperience },
{ field: 'Residence', label: '3.10 Проживание', ok: (d) => !!d.Residence, draft: true },
{ field: 'SelectionProbationPeriod', label: '5.1 Испытательный срок', ok: (d) => !!d.SelectionProbationPeriod },
{ field: 'RateProbation', label: '5.2 Ставка на ИС', ok: (d) => !!d.RateProbation.trim() },
{ field: 'RateAfterProbation', label: '5.3 Ставка после ИС', ok: (d) => !!d.RateAfterProbation.trim() },
{ field: 'WorkSchedule', label: '5.5 График работы', ok: (d) => !!d.WorkSchedule },
{ field: 'WorkHours', label: '5.6 Время работы', ok: (d) => !!d.WorkHours },
{ field: 'WorkplaceProvision', label: '5.8 Обеспечение рабочего места', ok: (d) => d.WorkplaceProvision.length > 0 },
{ field: 'DirectManager', label: '5.11 Непосредственный руководитель', ok: (d) => !!d.DirectManager },
{ field: 'HiringDecisionMaker', label: '5.12 Кто принимает решение о приёме', ok: (d) => d.HiringDecisionMaker.length > 0 }
];
/** Подписи незаполненных обязательных-для-отправки полей (пусто — всё заполнено). */
export function missingRequiredFields(data: IFormData): string[] {
return REQUIRED_FIELDS.filter((f) => !f.ok(data)).map((f) => f.label);
function missingFields(data: IFormData, draftOnly: boolean): IFieldIssue[] {
return REQUIRED_RULES.filter((r) => (!draftOnly || r.draft) && !r.ok(data)).map(({ field, label, message }) => ({
field,
label,
message
}));
}
/** Несогласованность причин (2.3) с количеством вакансий (2.2) — заполнено, но неверно. */
function vacancyReasonsIssue(data: IFormData): IFieldIssue | undefined {
const error = vacancyReasonsError(data.VacancyReasons, parseVacanciesCount(data.OpenVacanciesCount));
if (!error) return undefined;
// Текст ошибки — готовое предложение с заглавной; в списке после тире нужна строчная.
const message = error.charAt(0).toLowerCase() + error.slice(1);
return { field: 'VacancyReasons', label: '2.3 Причина открытия вакансии', message };
}
/** Что мешает сохранить черновик (пусто — сохранить можно). */
export function draftIssues(data: IFormData): IFieldIssue[] {
return missingFields(data, true);
}
/**
* Всё, что мешает отправке/согласованию, одной строкой: сначала незаполненные
* обязательные поля, затем согласованность причин с количеством вакансий.
* undefined — отправлять можно.
* Всё, что мешает отправке на согласование: незаполненные поля в порядке формы,
* затем несогласованность причин с количеством вакансий (пусто — отправлять можно).
*/
export function submitIssues(data: IFormData): IFieldIssue[] {
const reasonsIssue = vacancyReasonsIssue(data);
const missing = missingFields(data, false);
return reasonsIssue ? [...missing, reasonsIssue] : missing;
}
/** Причина, по которой нельзя отправить/согласовать, одной строкой (undefined — можно). */
export function submitBlocker(data: IFormData): string | undefined {
const missing = missingRequiredFields(data);
if (missing.length > 0) return `Заполните обязательные поля: ${missing.join(', ')}.`;
const missing = missingFields(data, false);
if (missing.length > 0) return `Заполните обязательные поля: ${missing.map((m) => m.label).join(', ')}.`;
return vacancyReasonsError(data.VacancyReasons, parseVacanciesCount(data.OpenVacanciesCount));
}