feat: подсветка ошибок полей в заявлении на отпуск без сохранения заработной платы
Кнопка отправки гасла без объяснения, если, например, указано больше 5 дней. Форма переходит на общий FieldIssuesProvider: над кнопками список проблем, по нажатию — подсветка поля с текстом ошибки и прокрутка к нему. Правила вынесены в validation.ts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user