Отпуск и отпуск без содержания: без даты окончания — начало и количество дней; EndDate убрана из провижининга, кода и SharePoint (из типа контента и представления)
Tests / frontend-test (pull_request) Successful in 1m36s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-10-05 11:49:37 +05:00
parent af5a91c092
commit 74de2b441e
14 changed files with 29 additions and 57 deletions
@@ -36,7 +36,7 @@ RequestTypeId» в `docs/forms.md`). Тип уже зарегистрирова
- **Сумма обучения** — `MoneyInput` (разделитель тысяч в UI, в списке — «сырое»
число), колонка `TrainingAmount` (Number, 2 знака), поле необязательное.
- **Дата расторжения договора** — обычный `input type=date`, без вычислений
(в отличие от `EndDate` в «Заявке на отпуск», которая считается).
(в отличие от даты окончания отпуска, которой нет совсем).
- **Заголовок элемента** (`Title`): `Обходной лист — {ФИО} — дд.мм.гггг`.
## Связь с заявлением на увольнение
@@ -25,11 +25,11 @@
- **Заголовок элемента** (`Title`): `Отпуск без содержания — {ФИО} — дд.мм.гггг`.
## Отличия от «Заявка на отпуск» (hr-vacation)
- Даты с/по вместо «дата начала + количество дней».
- Те же «дата начала + количество дней»: даты окончания нет, праздники и выходные не считаются.
- Добавлены поля «В связи с» (`Reason`) и «Лицо замещающее» (`Substitute`).
## Секции формы
RequestSection (сотрудник · должность · дата начала · дата окончания · основание · замещающий)
RequestSection (сотрудник · должность · дата начала · количество дней · основание · замещающий)
## Точки входа
- rptype: `hr-unpaid-leave`
@@ -183,8 +183,9 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
editId
});
// Даты корректны, если конец не раньше начала (обе обязательны).
const datesValid = !!(data.StartDate && data.EndDate && data.EndDate >= data.StartDate);
// Продолжительность: целое положительное число календарных дней.
const daysNum = Number(data.DaysCount);
const daysValid = Number.isFinite(daysNum) && daysNum > 0;
// Заявку нужно подавать не позднее чем за MIN_NOTICE_DAYS дней до даты начала
// отпуска — календарь (min у input type=date, см. RequestSection) не даёт
// выбрать более раннюю дату, но проверяем и здесь на случай обхода UI.
@@ -193,7 +194,8 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
const isValid = !!(
data.Employee &&
data.Position.trim() &&
datesValid &&
data.StartDate &&
daysValid &&
isStartDateValid &&
data.Reason.trim()
);
@@ -201,10 +203,10 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
if (data.StartDate && !isStartDateValid) {
return `Заявку на отпуск нужно подать не позднее чем за ${MIN_NOTICE_DAYS} дней до даты начала — минимально доступная дата: ${formatIsoDate(minStartDate)}.`;
}
if (!data.Employee || !data.Position.trim() || !data.StartDate || !data.EndDate || !data.Reason.trim()) {
return 'Заполните обязательные поля: сотрудник, должность, дата начала, дата окончания, основание.';
if (!data.Employee || !data.Position.trim() || !data.StartDate || !data.Reason.trim()) {
return 'Заполните обязательные поля: сотрудник, должность, дата начала, основание.';
}
if (!datesValid) return 'Дата окончания не может быть раньше даты начала.';
if (!daysValid) return 'Количество дней должно быть больше нуля.';
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
return undefined;
@@ -27,7 +27,7 @@ export async function loadUnpaidLeaveRequest(itemId: number, listId?: string): P
'Id',
'Position',
'StartDate',
'EndDate',
'DaysCount',
'Reason',
'Organization',
'Department',
@@ -45,14 +45,14 @@ export async function loadUnpaidLeaveRequest(itemId: number, listId?: string): P
const employee = item.Employee as { Title?: string; EMail?: string } | undefined;
const substitute = item.Substitute as { EMail?: string } | undefined;
const start = (item.StartDate as string) || '';
const end = (item.EndDate as string) || '';
const days = item.DaysCount;
const data: IFormData = {
...EMPTY_DATA,
Employee: employee?.EMail || '',
Position: (item.Position as string) || '',
StartDate: start.slice(0, 10), // ISO → YYYY-MM-DD для input type=date
EndDate: end.slice(0, 10),
DaysCount: days === null || days === undefined ? '' : String(days),
Reason: (item.Reason as string) || '',
Substitute: substitute?.EMail || '',
Organization: (item.Organization as string) || '',
@@ -12,8 +12,8 @@ export const FORM_ID: RequestTypeId = 'hr-unpaid-leave';
* по возможности = internalName колонок списка данных (см. shared/listTitles.ts).
* Employee/Substitute хранят e-mail выбранного сотрудника (то, что отдаёт
* PeopleField); по Employee резолвится маршрут согласования, поэтому заявку можно
* подать и за другого сотрудника. В отличие от «Заявки на отпуск» здесь обе даты
* (с/по) вводятся явно, а DaysCount считается при сохранении.
* подать и за другого сотрудника. Как и в «Заявке на отпуск», даты окончания нет:
* отпуск задаётся началом и количеством дней, чтобы не считать праздники и выходные.
*/
export interface IFormData {
/** E-mail сотрудника, за которого заявка (People). Пусто = не выбран. */
@@ -21,8 +21,8 @@ export interface IFormData {
Position: string;
/** ISO YYYY-MM-DD (input type=date). */
StartDate: string;
/** ISO YYYY-MM-DD (input type=date). */
EndDate: string;
/** Количество календарных дней (строка из input type=number). */
DaysCount: string;
/** Основание «В связи с …». */
Reason: string;
/** E-mail замещающего на время отсутствия (People). Пусто = не выбран. */
@@ -68,7 +68,7 @@ export const EMPTY_DATA: IFormData = {
Employee: '',
Position: '',
StartDate: '',
EndDate: '',
DaysCount: '',
Reason: '',
Substitute: '',
Organization: '',
@@ -7,7 +7,6 @@
{ "internalName": "Employee", "title": "Сотрудник", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
{ "internalName": "Position", "title": "Должность", "type": "Text" },
{ "internalName": "StartDate", "title": "Дата начала", "type": "DateTime", "dateOnly": true },
{ "internalName": "EndDate", "title": "Дата окончания", "type": "DateTime", "dateOnly": true },
{ "internalName": "DaysCount", "title": "Количество дней", "type": "Number" },
{ "internalName": "Reason", "title": "Основание (в связи с)", "type": "Note" },
{ "internalName": "Substitute", "title": "Лицо, замещающее на время отсутствия", "type": "User", "userSelectionMode": "PeopleOnly" },
@@ -51,8 +51,8 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
min={isoDatePlusDays(MIN_NOTICE_DAYS)}
/>
</FormField>
<FormField label="Дата окончания" htmlFor="EndDate" required>
<TextInput id="EndDate" type="date" value={data.EndDate} onChange={(v) => set('EndDate', v)} />
<FormField label="Количество дней" htmlFor="DaysCount" required>
<TextInput id="DaysCount" type="number" value={data.DaysCount} onChange={(v) => set('DaysCount', v)} />
</FormField>
<FormField label="В связи с" htmlFor="Reason" required full>
<TextArea id="Reason" value={data.Reason} onChange={(v) => set('Reason', v)} />
@@ -16,21 +16,13 @@ function pad2(n: number): string {
return n < 10 ? `0${n}` : `${n}`;
}
/** Число дней отпуска включительно по датам с/по. Пусто/некорректно → пусто (null). */
function computeDays(startIso: string, endIso: string): number | undefined {
if (!startIso || !endIso) return undefined;
const diff = new Date(endIso).getTime() - new Date(startIso).getTime();
if (!Number.isFinite(diff) || diff < 0) return undefined;
return Math.round(diff / 86400000) + 1;
}
/** Осмысленный заголовок элемента (системная колонка Title). */
function buildTitle(employeeName: string): string {
const now = new Date();
return `Отпуск без содержания — ${employeeName} — ${pad2(now.getDate())}.${pad2(now.getMonth() + 1)}.${now.getFullYear()}`;
}
/** Поля элемента списка данных из модели формы (+ Employee/Substitute по Id, + вычисленный DaysCount). */
/** Поля элемента списка данных из модели формы (+ Employee/Substitute по Id). */
function buildItem(
data: IFormData,
status: string,
@@ -38,15 +30,14 @@ function buildItem(
employeeName: string,
substituteId: number | undefined
): Record<string, unknown> {
const days = computeDays(data.StartDate, data.EndDate);
const days = Number(data.DaysCount);
return {
Title: buildTitle(employeeName),
RequestStatus: status,
EmployeeId: employeeId,
Position: data.Position || null,
StartDate: data.StartDate || null,
EndDate: data.EndDate || null,
DaysCount: days ?? null,
DaysCount: Number.isFinite(days) && days > 0 ? days : null,
Reason: data.Reason || null,
SubstituteId: substituteId ?? null,
Organization: data.Organization || null,
@@ -25,8 +25,8 @@
пользователем (email + должность из профиля `SPS-JobTitle` через
`approval.getJobTitle`). При выборе другого сотрудника его должность
подтягивается так же. Ошибка профиля не мешает форме — поле просто остаётся как есть.
- **Дата окончания не вводится** — считается при сохранении: `начало + (дни − 1)`
и пишется в колонку `EndDate` (обязательна в списке, но в UI её нет).
- **Даты окончания нет**: отпуск задаётся началом и количеством дней, чтобы не считать
праздники и выходные. Колонка `EndDate` не используется: из провижининга, типа контента и представления она убрана (само системное поле SharePoint `FromBaseType` удалить нельзя).
- **Заголовок элемента** (`Title`): `Отпуск — {ФИО} — дд.мм.гггг`.
## Подписание в ЭДО
@@ -1,10 +1,3 @@
/** «Дата окончания» в форме не вводится: начало + (дни − 1). Пусто → ''. */
export function computeEndDate(startIso: string, days: number): string {
if (!startIso || !Number.isFinite(days) || days <= 0) return '';
const endMs = new Date(startIso).getTime() + (days - 1) * 86400000;
return new Date(endMs).toISOString().slice(0, 10);
}
/** ISO `YYYY-MM-DD` → `дд.мм.гггг`. Не похоже на дату → строка как есть (чтобы документ не падал из-за мусора). */
export function formatRuDate(iso: string): string {
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
@@ -12,7 +12,7 @@ export const FORM_ID: RequestTypeId = 'hr-vacation';
* списка данных этой формы (см. shared/listTitles.ts). Employee хранит e-mail выбранного
* сотрудника (то, что отдаёт PeopleField) — по нему же резолвится маршрут
* согласования, поэтому заявку можно подать и за другого сотрудника.
* EndDate в форме не вводится: считается при сохранении как начало + (дни − 1).
* Даты окончания нет: отпуск задаётся началом и количеством дней, праздники и выходные не считаются.
*/
export interface IFormData {
/** E-mail сотрудника, за которого заявка (People). Пусто = не выбран. */
@@ -7,7 +7,6 @@
{ "internalName": "Employee", "title": "Сотрудник", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
{ "internalName": "Position", "title": "Должность", "type": "Text" },
{ "internalName": "StartDate", "title": "Дата начала", "type": "DateTime", "dateOnly": true },
{ "internalName": "EndDate", "title": "Дата окончания", "type": "DateTime", "dateOnly": true },
{ "internalName": "DaysCount", "title": "Количество дней", "type": "Number" },
{ "internalName": "Substitute", "title": "Лицо, замещающее на время отсутствия", "type": "User", "userSelectionMode": "PeopleOnly" },
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
@@ -1,7 +1,6 @@
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
import { ensureEmployee } from '../../shared/employee';
import { computeEndDate } from './dates';
import { FORM_ID, IFormData } from './model';
/**
@@ -23,7 +22,7 @@ function buildTitle(employeeName: string): string {
return `Отпуск — ${employeeName} — ${pad2(now.getDate())}.${pad2(now.getMonth() + 1)}.${now.getFullYear()}`;
}
/** Поля элемента списка данных из модели формы (+ Employee/Substitute по Id, + вычисленная EndDate). */
/** Поля элемента списка данных из модели формы (+ Employee/Substitute по Id). */
function buildItem(
data: IFormData,
status: string,
@@ -39,7 +38,6 @@ function buildItem(
Position: data.Position || null,
StartDate: data.StartDate || null,
DaysCount: Number.isFinite(days) && days > 0 ? days : null,
EndDate: computeEndDate(data.StartDate, days) || null,
SubstituteId: substituteId ?? null,
Organization: data.Organization || null,
Department: data.Department || null,
@@ -1,5 +1,5 @@
import JSZip from 'jszip';
import { computeEndDate, formatRuDate } from './dates';
import { formatRuDate } from './dates';
import {
IVacationDocumentInput,
buildDocumentXml,
@@ -30,16 +30,6 @@ function readBlob(blob: Blob): Promise<ArrayBuffer> {
}
describe('даты отпуска', () => {
it('конец = начало + (дни − 1)', () => {
expect(computeEndDate('2026-12-01', 14)).toBe('2026-12-14');
expect(computeEndDate('2026-12-01', 1)).toBe('2026-12-01');
});
it('без начала или дней — пусто', () => {
expect(computeEndDate('', 5)).toBe('');
expect(computeEndDate('2026-12-01', 0)).toBe('');
});
it('формат дд.мм.гггг, мусор остаётся как есть', () => {
expect(formatRuDate('2026-12-01')).toBe('01.12.2026');
expect(formatRuDate('завтра')).toBe('завтра');