Отпуск и отпуск без содержания: без даты окончания — начало и количество дней; EndDate убрана из провижининга, кода и SharePoint (из типа контента и представления)
Tests / frontend-test (pull_request) Successful in 1m36s
Tests / frontend-test (pull_request) Successful in 1m36s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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('завтра');
|
||||
|
||||
Reference in New Issue
Block a user