feat: выбор причины и лимит 5 дней в заявлении на отпуск без сохранения заработной платы

Вместо свободного «В связи с» — обязательный выбор причины (брак, рождение
ребёнка, смерть близких, иные случаи); текст причины нужен только для иных
случаев. Отпуск — не более 5 календарных дней. Подавать за 14 дней нужно
только в иных случаях: остальные события заранее не планируются.
Причина хранится в новой Choice-колонке UnpaidLeaveReasonType.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-08 11:10:58 +05:00
parent 7217d8bee6
commit 2652b4694e
7 changed files with 118 additions and 22 deletions
@@ -21,15 +21,22 @@
для текущего пользователя при создании и при выборе другого сотрудника. для текущего пользователя при создании и при выборе другого сотрудника.
- **Период вводится двумя датами** (с / по), обе обязательны, конец ≥ начала. - **Период вводится двумя датами** (с / по), обе обязательны, конец ≥ начала.
`DaysCount` считается при сохранении (включительно) для отчётности — в UI не показывается. `DaysCount` считается при сохранении (включительно) для отчётности — в UI не показывается.
- **Причина** (`UnpaidLeaveReasonType`, Choice) — обязательна: брак / рождение ребёнка /
смерть близких / «В иных случаях». Только при «иных случаях» появляется обязательный
текст причины (`Reason`). У заявок до появления колонки причины нет — их прежний
текст из `Reason` по-прежнему показывается (`isReasonTextShown`).
- **Не более 5 календарных дней** (`MAX_DAYS`) — при любой причине.
- **Правило «не позднее чем за 14 дней»** (`MIN_NOTICE_DAYS`) — только для «иных случаев»
(`requiresAdvanceNotice`): брак, рождение и смерть заранее не планируются.
- **Замещающий** (`Substitute`, People) — необязателен. - **Замещающий** (`Substitute`, People) — необязателен.
- **Заголовок элемента** (`Title`): `Отпуск без содержания — {ФИО} — дд.мм.гггг`. - **Заголовок элемента** (`Title`): `Отпуск без содержания — {ФИО} — дд.мм.гггг`.
## Отличия от «Заявка на отпуск» (hr-vacation) ## Отличия от «Заявка на отпуск» (hr-vacation)
- Те же «дата начала + количество дней»: даты окончания нет, праздники и выходные не считаются. - Те же «дата начала + количество дней»: даты окончания нет, праздники и выходные не считаются.
- Добавлены поля «В связи с» (`Reason`) и «Лицо замещающее» (`Substitute`). - Добавлены поля «Причина» (`UnpaidLeaveReasonType` + `Reason`) и «Лицо замещающее» (`Substitute`).
## Секции формы ## Секции формы
RequestSection (сотрудник · должность · дата начала · количество дней · основание · замещающий) RequestSection (сотрудник · должность · дата начала · количество дней · причина · замещающий)
## Точки входа ## Точки входа
- rptype: `hr-unpaid-leave` - rptype: `hr-unpaid-leave`
@@ -43,7 +43,16 @@ import {
dataListTitle, dataListTitle,
dataListTitleForListId dataListTitleForListId
} from '../../shared/listTitles'; } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField, MIN_NOTICE_DAYS } from './model'; import {
FORM_ID,
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';
@@ -183,30 +192,33 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
editId editId
}); });
// Продолжительность: целое положительное число календарных дней. // Продолжительность: от 1 до MAX_DAYS календарных дней.
const daysNum = Number(data.DaysCount); const daysNum = Number(data.DaysCount);
const daysValid = Number.isFinite(daysNum) && daysNum > 0; const daysValid = Number.isFinite(daysNum) && daysNum > 0 && daysNum <= MAX_DAYS;
// Заявку нужно подавать не позднее чем за MIN_NOTICE_DAYS дней до даты начала // В «иных случаях» заявку нужно подавать не позднее чем за MIN_NOTICE_DAYS дней
// отпуска — календарь (min у input type=date, см. RequestSection) не даёт // до даты начала — календарь (min у input type=date, см. RequestSection) не даёт
// выбрать более раннюю дату, но проверяем и здесь на случай обхода UI. // выбрать более раннюю дату, но проверяем и здесь на случай обхода UI.
const minStartDate = isoDatePlusDays(MIN_NOTICE_DAYS); const minStartDate = isoDatePlusDays(MIN_NOTICE_DAYS);
const isStartDateValid = !data.StartDate || data.StartDate >= minStartDate; const isStartDateValid =
!data.StartDate || !requiresAdvanceNotice(data.ReasonType) || data.StartDate >= minStartDate;
// Текст причины обязателен только для «иных случаев».
const isReasonValid = !!data.ReasonType && (data.ReasonType !== REASON_OTHER || !!data.Reason.trim());
const isValid = !!( const isValid = !!(
data.Employee && data.Employee &&
data.Position.trim() && data.Position.trim() &&
data.StartDate && data.StartDate &&
daysValid && daysValid &&
isStartDateValid && isStartDateValid &&
data.Reason.trim() isReasonValid
); );
const validate = (): string | undefined => { const validate = (): string | undefined => {
if (data.StartDate && !isStartDateValid) { if (data.StartDate && !isStartDateValid) {
return `Заявку на отпуск нужно подать не позднее чем за ${MIN_NOTICE_DAYS} дней до даты начала — минимально доступная дата: ${formatIsoDate(minStartDate)}.`; return `Заявку на отпуск нужно подать не позднее чем за ${MIN_NOTICE_DAYS} дней до даты начала — минимально доступная дата: ${formatIsoDate(minStartDate)}.`;
} }
if (!data.Employee || !data.Position.trim() || !data.StartDate || !data.Reason.trim()) { if (!data.Employee || !data.Position.trim() || !data.StartDate || !isReasonValid) {
return 'Заполните обязательные поля: сотрудник, должность, дата начала, основание.'; return 'Заполните обязательные поля: сотрудник, должность, дата начала, причина.';
} }
if (!daysValid) 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;
@@ -29,6 +29,7 @@ export async function loadUnpaidLeaveRequest(itemId: number, listId?: string): P
'Position', 'Position',
'StartDate', 'StartDate',
'DaysCount', 'DaysCount',
'UnpaidLeaveReasonType',
'Reason', 'Reason',
'Organization', 'Organization',
'Department', 'Department',
@@ -54,6 +55,7 @@ export async function loadUnpaidLeaveRequest(itemId: number, listId?: string): P
Position: (item.Position as string) || '', Position: (item.Position as string) || '',
StartDate: spDateOnlyToIso(start), StartDate: spDateOnlyToIso(start),
DaysCount: days === null || days === undefined ? '' : String(days), DaysCount: days === null || days === undefined ? '' : String(days),
ReasonType: (item.UnpaidLeaveReasonType as string) || '',
Reason: (item.Reason as string) || '', Reason: (item.Reason as string) || '',
Substitute: substitute?.EMail || '', Substitute: substitute?.EMail || '',
Organization: (item.Organization as string) || '', Organization: (item.Organization as string) || '',
@@ -1,5 +1,6 @@
import { WebPartContext } from '@microsoft/sp-webpart-base'; import { WebPartContext } from '@microsoft/sp-webpart-base';
import { RequestTypeId } from '../../models/types'; import { RequestTypeId } from '../../models/types';
import { ISelectOption } from '../../shared/ui';
/** /**
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков * Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
@@ -23,7 +24,12 @@ export interface IFormData {
StartDate: string; StartDate: string;
/** Количество календарных дней (строка из input type=number). */ /** Количество календарных дней (строка из input type=number). */
DaysCount: string; DaysCount: string;
/** Основание «В связи с …». */ /** Причина отпуска — одно из UNPAID_LEAVE_REASONS (Choice-колонка). '' — не выбрана или заявка подана до появления поля. */
ReasonType: string;
/**
* Свободный текст причины — заполняется только при REASON_OTHER. У заявок,
* поданных до появления ReasonType, здесь лежит прежнее основание «В связи с …».
*/
Reason: string; Reason: string;
/** E-mail замещающего на время отсутствия (People). Пусто = не выбран. */ /** E-mail замещающего на время отсутствия (People). Пусто = не выбран. */
Substitute: string; Substitute: string;
@@ -69,6 +75,7 @@ export const EMPTY_DATA: IFormData = {
Position: '', Position: '',
StartDate: '', StartDate: '',
DaysCount: '', DaysCount: '',
ReasonType: '',
Reason: '', Reason: '',
Substitute: '', Substitute: '',
Organization: '', Organization: '',
@@ -82,5 +89,40 @@ export const EMPTY_DATA: IFormData = {
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…'; export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
/** Плейсхолдер для поля выбора замещающего. */ /** Плейсхолдер для поля выбора замещающего. */
export const SUBSTITUTE_PLACEHOLDER = 'Начните вводить Ф.И.О. замещающего…'; export const SUBSTITUTE_PLACEHOLDER = 'Начните вводить Ф.И.О. замещающего…';
/** Заявку нужно подавать не позднее чем за столько дней до даты начала отпуска. */ /**
* Заявку нужно подавать не позднее чем за столько дней до даты начала отпуска.
* Действует только для «иных случаев» — см. requiresAdvanceNotice.
*/
export const MIN_NOTICE_DAYS = 14; export const MIN_NOTICE_DAYS = 14;
/** Отпуск без сохранения заработной платы — не более стольких календарных дней (по любой причине). */
export const MAX_DAYS = 5;
/** Причины отпуска. Значения совпадают с choices колонки UnpaidLeaveReasonType. */
export const REASON_MARRIAGE = 'Регистрация брака';
export const REASON_CHILD_BIRTH = 'Рождение ребенка';
export const REASON_RELATIVE_DEATH =
'Смерть близких родственников, а также супруга (супруги) и (или) их свойственников (полнородные и неполнородные братья и сестры, родители (родитель), дети, дедушка, бабушка, внуки)';
export const REASON_OTHER = 'В иных случаях';
export const UNPAID_LEAVE_REASONS: ISelectOption[] = [
{ value: REASON_MARRIAGE, label: REASON_MARRIAGE },
{ value: REASON_CHILD_BIRTH, label: REASON_CHILD_BIRTH },
{ value: REASON_RELATIVE_DEATH, label: REASON_RELATIVE_DEATH },
{ value: REASON_OTHER, label: REASON_OTHER }
];
/**
* Предупреждать за MIN_NOTICE_DAYS нужно только в «иных случаях»: брак, рождение
* ребёнка и смерть близких заранее не планируются.
*/
export function requiresAdvanceNotice(reasonType: string): boolean {
return reasonType === REASON_OTHER;
}
/**
* Показывать ли поле свободного текста причины: при «иных случаях» и у старых
* заявок без ReasonType, где в Reason лежит прежнее основание — чтобы его не потерять.
*/
export function isReasonTextShown(data: IFormData): boolean {
return data.ReasonType === REASON_OTHER || (!data.ReasonType && !!data.Reason.trim());
}
@@ -8,6 +8,7 @@
{ "internalName": "Position", "title": "Должность", "type": "Text" }, { "internalName": "Position", "title": "Должность", "type": "Text" },
{ "internalName": "StartDate", "title": "Дата начала", "type": "DateTime", "dateOnly": true }, { "internalName": "StartDate", "title": "Дата начала", "type": "DateTime", "dateOnly": true },
{ "internalName": "DaysCount", "title": "Количество дней", "type": "Number" }, { "internalName": "DaysCount", "title": "Количество дней", "type": "Number" },
{ "internalName": "UnpaidLeaveReasonType", "title": "Причина", "type": "Choice", "choices": ["Регистрация брака", "Рождение ребенка", "Смерть близких родственников, а также супруга (супруги) и (или) их свойственников (полнородные и неполнородные братья и сестры, родители (родитель), дети, дедушка, бабушка, внуки)", "В иных случаях"] },
{ "internalName": "Reason", "title": "Основание (в связи с)", "type": "Note" }, { "internalName": "Reason", "title": "Основание (в связи с)", "type": "Note" },
{ "internalName": "Substitute", "title": "Лицо, замещающее на время отсутствия", "type": "User", "userSelectionMode": "PeopleOnly" }, { "internalName": "Substitute", "title": "Лицо, замещающее на время отсутствия", "type": "User", "userSelectionMode": "PeopleOnly" },
{ "internalName": "Organization", "title": "Организация", "type": "Note" }, { "internalName": "Organization", "title": "Организация", "type": "Note" },
@@ -1,10 +1,20 @@
import * as React from 'react'; import * as React from 'react';
import { FormGrid, FormField, TextInput, TextArea, PeopleField, OrganizationField } from '../../../shared/ui'; import { FormGrid, FormField, TextInput, TextArea, Select, PeopleField, OrganizationField } from '../../../shared/ui';
import { DepartmentPicker, ProjectPicker } from '../../../shared/dictionary'; import { DepartmentPicker, ProjectPicker } from '../../../shared/dictionary';
import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS } from '../model'; import {
ISectionProps,
EMPLOYEE_PLACEHOLDER,
SUBSTITUTE_PLACEHOLDER,
MIN_NOTICE_DAYS,
MAX_DAYS,
REASON_OTHER,
UNPAID_LEAVE_REASONS,
requiresAdvanceNotice,
isReasonTextShown
} from '../model';
import { isoDatePlusDays } from '../../../shared/dateUtils'; import { isoDatePlusDays } from '../../../shared/dateUtils';
/** Единственная секция формы «Отпуск без содержания»: сотрудник, должность, период, основание, замещающий. */ /** Единственная секция формы «Отпуск без содержания»: сотрудник, должность, период, причина, замещающий. */
export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange, printOrgIndex }) => ( export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange, printOrgIndex }) => (
<FormGrid> <FormGrid>
<FormField label="Сотрудник" htmlFor="Employee" required full> <FormField label="Сотрудник" htmlFor="Employee" required full>
@@ -48,15 +58,36 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
type="date" type="date"
value={data.StartDate} value={data.StartDate}
onChange={(v) => set('StartDate', v)} onChange={(v) => set('StartDate', v)}
min={isoDatePlusDays(MIN_NOTICE_DAYS)} min={requiresAdvanceNotice(data.ReasonType) ? isoDatePlusDays(MIN_NOTICE_DAYS) : undefined}
/> />
</FormField> </FormField>
<FormField label="Количество дней" htmlFor="DaysCount" required> <FormField label={`Количество дней (не более ${MAX_DAYS})`} htmlFor="DaysCount" required>
<TextInput id="DaysCount" type="number" value={data.DaysCount} onChange={(v) => set('DaysCount', v)} /> <TextInput
id="DaysCount"
type="number"
value={data.DaysCount}
onChange={(v) => set('DaysCount', v)}
min={1}
max={MAX_DAYS}
/>
</FormField> </FormField>
<FormField label="В связи с" htmlFor="Reason" required full> <FormField label="Причина" htmlFor="UnpaidLeaveReasonType" required full>
<Select
id="UnpaidLeaveReasonType"
value={data.ReasonType}
onChange={(v) => {
set('ReasonType', v);
if (v !== REASON_OTHER) set('Reason', '');
}}
options={UNPAID_LEAVE_REASONS}
placeholder="— выберите —"
/>
</FormField>
{isReasonTextShown(data) && (
<FormField label="Укажите причину" htmlFor="Reason" required full>
<TextArea id="Reason" value={data.Reason} onChange={(v) => set('Reason', v)} /> <TextArea id="Reason" value={data.Reason} onChange={(v) => set('Reason', v)} />
</FormField> </FormField>
)}
<FormField label="Лицо, замещающее на время отсутствия" htmlFor="Substitute" full> <FormField label="Лицо, замещающее на время отсутствия" htmlFor="Substitute" full>
<PeopleField <PeopleField
context={context} context={context}
@@ -38,6 +38,7 @@ function buildItem(
Position: data.Position || null, Position: data.Position || null,
StartDate: data.StartDate || null, StartDate: data.StartDate || null,
DaysCount: Number.isFinite(days) && days > 0 ? days : null, DaysCount: Number.isFinite(days) && days > 0 ? days : null,
UnpaidLeaveReasonType: data.ReasonType || null,
Reason: data.Reason || null, Reason: data.Reason || null,
SubstituteId: substituteId ?? null, SubstituteId: substituteId ?? null,
Organization: data.Organization || null, Organization: data.Organization || null,