feat: памятка HR и подсветка ошибок полей в заявках на отпуск
Tests / frontend-test (push) Successful in 2m5s

LeavePrintNotice заменён общим LeaveNotice с заголовком и пунктами из model.ts
каждой формы: в отпуске — примечание HR (ст. 92 ТК РК, срок подачи, подписание
в отделе кадров), в отпуске без содержания — прежняя памятка про печать.
Проверки отпуска вынесены в validation.ts и показываются под полями через
FieldIssuesProvider, как в приёме и подборе.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-15 15:13:02 +05:00
parent e811186cec
commit ac1b66d0df
12 changed files with 137 additions and 58 deletions
@@ -13,7 +13,7 @@ import {
SubmitActions,
getDraftDeleteHandler,
useRequestSubmit,
LeavePrintNotice
LeaveNotice
} from '../../shared/ui';
import {
composeRemoveRowRule,
@@ -46,7 +46,7 @@ import {
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { FORM_ID, IFormData, EMPTY_DATA, SetField, NOTICE_TITLE, NOTICE_ITEMS } from './model';
import { organizationRouteFieldValues } from '../../shared/dictionary/organizationRouteFields';
import { formIssues } from './validation';
import { saveUnpaidLeaveRequest, unpaidLeaveApprovalService } from './submit';
@@ -335,7 +335,7 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
)}
{submit.error && <Alert variant="err">{submit.error}</Alert>}
<LeavePrintNotice />
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
<FormModeProvider readOnly={readOnly}>
<RequestSection {...sectionProps} />
@@ -9,7 +9,7 @@ import {
FormModeProvider,
PrintSplitButton,
OrgLetterhead,
LeavePrintNotice
LeaveNotice
} from '../../shared/ui';
import {
ApproverService,
@@ -31,7 +31,7 @@ import {
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { FORM_ID, IFormData, EMPTY_DATA, SetField, NOTICE_TITLE, NOTICE_ITEMS } from './model';
import { loadUnpaidLeaveRequest } from './load';
import { unpaidLeaveApprovalService } from './submit';
import { RequestSection } from './sections/RequestSection';
@@ -156,7 +156,7 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
}
headerRight={status ? <StatusBadge status={status} /> : undefined}
>
<LeavePrintNotice />
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
<FormModeProvider readOnly>
<RequestSection {...sectionProps} />
</FormModeProvider>
@@ -95,6 +95,13 @@ export const SUBSTITUTE_PLACEHOLDER = 'Начните вводить Ф.И.О.
*/
export const MIN_NOTICE_DAYS = 14;
/** Памятка вверху формы и просмотра (см. shared/ui/LeaveNotice). Текст — по просьбе HR. */
export const NOTICE_TITLE = 'Памятка';
export const NOTICE_ITEMS: string[] = [
'После согласования заявления его необходимо распечатать с портала (кнопка «Печать» на странице заявки), ' +
'собственноручно подписать и передать специалисту отдела кадров.'
];
/** Причины отпуска. Значения совпадают с choices колонки UnpaidLeaveReasonType. */
export const REASON_MARRIAGE = 'Регистрация брака';
export const REASON_CHILD_BIRTH = 'Рождение ребенка';
@@ -3,6 +3,8 @@ import { useState, useMemo, useCallback, useEffect } from 'react';
import styles from './VacationForm.module.scss';
import {
Alert,
FieldIssuesProvider,
issueLabels,
FormCard,
InitiatorLine,
FormModeProvider,
@@ -11,7 +13,7 @@ import {
SubmitActions,
getDraftDeleteHandler,
useRequestSubmit,
LeavePrintNotice
LeaveNotice
} from '../../shared/ui';
import {
composeRemoveRowRule,
@@ -38,14 +40,14 @@ import { effectiveUserEmail } from '../../shared/impersonation';
import { IRequestFormProps } from '../../models/types';
import { getEmployeeOrganizations, formatEmployeeOrganizationsWithDepartment, IEmployeeOrganization } 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 } from './model';
import { FORM_ID, IFormData, EMPTY_DATA, SetField, NOTICE_TITLE, NOTICE_ITEMS } from './model';
import { formIssues } from './validation';
import { organizationRouteFieldValues } from '../../shared/dictionary/organizationRouteFields';
import { saveVacationRequest, vacationApprovalService } from './submit';
import { loadVacationRequest } from './load';
@@ -207,27 +209,11 @@ const VacationForm: React.FC<IRequestFormProps> = ({
fieldValues: routeFieldValues
});
const daysNum = Number(data.DaysCount);
// Заявку нужно подавать не позднее чем за MIN_NOTICE_DAYS дней до даты начала
// отпуска — календарь (min у input type=date, см. RequestSection) не даёт
// выбрать более раннюю дату, но проверяем и здесь на случай обхода UI.
const minStartDate = isoDatePlusDays(MIN_NOTICE_DAYS);
const isStartDateValid = !data.StartDate || data.StartDate >= minStartDate;
const isDaysCountTooLarge = Number.isFinite(daysNum) && daysNum > MAX_DAYS;
const isValid = !!(
data.Employee &&
data.StartDate &&
isStartDateValid &&
Number.isFinite(daysNum) &&
daysNum > 0 &&
!isDaysCountTooLarge
);
// Проблемы полей (см. 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 (isDaysCountTooLarge) return `Отпуск — не более ${MAX_DAYS} календарных дней в одной заявке.`;
if (!isValid) return 'Заполните обязательные поля: сотрудник, дата начала, количество дней (> 0).';
if (!isValid) return `Заполните или исправьте поля: ${issueLabels(issues)}.`;
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
return undefined;
@@ -337,7 +323,7 @@ const VacationForm: 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>
@@ -368,7 +354,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
)}
{submit.error && <Alert variant="err">{submit.error}</Alert>}
<LeavePrintNotice />
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
<FormModeProvider readOnly={readOnly}>
<RequestSection {...sectionProps} />
@@ -406,7 +392,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
submitLabel="Отправить на согласование"
/>
</FormCard>
</div>
</FieldIssuesProvider>
);
};
@@ -10,7 +10,7 @@ import {
FormModeProvider,
PrintSplitButton,
OrgLetterhead,
LeavePrintNotice
LeaveNotice
} from '../../shared/ui';
import {
ApproverService,
@@ -36,7 +36,7 @@ import {
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { FORM_ID, IFormData, EMPTY_DATA, SetField, NOTICE_TITLE, NOTICE_ITEMS } from './model';
import { loadVacationRequest } from './load';
import { vacationApprovalService } from './submit';
import { IVacationSigningResult, sendVacationForSigning } from './sendToSign';
@@ -217,7 +217,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
}
headerRight={status ? <StatusBadge status={status} /> : undefined}
>
<LeavePrintNotice />
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
<FormModeProvider readOnly>
<RequestSection {...sectionProps} />
</FormModeProvider>
@@ -82,5 +82,22 @@ export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. с
export const MIN_NOTICE_DAYS = 7;
/** Ежегодный трудовой отпуск — не более стольких календарных дней в одной заявке (требование HR). */
export const MAX_DAYS = 24;
/**
* Примечание вверху формы и просмотра (см. shared/ui/LeaveNotice). Текст — от HR,
* дословно. «Одну календарную неделю» — это MIN_NOTICE_DAYS: поменяли срок — поправьте и текст.
* Правило про часть не менее 14 дней только показываем, а не проверяем: форма не знает,
* брал ли сотрудник уже часть отпуска.
*/
export const NOTICE_TITLE = 'Примечание';
export const NOTICE_ITEMS: string[] = [
'Согласно пункту 3 статьи 92 Трудового кодекса Республики Казахстан, оплачиваемый ежегодный трудовой отпуск ' +
'может быть разделен на части по соглашению сторон, но одна из частей должна составлять не менее 14 календарных дней.',
'Заявление на оплачиваемый ежегодный трудовой отпуск направляется не менее чем за одну календарную неделю ' +
'до начала отпуска.',
'После согласования заявления на оплачиваемый ежегодный трудовой отпуск по маршруту на портале сотрудник ' +
'должен подойти в отдел кадров для подписания заявления на бумажном носителе.'
];
/** Плейсхолдер для поля выбора замещающего. */
export const SUBSTITUTE_PLACEHOLDER = 'Начните вводить Ф.И.О. замещающего…';
@@ -45,6 +45,7 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
onChange={(v) => set('DaysCount', v)}
min={1}
max={MAX_DAYS}
placeholder={`от 1 до ${MAX_DAYS}`}
/>
</FormField>
<FormField label="Лицо, замещающее на время отсутствия" htmlFor="Substitute" full>
@@ -0,0 +1,43 @@
import type { IFieldIssue } from '../../shared/ui';
import { isoDatePlusDays, formatIsoDate } from '../../shared/dateUtils';
import { IFormData, MAX_DAYS, MIN_NOTICE_DAYS } from './model';
/**
* Дата начала: обязательна и не раньше чем через MIN_NOTICE_DAYS дней. Календарь
* (min у input type=date, см. RequestSection) раннюю дату выбрать не даст,
* но её можно вписать с клавиатуры — поэтому проверяем и здесь.
*/
function startDateIssue(data: IFormData): IFieldIssue | undefined {
if (!data.StartDate) return { field: 'StartDate', label: 'Дата начала' };
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.isInteger(days) && days >= 1 && 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: 'Сотрудник' },
startDateIssue(data),
daysCountIssue(data)
];
return issues.filter((issue): issue is IFieldIssue => issue !== undefined);
}
@@ -0,0 +1,8 @@
.list {
margin: 6px 0 0;
padding-left: 20px;
}
.item + .item {
margin-top: 4px;
}
@@ -0,0 +1,39 @@
import * as React from 'react';
import { Alert } from '@tnsu/ui-kit';
import styles from './LeaveNotice.module.scss';
interface Props {
/** Заголовок плашки, например «Примечание» или «Памятка». */
title: string;
/** Пункты текста. Один пункт — идёт сразу за заголовком, несколько — списком. */
items: string[];
}
/**
* Плашка с правилами для заявлений на отпуск (трудовой и без содержания).
* Тексты у форм разные, поэтому приходят из model.ts каждой формы.
* Вариант warn, а не info: нейтральная плашка терялась на фоне формы.
* На печать не выводится — это подсказка для сотрудника, а не часть заявления.
*/
export const LeaveNotice: React.FC<Props> = ({ title, items }) => {
if (items.length === 0) return null;
return (
<div className="rp-no-print">
<Alert variant="warn">
<strong>{title}.</strong>
{items.length === 1 ? (
<> {items[0]}</>
) : (
<ul className={styles.list}>
{items.map((item) => (
<li key={item} className={styles.item}>
{item}
</li>
))}
</ul>
)}
</Alert>
</div>
);
};
@@ -1,22 +0,0 @@
import * as React from 'react';
import { Alert } from '@tnsu/ui-kit';
/** Пользовательские тексты памятки — в одном месте (задел под локализацию). */
const NOTICE_TITLE = 'Памятка';
const NOTICE_TEXT =
'После согласования заявления его необходимо распечатать с портала (кнопка «Печать» на странице заявки), ' +
'собственноручно подписать и передать специалисту отдела кадров.';
/**
* Памятка для заявлений на отпуск (трудовой и без содержания): что сделать после
* согласования. Подписанный бумажный экземпляр нужен отделу кадров, поэтому
* напоминаем и при подаче, и при просмотре. На печать не выводится.
* Вариант warn, а не info: нейтральная плашка терялась на фоне формы.
*/
export const LeavePrintNotice: React.FC = () => (
<div className="rp-no-print">
<Alert variant="warn">
<strong>{NOTICE_TITLE}.</strong> {NOTICE_TEXT}
</Alert>
</div>
);
@@ -59,6 +59,6 @@ export { issueLabels } from './FieldIssues/fieldIssues';
export { getDraftDeleteHandler } from './SubmitActions/getDraftDeleteHandler';
export { InitiatorLine } from './InitiatorLine/InitiatorLine';
export type { IInitiatorLineProps } from './InitiatorLine/InitiatorLine';
export { LeavePrintNotice } from './LeavePrintNotice/LeavePrintNotice';
export { LeaveNotice } from './LeaveNotice/LeaveNotice';
export { useRequestSubmit } from './useRequestSubmit';
export type { IRequestSubmit, SubmitKind } from './useRequestSubmit';