7 Commits

Author SHA1 Message Date
Тимур Городецкий ac1b66d0df feat: памятка HR и подсветка ошибок полей в заявках на отпуск
Tests / frontend-test (push) Successful in 2m5s
LeavePrintNotice заменён общим LeaveNotice с заголовком и пунктами из model.ts
каждой формы: в отпуске — примечание HR (ст. 92 ТК РК, срок подачи, подписание
в отделе кадров), в отпуске без содержания — прежняя памятка про печать.
Проверки отпуска вынесены в validation.ts и показываются под полями через
FieldIssuesProvider, как в приёме и подборе.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 15:13:02 +05:00
Тимур Городецкий e811186cec feat: маршрут отпуска и отпуска без содержания учитывает организацию сотрудника
Кадровый сотрудник и исполнитель в этих маршрутах зависят от компании
сотрудника (Ольга Кудрящова и Айнур — Тансу/КПС/CT, Мольдир Хамит —
остальные), но формы не передавали организацию в расчёт маршрута, и
условные шаги в админке не срабатывали. Теперь названия компаний из поля
«Организация» (Employee/ByEmail) уходят как Organization через пробел — и в
согласование, и в исполнителей; маршрут пересчитывается, когда организация
подгрузится. Общий хелпер — shared/dictionary/organizationRouteFields.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 12:44:29 +05:00
Тимур Городецкий 91c66bbf81 Merge branch 'main' of http://git.tnsu.kz/timur.gorodetskiy/request-portal 2026-10-15 12:32:19 +05:00
Тимур Городецкий 7fce5a789c chore: доработки скриптов браузерного QA
guard пропускает GetClientSideComponents (без него страница не грузит
SPFx), probe с --local-build запускает Chromium без проверок Local Network
Access, которые режут загрузку https://localhost:4321 с SharePoint.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 12:31:59 +05:00
Тимур Городецкий ea6eb3663b refactor: расчёт маршрута заявки в 1С вынесен в planRoute
Сбор условий маршрута (вид работ, база 1С, отдел) и запрос маршрута с
схлопыванием повторов переехали из формы в planRoute.ts. Поведение формы
не меняется; функцию сможет использовать страница «Проверка маршрутов»,
чтобы проверять ровно тот маршрут, что видит заявитель.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 12:31:59 +05:00
Тимур Городецкий 99f3558e4a fix: один согласующий не дублируется на шаге маршрута заявки в 1С
База 1С — мультивыбор, поэтому на одном шаге могут сработать сразу
несколько строк матрицы с одним исполнителем (Гульжан и за ЗУП, и за
АТП от финдепа). Он получал бы два уведомления и решал шаг дважды.
Запланированный маршрут теперь оставляет одну строку на человека в
пределах шага; один человек на разных шагах не схлопывается.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 09:37:10 +05:00
Тимур Городецкий a6f4218e7e feat: маршрут заявки в 1С учитывает базу 1С
Новая матрица маршрута #8 назначает согласующих по выбранной базе
(ЗУП, АТП, ATS), а форма передавала в approval-route только вид работ
и отдел — такие шаги никогда не срабатывали. Теперь DB1C уходит в
fieldValues одной строкой через пробел (мультивыбор, value у API
только строка), и маршрут пересчитывается при смене набора баз.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 09:36:48 +05:00
21 changed files with 337 additions and 87 deletions
+1
View File
@@ -17,6 +17,7 @@ const READ_VIA_POST = [
/\/GetItems\b/i,
/\/_api\/search\/postquery/i,
/\/GetUpdatedFormDigest/i,
/\/_api\/web\/GetClientSideComponents/i, // манифесты веб-партов, без них страница не грузит SPFx
/\/_api\/SP\.Utilities\.Utility\.ResolvePrincipalInCurrentContext/i,
/\/_api\/SP\.UI\.ApplicationPages\.ClientPeoplePickerWebServiceInterface\.clientPeoplePickerSearchUser/i,
/\/_api\/sp\.userprofiles\.peoplemanager/i,
+3 -1
View File
@@ -46,7 +46,9 @@ let AxeBuilder = null;
try { ({ default: AxeBuilder } = await import('@axe-core/playwright')); } catch { /* axe не установлен */ }
await fs.mkdir(out, { recursive: true });
const browser = await chromium.launch();
// Local Network Access в свежем Chromium режет загрузку https://localhost:4321 с публичного сайта SharePoint
const LOCAL_BUILD_ARGS = ['--disable-features=LocalNetworkAccessChecks,PrivateNetworkAccessSendPreflights,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessRespectPreflightResults'];
const browser = await chromium.launch(args['local-build'] ? { args: LOCAL_BUILD_ARGS } : {});
const result = { url, surface: tenant ? 'tenant' : 'harness', date: new Date().toISOString(), axe: Boolean(AxeBuilder), runs: [] };
// Признаки непереведённых строк и утёкших значений в тексте
@@ -12,11 +12,13 @@
Если для вида работ согласование не требуется — заявка сразу «Выполнение».
Работу отдел 1С ведёт в карточке Microsoft Planner (см. «Особое поведение»).
- Особенности: маршрут условный — набор шагов зависит от полей **«Виды работ»**
(`WorkType`) и **«Отдел»** (`Department`, свободный текст из профиля AAD, см.
«Особое поведение»), которые передаются в `resolveRoute`/`getPlannedMatrix` через
параметр `fieldValues: [{ fieldName: 'WorkType', value: data.WorkType },
{ fieldName: 'Department', value: data.Department }]` (см. `IResolveFieldValue` в
`shared/approval/models/IApprovalRouteApi.ts`). В админке маршрутов условие по
(`WorkType`), **«База 1С»** (`DB1C`) и **«Отдел»** (`Department`, свободный текст
из профиля AAD, см. «Особое поведение»), которые передаются в
`resolveRoute`/`getPlannedMatrix` через параметр `fieldValues` (см.
`IResolveFieldValue` в `shared/approval/models/IApprovalRouteApi.ts`). «База 1С» —
мультивыбор, а `value` у API только строка, поэтому все выбранные базы уходят
одной записью через пробел; условия по ней в админке — только «содержит»
(напр. «База 1С содержит ЗУП»). В админке маршрутов условие по
отделу настроено оператором «содержит» (напр. «Department содержит Бухгалтерия»),
поэтому важно фактическое значение поля, а не Id из справочника — отдельного
DepartmentId у этой формы нет. Для части видов работ маршрут пуст — согласование
@@ -21,7 +21,6 @@ import {
ApprovalHistory,
StatusBadge,
useMatrixEditor,
getPlannedMatrix,
loadRoutePhase,
isOpenedRouteReady,
resolveWriteListId,
@@ -58,6 +57,7 @@ import { RequestSection } from './sections/RequestSection';
import { ContactSection } from './sections/ContactSection';
import { useStableCallback } from '../../shared/useStableCallback';
import { applyManagerOverride, managerEmail } from './managerOverride';
import { planTicket1CPhase, ticket1cRouteFieldValues } from './planRoute';
import { handoffToPlanner } from './plannerHandoff';
import { submitStatus } from './submitStatus';
@@ -169,26 +169,24 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
const requestTypeIdState = useRequestTypeId(context, requestType.id);
const requestTypeId = requestTypeIdState.id;
// Маршрут полностью и однозначно определяется видом работ и отделом — настроен
// в админке маршрутов (условные шаги по WorkType и Department, см. fieldValues),
// а не выбирается заявителем. Поэтому матрица здесь ВСЕГДА нередактируема
// (editable=false) — заявитель только видит, кто согласует, без возможности
// добавить/убрать/переставить согласующего.
// Условия шагов (и согласования, и исполнения) — по виду работ и отделу.
// Маршрут полностью и однозначно определяется видом работ, базой 1С и отделом —
// настроен в админке маршрутов (условные шаги по WorkType, DB1C и Department,
// см. fieldValues), а не выбирается заявителем. Поэтому матрица здесь ВСЕГДА
// нередактируема (editable=false) — заявитель только видит, кто согласует, без
// возможности добавить/убрать/переставить согласующего.
// Расчёт маршрута вынесен в planRoute.ts — им же пользуется страница
// «Проверка маршрутов», чтобы проверять ровно то, что видит заявитель.
const fieldValues = useMemo(
() => [
{ fieldName: 'WorkType', value: data.WorkType },
{ fieldName: 'Department', value: data.Department }
],
[data.WorkType, data.Department]
() => ticket1cRouteFieldValues({ WorkType: data.WorkType, DB1C: data.DB1C, Department: data.Department }),
[data.WorkType, data.DB1C, data.Department]
);
const matrixKey = `${userEmail}-${editId ?? 'new'}-${data.WorkType}-${data.Department}`;
const matrixKey = [userEmail, editId ?? 'new', ...fieldValues.map((f) => f.value)].join('-');
const matrixReady =
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!data.WorkType && !!requestTypeId));
const loadMatrix = useCallback(
() =>
loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, fieldValues)
planTicket1CPhase(context, requestTypeId as number, userEmail, approval, 'Approval', fieldValues)
),
[context, userEmail, approval, editId, requestStatus, fieldValues, requestTypeId]
);
@@ -199,7 +197,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
const loadExecutors = useCallback(
() =>
loadRoutePhase(approval, editId, requestStatus, 'Execution', () =>
getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Execution', null, fieldValues)
planTicket1CPhase(context, requestTypeId as number, userEmail, approval, 'Execution', fieldValues)
),
[context, userEmail, approval, editId, requestStatus, fieldValues, requestTypeId]
);
@@ -0,0 +1,28 @@
import { IMatrixRow } from '../../shared/approval';
import { dedupeRouteRows } from './dedupeRoute';
function row(order: number, email: string): IMatrixRow {
return { order, who: email, email, position: '', status: '—', date: '', comment: '', allowReassign: false };
}
describe('dedupeRouteRows', () => {
it('схлопывает одного человека на одном шаге (ЗУП + АТП от финдепа)', () => {
const rows = [row(1, 'boss@tnsu.kz'), row(2, 'gulzhan@tnsu.kz'), row(2, 'Gulzhan@tnsu.kz')];
expect(dedupeRouteRows(rows).map((r) => r.email)).toEqual(['boss@tnsu.kz', 'gulzhan@tnsu.kz']);
});
it('оставляет разных людей на одном шаге', () => {
const rows = [row(2, 'gulzhan@tnsu.kz'), row(2, 'zhanat@tnsu.kz')];
expect(dedupeRouteRows(rows)).toHaveLength(2);
});
it('оставляет одного человека на разных шагах', () => {
const rows = [row(1, 'gulzhan@tnsu.kz'), row(2, 'gulzhan@tnsu.kz')];
expect(dedupeRouteRows(rows)).toHaveLength(2);
});
it('не трогает неназначенные строки', () => {
const rows = [row(2, ''), row(2, '')];
expect(dedupeRouteRows(rows)).toHaveLength(2);
});
});
@@ -0,0 +1,23 @@
import { IMatrixRow } from '../../shared/approval';
/**
* Убирает повторы одного и того же человека внутри одного шага маршрута.
*
* Зачем: «База 1С» — мультивыбор, и при нескольких базах на одном шаге могут
* сработать несколько строк матрицы с одним исполнителем (напр. Гульжан и за
* ЗУП, и за АТП от финдепа). Без схлопывания он получил бы две строки
* параллельного шага — два уведомления и два решения.
*
* Один человек на РАЗНЫХ шагах не схлопывается — это разные согласования.
* Строки без email (неназначенный шаг) не трогаем.
*/
export function dedupeRouteRows(rows: IMatrixRow[]): IMatrixRow[] {
const seen = new Set<string>();
return rows.filter((row) => {
if (!row.email) return true;
const key = `${row.order}|${row.email.toLowerCase()}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
@@ -0,0 +1,45 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import {
ApprovalListService,
ApprovalStatus,
getPlannedMatrix,
IMatrixRow,
IResolveFieldValue
} from '../../shared/approval';
import { IFormData } from './model';
import { dedupeRouteRows } from './dedupeRoute';
/** Поля заявки, от которых зависит маршрут 1С (условия шагов в админке маршрута #8). */
export type Ticket1CRouteInput = Pick<IFormData, 'WorkType' | 'DB1C' | 'Department'>;
/**
* Значения полей для условных шагов маршрута (fieldValues при resolve).
*
* Базы 1С — мультивыбор, а approval-route принимает value только строкой:
* все выбранные базы уходят одной записью через пробел (как CompaniesJson в
* RecruitingHire), условия в админке — «DB1C содержит ЗУП/АТП/ATS».
*/
export function ticket1cRouteFieldValues(input: Ticket1CRouteInput): IResolveFieldValue[] {
return [
{ fieldName: 'WorkType', value: input.WorkType },
{ fieldName: 'DB1C', value: input.DB1C.slice().sort().join(' ') },
{ fieldName: 'Department', value: input.Department }
];
}
/**
* Запланированный маршрут одной фазы заявки в 1С. Один источник для формы и для
* страницы «Проверка маршрутов» — проверка должна видеть ровно то, что увидит
* пользователь, включая схлопывание повторов на шаге.
*/
export async function planTicket1CPhase(
context: WebPartContext,
requestTypeId: number,
userEmail: string,
approval: ApprovalListService,
phase: ApprovalStatus,
fieldValues: IResolveFieldValue[]
): Promise<IMatrixRow[]> {
const rows = await getPlannedMatrix(context, requestTypeId, userEmail, approval, phase, null, fieldValues);
return dedupeRouteRows(rows);
}
@@ -13,7 +13,7 @@ import {
SubmitActions,
getDraftDeleteHandler,
useRequestSubmit,
LeavePrintNotice
LeaveNotice
} from '../../shared/ui';
import {
composeRemoveRowRule,
@@ -46,7 +46,8 @@ 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';
import { loadUnpaidLeaveRequest } from './load';
@@ -162,16 +163,22 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
// Планируемый маршрут резолвится по e-mail ВЫБРАННОГО сотрудника (не текущего
// пользователя) — так работает подача за другого. У заявки на редактировании
// маршрут читается из уже записанных строк, чтобы не затереть правки инициатора.
//
// Кадровый сотрудник и исполнитель зависят от организации сотрудника (условия
// «Organization содержит …» в админке) — см. shared/dictionary/organizationRouteFields.ts.
// Организация приходит позже выбора сотрудника, поэтому она в ключе перезагрузки.
const routeFieldValues = useMemo(() => organizationRouteFieldValues(data.Organization), [data.Organization]);
const routeOrgKey = routeFieldValues.map((f) => f.value).join('|');
const loadMatrix = useCallback(
() =>
loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval')
getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval', null, routeFieldValues)
),
[context, approval, editId, requestStatus, data.Employee, requestTypeId]
[context, approval, editId, requestStatus, data.Employee, requestTypeId, routeFieldValues]
);
const matrix = useMatrixEditor(
loadMatrix,
`${data.Employee}-${editId ?? 'new'}`,
`${data.Employee}-${editId ?? 'new'}-${routeOrgKey}`,
!readOnly,
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!data.Employee && !!requestTypeId)),
(login) => approval.getJobTitle(login)
@@ -185,7 +192,8 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
requestTypeId,
userEmail: data.Employee,
editId,
requestStatus
requestStatus,
fieldValues: routeFieldValues
});
// Проблемы полей (см. validation.ts) подсвечиваются под полями через FieldIssuesProvider.
@@ -327,7 +335,7 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
)}
{submit.error && <Alert variant="err">{submit.error}</Alert>}
<LeavePrintNotice />
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
<FormModeProvider readOnly={readOnly}>
<RequestSection {...sectionProps} />
@@ -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<IUnpaidLeaveViewProps> = ({ context, itemId, for
}
headerRight={status ? <StatusBadge status={status} /> : undefined}
>
<LeavePrintNotice />
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
<FormModeProvider readOnly>
<RequestSection {...sectionProps} />
</FormModeProvider>
@@ -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 = 'Рождение ребенка';
@@ -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,15 @@ 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';
import { RequestSection } from './sections/RequestSection';
@@ -170,16 +173,24 @@ const VacationForm: React.FC<IRequestFormProps> = ({
// Планируемый маршрут резолвится по e-mail ВЫБРАННОГО сотрудника (не текущего
// пользователя) — так работает подача за другого. У заявки на редактировании
// маршрут читается из уже записанных строк, чтобы не затереть правки инициатора.
//
// Кадровый сотрудник и исполнитель в маршруте зависят от организации
// сотрудника (условия «Organization содержит …» в админке) — см.
// shared/dictionary/organizationRouteFields.ts. Организация приходит позже
// выбора сотрудника (Employee/ByEmail), поэтому она в ключе перезагрузки:
// маршрут пересчитается, когда придёт, а не останется рассчитанным без условий.
const routeFieldValues = useMemo(() => organizationRouteFieldValues(data.Organization), [data.Organization]);
const routeOrgKey = routeFieldValues.map((f) => f.value).join('|');
const loadMatrix = useCallback(
() =>
loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval')
getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval', null, routeFieldValues)
),
[context, approval, editId, requestStatus, data.Employee, requestTypeId]
[context, approval, editId, requestStatus, data.Employee, requestTypeId, routeFieldValues]
);
const matrix = useMatrixEditor(
loadMatrix,
`${data.Employee}-${editId ?? 'new'}`,
`${data.Employee}-${editId ?? 'new'}-${routeOrgKey}`,
!readOnly,
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!data.Employee && !!requestTypeId)),
(login) => approval.getJobTitle(login)
@@ -194,30 +205,15 @@ const VacationForm: React.FC<IRequestFormProps> = ({
requestTypeId,
userEmail: data.Employee,
editId,
requestStatus
requestStatus,
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;
@@ -327,7 +323,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
});
return (
<div>
<FieldIssuesProvider draftIssues={issues} submitIssues={issues}>
<button type="button" className={styles.back} onClick={onCancel}>
<Icon name="arrow-left" size="sm" />
<span>К списку заявок</span>
@@ -358,7 +354,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
)}
{submit.error && <Alert variant="err">{submit.error}</Alert>}
<LeavePrintNotice />
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
<FormModeProvider readOnly={readOnly}>
<RequestSection {...sectionProps} />
@@ -396,7 +392,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
submitLabel="Отправить на согласование"
/>
</FormCard>
</div>
</FieldIssuesProvider>
);
};
@@ -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<IVacationViewProps> = ({ context, itemId, formKey,
}
headerRight={status ? <StatusBadge status={status} /> : undefined}
>
<LeavePrintNotice />
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
<FormModeProvider readOnly>
<RequestSection {...sectionProps} />
</FormModeProvider>
@@ -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 = 'Начните вводить Ф.И.О. замещающего…';
@@ -45,6 +45,7 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
onChange={(v) => set('DaysCount', v)}
min={1}
max={MAX_DAYS}
placeholder={`от 1 до ${MAX_DAYS}`}
/>
</FormField>
<FormField label="Лицо, замещающее на время отсутствия" htmlFor="Substitute" full>
@@ -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<IFieldIssue | undefined> = [
data.Employee ? undefined : { field: 'Employee', label: 'Сотрудник' },
startDateIssue(data),
daysCountIssue(data)
];
return issues.filter((issue): issue is IFieldIssue => issue !== undefined);
}
@@ -0,0 +1,30 @@
import { organizationRouteFieldValues } from './organizationRouteFields';
describe('organizationRouteFieldValues', () => {
it('одна организация — её название', () => {
expect(organizationRouteFieldValues('ТОО TANSU Construction — Инженер — ИТ департамент')).toEqual([
{ fieldName: 'Organization', value: 'ТОО TANSU Construction' },
]);
});
it('строки без подразделения (формат отпуска без содержания)', () => {
expect(organizationRouteFieldValues('ТОО АСТЕЛ-К — Экономист')).toEqual([
{ fieldName: 'Organization', value: 'ТОО АСТЕЛ-К' },
]);
});
it('несколько организаций — все названия через пробел, без повторов', () => {
const organization = [
'ТОО АСТЕЛ-К — Экономист',
'ТОО TANSU Construction — Инженер — ИТ департамент',
'ТОО АСТЕЛ-К — Аналитик',
].join('\n');
expect(organizationRouteFieldValues(organization)).toEqual([
{ fieldName: 'Organization', value: 'ТОО TANSU Construction ТОО АСТЕЛ-К' },
]);
});
it('организация ещё не загружена — условий нет', () => {
expect(organizationRouteFieldValues('')).toEqual([]);
});
});
@@ -0,0 +1,24 @@
// Напрямую из модулей, а не из баррелей: баррели тянут @microsoft/sp-http,
// который не грузится в Jest (см. features/myRequests/filtersStorage.ts).
import type { IResolveFieldValue } from '../approval/models/IApprovalRouteApi';
import { parseOrganizationLine } from './organizationLines';
/**
* Значения для условных шагов маршрута (fieldValues при resolve) по
* организации сотрудника — для форм, где кадровый сотрудник и исполнитель
* зависят от компании (заявка на отпуск, отпуск без содержания). Условия в
* админке approval-route: «Organization содержит TANSU …».
*
* Названия компаний — из поля «Организация» (строки «Компания — должность[ —
* подразделение]», источник Employee/ByEmail), одной строкой через пробел:
* бэкенд принимает только строку (массив → 400), а «содержит» ищет подстроку.
* Сотрудник в нескольких компаниях попадает под условия каждой — так и задумано.
*/
export function organizationRouteFieldValues(organization: string): IResolveFieldValue[] {
const companies = organization
.split('\n')
.map((line) => parseOrganizationLine(line).company)
.filter((name) => !!name);
const names = companies.filter((name, i) => companies.indexOf(name) === i).sort().join(' ');
return names ? [{ fieldName: 'Organization', value: names }] : [];
}
@@ -0,0 +1,8 @@
.list {
margin: 6px 0 0;
padding-left: 20px;
}
.item + .item {
margin-top: 4px;
}
@@ -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<Props> = ({ title, items }) => {
if (items.length === 0) return null;
return (
<div className="rp-no-print">
<Alert variant="warn">
<strong>{title}.</strong>
{items.length === 1 ? (
<> {items[0]}</>
) : (
<ul className={styles.list}>
{items.map((item) => (
<li key={item} className={styles.item}>
{item}
</li>
))}
</ul>
)}
</Alert>
</div>
);
};
@@ -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 = () => (
<div className="rp-no-print">
<Alert variant="warn">
<strong>{NOTICE_TITLE}.</strong> {NOTICE_TEXT}
</Alert>
</div>
);
@@ -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';