Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac1b66d0df | |||
| e811186cec | |||
| 91c66bbf81 | |||
| 7fce5a789c | |||
| ea6eb3663b | |||
| 99f3558e4a | |||
| a6f4218e7e |
@@ -17,6 +17,7 @@ const READ_VIA_POST = [
|
|||||||
/\/GetItems\b/i,
|
/\/GetItems\b/i,
|
||||||
/\/_api\/search\/postquery/i,
|
/\/_api\/search\/postquery/i,
|
||||||
/\/GetUpdatedFormDigest/i,
|
/\/GetUpdatedFormDigest/i,
|
||||||
|
/\/_api\/web\/GetClientSideComponents/i, // манифесты веб-партов, без них страница не грузит SPFx
|
||||||
/\/_api\/SP\.Utilities\.Utility\.ResolvePrincipalInCurrentContext/i,
|
/\/_api\/SP\.Utilities\.Utility\.ResolvePrincipalInCurrentContext/i,
|
||||||
/\/_api\/SP\.UI\.ApplicationPages\.ClientPeoplePickerWebServiceInterface\.clientPeoplePickerSearchUser/i,
|
/\/_api\/SP\.UI\.ApplicationPages\.ClientPeoplePickerWebServiceInterface\.clientPeoplePickerSearchUser/i,
|
||||||
/\/_api\/sp\.userprofiles\.peoplemanager/i,
|
/\/_api\/sp\.userprofiles\.peoplemanager/i,
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ let AxeBuilder = null;
|
|||||||
try { ({ default: AxeBuilder } = await import('@axe-core/playwright')); } catch { /* axe не установлен */ }
|
try { ({ default: AxeBuilder } = await import('@axe-core/playwright')); } catch { /* axe не установлен */ }
|
||||||
|
|
||||||
await fs.mkdir(out, { recursive: true });
|
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: [] };
|
const result = { url, surface: tenant ? 'tenant' : 'harness', date: new Date().toISOString(), axe: Boolean(AxeBuilder), runs: [] };
|
||||||
|
|
||||||
// Признаки непереведённых строк и утёкших значений в тексте
|
// Признаки непереведённых строк и утёкших значений в тексте
|
||||||
|
|||||||
@@ -12,11 +12,13 @@
|
|||||||
Если для вида работ согласование не требуется — заявка сразу «Выполнение».
|
Если для вида работ согласование не требуется — заявка сразу «Выполнение».
|
||||||
Работу отдел 1С ведёт в карточке Microsoft Planner (см. «Особое поведение»).
|
Работу отдел 1С ведёт в карточке Microsoft Planner (см. «Особое поведение»).
|
||||||
- Особенности: маршрут условный — набор шагов зависит от полей **«Виды работ»**
|
- Особенности: маршрут условный — набор шагов зависит от полей **«Виды работ»**
|
||||||
(`WorkType`) и **«Отдел»** (`Department`, свободный текст из профиля AAD, см.
|
(`WorkType`), **«База 1С»** (`DB1C`) и **«Отдел»** (`Department`, свободный текст
|
||||||
«Особое поведение»), которые передаются в `resolveRoute`/`getPlannedMatrix` через
|
из профиля AAD, см. «Особое поведение»), которые передаются в
|
||||||
параметр `fieldValues: [{ fieldName: 'WorkType', value: data.WorkType },
|
`resolveRoute`/`getPlannedMatrix` через параметр `fieldValues` (см.
|
||||||
{ fieldName: 'Department', value: data.Department }]` (см. `IResolveFieldValue` в
|
`IResolveFieldValue` в `shared/approval/models/IApprovalRouteApi.ts`). «База 1С» —
|
||||||
`shared/approval/models/IApprovalRouteApi.ts`). В админке маршрутов условие по
|
мультивыбор, а `value` у API только строка, поэтому все выбранные базы уходят
|
||||||
|
одной записью через пробел; условия по ней в админке — только «содержит»
|
||||||
|
(напр. «База 1С содержит ЗУП»). В админке маршрутов условие по
|
||||||
отделу настроено оператором «содержит» (напр. «Department содержит Бухгалтерия»),
|
отделу настроено оператором «содержит» (напр. «Department содержит Бухгалтерия»),
|
||||||
поэтому важно фактическое значение поля, а не Id из справочника — отдельного
|
поэтому важно фактическое значение поля, а не Id из справочника — отдельного
|
||||||
DepartmentId у этой формы нет. Для части видов работ маршрут пуст — согласование
|
DepartmentId у этой формы нет. Для части видов работ маршрут пуст — согласование
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
getPlannedMatrix,
|
|
||||||
loadRoutePhase,
|
loadRoutePhase,
|
||||||
isOpenedRouteReady,
|
isOpenedRouteReady,
|
||||||
resolveWriteListId,
|
resolveWriteListId,
|
||||||
@@ -58,6 +57,7 @@ import { RequestSection } from './sections/RequestSection';
|
|||||||
import { ContactSection } from './sections/ContactSection';
|
import { ContactSection } from './sections/ContactSection';
|
||||||
import { useStableCallback } from '../../shared/useStableCallback';
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
import { applyManagerOverride, managerEmail } from './managerOverride';
|
import { applyManagerOverride, managerEmail } from './managerOverride';
|
||||||
|
import { planTicket1CPhase, ticket1cRouteFieldValues } from './planRoute';
|
||||||
import { handoffToPlanner } from './plannerHandoff';
|
import { handoffToPlanner } from './plannerHandoff';
|
||||||
import { submitStatus } from './submitStatus';
|
import { submitStatus } from './submitStatus';
|
||||||
|
|
||||||
@@ -169,26 +169,24 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
const requestTypeIdState = useRequestTypeId(context, requestType.id);
|
const requestTypeIdState = useRequestTypeId(context, requestType.id);
|
||||||
const requestTypeId = requestTypeIdState.id;
|
const requestTypeId = requestTypeIdState.id;
|
||||||
|
|
||||||
// Маршрут полностью и однозначно определяется видом работ и отделом — настроен
|
// Маршрут полностью и однозначно определяется видом работ, базой 1С и отделом —
|
||||||
// в админке маршрутов (условные шаги по WorkType и Department, см. fieldValues),
|
// настроен в админке маршрутов (условные шаги по WorkType, DB1C и Department,
|
||||||
// а не выбирается заявителем. Поэтому матрица здесь ВСЕГДА нередактируема
|
// см. fieldValues), а не выбирается заявителем. Поэтому матрица здесь ВСЕГДА
|
||||||
// (editable=false) — заявитель только видит, кто согласует, без возможности
|
// нередактируема (editable=false) — заявитель только видит, кто согласует, без
|
||||||
// добавить/убрать/переставить согласующего.
|
// возможности добавить/убрать/переставить согласующего.
|
||||||
// Условия шагов (и согласования, и исполнения) — по виду работ и отделу.
|
// Расчёт маршрута вынесен в planRoute.ts — им же пользуется страница
|
||||||
|
// «Проверка маршрутов», чтобы проверять ровно то, что видит заявитель.
|
||||||
const fieldValues = useMemo(
|
const fieldValues = useMemo(
|
||||||
() => [
|
() => ticket1cRouteFieldValues({ WorkType: data.WorkType, DB1C: data.DB1C, Department: data.Department }),
|
||||||
{ fieldName: 'WorkType', value: data.WorkType },
|
[data.WorkType, data.DB1C, data.Department]
|
||||||
{ fieldName: 'Department', value: data.Department }
|
|
||||||
],
|
|
||||||
[data.WorkType, data.Department]
|
|
||||||
);
|
);
|
||||||
const matrixKey = `${userEmail}-${editId ?? 'new'}-${data.WorkType}-${data.Department}`;
|
const matrixKey = [userEmail, editId ?? 'new', ...fieldValues.map((f) => f.value)].join('-');
|
||||||
const matrixReady =
|
const matrixReady =
|
||||||
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!data.WorkType && !!requestTypeId));
|
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!data.WorkType && !!requestTypeId));
|
||||||
const loadMatrix = useCallback(
|
const loadMatrix = useCallback(
|
||||||
() =>
|
() =>
|
||||||
loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
|
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]
|
[context, userEmail, approval, editId, requestStatus, fieldValues, requestTypeId]
|
||||||
);
|
);
|
||||||
@@ -199,7 +197,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
const loadExecutors = useCallback(
|
const loadExecutors = useCallback(
|
||||||
() =>
|
() =>
|
||||||
loadRoutePhase(approval, editId, requestStatus, 'Execution', () =>
|
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]
|
[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,
|
SubmitActions,
|
||||||
getDraftDeleteHandler,
|
getDraftDeleteHandler,
|
||||||
useRequestSubmit,
|
useRequestSubmit,
|
||||||
LeavePrintNotice
|
LeaveNotice
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
composeRemoveRowRule,
|
composeRemoveRowRule,
|
||||||
@@ -46,7 +46,8 @@ import {
|
|||||||
dataListTitle,
|
dataListTitle,
|
||||||
dataListTitleForListId
|
dataListTitleForListId
|
||||||
} from '../../shared/listTitles';
|
} 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 { formIssues } from './validation';
|
||||||
import { saveUnpaidLeaveRequest, unpaidLeaveApprovalService } from './submit';
|
import { saveUnpaidLeaveRequest, unpaidLeaveApprovalService } from './submit';
|
||||||
import { loadUnpaidLeaveRequest } from './load';
|
import { loadUnpaidLeaveRequest } from './load';
|
||||||
@@ -162,16 +163,22 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
// Планируемый маршрут резолвится по e-mail ВЫБРАННОГО сотрудника (не текущего
|
// Планируемый маршрут резолвится по 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(
|
const loadMatrix = useCallback(
|
||||||
() =>
|
() =>
|
||||||
loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
|
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(
|
const matrix = useMatrixEditor(
|
||||||
loadMatrix,
|
loadMatrix,
|
||||||
`${data.Employee}-${editId ?? 'new'}`,
|
`${data.Employee}-${editId ?? 'new'}-${routeOrgKey}`,
|
||||||
!readOnly,
|
!readOnly,
|
||||||
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!data.Employee && !!requestTypeId)),
|
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!data.Employee && !!requestTypeId)),
|
||||||
(login) => approval.getJobTitle(login)
|
(login) => approval.getJobTitle(login)
|
||||||
@@ -185,7 +192,8 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
requestTypeId,
|
requestTypeId,
|
||||||
userEmail: data.Employee,
|
userEmail: data.Employee,
|
||||||
editId,
|
editId,
|
||||||
requestStatus
|
requestStatus,
|
||||||
|
fieldValues: routeFieldValues
|
||||||
});
|
});
|
||||||
|
|
||||||
// Проблемы полей (см. validation.ts) подсвечиваются под полями через FieldIssuesProvider.
|
// Проблемы полей (см. validation.ts) подсвечиваются под полями через FieldIssuesProvider.
|
||||||
@@ -327,7 +335,7 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
)}
|
)}
|
||||||
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
||||||
|
|
||||||
<LeavePrintNotice />
|
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
|
||||||
|
|
||||||
<FormModeProvider readOnly={readOnly}>
|
<FormModeProvider readOnly={readOnly}>
|
||||||
<RequestSection {...sectionProps} />
|
<RequestSection {...sectionProps} />
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
FormModeProvider,
|
FormModeProvider,
|
||||||
PrintSplitButton,
|
PrintSplitButton,
|
||||||
OrgLetterhead,
|
OrgLetterhead,
|
||||||
LeavePrintNotice
|
LeaveNotice
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApproverService,
|
ApproverService,
|
||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
dataListTitle,
|
dataListTitle,
|
||||||
dataListTitleForListId
|
dataListTitleForListId
|
||||||
} from '../../shared/listTitles';
|
} 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 { loadUnpaidLeaveRequest } from './load';
|
||||||
import { unpaidLeaveApprovalService } from './submit';
|
import { unpaidLeaveApprovalService } from './submit';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
@@ -156,7 +156,7 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
|
|||||||
}
|
}
|
||||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||||
>
|
>
|
||||||
<LeavePrintNotice />
|
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
|
||||||
<FormModeProvider readOnly>
|
<FormModeProvider readOnly>
|
||||||
<RequestSection {...sectionProps} />
|
<RequestSection {...sectionProps} />
|
||||||
</FormModeProvider>
|
</FormModeProvider>
|
||||||
|
|||||||
@@ -95,6 +95,13 @@ export const SUBSTITUTE_PLACEHOLDER = 'Начните вводить Ф.И.О.
|
|||||||
*/
|
*/
|
||||||
export const MIN_NOTICE_DAYS = 14;
|
export const MIN_NOTICE_DAYS = 14;
|
||||||
|
|
||||||
|
/** Памятка вверху формы и просмотра (см. shared/ui/LeaveNotice). Текст — по просьбе HR. */
|
||||||
|
export const NOTICE_TITLE = 'Памятка';
|
||||||
|
export const NOTICE_ITEMS: string[] = [
|
||||||
|
'После согласования заявления его необходимо распечатать с портала (кнопка «Печать» на странице заявки), ' +
|
||||||
|
'собственноручно подписать и передать специалисту отдела кадров.'
|
||||||
|
];
|
||||||
|
|
||||||
/** Причины отпуска. Значения совпадают с choices колонки UnpaidLeaveReasonType. */
|
/** Причины отпуска. Значения совпадают с choices колонки UnpaidLeaveReasonType. */
|
||||||
export const REASON_MARRIAGE = 'Регистрация брака';
|
export const REASON_MARRIAGE = 'Регистрация брака';
|
||||||
export const REASON_CHILD_BIRTH = 'Рождение ребенка';
|
export const REASON_CHILD_BIRTH = 'Рождение ребенка';
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { useState, useMemo, useCallback, useEffect } from 'react';
|
|||||||
import styles from './VacationForm.module.scss';
|
import styles from './VacationForm.module.scss';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
FieldIssuesProvider,
|
||||||
|
issueLabels,
|
||||||
FormCard,
|
FormCard,
|
||||||
InitiatorLine,
|
InitiatorLine,
|
||||||
FormModeProvider,
|
FormModeProvider,
|
||||||
@@ -11,7 +13,7 @@ import {
|
|||||||
SubmitActions,
|
SubmitActions,
|
||||||
getDraftDeleteHandler,
|
getDraftDeleteHandler,
|
||||||
useRequestSubmit,
|
useRequestSubmit,
|
||||||
LeavePrintNotice
|
LeaveNotice
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
composeRemoveRowRule,
|
composeRemoveRowRule,
|
||||||
@@ -38,14 +40,15 @@ import { effectiveUserEmail } from '../../shared/impersonation';
|
|||||||
import { IRequestFormProps } from '../../models/types';
|
import { IRequestFormProps } from '../../models/types';
|
||||||
import { getEmployeeOrganizations, formatEmployeeOrganizationsWithDepartment, IEmployeeOrganization } from '../../shared/dictionary';
|
import { getEmployeeOrganizations, formatEmployeeOrganizationsWithDepartment, IEmployeeOrganization } from '../../shared/dictionary';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
import { isoDatePlusDays, formatIsoDate } from '../../shared/dateUtils';
|
|
||||||
import {
|
import {
|
||||||
approvalListTitle,
|
approvalListTitle,
|
||||||
approvalListTitleForListId,
|
approvalListTitleForListId,
|
||||||
dataListTitle,
|
dataListTitle,
|
||||||
dataListTitleForListId
|
dataListTitleForListId
|
||||||
} from '../../shared/listTitles';
|
} 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 { saveVacationRequest, vacationApprovalService } from './submit';
|
||||||
import { loadVacationRequest } from './load';
|
import { loadVacationRequest } from './load';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
@@ -170,16 +173,24 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
// Планируемый маршрут резолвится по e-mail ВЫБРАННОГО сотрудника (не текущего
|
// Планируемый маршрут резолвится по 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(
|
const loadMatrix = useCallback(
|
||||||
() =>
|
() =>
|
||||||
loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
|
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(
|
const matrix = useMatrixEditor(
|
||||||
loadMatrix,
|
loadMatrix,
|
||||||
`${data.Employee}-${editId ?? 'new'}`,
|
`${data.Employee}-${editId ?? 'new'}-${routeOrgKey}`,
|
||||||
!readOnly,
|
!readOnly,
|
||||||
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!data.Employee && !!requestTypeId)),
|
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!data.Employee && !!requestTypeId)),
|
||||||
(login) => approval.getJobTitle(login)
|
(login) => approval.getJobTitle(login)
|
||||||
@@ -194,30 +205,15 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
requestTypeId,
|
requestTypeId,
|
||||||
userEmail: data.Employee,
|
userEmail: data.Employee,
|
||||||
editId,
|
editId,
|
||||||
requestStatus
|
requestStatus,
|
||||||
|
fieldValues: routeFieldValues
|
||||||
});
|
});
|
||||||
|
|
||||||
const daysNum = Number(data.DaysCount);
|
// Проблемы полей (см. validation.ts) подсвечиваются под полями через FieldIssuesProvider.
|
||||||
// Заявку нужно подавать не позднее чем за MIN_NOTICE_DAYS дней до даты начала
|
const issues = formIssues(data);
|
||||||
// отпуска — календарь (min у input type=date, см. RequestSection) не даёт
|
const isValid = issues.length === 0;
|
||||||
// выбрать более раннюю дату, но проверяем и здесь на случай обхода 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
|
|
||||||
);
|
|
||||||
const validate = (): string | undefined => {
|
const validate = (): string | undefined => {
|
||||||
if (data.StartDate && !isStartDateValid) {
|
if (!isValid) return `Заполните или исправьте поля: ${issueLabels(issues)}.`;
|
||||||
return `Заявку на отпуск нужно подать не позднее чем за ${MIN_NOTICE_DAYS} дней до даты начала — минимально доступная дата: ${formatIsoDate(minStartDate)}.`;
|
|
||||||
}
|
|
||||||
if (isDaysCountTooLarge) return `Отпуск — не более ${MAX_DAYS} календарных дней в одной заявке.`;
|
|
||||||
if (!isValid) return 'Заполните обязательные поля: сотрудник, дата начала, количество дней (> 0).';
|
|
||||||
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
||||||
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -327,7 +323,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<FieldIssuesProvider draftIssues={issues} submitIssues={issues}>
|
||||||
<button type="button" className={styles.back} onClick={onCancel}>
|
<button type="button" className={styles.back} onClick={onCancel}>
|
||||||
<Icon name="arrow-left" size="sm" />
|
<Icon name="arrow-left" size="sm" />
|
||||||
<span>К списку заявок</span>
|
<span>К списку заявок</span>
|
||||||
@@ -358,7 +354,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
)}
|
)}
|
||||||
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
||||||
|
|
||||||
<LeavePrintNotice />
|
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
|
||||||
|
|
||||||
<FormModeProvider readOnly={readOnly}>
|
<FormModeProvider readOnly={readOnly}>
|
||||||
<RequestSection {...sectionProps} />
|
<RequestSection {...sectionProps} />
|
||||||
@@ -396,7 +392,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
submitLabel="Отправить на согласование"
|
submitLabel="Отправить на согласование"
|
||||||
/>
|
/>
|
||||||
</FormCard>
|
</FormCard>
|
||||||
</div>
|
</FieldIssuesProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
FormModeProvider,
|
FormModeProvider,
|
||||||
PrintSplitButton,
|
PrintSplitButton,
|
||||||
OrgLetterhead,
|
OrgLetterhead,
|
||||||
LeavePrintNotice
|
LeaveNotice
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApproverService,
|
ApproverService,
|
||||||
@@ -36,7 +36,7 @@ import {
|
|||||||
dataListTitle,
|
dataListTitle,
|
||||||
dataListTitleForListId
|
dataListTitleForListId
|
||||||
} from '../../shared/listTitles';
|
} 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 { loadVacationRequest } from './load';
|
||||||
import { vacationApprovalService } from './submit';
|
import { vacationApprovalService } from './submit';
|
||||||
import { IVacationSigningResult, sendVacationForSigning } from './sendToSign';
|
import { IVacationSigningResult, sendVacationForSigning } from './sendToSign';
|
||||||
@@ -217,7 +217,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
|||||||
}
|
}
|
||||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||||
>
|
>
|
||||||
<LeavePrintNotice />
|
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
|
||||||
<FormModeProvider readOnly>
|
<FormModeProvider readOnly>
|
||||||
<RequestSection {...sectionProps} />
|
<RequestSection {...sectionProps} />
|
||||||
</FormModeProvider>
|
</FormModeProvider>
|
||||||
|
|||||||
@@ -82,5 +82,22 @@ export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. с
|
|||||||
export const MIN_NOTICE_DAYS = 7;
|
export const MIN_NOTICE_DAYS = 7;
|
||||||
/** Ежегодный трудовой отпуск — не более стольких календарных дней в одной заявке (требование HR). */
|
/** Ежегодный трудовой отпуск — не более стольких календарных дней в одной заявке (требование HR). */
|
||||||
export const MAX_DAYS = 24;
|
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 = 'Начните вводить Ф.И.О. замещающего…';
|
export const SUBSTITUTE_PLACEHOLDER = 'Начните вводить Ф.И.О. замещающего…';
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
|
|||||||
onChange={(v) => set('DaysCount', v)}
|
onChange={(v) => set('DaysCount', v)}
|
||||||
min={1}
|
min={1}
|
||||||
max={MAX_DAYS}
|
max={MAX_DAYS}
|
||||||
|
placeholder={`от 1 до ${MAX_DAYS}`}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Лицо, замещающее на время отсутствия" htmlFor="Substitute" full>
|
<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 { 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 { LeaveNotice } from './LeaveNotice/LeaveNotice';
|
||||||
export { useRequestSubmit } from './useRequestSubmit';
|
export { useRequestSubmit } from './useRequestSubmit';
|
||||||
export type { IRequestSubmit, SubmitKind } from './useRequestSubmit';
|
export type { IRequestSubmit, SubmitKind } from './useRequestSubmit';
|
||||||
|
|||||||
Reference in New Issue
Block a user