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`) — при любой причине.
|
- **Не более 5 календарных дней** (`MAX_DAYS`) — при любой причине.
|
||||||
- **Правило «не позднее чем за 14 дней»** (`MIN_NOTICE_DAYS`) — только для «иных случаев»
|
- **Правило «не позднее чем за 14 дней»** (`MIN_NOTICE_DAYS`) — только для «иных случаев»
|
||||||
(`requiresAdvanceNotice`): брак, рождение и смерть заранее не планируются.
|
(`requiresAdvanceNotice`): брак, рождение и смерть заранее не планируются.
|
||||||
|
- **Подсветка проблемных полей** — `FieldIssuesProvider` + правила в `validation.ts`
|
||||||
|
(черновик проверяется так же строго, как отправка).
|
||||||
- **Замещающий** (`Substitute`, People) — необязателен.
|
- **Замещающий** (`Substitute`, People) — необязателен.
|
||||||
- **Заголовок элемента** (`Title`): `Отпуск без содержания — {ФИО} — дд.мм.гггг`.
|
- **Заголовок элемента** (`Title`): `Отпуск без содержания — {ФИО} — дд.мм.гггг`.
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { useState, useMemo, useCallback, useEffect } from 'react';
|
|||||||
import styles from './UnpaidLeaveForm.module.scss';
|
import styles from './UnpaidLeaveForm.module.scss';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
FieldIssuesProvider,
|
||||||
|
issueLabels,
|
||||||
FormCard,
|
FormCard,
|
||||||
InitiatorLine,
|
InitiatorLine,
|
||||||
FormModeProvider,
|
FormModeProvider,
|
||||||
@@ -36,23 +38,14 @@ import { effectiveUserEmail } from '../../shared/impersonation';
|
|||||||
import { IRequestFormProps } from '../../models/types';
|
import { IRequestFormProps } from '../../models/types';
|
||||||
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
|
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
import { isoDatePlusDays, formatIsoDate } from '../../shared/dateUtils';
|
|
||||||
import {
|
import {
|
||||||
approvalListTitle,
|
approvalListTitle,
|
||||||
approvalListTitleForListId,
|
approvalListTitleForListId,
|
||||||
dataListTitle,
|
dataListTitle,
|
||||||
dataListTitleForListId
|
dataListTitleForListId
|
||||||
} from '../../shared/listTitles';
|
} from '../../shared/listTitles';
|
||||||
import {
|
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||||
FORM_ID,
|
import { formIssues } from './validation';
|
||||||
IFormData,
|
|
||||||
EMPTY_DATA,
|
|
||||||
SetField,
|
|
||||||
MIN_NOTICE_DAYS,
|
|
||||||
MAX_DAYS,
|
|
||||||
REASON_OTHER,
|
|
||||||
requiresAdvanceNotice
|
|
||||||
} from './model';
|
|
||||||
import { saveUnpaidLeaveRequest, unpaidLeaveApprovalService } from './submit';
|
import { saveUnpaidLeaveRequest, unpaidLeaveApprovalService } from './submit';
|
||||||
import { loadUnpaidLeaveRequest } from './load';
|
import { loadUnpaidLeaveRequest } from './load';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
@@ -192,33 +185,11 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
editId
|
editId
|
||||||
});
|
});
|
||||||
|
|
||||||
// Продолжительность: от 1 до MAX_DAYS календарных дней.
|
// Проблемы полей (см. validation.ts) подсвечиваются под полями через FieldIssuesProvider.
|
||||||
const daysNum = Number(data.DaysCount);
|
const issues = formIssues(data);
|
||||||
const daysValid = Number.isFinite(daysNum) && daysNum > 0 && daysNum <= MAX_DAYS;
|
const isValid = issues.length === 0;
|
||||||
// В «иных случаях» заявку нужно подавать не позднее чем за 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
|
|
||||||
);
|
|
||||||
const validate = (): string | undefined => {
|
const validate = (): string | undefined => {
|
||||||
if (data.StartDate && !isStartDateValid) {
|
if (!isValid) return `Заполните или исправьте поля: ${issueLabels(issues)}.`;
|
||||||
return `Заявку на отпуск нужно подать не позднее чем за ${MIN_NOTICE_DAYS} дней до даты начала — минимально доступная дата: ${formatIsoDate(minStartDate)}.`;
|
|
||||||
}
|
|
||||||
if (!data.Employee || !data.Position.trim() || !data.StartDate || !isReasonValid) {
|
|
||||||
return 'Заполните обязательные поля: сотрудник, должность, дата начала, причина.';
|
|
||||||
}
|
|
||||||
if (!daysValid) return `Количество дней — от 1 до ${MAX_DAYS}.`;
|
|
||||||
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
||||||
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -322,7 +293,7 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<FieldIssuesProvider draftIssues={issues} submitIssues={issues}>
|
||||||
<button type="button" className={styles.back} onClick={onCancel}>
|
<button type="button" className={styles.back} onClick={onCancel}>
|
||||||
<Icon name="arrow-left" size="sm" />
|
<Icon name="arrow-left" size="sm" />
|
||||||
<span>К списку заявок</span>
|
<span>К списку заявок</span>
|
||||||
@@ -389,7 +360,7 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
submitLabel="Отправить на согласование"
|
submitLabel="Отправить на согласование"
|
||||||
/>
|
/>
|
||||||
</FormCard>
|
</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