diff --git a/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx b/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx index d297ae7..50f4518 100644 --- a/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx +++ b/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx @@ -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 = ({ )} {submit.error && {submit.error}} - + diff --git a/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveView.tsx b/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveView.tsx index 365ac75..27ca2b4 100644 --- a/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveView.tsx +++ b/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveView.tsx @@ -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 = ({ context, itemId, for } headerRight={status ? : undefined} > - + diff --git a/src/webparts/requestPortal/forms/UnpaidLeave/model.ts b/src/webparts/requestPortal/forms/UnpaidLeave/model.ts index 2537211..bd0a321 100644 --- a/src/webparts/requestPortal/forms/UnpaidLeave/model.ts +++ b/src/webparts/requestPortal/forms/UnpaidLeave/model.ts @@ -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 = 'Рождение ребенка'; diff --git a/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx b/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx index b271c99..18e24c7 100644 --- a/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx +++ b/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx @@ -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 = ({ 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 = ({ }); return ( -
+
+ ); }; diff --git a/src/webparts/requestPortal/forms/Vacation/VacationView.tsx b/src/webparts/requestPortal/forms/Vacation/VacationView.tsx index 9a8113e..fede67b 100644 --- a/src/webparts/requestPortal/forms/Vacation/VacationView.tsx +++ b/src/webparts/requestPortal/forms/Vacation/VacationView.tsx @@ -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 = ({ context, itemId, formKey, } headerRight={status ? : undefined} > - + diff --git a/src/webparts/requestPortal/forms/Vacation/model.ts b/src/webparts/requestPortal/forms/Vacation/model.ts index 2cf15d8..3f55b10 100644 --- a/src/webparts/requestPortal/forms/Vacation/model.ts +++ b/src/webparts/requestPortal/forms/Vacation/model.ts @@ -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 = 'Начните вводить Ф.И.О. замещающего…'; diff --git a/src/webparts/requestPortal/forms/Vacation/sections/RequestSection.tsx b/src/webparts/requestPortal/forms/Vacation/sections/RequestSection.tsx index 6f9baba..8c2603c 100644 --- a/src/webparts/requestPortal/forms/Vacation/sections/RequestSection.tsx +++ b/src/webparts/requestPortal/forms/Vacation/sections/RequestSection.tsx @@ -45,6 +45,7 @@ export const RequestSection: React.FC = ({ data, set, context, on onChange={(v) => set('DaysCount', v)} min={1} max={MAX_DAYS} + placeholder={`от 1 до ${MAX_DAYS}`} /> diff --git a/src/webparts/requestPortal/forms/Vacation/validation.ts b/src/webparts/requestPortal/forms/Vacation/validation.ts new file mode 100644 index 0000000..9e82091 --- /dev/null +++ b/src/webparts/requestPortal/forms/Vacation/validation.ts @@ -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 = [ + data.Employee ? undefined : { field: 'Employee', label: 'Сотрудник' }, + startDateIssue(data), + daysCountIssue(data) + ]; + return issues.filter((issue): issue is IFieldIssue => issue !== undefined); +} diff --git a/src/webparts/requestPortal/shared/ui/LeaveNotice/LeaveNotice.module.scss b/src/webparts/requestPortal/shared/ui/LeaveNotice/LeaveNotice.module.scss new file mode 100644 index 0000000..8771e3e --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/LeaveNotice/LeaveNotice.module.scss @@ -0,0 +1,8 @@ +.list { + margin: 6px 0 0; + padding-left: 20px; +} + +.item + .item { + margin-top: 4px; +} diff --git a/src/webparts/requestPortal/shared/ui/LeaveNotice/LeaveNotice.tsx b/src/webparts/requestPortal/shared/ui/LeaveNotice/LeaveNotice.tsx new file mode 100644 index 0000000..7c44c7b --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/LeaveNotice/LeaveNotice.tsx @@ -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 = ({ title, items }) => { + if (items.length === 0) return null; + + return ( +
+ + {title}. + {items.length === 1 ? ( + <> {items[0]} + ) : ( +
    + {items.map((item) => ( +
  • + {item} +
  • + ))} +
+ )} +
+
+ ); +}; diff --git a/src/webparts/requestPortal/shared/ui/LeavePrintNotice/LeavePrintNotice.tsx b/src/webparts/requestPortal/shared/ui/LeavePrintNotice/LeavePrintNotice.tsx deleted file mode 100644 index 74ec1e0..0000000 --- a/src/webparts/requestPortal/shared/ui/LeavePrintNotice/LeavePrintNotice.tsx +++ /dev/null @@ -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 = () => ( -
- - {NOTICE_TITLE}. {NOTICE_TEXT} - -
-); diff --git a/src/webparts/requestPortal/shared/ui/index.ts b/src/webparts/requestPortal/shared/ui/index.ts index 16916ff..75ec2e9 100644 --- a/src/webparts/requestPortal/shared/ui/index.ts +++ b/src/webparts/requestPortal/shared/ui/index.ts @@ -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';