3 Commits

Author SHA1 Message Date
Тимур Городецкий 96f53da020 feat: столбец «Дата выхода» в разделах при фильтре по рабочему месту
Tests / frontend-test (push) Successful in 2m7s
Раньше дата выхода показывалась только во «Все заявки». Теперь — в любом
разделе (Мои заявки, очереди), когда фильтр по типу оставляет только заявки
на организацию рабочего места.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 15:05:42 +05:00
Тимур Городецкий d6bb76fe47 feat: памятка о бумажном заявлении в заявках на отпуск
После согласования заявление нужно распечатать, подписать и сдать в
отдел кадров — напоминаем об этом в трудовом отпуске и отпуске без
содержания (подача и просмотр). Общий компонент LeavePrintNotice,
на печать не выводится.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 13:59:58 +05:00
Тимур Городецкий 4b05da5469 feat: трудовой отпуск — не более 24 дней в одной заявке
Требование HR: ограничение на поле «Количество дней» и проверка при
отправке с понятным сообщением.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 13:59:58 +05:00
9 changed files with 86 additions and 18 deletions
@@ -143,7 +143,7 @@ interface ITableColumns {
assignee: boolean; assignee: boolean;
/** Колонка «Исполнено» — кто из исполнителей уже выполнил (см. showsExecuted). */ /** Колонка «Исполнено» — кто из исполнителей уже выполнил (см. showsExecuted). */
executed: boolean; executed: boolean;
/** Колонка «Дата выхода» — только «Все заявки» по организации рабочего места. */ /** Колонка «Дата выхода» — только таблица из заявок на организацию рабочего места (см. showsStartDate). */
startDate?: boolean; startDate?: boolean;
/** Заголовок колонки с комментарием; undefined — колонки нет. */ /** Заголовок колонки с комментарием; undefined — колонки нет. */
commentLabel?: string; commentLabel?: string;
@@ -178,9 +178,15 @@ function showsExecuted(view: ListView, statusFilter: string): boolean {
return true; return true;
} }
/** Дата выхода сотрудника есть только у заявки на организацию рабочего места, и показываем её во «Все заявки». */ /**
function showsStartDate(view: ListView, type: IRequestType | undefined): boolean { * Дата выхода сотрудника есть только у заявки на организацию рабочего места.
return view === 'all' && type?.pageUrl === WORKPLACE_SETUP_FORM_ID; * Показываем её, когда таблица целиком из таких заявок: «Все заявки» этого типа
* или любой раздел с фильтром по этому типу. Без фильтра типы вперемешку —
* колонка была бы почти пустой.
*/
function showsStartDate(view: ListView, allRequestsType: IRequestType | undefined, filteredType: IRequestType | undefined): boolean {
const tableType = view === 'all' ? allRequestsType : filteredType;
return tableType?.pageUrl === WORKPLACE_SETUP_FORM_ID;
} }
function columnsFor(view: ListView, statusFilter: string, startDate = false): ITableColumns { function columnsFor(view: ListView, statusFilter: string, startDate = false): ITableColumns {
@@ -194,15 +200,15 @@ function columnsFor(view: ListView, statusFilter: string, startDate = false): IT
? 'Причина возврата' ? 'Причина возврата'
: undefined; : undefined;
if (view === 'approvals' || view === 'execution') return { author: true, status: false, assignee, executed }; if (view === 'approvals' || view === 'execution') return { author: true, status: false, assignee, executed, startDate };
if (view === 'rework') return { author: false, status: false, assignee, executed, commentLabel }; if (view === 'rework') return { author: false, status: false, assignee, executed, startDate, commentLabel };
if (view === 'own:created') return { author: false, status: !statusFilter, assignee, executed, commentLabel }; if (view === 'own:created') return { author: false, status: !statusFilter, assignee, executed, startDate, commentLabel };
// 'all' — заявки всех авторов вперемешку, поэтому нужны и автор, и статус. // 'all' — заявки всех авторов вперемешку, поэтому нужны и автор, и статус.
if (view === 'all') return { author: true, status: true, assignee, executed, startDate }; if (view === 'all') return { author: true, status: true, assignee, executed, startDate };
// История решений: заявка чужая, поэтому нужен автор. Статус — только если он // История решений: заявка чужая, поэтому нужен автор. Статус — только если он
// не предопределён самой вкладкой («Отклонённые мной» — это всегда «Отклонено», // не предопределён самой вкладкой («Отклонённые мной» — это всегда «Отклонено»,
// а вот у согласованных/выполненных заявка могла уйти дальше по маршруту). // а вот у согласованных/выполненных заявка могла уйти дальше по маршруту).
return { author: true, status: !decisionStatus, assignee, executed, commentLabel }; return { author: true, status: !decisionStatus, assignee, executed, startDate, commentLabel };
} }
export interface IRequestsTableViewProps { export interface IRequestsTableViewProps {
@@ -494,6 +500,8 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
}); });
const { localFilters, sort, isRefined } = refinement; const { localFilters, sort, isRefined } = refinement;
const refinementSig = refinement.signature; const refinementSig = refinement.signature;
const filteredType = types?.find((t) => t.id === localFilters.typeId);
const withStartDate = showsStartDate(view, allRequestsType, filteredType);
// Полный список раздела для режима с фильтрами — последний загруженный. // Полный список раздела для режима с фильтрами — последний загруженный.
// Листание страниц и смена фильтра его переиспользуют (заново по сети не // Листание страниц и смена фильтра его переиспользуют (заново по сети не
@@ -664,7 +672,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
decisionStatus ? hydrateDecisionComments(context, types, result.rows, decisionStatus) : undefined, decisionStatus ? hydrateDecisionComments(context, types, result.rows, decisionStatus) : undefined,
showsAssignee(view, statusFilter) ? hydrateCurrentAssignees(context, types, result.rows) : undefined, showsAssignee(view, statusFilter) ? hydrateCurrentAssignees(context, types, result.rows) : undefined,
showsExecuted(view, statusFilter) ? hydrateExecutedSteps(context, types, result.rows) : undefined, showsExecuted(view, statusFilter) ? hydrateExecutedSteps(context, types, result.rows) : undefined,
showsStartDate(view, allRequestsType) ? hydrateStartDates(context, result.rows) : undefined, withStartDate ? hydrateStartDates(context, result.rows) : undefined,
]); ]);
return result; return result;
}; };
@@ -850,7 +858,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
rows={visible} rows={visible}
view={view} view={view}
statusFilter={statusFilter} statusFilter={statusFilter}
withStartDate={showsStartDate(view, allRequestsType)} withStartDate={withStartDate}
sort={sort} sort={sort}
onSort={refinement.onSort} onSort={refinement.onSort}
onOpen={onOpen} onOpen={onOpen}
@@ -12,7 +12,8 @@ import {
RequestNumber, RequestNumber,
SubmitActions, SubmitActions,
getDraftDeleteHandler, getDraftDeleteHandler,
useRequestSubmit useRequestSubmit,
LeavePrintNotice
} from '../../shared/ui'; } from '../../shared/ui';
import { import {
composeRemoveRowRule, composeRemoveRowRule,
@@ -326,6 +327,8 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
)} )}
{submit.error && <Alert variant="err">{submit.error}</Alert>} {submit.error && <Alert variant="err">{submit.error}</Alert>}
<LeavePrintNotice />
<FormModeProvider readOnly={readOnly}> <FormModeProvider readOnly={readOnly}>
<RequestSection {...sectionProps} /> <RequestSection {...sectionProps} />
</FormModeProvider> </FormModeProvider>
@@ -2,7 +2,15 @@ import * as React from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base'; import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './UnpaidLeaveView.module.scss'; import styles from './UnpaidLeaveView.module.scss';
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui'; import {
Alert,
FormCard,
InitiatorLine,
FormModeProvider,
PrintSplitButton,
OrgLetterhead,
LeavePrintNotice
} from '../../shared/ui';
import { import {
ApproverService, ApproverService,
DecisionPanel, DecisionPanel,
@@ -148,6 +156,7 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
} }
headerRight={status ? <StatusBadge status={status} /> : undefined} headerRight={status ? <StatusBadge status={status} /> : undefined}
> >
<LeavePrintNotice />
<FormModeProvider readOnly> <FormModeProvider readOnly>
<RequestSection {...sectionProps} /> <RequestSection {...sectionProps} />
</FormModeProvider> </FormModeProvider>
@@ -10,7 +10,8 @@ import {
RequestNumber, RequestNumber,
SubmitActions, SubmitActions,
getDraftDeleteHandler, getDraftDeleteHandler,
useRequestSubmit useRequestSubmit,
LeavePrintNotice
} from '../../shared/ui'; } from '../../shared/ui';
import { import {
composeRemoveRowRule, composeRemoveRowRule,
@@ -44,7 +45,7 @@ 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 } from './model';
import { saveVacationRequest, vacationApprovalService } from './submit'; import { saveVacationRequest, vacationApprovalService } from './submit';
import { loadVacationRequest } from './load'; import { loadVacationRequest } from './load';
import { RequestSection } from './sections/RequestSection'; import { RequestSection } from './sections/RequestSection';
@@ -202,17 +203,20 @@ const VacationForm: React.FC<IRequestFormProps> = ({
// выбрать более раннюю дату, но проверяем и здесь на случай обхода 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 || data.StartDate >= minStartDate;
const isDaysCountTooLarge = Number.isFinite(daysNum) && daysNum > MAX_DAYS;
const isValid = !!( const isValid = !!(
data.Employee && data.Employee &&
data.StartDate && data.StartDate &&
isStartDateValid && isStartDateValid &&
Number.isFinite(daysNum) && Number.isFinite(daysNum) &&
daysNum > 0 daysNum > 0 &&
!isDaysCountTooLarge
); );
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 (isDaysCountTooLarge) return `Отпуск — не более ${MAX_DAYS} календарных дней в одной заявке.`;
if (!isValid) return 'Заполните обязательные поля: сотрудник, дата начала, количество дней (> 0).'; if (!isValid) return 'Заполните обязательные поля: сотрудник, дата начала, количество дней (> 0).';
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.'; if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
@@ -354,6 +358,8 @@ const VacationForm: React.FC<IRequestFormProps> = ({
)} )}
{submit.error && <Alert variant="err">{submit.error}</Alert>} {submit.error && <Alert variant="err">{submit.error}</Alert>}
<LeavePrintNotice />
<FormModeProvider readOnly={readOnly}> <FormModeProvider readOnly={readOnly}>
<RequestSection {...sectionProps} /> <RequestSection {...sectionProps} />
</FormModeProvider> </FormModeProvider>
@@ -2,7 +2,16 @@ import * as React from 'react';
import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base'; import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './VacationView.module.scss'; import styles from './VacationView.module.scss';
import { Alert, Button, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui'; import {
Alert,
Button,
FormCard,
InitiatorLine,
FormModeProvider,
PrintSplitButton,
OrgLetterhead,
LeavePrintNotice
} from '../../shared/ui';
import { import {
ApproverService, ApproverService,
DecisionPanel, DecisionPanel,
@@ -208,6 +217,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
} }
headerRight={status ? <StatusBadge status={status} /> : undefined} headerRight={status ? <StatusBadge status={status} /> : undefined}
> >
<LeavePrintNotice />
<FormModeProvider readOnly> <FormModeProvider readOnly>
<RequestSection {...sectionProps} /> <RequestSection {...sectionProps} />
</FormModeProvider> </FormModeProvider>
@@ -80,5 +80,7 @@ export const EMPTY_DATA: IFormData = {
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…'; export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
/** Заявку нужно подавать не позднее чем за столько дней до даты начала отпуска. */ /** Заявку нужно подавать не позднее чем за столько дней до даты начала отпуска. */
export const MIN_NOTICE_DAYS = 7; export const MIN_NOTICE_DAYS = 7;
/** Ежегодный трудовой отпуск — не более стольких календарных дней в одной заявке (требование HR). */
export const MAX_DAYS = 24;
/** Плейсхолдер для поля выбора замещающего. */ /** Плейсхолдер для поля выбора замещающего. */
export const SUBSTITUTE_PLACEHOLDER = 'Начните вводить Ф.И.О. замещающего…'; export const SUBSTITUTE_PLACEHOLDER = 'Начните вводить Ф.И.О. замещающего…';
@@ -1,7 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import { FormGrid, FormField, TextInput, PeopleField, OrganizationTable } from '../../../shared/ui'; import { FormGrid, FormField, TextInput, PeopleField, OrganizationTable } from '../../../shared/ui';
import { ProjectPicker } from '../../../shared/dictionary'; import { 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 } from '../model';
import { isoDatePlusDays } from '../../../shared/dateUtils'; import { isoDatePlusDays } from '../../../shared/dateUtils';
/** Единственная секция формы «Заявка на отпуск»: сотрудник (должность и подразделение подтягиваются из справочника), даты. */ /** Единственная секция формы «Заявка на отпуск»: сотрудник (должность и подразделение подтягиваются из справочника), даты. */
@@ -38,7 +38,14 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
/> />
</FormField> </FormField>
<FormField label="Количество дней" htmlFor="DaysCount" required> <FormField label="Количество дней" 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="Substitute" full> <FormField label="Лицо, замещающее на время отсутствия" htmlFor="Substitute" full>
<PeopleField <PeopleField
@@ -0,0 +1,22 @@
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,5 +59,6 @@ export { issueLabels } from './FieldIssues/fieldIssues';
export { getDraftDeleteHandler } from './SubmitActions/getDraftDeleteHandler'; export { getDraftDeleteHandler } from './SubmitActions/getDraftDeleteHandler';
export { InitiatorLine } from './InitiatorLine/InitiatorLine'; export { InitiatorLine } from './InitiatorLine/InitiatorLine';
export type { IInitiatorLineProps } from './InitiatorLine/InitiatorLine'; export type { IInitiatorLineProps } from './InitiatorLine/InitiatorLine';
export { LeavePrintNotice } from './LeavePrintNotice/LeavePrintNotice';
export { useRequestSubmit } from './useRequestSubmit'; export { useRequestSubmit } from './useRequestSubmit';
export type { IRequestSubmit, SubmitKind } from './useRequestSubmit'; export type { IRequestSubmit, SubmitKind } from './useRequestSubmit';