feat: подсветка незаполненных полей и переход к ним в приёме и подборе
Пользователи не понимали, почему кнопка «Отправить на согласование» серая. Теперь в заявках на приём и подбор кнопки активны: нажатие при незаполненных или неверных полях ничего не сохраняет, а подсвечивает эти поля с подсказкой под ними и прокручивает к первому. Над кнопками — список проблем, пункты ведут к полю. Механизм общий (FieldIssuesProvider + FormField), остальные формы пока работают по-старому и переводятся по тому же паттерну. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import * as React from 'react';
|
||||
import { useContext, useState } from 'react';
|
||||
import { IFieldIssue, issueFieldText, scrollToField } from './fieldIssues';
|
||||
|
||||
/** Какую кнопку нажимали: от этого зависит, какие поля подсвечивать. */
|
||||
export type RevealMode = 'draft' | 'submit';
|
||||
|
||||
interface IFieldIssuesContext {
|
||||
draftIssues: IFieldIssue[];
|
||||
submitIssues: IFieldIssue[];
|
||||
/** Подсвечиваемые сейчас поля: ключ поля → текст под ним. */
|
||||
visible: Map<string, string>;
|
||||
/** Показать проблемы под полями и прокрутить к полю (по умолчанию — к первому). */
|
||||
reveal: (mode: RevealMode, field?: string) => void;
|
||||
}
|
||||
|
||||
const FieldIssuesContext = React.createContext<IFieldIssuesContext | undefined>(undefined);
|
||||
|
||||
interface IFieldIssuesProviderProps {
|
||||
/** Что мешает сохранить черновик (обычно подмножество submitIssues). */
|
||||
draftIssues: IFieldIssue[];
|
||||
/** Что мешает отправке на согласование. */
|
||||
submitIssues: IFieldIssue[];
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function toVisibleMap(issues: IFieldIssue[]): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
issues.forEach((issue) => {
|
||||
// Первое сообщение по полю важнее: правила идут в порядке формы.
|
||||
if (!map.has(issue.field)) map.set(issue.field, issueFieldText(issue));
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Подсветка незаполненных/неверных полей формы. До первого нажатия кнопки
|
||||
* поля не краснеют (пустая форма не должна пугать); после — подсвечиваются
|
||||
* и гаснут сами, как только поле исправлено: списки пересчитываются из данных.
|
||||
*/
|
||||
export const FieldIssuesProvider: React.FC<IFieldIssuesProviderProps> = ({ draftIssues, submitIssues, children }) => {
|
||||
const [mode, setMode] = useState<RevealMode | undefined>(undefined);
|
||||
|
||||
const reveal = (nextMode: RevealMode, field?: string): void => {
|
||||
// Раз показали проблемы отправки — не сужаем обратно до черновика.
|
||||
setMode((prev) => (prev === 'submit' ? prev : nextMode));
|
||||
const issues = nextMode === 'draft' ? draftIssues : submitIssues;
|
||||
const target = field || (issues.length > 0 ? issues[0].field : undefined);
|
||||
if (target) scrollToField(target);
|
||||
};
|
||||
|
||||
let shown: IFieldIssue[] = [];
|
||||
if (mode === 'submit') shown = submitIssues;
|
||||
if (mode === 'draft') shown = draftIssues;
|
||||
|
||||
const value: IFieldIssuesContext = { draftIssues, submitIssues, visible: toVisibleMap(shown), reveal };
|
||||
return <FieldIssuesContext.Provider value={value}>{children}</FieldIssuesContext.Provider>;
|
||||
};
|
||||
|
||||
/** Управление подсветкой для кнопок формы; undefined — форма без FieldIssuesProvider. */
|
||||
export function useFieldIssuesControl(): IFieldIssuesContext | undefined {
|
||||
return useContext(FieldIssuesContext);
|
||||
}
|
||||
|
||||
/** Текст проблемы под полем, если его сейчас надо подсветить. */
|
||||
export function useFieldIssue(field: string | undefined): string | undefined {
|
||||
const context = useContext(FieldIssuesContext);
|
||||
if (!context || !field) return undefined;
|
||||
return context.visible.get(field);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/** Проблема с полем формы, из-за которой заявку нельзя сохранить/отправить. */
|
||||
export interface IFieldIssue {
|
||||
/** Ключ поля — тот же, что fieldKey/htmlFor у FormField: по нему подсветка и прокрутка. */
|
||||
field: string;
|
||||
/** Подпись в списке над кнопками — как у поля на форме, с номером. */
|
||||
label: string;
|
||||
/** Уточнение со строчной буквы («выберите вариант из списка»). Не задано — «заполните поле». */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_ISSUE_MESSAGE = 'заполните поле';
|
||||
|
||||
/** Атрибут-метка обёртки поля в FormField — по нему ищем поле для прокрутки. */
|
||||
export const FIELD_ANCHOR_ATTR = 'data-rp-field';
|
||||
/** Атрибут-метка списка проблем над кнопками — запасная цель прокрутки. */
|
||||
export const ISSUES_ANCHOR_ATTR = 'data-rp-issues';
|
||||
|
||||
const FOCUSABLE = 'input:not([type="hidden"]):not([disabled]), select, textarea, button:not([disabled])';
|
||||
|
||||
/** Строка для списка над кнопками: «1.3 Проект — выберите вариант из списка». */
|
||||
export function issueListText(issue: IFieldIssue): string {
|
||||
return issue.message ? `${issue.label} — ${issue.message}` : issue.label;
|
||||
}
|
||||
|
||||
/** Текст под полем: «Выберите вариант из списка» / «Заполните поле». */
|
||||
export function issueFieldText(issue: IFieldIssue): string {
|
||||
const text = issue.message || DEFAULT_ISSUE_MESSAGE;
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
}
|
||||
|
||||
/** Подписи проблем одной строкой — для текста ошибки: «1.4 Телефон, 1.5 Тип приёма». */
|
||||
export function issueLabels(issues: IFieldIssue[]): string {
|
||||
return issues.map((i) => i.label).join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Прокрутить к полю и поставить в него курсор. Поле может отсутствовать в DOM —
|
||||
* CollapseSection из ui-kit убирает тело свёрнутой секции целиком; тогда
|
||||
* прокручиваем к списку проблем над кнопками, там поле названо.
|
||||
*/
|
||||
export function scrollToField(field: string): void {
|
||||
const anchor =
|
||||
document.querySelector<HTMLElement>(`[${FIELD_ANCHOR_ATTR}="${field}"]`) ||
|
||||
document.querySelector<HTMLElement>(`[${ISSUES_ANCHOR_ATTR}]`);
|
||||
if (!anchor) return;
|
||||
|
||||
anchor.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
const focusable = anchor.querySelector<HTMLElement>(FOCUSABLE);
|
||||
if (focusable) {
|
||||
// preventScroll: иначе фокус мгновенно дёрнет страницу и собьёт плавную прокрутку.
|
||||
focusable.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,22 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// Незаполненное/неверное поле после нажатия «Отправить» (см. FieldIssuesProvider).
|
||||
// Полоса слева, а не рамка контрола: у пикеров, мультивыборов и переключателей
|
||||
// своей рамки нет или она во Fluent — полоса одинаково видна у любого поля.
|
||||
.invalid {
|
||||
padding-left: $sp-2;
|
||||
border-left: 3px solid $red-500;
|
||||
border-radius: $r-sm;
|
||||
}
|
||||
|
||||
.issue {
|
||||
margin-top: $sp-1;
|
||||
font-size: $fs-caption;
|
||||
color: $red-500;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
// На печати подсветка не нужна — бумажная форма показывает итоговые значения.
|
||||
@media print {
|
||||
.changed {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { FormField as KitFormField } from '@tnsu/ui-kit';
|
||||
import styles from './FormField.module.scss';
|
||||
import { useFieldChange } from '../FieldChanges/FieldChangesContext';
|
||||
import { formatDateTime } from '../FieldChanges/formatDateTime';
|
||||
import { useFieldIssue } from '../FieldIssues/FieldIssuesContext';
|
||||
import { FIELD_ANCHOR_ATTR } from '../FieldIssues/fieldIssues';
|
||||
|
||||
interface IFormFieldProps {
|
||||
label: string;
|
||||
@@ -11,30 +13,43 @@ interface IFormFieldProps {
|
||||
/** Занять обе колонки сетки. */
|
||||
full?: boolean;
|
||||
/**
|
||||
* Ключ поля в журнале правок (internalName колонки). По умолчанию — htmlFor;
|
||||
* задаётся явно у полей без htmlFor (люди, мультивыбор, переключатели).
|
||||
* Ключ поля в журнале правок (internalName колонки) и в списке проблем формы
|
||||
* (FieldIssuesProvider). По умолчанию — htmlFor; задаётся явно у полей без
|
||||
* htmlFor (люди, мультивыбор, переключатели).
|
||||
*/
|
||||
fieldKey?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* FormField из ui-kit + подсветка правки согласующего: если поле есть в
|
||||
* журнале (см. FieldChangesProvider), контрол подсвечивается, а под ним —
|
||||
* прежнее значение. Без журнала ведёт себя ровно как FormField из ui-kit.
|
||||
* FormField из ui-kit + две подсветки:
|
||||
* - правка согласующего (см. FieldChangesProvider): контрол выделен, под ним — прежнее значение;
|
||||
* - проблема поля (см. FieldIssuesProvider): красная полоса и текст под контролом.
|
||||
*
|
||||
* Обёртка с меткой поля есть всегда, а не только при ошибке: иначе при появлении
|
||||
* и исчезновении ошибки React пересоздавал бы контрол и сбрасывал фокус посреди ввода.
|
||||
*/
|
||||
export const FormField: React.FC<IFormFieldProps> = ({ fieldKey, children, ...fieldProps }) => {
|
||||
const change = useFieldChange(fieldKey ?? fieldProps.htmlFor);
|
||||
if (!change) {
|
||||
return <KitFormField {...fieldProps}>{children}</KitFormField>;
|
||||
}
|
||||
const key = fieldKey ?? fieldProps.htmlFor;
|
||||
const change = useFieldChange(key);
|
||||
const issue = useFieldIssue(key);
|
||||
const anchorProps = key ? { [FIELD_ANCHOR_ATTR]: key } : {};
|
||||
|
||||
return (
|
||||
<KitFormField {...fieldProps}>
|
||||
<div className={styles.changed}>{children}</div>
|
||||
<div className={`${styles.before} rp-no-print`} title={`Изменено: ${change.byName}, ${formatDateTime(change.at)}`}>
|
||||
<span className={styles.beforeLabel}>Было:</span> {change.before}
|
||||
<div {...anchorProps} className={issue ? styles.invalid : undefined}>
|
||||
{change ? <div className={styles.changed}>{children}</div> : children}
|
||||
</div>
|
||||
{change && (
|
||||
<div className={`${styles.before} rp-no-print`} title={`Изменено: ${change.byName}, ${formatDateTime(change.at)}`}>
|
||||
<span className={styles.beforeLabel}>Было:</span> {change.before}
|
||||
</div>
|
||||
)}
|
||||
{issue && (
|
||||
<div className={`${styles.issue} rp-no-print`} role="alert">
|
||||
{issue}
|
||||
</div>
|
||||
)}
|
||||
</KitFormField>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,8 @@ import * as React from 'react';
|
||||
import { Button, Icon, FormActions, Alert } from '@tnsu/ui-kit';
|
||||
import { IRequestSubmit } from '../useRequestSubmit';
|
||||
import { isCurrentUserTester } from './testerGroupAccess';
|
||||
import { SubmitIssues } from './SubmitIssues';
|
||||
import { useFieldIssuesControl } from '../FieldIssues/FieldIssuesContext';
|
||||
|
||||
/**
|
||||
* ВРЕМЕННАЯ ЗАМОРОЗКА (включена 2026-09-21 по просьбе Тимура Городецкого):
|
||||
@@ -65,6 +67,11 @@ export interface ISubmitActionsProps {
|
||||
// Логика доступности — здесь, в одном месте, чтобы все формы вели себя одинаково:
|
||||
// • сохранять/отправлять нельзя, пока идёт сохранение или заявка уже read-only;
|
||||
// • «Отмена» доступна всегда (возврат к списку), кроме момента сохранения.
|
||||
//
|
||||
// Форма внутри FieldIssuesProvider (см. shared/ui/FieldIssues) ведёт себя иначе:
|
||||
// кнопки сохранения не гаснут из-за незаполненных полей — нажатие при проблемах
|
||||
// ничего не сохраняет, а подсвечивает поля и прокручивает к первому. Над кнопками
|
||||
// список проблем со ссылками на поля. Формы без провайдера — как раньше, по isValid.
|
||||
export const SubmitActions: React.FC<ISubmitActionsProps> = ({
|
||||
submit,
|
||||
isValid,
|
||||
@@ -98,11 +105,33 @@ export const SubmitActions: React.FC<ISubmitActionsProps> = ({
|
||||
|
||||
const frozen = SUBMISSION_FROZEN && !unfrozenForm && !isTester;
|
||||
const locked = saving || readOnly || frozen;
|
||||
const canSubmit = submitValid ?? isValid;
|
||||
const issuesControl = useFieldIssuesControl();
|
||||
// С провайдером кнопки не гаснут по полям: проблемы показываются по нажатию.
|
||||
const canSaveDraft = issuesControl ? true : isValid;
|
||||
const canSubmit = issuesControl ? true : submitValid ?? isValid;
|
||||
|
||||
const handleSaveDraft = (): void => {
|
||||
if (issuesControl && issuesControl.draftIssues.length > 0) {
|
||||
issuesControl.reveal('draft');
|
||||
return;
|
||||
}
|
||||
onSaveDraft();
|
||||
};
|
||||
const handleSubmit = (): void => {
|
||||
if (issuesControl && issuesControl.submitIssues.length > 0) {
|
||||
issuesControl.reveal('submit');
|
||||
return;
|
||||
}
|
||||
onSubmit();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{frozen && !readOnly && <Alert variant="warn">Подача заявок временно недоступна.</Alert>}
|
||||
{/* При заморозке кнопки неактивны не из-за полей — список только сбил бы с толку. */}
|
||||
{issuesControl && !frozen && !readOnly && (
|
||||
<SubmitIssues issues={issuesControl.submitIssues} onSelect={(field) => issuesControl.reveal('submit', field)} />
|
||||
)}
|
||||
<FormActions>
|
||||
{onDelete && (
|
||||
<Button variant="danger" onClick={onDelete} disabled={saving}>
|
||||
@@ -113,11 +142,11 @@ export const SubmitActions: React.FC<ISubmitActionsProps> = ({
|
||||
<Button variant="secondary" onClick={onCancel} disabled={saving}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={onSaveDraft} disabled={!isValid || locked}>
|
||||
<Button variant="secondary" onClick={handleSaveDraft} disabled={!canSaveDraft || locked}>
|
||||
<Icon name="document" size="sm" />
|
||||
Сохранить черновик
|
||||
</Button>
|
||||
<Button variant="accent" onClick={onSubmit} disabled={!canSubmit || locked}>
|
||||
<Button variant="accent" onClick={handleSubmit} disabled={!canSubmit || locked}>
|
||||
<Icon name="send" size="sm" />
|
||||
{saving && kind === 'submit' ? 'Отправка…' : submitLabel}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.list {
|
||||
margin: $sp-1 0 0;
|
||||
padding-left: $sp-5;
|
||||
}
|
||||
|
||||
// Пункт списка — ссылка на поле: выглядит как текст с подчёркиванием, без рамки кнопки.
|
||||
.link {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
cursor: pointer;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as React from 'react';
|
||||
import { Alert } from '@tnsu/ui-kit';
|
||||
import styles from './SubmitIssues.module.scss';
|
||||
import { IFieldIssue, ISSUES_ANCHOR_ATTR, issueListText } from '../FieldIssues/fieldIssues';
|
||||
|
||||
interface ISubmitIssuesProps {
|
||||
/** Что мешает отправке — в порядке формы. */
|
||||
issues: IFieldIssue[];
|
||||
/** Клик по пункту — прокрутка к полю. */
|
||||
onSelect: (field: string) => void;
|
||||
}
|
||||
|
||||
const TITLE = 'Чтобы отправить на согласование, заполните или исправьте:';
|
||||
|
||||
/**
|
||||
* Список причин, по которым заявку пока нельзя отправить. Пункт кликабелен —
|
||||
* ведёт к полю: на длинной форме иначе пришлось бы искать его самому.
|
||||
*/
|
||||
export const SubmitIssues: React.FC<ISubmitIssuesProps> = ({ issues, onSelect }) => {
|
||||
if (issues.length === 0) return null;
|
||||
|
||||
const anchorProps = { [ISSUES_ANCHOR_ATTR]: '' };
|
||||
return (
|
||||
<div {...anchorProps}>
|
||||
<Alert variant="warn">
|
||||
<div className={styles.title}>{TITLE}</div>
|
||||
<ul className={styles.list}>
|
||||
{issues.map((issue) => (
|
||||
<li key={`${issue.field}-${issue.label}`}>
|
||||
<button type="button" className={styles.link} onClick={() => onSelect(issue.field)}>
|
||||
{issueListText(issue)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -52,6 +52,9 @@ export { OrgLetterhead } from './OrgLetterhead/OrgLetterhead';
|
||||
export { LinkField } from './LinkField/LinkField';
|
||||
export { ErrorOverlay } from './ErrorOverlay/ErrorOverlay';
|
||||
export { SubmitActions } from './SubmitActions/SubmitActions';
|
||||
export { FieldIssuesProvider } from './FieldIssues/FieldIssuesContext';
|
||||
export type { IFieldIssue } from './FieldIssues/fieldIssues';
|
||||
export { issueLabels } from './FieldIssues/fieldIssues';
|
||||
export { getDraftDeleteHandler } from './SubmitActions/getDraftDeleteHandler';
|
||||
export { InitiatorLine } from './InitiatorLine/InitiatorLine';
|
||||
export type { IInitiatorLineProps } from './InitiatorLine/InitiatorLine';
|
||||
|
||||
Reference in New Issue
Block a user