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

Кнопка отправки гасла без объяснения, если, например, указано больше 5 дней.
Форма переходит на общий FieldIssuesProvider: над кнопками список проблем,
по нажатию — подсветка поля с текстом ошибки и прокрутка к нему. Правила
вынесены в validation.ts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-08 11:55:27 +05:00
parent 2652b4694e
commit cf1a8194eb
3 changed files with 57 additions and 39 deletions
@@ -28,6 +28,8 @@
- **Не более 5 календарных дней** (`MAX_DAYS`) — при любой причине.
- **Правило «не позднее чем за 14 дней»** (`MIN_NOTICE_DAYS`) — только для «иных случаев»
(`requiresAdvanceNotice`): брак, рождение и смерть заранее не планируются.
- **Подсветка проблемных полей** — `FieldIssuesProvider` + правила в `validation.ts`
(черновик проверяется так же строго, как отправка).
- **Замещающий** (`Substitute`, People) — необязателен.
- **Заголовок элемента** (`Title`): `Отпуск без содержания — {ФИО} — дд.мм.гггг`.
@@ -3,6 +3,8 @@ import { useState, useMemo, useCallback, useEffect } from 'react';
import styles from './UnpaidLeaveForm.module.scss';
import {
Alert,
FieldIssuesProvider,
issueLabels,
FormCard,
InitiatorLine,
FormModeProvider,
@@ -36,23 +38,14 @@ import { effectiveUserEmail } from '../../shared/impersonation';
import { IRequestFormProps } from '../../models/types';
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import { isoDatePlusDays, formatIsoDate } from '../../shared/dateUtils';
import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import {
FORM_ID,
IFormData,
EMPTY_DATA,
SetField,
MIN_NOTICE_DAYS,
MAX_DAYS,
REASON_OTHER,
requiresAdvanceNotice
} from './model';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { formIssues } from './validation';
import { saveUnpaidLeaveRequest, unpaidLeaveApprovalService } from './submit';
import { loadUnpaidLeaveRequest } from './load';
import { RequestSection } from './sections/RequestSection';
@@ -192,33 +185,11 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
editId
});
// Продолжительность: от 1 до MAX_DAYS календарных дней.
const daysNum = Number(data.DaysCount);
const daysValid = Number.isFinite(daysNum) && daysNum > 0 && daysNum <= MAX_DAYS;
// В «иных случаях» заявку нужно подавать не позднее чем за MIN_NOTICE_DAYS дней
// до даты начала — календарь (min у input type=date, см. RequestSection) не даёт
// выбрать более раннюю дату, но проверяем и здесь на случай обхода UI.
const minStartDate = isoDatePlusDays(MIN_NOTICE_DAYS);
const isStartDateValid =
!data.StartDate || !requiresAdvanceNotice(data.ReasonType) || data.StartDate >= minStartDate;
// Текст причины обязателен только для «иных случаев».
const isReasonValid = !!data.ReasonType && (data.ReasonType !== REASON_OTHER || !!data.Reason.trim());
const isValid = !!(
data.Employee &&
data.Position.trim() &&
data.StartDate &&
daysValid &&
isStartDateValid &&
isReasonValid
);
// Проблемы полей (см. validation.ts) подсвечиваются под полями через FieldIssuesProvider.
const issues = formIssues(data);
const isValid = issues.length === 0;
const validate = (): string | undefined => {
if (data.StartDate && !isStartDateValid) {
return `Заявку на отпуск нужно подать не позднее чем за ${MIN_NOTICE_DAYS} дней до даты начала — минимально доступная дата: ${formatIsoDate(minStartDate)}.`;
}
if (!data.Employee || !data.Position.trim() || !data.StartDate || !isReasonValid) {
return 'Заполните обязательные поля: сотрудник, должность, дата начала, причина.';
}
if (!daysValid) return `Количество дней — от 1 до ${MAX_DAYS}.`;
if (!isValid) return `Заполните или исправьте поля: ${issueLabels(issues)}.`;
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
return undefined;
@@ -322,7 +293,7 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
});
return (
<div>
<FieldIssuesProvider draftIssues={issues} submitIssues={issues}>
<button type="button" className={styles.back} onClick={onCancel}>
<Icon name="arrow-left" size="sm" />
<span>К списку заявок</span>
@@ -389,7 +360,7 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
submitLabel="Отправить на согласование"
/>
</FormCard>
</div>
</FieldIssuesProvider>
);
};
@@ -0,0 +1,45 @@
import type { IFieldIssue } from '../../shared/ui';
import { isoDatePlusDays, formatIsoDate } from '../../shared/dateUtils';
import { IFormData, MAX_DAYS, MIN_NOTICE_DAYS, REASON_OTHER, requiresAdvanceNotice } from './model';
/** Дата начала: обязательна, а в «иных случаях» — не раньше чем через MIN_NOTICE_DAYS дней. */
function startDateIssue(data: IFormData): IFieldIssue | undefined {
if (!data.StartDate) return { field: 'StartDate', label: 'Дата начала' };
if (!requiresAdvanceNotice(data.ReasonType)) return undefined;
const minStartDate = isoDatePlusDays(MIN_NOTICE_DAYS);
if (data.StartDate >= minStartDate) return undefined;
return {
field: 'StartDate',
label: 'Дата начала',
message: `в иных случаях заявление подают не позднее чем за ${MIN_NOTICE_DAYS} дней — не раньше ${formatIsoDate(minStartDate)}`
};
}
/** Количество дней: обязательно, от 1 до MAX_DAYS. */
function daysCountIssue(data: IFormData): IFieldIssue | undefined {
if (!data.DaysCount.trim()) return { field: 'DaysCount', label: 'Количество дней' };
const days = Number(data.DaysCount);
if (Number.isFinite(days) && days > 0 && days <= MAX_DAYS) return undefined;
return { field: 'DaysCount', label: 'Количество дней', message: `от 1 до ${MAX_DAYS} календарных дней` };
}
/**
* Всё, что мешает сохранить или отправить заявку, в порядке формы (пусто — можно).
* field — ключ FormField (htmlFor): по нему поле подсвечивается и к нему
* прокручивается форма. Черновик проверяется так же строго, как отправка.
*/
export function formIssues(data: IFormData): IFieldIssue[] {
const issues: Array<IFieldIssue | undefined> = [
data.Employee ? undefined : { field: 'Employee', label: 'Сотрудник' },
data.Position.trim() ? undefined : { field: 'Position', label: 'Должность' },
startDateIssue(data),
daysCountIssue(data),
data.ReasonType
? undefined
: { field: 'UnpaidLeaveReasonType', label: 'Причина', message: 'выберите вариант из списка' },
data.ReasonType === REASON_OTHER && !data.Reason.trim() ? { field: 'Reason', label: 'Укажите причину' } : undefined
];
return issues.filter((issue): issue is IFieldIssue => issue !== undefined);
}