feat: памятка HR и подсветка ошибок полей в заявках на отпуск
Tests / frontend-test (push) Successful in 2m5s
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:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user