Compare commits
4 Commits
14ac640c50
...
3495e681e7
| Author | SHA1 | Date | |
|---|---|---|---|
| 3495e681e7 | |||
| 583f400e26 | |||
| 36432c75e6 | |||
| 45fc06a6ac |
+1
-1
@@ -26,7 +26,7 @@
|
||||
| Править **все поля и вложения** заявки (добавлять и удалять файлы) на своём активном шаге согласования | Алтынай Бултурукова | email в коде (список по каждой форме) | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` |
|
||||
| То же: **все поля и вложения**, но **не только на своём шаге**: достаточно быть в маршруте (согласование или исполнение), пока заявка «На согласовании» или «Выполнение» | Ербол Шаймерденов | email в коде (список по каждой форме) + участие в маршруте | **Только** Организация рабочего места | `shared/approval/fullEditRights.ts` → `canEditAllFields` + `forms/WorkplaceSetup/useApproverRights.ts` |
|
||||
| Заменять человека на шаге согласования (не удалять шаг) **до отправки**: новая заявка, черновик, доработка | Любой инициатор | право по статусу заявки (форма редактируема) | **Только** Организация рабочего места | `forms/WorkplaceSetup/WorkplaceSetupForm.tsx` (`canReplaceRow`) + `useMatrixEditor.replace` |
|
||||
| Заполнять «Необходимое ПО» и «Доступы» на своём шаге согласования | Первый шаг маршрута согласования (по маршруту — непосредственный руководитель) | порядок шага в маршруте | Организация рабочего места | `forms/WorkplaceSetup/useApproverRights.ts` (`canEditReviewFields`) |
|
||||
| Заполнять «Необходимое ПО», «Доступы» и «Мебель» на своём шаге согласования | Первый шаг маршрута согласования (по маршруту — непосредственный руководитель) | порядок шага в маршруте | Организация рабочего места | `forms/WorkplaceSetup/useApproverRights.ts` (`canEditReviewFields`) |
|
||||
| Править оклад на ИС, оклад после ИС, «Отдел/Участок» на своём шаге согласования | Начальник расчётной группы (сейчас Ким Анна) | должность шага: «начальник/руководитель» + «расчет» | Заявка на приём | `forms/RecruitingHire/approverRights.ts` → `canEditPayrollFields` |
|
||||
| Ставить дату приёма (2.2) | Активный исполнитель | роль в маршруте (шаг «Исполнение»), без проверки должности | Заявка на приём | `forms/RecruitingHire/RecruitingHireView.tsx` (`hireDateEditable`) |
|
||||
| Править карту СКУД и сумму за обучение | Начальник отдела кадрового делопроизводства | должность шага: «начальник/руководитель» + «кадров» + «делопроизводств» | Обходной лист | `forms/Clearance/approverRights.ts` → `canEditHrRecordsFields` |
|
||||
|
||||
Generated
+4
-4
@@ -17,7 +17,7 @@
|
||||
"@microsoft/sp-webpart-base": "1.22.1",
|
||||
"@pnp/sp": "^4.20.0",
|
||||
"@pnp/spfx-controls-react": "^3.25.0",
|
||||
"@tnsu/ui-kit": "^0.14.0",
|
||||
"@tnsu/ui-kit": "^0.15.0",
|
||||
"jszip": "~3.8.0",
|
||||
"react": "17.0.1",
|
||||
"react-dom": "17.0.1",
|
||||
@@ -9886,9 +9886,9 @@
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@tnsu/ui-kit": {
|
||||
"version": "0.14.0",
|
||||
"resolved": "https://git.tnsu.kz/api/packages/tansu/npm/%40tnsu%2Fui-kit/-/0.14.0/ui-kit-0.14.0.tgz",
|
||||
"integrity": "sha512-Vun9AUM9jL8rttbsbKo5Yh6TUz1srkNqxiO6mmzebrSo1JOASNJqNjJPMTS+8Jt3MTrJPklvh0TbeqGQzw7qxQ==",
|
||||
"version": "0.15.0",
|
||||
"resolved": "https://git.tnsu.kz/api/packages/tansu/npm/%40tnsu%2Fui-kit/-/0.15.0/ui-kit-0.15.0.tgz",
|
||||
"integrity": "sha512-vs+7NPem7PDaYj00nwaFztkw2K/nOkw6m2bvqgMABZG5rWjLVqfyFyiopKFkPYgqPWcMPwmQuxoFnc9xi7rwjQ==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"@fluentui/react": "^8.106.4",
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
"@microsoft/sp-webpart-base": "1.22.1",
|
||||
"@pnp/sp": "^4.20.0",
|
||||
"@pnp/spfx-controls-react": "^3.25.0",
|
||||
"@tnsu/ui-kit": "^0.14.0",
|
||||
"@tnsu/ui-kit": "^0.15.0",
|
||||
"jszip": "~3.8.0",
|
||||
"react": "17.0.1",
|
||||
"react-dom": "17.0.1",
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import styles from './BusinessTripView.module.scss';
|
||||
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton } from '../../shared/ui';
|
||||
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton, buildPrintTitle } from '../../shared/ui';
|
||||
import {
|
||||
ApproverService,
|
||||
ApprovalMatrix,
|
||||
@@ -55,6 +55,7 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [code, setCode] = useState<string>('');
|
||||
const [employeeName, setEmployeeName] = useState<string>('');
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -84,6 +85,7 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
|
||||
setData(loaded.data);
|
||||
setStatus(loaded.status);
|
||||
setCode(loaded.code);
|
||||
setEmployeeName(loaded.employeeName);
|
||||
notifyCode(loaded.code);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
@@ -128,7 +130,9 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
|
||||
return (
|
||||
<div>
|
||||
<div className={`${styles.toolbar} rp-no-print`}>
|
||||
<PrintButton />
|
||||
<PrintButton
|
||||
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rp-print-root">
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface ILoadedRequest {
|
||||
status: string;
|
||||
/** Уникальный код заявки; '' — если запись создана до появления поля. */
|
||||
code: string;
|
||||
/** ФИО командируемого (Employee/Title) — для имени печатного документа. */
|
||||
employeeName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,11 +39,12 @@ export async function loadBusinessTripRequest(itemId: number, listId?: string):
|
||||
'TripObligationAccepted',
|
||||
'RequestStatus',
|
||||
'RequestUniqueCode',
|
||||
'Employee/EMail'
|
||||
'Employee/EMail',
|
||||
'Employee/Title'
|
||||
)
|
||||
.expand('Employee')()) as Record<string, unknown>;
|
||||
|
||||
const employee = item.Employee as { EMail?: string } | undefined;
|
||||
const employee = item.Employee as { EMail?: string; Title?: string } | undefined;
|
||||
|
||||
const data: IFormData = {
|
||||
...EMPTY_DATA,
|
||||
@@ -61,6 +64,7 @@ export async function loadBusinessTripRequest(itemId: number, listId?: string):
|
||||
return {
|
||||
data,
|
||||
status: (item.RequestStatus as string) || '',
|
||||
code: (item.RequestUniqueCode as string) || ''
|
||||
code: (item.RequestUniqueCode as string) || '',
|
||||
employeeName: employee?.Title || ''
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,16 @@ import * as React from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import styles from './ClearanceView.module.scss';
|
||||
import { Alert, Button, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
FormCard,
|
||||
InitiatorLine,
|
||||
FormModeProvider,
|
||||
PrintSplitButton,
|
||||
OrgLetterhead,
|
||||
buildPrintTitle
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApproverService,
|
||||
DecisionPanel,
|
||||
@@ -48,6 +57,8 @@ interface IClearanceViewProps {
|
||||
|
||||
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
|
||||
const FORM_TITLE = 'Обходной лист';
|
||||
|
||||
// Обходной лист в режиме согласующего. Форма read-only, КРОМЕ отметок, которые
|
||||
// заполняет профильный отдел на своём шаге: КДП — карта СКУД и сумма обучения,
|
||||
// отдел подбора — парковочная карта (см. approverRights.ts).
|
||||
@@ -60,6 +71,8 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [code, setCode] = useState<string>('');
|
||||
// ФИО увольняемого — для имени печатного документа.
|
||||
const [employeeName, setEmployeeName] = useState<string>('');
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hrRecordsEditable, setHrRecordsEditable] = useState<boolean>(false);
|
||||
@@ -123,6 +136,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
||||
setData(loaded.data);
|
||||
setStatus(loaded.status);
|
||||
setCode(loaded.code);
|
||||
setEmployeeName(loaded.employeeName);
|
||||
notifyCode(loaded.code);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
@@ -274,7 +288,12 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
||||
return (
|
||||
<div>
|
||||
<div className={`${styles.toolbar} rp-no-print`}>
|
||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
||||
<PrintSplitButton
|
||||
value={data.Organization}
|
||||
selectedIndex={printOrgIndex}
|
||||
onChange={setPrintOrgIndex}
|
||||
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{canEditAnything && (
|
||||
@@ -295,7 +314,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
||||
<div className="rp-print-root">
|
||||
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||
<FormCard
|
||||
title="Обходной лист"
|
||||
title={FORM_TITLE}
|
||||
subtitle={`№${code || itemId}`}
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
|
||||
@@ -2,7 +2,15 @@ import * as React from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import styles from './ConcreteRequestView.module.scss';
|
||||
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
|
||||
import {
|
||||
Alert,
|
||||
FormCard,
|
||||
InitiatorLine,
|
||||
FormModeProvider,
|
||||
PrintSplitButton,
|
||||
OrgLetterhead,
|
||||
buildPrintTitle
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApproverService,
|
||||
DecisionPanel,
|
||||
@@ -42,6 +50,8 @@ interface IConcreteRequestViewProps {
|
||||
onCodeLoaded?: (code: string) => void;
|
||||
}
|
||||
|
||||
const FORM_TITLE = 'Заявка на бетон';
|
||||
|
||||
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
||||
const NOOP_SET: SetField = () => undefined;
|
||||
|
||||
@@ -137,13 +147,18 @@ const ConcreteRequestView: React.FC<IConcreteRequestViewProps> = ({ context, ite
|
||||
return (
|
||||
<div>
|
||||
<div className={`${styles.toolbar} rp-no-print`}>
|
||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
||||
<PrintSplitButton
|
||||
value={data.Organization}
|
||||
selectedIndex={printOrgIndex}
|
||||
onChange={setPrintOrgIndex}
|
||||
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.SiteChiefName, code, itemId })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rp-print-root">
|
||||
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||
<FormCard
|
||||
title="Заявка на бетон"
|
||||
title={FORM_TITLE}
|
||||
subtitle={`№${code || itemId}`}
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import styles from './DismissalView.module.scss';
|
||||
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton, FormNotice } from '../../shared/ui';
|
||||
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton, FormNotice, buildPrintTitle } from '../../shared/ui';
|
||||
import {
|
||||
ApproverService,
|
||||
DecisionPanel,
|
||||
@@ -58,6 +58,8 @@ interface IDismissalViewProps {
|
||||
onCodeLoaded?: (code: string) => void;
|
||||
}
|
||||
|
||||
const FORM_TITLE = 'Заявление на увольнение';
|
||||
|
||||
// Заявление на увольнение только для просмотра (режим согласующего).
|
||||
// Переиспользует ту же секцию, что и форма подачи; редактирование выключено через
|
||||
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
||||
@@ -70,6 +72,8 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [code, setCode] = useState<string>('');
|
||||
// ФИО увольняющегося — для имени печатного документа.
|
||||
const [employeeName, setEmployeeName] = useState<string>('');
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Итог автосоздания обходного листа после согласования (см. onDecided ниже).
|
||||
@@ -107,6 +111,7 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
||||
setData(loaded.data);
|
||||
setStatus(loaded.status);
|
||||
setCode(loaded.code);
|
||||
setEmployeeName(loaded.employeeName);
|
||||
notifyCode(loaded.code);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
@@ -265,7 +270,9 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
||||
<div>
|
||||
<div className={`${styles.toolbar} rp-no-print`}>
|
||||
{printGate === 'allowed' ? (
|
||||
<PrintButton />
|
||||
<PrintButton
|
||||
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
|
||||
/>
|
||||
) : (
|
||||
<span className={styles.printBlocked} title="Печать откроется после согласования обходного листа по этому увольнению">
|
||||
Печать станет доступна после согласования обходного листа
|
||||
@@ -275,7 +282,7 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
||||
|
||||
<div className="rp-print-root">
|
||||
<FormCard
|
||||
title="Заявление на увольнение"
|
||||
title={FORM_TITLE}
|
||||
subtitle={`№${code || itemId}`}
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
|
||||
@@ -2,7 +2,15 @@ import * as React from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import styles from './EmployeeTransferView.module.scss';
|
||||
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
|
||||
import {
|
||||
Alert,
|
||||
FormCard,
|
||||
InitiatorLine,
|
||||
FormModeProvider,
|
||||
PrintSplitButton,
|
||||
OrgLetterhead,
|
||||
buildPrintTitle
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApproverService,
|
||||
DecisionPanel,
|
||||
@@ -41,6 +49,8 @@ interface IEmployeeTransferViewProps {
|
||||
onCodeLoaded?: (code: string) => void;
|
||||
}
|
||||
|
||||
const FORM_TITLE = 'Заявка на перемещение сотрудника';
|
||||
|
||||
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
||||
const NOOP_SET: SetField = () => undefined;
|
||||
|
||||
@@ -56,6 +66,8 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [code, setCode] = useState<string>('');
|
||||
// ФИО перемещаемого — для имени печатного документа.
|
||||
const [employeeName, setEmployeeName] = useState<string>('');
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Какая из организаций сотрудника (см. OrganizationField) идёт в печать —
|
||||
@@ -89,6 +101,7 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
|
||||
setData(loaded.data);
|
||||
setStatus(loaded.status);
|
||||
setCode(loaded.code);
|
||||
setEmployeeName(loaded.employeeName);
|
||||
notifyCode(loaded.code);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
@@ -136,13 +149,18 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
|
||||
return (
|
||||
<div>
|
||||
<div className={`${styles.toolbar} rp-no-print`}>
|
||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
||||
<PrintSplitButton
|
||||
value={data.Organization}
|
||||
selectedIndex={printOrgIndex}
|
||||
onChange={setPrintOrgIndex}
|
||||
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rp-print-root">
|
||||
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||
<FormCard
|
||||
title="Заявка на перемещение сотрудника"
|
||||
title={FORM_TITLE}
|
||||
subtitle={`№${code || itemId}`}
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
PrintButton,
|
||||
FieldChangesProvider,
|
||||
FieldChangesBanner,
|
||||
issueLabels
|
||||
issueLabels,
|
||||
buildPrintTitle
|
||||
} from '../../shared/ui';
|
||||
import { IFieldChange, mergeFieldChanges, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
|
||||
import { HIRE_CHANGE_TRACKING } from './changeTracking';
|
||||
@@ -87,6 +88,7 @@ const HIRE_DATE_REQUIRED_MESSAGE = 'укажите дату приёма (п. 2.
|
||||
const PAYROLL_HINT = 'Как согласующий вы можете указать оклады (п. 4.2, 4.3) и отдел/участок (п. 2.4).';
|
||||
const HIRE_DATE_HINT = 'Как исполнитель укажите дату приёма (п. 2.2) — она обязательна для отметки «Выполнено».';
|
||||
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.';
|
||||
const FORM_TITLE = 'Заявка на прием';
|
||||
|
||||
// Заявка «на прием» только для просмотра (режим согласующего). Переиспользует
|
||||
// те же секции, что и форма подачи; редактирование выключено через
|
||||
@@ -382,7 +384,11 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
||||
<Icon name="copy" size="sm" />
|
||||
Организация рабочего места на основе этой
|
||||
</Button>
|
||||
{isApprovedStatus(status) && <PrintButton />}
|
||||
{isApprovedStatus(status) && (
|
||||
<PrintButton
|
||||
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.HireCandidateName, code, itemId })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasEditableFields && (
|
||||
@@ -400,7 +406,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
||||
|
||||
<div className="rp-print-root">
|
||||
<FormCard
|
||||
title="Заявка на прием"
|
||||
title={FORM_TITLE}
|
||||
subtitle={`№${code || itemId}`}
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
FormModeProvider,
|
||||
PrintButton,
|
||||
FieldChangesProvider,
|
||||
FieldChangesBanner
|
||||
FieldChangesBanner,
|
||||
buildPrintTitle
|
||||
} from '../../shared/ui';
|
||||
import { IFieldChange, mergeFieldChanges, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
|
||||
import { SELECTION_CHANGE_TRACKING } from './changeTracking';
|
||||
@@ -80,6 +81,7 @@ interface IRecruitingSelectionViewProps {
|
||||
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
|
||||
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.';
|
||||
const FORM_TITLE = 'Заявка на подбор';
|
||||
|
||||
// Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же
|
||||
// секции, что и форма подачи; редактирование выключено через <FormModeProvider readOnly>,
|
||||
@@ -366,7 +368,10 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
||||
return (
|
||||
<div>
|
||||
<div className={`${styles.toolbar} rp-no-print`}>
|
||||
{isApprovedStatus(status) && <PrintButton />}
|
||||
{/* Подбор — на вакансию, конкретного человека ещё нет: имя файла без ФИО. */}
|
||||
{isApprovedStatus(status) && (
|
||||
<PrintButton documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: '', code, itemId })} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{fullEditable && (
|
||||
@@ -384,7 +389,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
||||
|
||||
<div className="rp-print-root">
|
||||
<FormCard
|
||||
title="Заявка на подбор"
|
||||
title={FORM_TITLE}
|
||||
subtitle={`№${code || itemId}`}
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
FormField,
|
||||
AttachmentList,
|
||||
PrintSplitButton,
|
||||
OrgLetterhead
|
||||
OrgLetterhead,
|
||||
buildPrintTitle
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApproverService,
|
||||
@@ -60,6 +61,8 @@ interface IReferralViewProps {
|
||||
onCodeLoaded?: (code: string) => void;
|
||||
}
|
||||
|
||||
const FORM_TITLE = 'Реферальная рекомендация';
|
||||
|
||||
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
||||
const NOOP_SET: SetField = () => undefined;
|
||||
|
||||
@@ -189,14 +192,19 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
|
||||
<div>
|
||||
<div className={`${styles.toolbar} rp-no-print`}>
|
||||
{isApprovedStatus(status) && (
|
||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
||||
<PrintSplitButton
|
||||
value={data.Organization}
|
||||
selectedIndex={printOrgIndex}
|
||||
onChange={setPrintOrgIndex}
|
||||
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.CandidateName, code, itemId })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rp-print-root">
|
||||
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||
<FormCard
|
||||
title="Реферальная рекомендация"
|
||||
title={FORM_TITLE}
|
||||
subtitle={`№${code || itemId}`}
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
FormModeProvider,
|
||||
PrintSplitButton,
|
||||
OrgLetterhead,
|
||||
Icon
|
||||
Icon,
|
||||
buildPrintTitle
|
||||
} from '../../shared/ui';
|
||||
import { navigate } from '../../shared/routing';
|
||||
import {
|
||||
@@ -57,6 +58,8 @@ interface IServiceRequestViewProps {
|
||||
onCodeLoaded?: (code: string) => void;
|
||||
}
|
||||
|
||||
const FORM_TITLE = 'Заявка на услуги';
|
||||
|
||||
// Заявка «Заявка на услуги» только для просмотра (режим согласующего).
|
||||
const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||
// onCodeLoaded — колбэк родителя, его ссылка меняется на каждом рендере; в зависимостях эффекта
|
||||
@@ -166,13 +169,18 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
|
||||
Редактировать
|
||||
</Button>
|
||||
)}
|
||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
||||
<PrintSplitButton
|
||||
value={data.Organization}
|
||||
selectedIndex={printOrgIndex}
|
||||
onChange={setPrintOrgIndex}
|
||||
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.RequesterName, code, itemId })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rp-print-root">
|
||||
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||
<FormCard
|
||||
title="Заявка на услуги"
|
||||
title={FORM_TITLE}
|
||||
subtitle={`№${code || itemId}`}
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
|
||||
@@ -2,7 +2,15 @@ import * as React from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import styles from './SkudExplanationView.module.scss';
|
||||
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
|
||||
import {
|
||||
Alert,
|
||||
FormCard,
|
||||
InitiatorLine,
|
||||
FormModeProvider,
|
||||
PrintSplitButton,
|
||||
OrgLetterhead,
|
||||
buildPrintTitle
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApproverService,
|
||||
DecisionPanel,
|
||||
@@ -130,7 +138,12 @@ const SkudExplanationView: React.FC<ISkudExplanationViewProps> = ({ context, ite
|
||||
return (
|
||||
<div>
|
||||
<div className={`${styles.toolbar} rp-no-print`}>
|
||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
||||
<PrintSplitButton
|
||||
value={data.Organization}
|
||||
selectedIndex={printOrgIndex}
|
||||
onChange={setPrintOrgIndex}
|
||||
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.EmployeeFio, code, itemId })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormCard
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
FormField,
|
||||
AttachmentList,
|
||||
PrintButton,
|
||||
Icon
|
||||
Icon,
|
||||
buildPrintTitle
|
||||
} from '../../shared/ui';
|
||||
import { navigate } from '../../shared/routing';
|
||||
import {
|
||||
@@ -56,6 +57,8 @@ interface ITicket1CViewProps {
|
||||
onCodeLoaded?: (code: string) => void;
|
||||
}
|
||||
|
||||
const FORM_TITLE = 'Заявка в отдел 1С';
|
||||
|
||||
// Заявка в отдел 1С только для просмотра (режим согласующего).
|
||||
const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||
@@ -185,12 +188,12 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
|
||||
Редактировать
|
||||
</Button>
|
||||
)}
|
||||
<PrintButton />
|
||||
<PrintButton documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.FIO, code, itemId })} />
|
||||
</div>
|
||||
|
||||
<div className="rp-print-root">
|
||||
<FormCard
|
||||
title="Заявка в отдел 1С"
|
||||
title={FORM_TITLE}
|
||||
subtitle={`№${code || itemId}`}
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
AttachmentList,
|
||||
PrintSplitButton,
|
||||
OrgLetterhead,
|
||||
Icon
|
||||
Icon,
|
||||
buildPrintTitle
|
||||
} from '../../shared/ui';
|
||||
import { navigate } from '../../shared/routing';
|
||||
import {
|
||||
@@ -55,6 +56,8 @@ interface ITmcTransferViewProps {
|
||||
onCodeLoaded?: (code: string) => void;
|
||||
}
|
||||
|
||||
const FORM_TITLE = 'Служебная записка на перемещение ТМЦ';
|
||||
|
||||
// Заявка «СЗ на перемещение ТМЦ» только для просмотра (режим согласующего).
|
||||
const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||
@@ -169,13 +172,18 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
|
||||
Редактировать
|
||||
</Button>
|
||||
)}
|
||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
||||
<PrintSplitButton
|
||||
value={data.Organization}
|
||||
selectedIndex={printOrgIndex}
|
||||
onChange={setPrintOrgIndex}
|
||||
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.AuthorName, code, itemId })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rp-print-root">
|
||||
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||
<FormCard
|
||||
title="Служебная записка на перемещение ТМЦ"
|
||||
title={FORM_TITLE}
|
||||
subtitle={`№${code || itemId}`}
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
PrintSplitButton,
|
||||
OrgLetterhead,
|
||||
FormNotice,
|
||||
PrintSignature
|
||||
PrintSignature,
|
||||
buildPrintTitle
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApproverService,
|
||||
@@ -50,6 +51,8 @@ interface IUnpaidLeaveViewProps {
|
||||
onCodeLoaded?: (code: string) => void;
|
||||
}
|
||||
|
||||
const FORM_TITLE = 'Заявление на отпуск без сохранения заработной платы';
|
||||
|
||||
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
||||
const NOOP_SET: SetField = () => undefined;
|
||||
|
||||
@@ -148,13 +151,18 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
|
||||
return (
|
||||
<div>
|
||||
<div className={`${styles.toolbar} rp-no-print`}>
|
||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
||||
<PrintSplitButton
|
||||
value={data.Organization}
|
||||
selectedIndex={printOrgIndex}
|
||||
onChange={setPrintOrgIndex}
|
||||
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rp-print-root">
|
||||
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||
<FormCard
|
||||
title="Заявление на отпуск без сохранения заработной платы"
|
||||
title={FORM_TITLE}
|
||||
subtitle={`№${code || itemId}`}
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
PrintSplitButton,
|
||||
OrgLetterhead,
|
||||
FormNotice,
|
||||
PrintSignature
|
||||
PrintSignature,
|
||||
buildPrintTitle
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApproverService,
|
||||
@@ -57,6 +58,8 @@ interface IVacationViewProps {
|
||||
onCodeLoaded?: (code: string) => void;
|
||||
}
|
||||
|
||||
const FORM_TITLE = 'Заявление на оплачиваемый ежегодный трудовой отпуск';
|
||||
|
||||
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
||||
const NOOP_SET: SetField = () => undefined;
|
||||
|
||||
@@ -207,13 +210,18 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
||||
return (
|
||||
<div>
|
||||
<div className={`${styles.toolbar} rp-no-print`}>
|
||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
||||
<PrintSplitButton
|
||||
value={data.Organization}
|
||||
selectedIndex={printOrgIndex}
|
||||
onChange={setPrintOrgIndex}
|
||||
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rp-print-root">
|
||||
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||
<FormCard
|
||||
title="Заявление на оплачиваемый ежегодный трудовой отпуск"
|
||||
title={FORM_TITLE}
|
||||
subtitle={`№${code || itemId}`}
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
|
||||
@@ -23,16 +23,28 @@
|
||||
- **Полное право правки** (`shared/approval/fullEditRights.ts`, сейчас Ербол
|
||||
Шаймерденов): все поля и вложения, если он есть в маршруте и заявка «На
|
||||
согласовании»/«Выполнение» (`useApproverRights.ts`).
|
||||
- **Поля «Необходимое ПО» и «Доступы» заполняет согласующий-руководитель** на своём
|
||||
этапе (инициатор их обычно не знает). Доступны для правки, когда очередь дошла до
|
||||
- **Поля «Необходимое ПО», «Доступы» и «Мебель» заполняет согласующий-руководитель**
|
||||
на своём этапе (инициатор их обычно не знает). Доступны для правки, когда очередь дошла до
|
||||
**первого** шага согласования и это текущий пользователь; сохраняются кнопкой
|
||||
«Сохранить» и автоматически при «Согласовать» (`updateReviewFields`,
|
||||
`DecisionPanel.onBeforeDecide`). Остальная форма при этом read-only.
|
||||
- **Мебель** — мультивыбор (`FURNITURE_OPTIONS`); при «Другое» появляется поле
|
||||
своего значения. В SP — `WorkplaceFurnitureJson` (JSON-массив) и
|
||||
`WorkplaceFurnitureOther`. Необязательная. Уходит в маршрут как
|
||||
`WorkplaceFurnitureJson` (пункты через запятую, `routeFields.ts`) — на условие
|
||||
шага исполнения «исполнитель по мебели».
|
||||
- **Добавление исполнителей по правке согласующего** (`routeExecutorsSync.ts`):
|
||||
маршрут планируется при подаче, поэтому если руководитель/Ербол поменял мебель
|
||||
или ПО, при сохранении (и перед решением) строятся два плана исполнения — со
|
||||
старыми и новыми значениями — и разница добавляется в конец исполнения
|
||||
(«Ждёт очереди», `ApproverService.addMissingExecutors`). Никого не удаляем;
|
||||
уже стоящих в исполнении (в любом статусе) не дублируем
|
||||
(`shared/approval/missingExecutors.ts`).
|
||||
- **Маска телефона** (PhoneInput).
|
||||
- Печать — общий механизм (без «Вложений»).
|
||||
|
||||
## Секции формы
|
||||
HireRequestPicker (только в редактируемой форме) · EmployeeSection · EquipmentSection
|
||||
HireRequestPicker (только в редактируемой форме) · EmployeeSection · EquipmentSection · FurnitureSection
|
||||
|
||||
## Точки входа
|
||||
- rptype: `it-workplace-setup`
|
||||
|
||||
@@ -63,6 +63,7 @@ import { loadWorkplaceRequest } from './load';
|
||||
import { hasRequiredFields, REQUIRED_FIELDS_MESSAGE } from './validation';
|
||||
import { EmployeeSection } from './sections/EmployeeSection';
|
||||
import { EquipmentSection } from './sections/EquipmentSection';
|
||||
import { FurnitureSection } from './sections/FurnitureSection';
|
||||
import { useStableCallback } from '../../shared/useStableCallback';
|
||||
|
||||
const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
||||
@@ -392,6 +393,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
||||
<FormModeProvider readOnly={readOnly}>
|
||||
<EmployeeSection {...sectionProps} />
|
||||
<EquipmentSection {...sectionProps} />
|
||||
<FurnitureSection {...sectionProps} />
|
||||
</FormModeProvider>
|
||||
</FieldChangesProvider>
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
OrgLetterhead,
|
||||
Icon,
|
||||
FieldChangesProvider,
|
||||
FieldChangesBanner
|
||||
FieldChangesBanner,
|
||||
buildPrintTitle
|
||||
} from '../../shared/ui';
|
||||
import { navigate } from '../../shared/routing';
|
||||
import {
|
||||
@@ -47,8 +48,10 @@ import { workplaceApprovalService, updateReviewFields, updateAllWorkplaceFields
|
||||
import { WORKPLACE_CHANGE_TRACKING } from './changeTracking';
|
||||
import { hasRequiredFields, REQUIRED_FIELDS_MESSAGE } from './validation';
|
||||
import { useApproverRights } from './useApproverRights';
|
||||
import { addExecutorsForEditedFields, IExecutorsSyncResult } from './routeExecutorsSync';
|
||||
import { EmployeeSection } from './sections/EmployeeSection';
|
||||
import { EquipmentSection } from './sections/EquipmentSection';
|
||||
import { FurnitureSection } from './sections/FurnitureSection';
|
||||
import { useStableCallback } from '../../shared/useStableCallback';
|
||||
|
||||
interface IWorkplaceSetupViewProps {
|
||||
@@ -64,13 +67,14 @@ interface IWorkplaceSetupViewProps {
|
||||
|
||||
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
|
||||
const REVIEW_FIELDS_HINT = 'Как согласующий вы можете заполнить «Необходимое ПО» и «Доступы».';
|
||||
const FORM_TITLE = 'Организация рабочего места';
|
||||
const REVIEW_FIELDS_HINT = 'Как согласующий вы можете заполнить «Необходимое ПО», «Доступы» и «Мебель».';
|
||||
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.';
|
||||
|
||||
// Заявка «орг. рабочего места» только для просмотра (режим согласующего).
|
||||
// Исключения (оба — только на своём активном шаге, см. useApproverRights):
|
||||
// - первый согласующий шаг (по маршруту — непосредственный руководитель) может
|
||||
// заполнить «Необходимое ПО» и «Доступы» (инициатор их не знает);
|
||||
// заполнить «Необходимое ПО», «Доступы» и «Мебель» (инициатор их не знает);
|
||||
// - согласующий из shared/approval/fullEditRights.ts правит все поля и вложения.
|
||||
const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||
@@ -102,6 +106,8 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
||||
// Ref списка данных для ApproverService.updateRequestStatus: listId (url) для
|
||||
// архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
|
||||
const dataListRef = listId || dataListTitle(FORM_ID);
|
||||
// Title списка данных (а не url) — для оповещений, шапки «Инициатор» и чтения инициатора.
|
||||
const dataListName = listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID);
|
||||
const approval = useMemo(() => workplaceApprovalService(listId), [listId]);
|
||||
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
|
||||
const attachments = useAttachmentEditor(dataListRef, itemId);
|
||||
@@ -174,6 +180,18 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
||||
};
|
||||
}, [itemId, listId, dataListRef, notifyCode, setAttachmentItems]);
|
||||
|
||||
const notify = (newStatus?: string): void => {
|
||||
if (!requestTypeId) return;
|
||||
const target = {
|
||||
requestTypeId,
|
||||
dataListName,
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID),
|
||||
};
|
||||
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||
};
|
||||
|
||||
const saveAllFields = async (): Promise<void> => {
|
||||
const editor = { name: context.pageContext.user.displayName, email: effectiveUserEmail(context) };
|
||||
const nextChanges = mergeFieldChanges(approverChanges, savedData, data, WORKPLACE_CHANGE_TRACKING, editor, new Date());
|
||||
@@ -183,18 +201,36 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
||||
await attachments.uploadPending();
|
||||
};
|
||||
|
||||
const saveReviewFields = (): Promise<void> =>
|
||||
updateReviewFields(itemId, { Software: data.Software, AccessRights: data.AccessRights }, listId);
|
||||
// updateReviewFields пишет только поля руководителя (ReviewFields), остальное из data игнорирует.
|
||||
const saveReviewFields = (): Promise<void> => updateReviewFields(itemId, data, listId);
|
||||
|
||||
// Полное право шире: оно включает и «ПО»/«Доступы».
|
||||
const saveFields = (): Promise<void> => (canEditAll ? saveAllFields() : saveReviewFields());
|
||||
// Полное право шире: оно включает и поля руководителя. После записи полей —
|
||||
// исполнители, которых привела правка мебели/ПО (маршрут планировался при
|
||||
// подаче и сам их не добавит, см. routeExecutorsSync.ts).
|
||||
const saveFields = async (): Promise<IExecutorsSyncResult> => {
|
||||
const before = savedData;
|
||||
if (canEditAll) {
|
||||
await saveAllFields();
|
||||
} else {
|
||||
await saveReviewFields();
|
||||
setSavedData(data);
|
||||
}
|
||||
return addExecutorsForEditedFields(
|
||||
{ context, approval, approverService, requestTypeId, itemId, dataListTitle: dataListName },
|
||||
before,
|
||||
data
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
setSaveState('saving');
|
||||
setSaveErrorMessage('');
|
||||
try {
|
||||
await saveFields();
|
||||
const executorsSync = await saveFields();
|
||||
setSaveState('saved');
|
||||
// Новые исполнители должны появиться в таблице «Исполнение» — перечитываем маршрут.
|
||||
if (executorsSync.addedCount > 0) setDecisionSeq((n) => n + 1);
|
||||
if (executorsSync.activatedRowIds.length > 0) notify();
|
||||
} catch (e) {
|
||||
setSaveErrorMessage(friendlyErrorMessage(e));
|
||||
setSaveState('error');
|
||||
@@ -225,18 +261,6 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
||||
const sectionProps = { data, set, context, printOrgIndex };
|
||||
const canEditAnything = canEditAll || canEditReviewFields;
|
||||
|
||||
const notify = (newStatus?: string): void => {
|
||||
if (!requestTypeId) return;
|
||||
const target = {
|
||||
requestTypeId,
|
||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID),
|
||||
};
|
||||
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={`${styles.toolbar} rp-no-print`}>
|
||||
@@ -249,7 +273,12 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
||||
Редактировать
|
||||
</Button>
|
||||
)}
|
||||
<PrintSplitButton value={organizationText(data)} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
||||
<PrintSplitButton
|
||||
value={organizationText(data)}
|
||||
selectedIndex={printOrgIndex}
|
||||
onChange={setPrintOrgIndex}
|
||||
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.FullName, code, itemId })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{canEditAnything && (
|
||||
@@ -268,13 +297,10 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
||||
<div className="rp-print-root">
|
||||
<OrgLetterhead value={organizationText(data)} selectedIndex={printOrgIndex} />
|
||||
<FormCard
|
||||
title="Организация рабочего места"
|
||||
title={FORM_TITLE}
|
||||
subtitle={`№${code || itemId}`}
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
listTitle={listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID)}
|
||||
itemId={itemId}
|
||||
/>
|
||||
<InitiatorLine listTitle={dataListName} itemId={itemId} />
|
||||
}
|
||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||
>
|
||||
@@ -283,6 +309,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
||||
<FormModeProvider readOnly={!canEditAll}>
|
||||
<EmployeeSection {...sectionProps} />
|
||||
<EquipmentSection {...sectionProps} approverEditable={canEditReviewFields} />
|
||||
<FurnitureSection {...sectionProps} approverEditable={canEditReviewFields} />
|
||||
</FormModeProvider>
|
||||
</FieldChangesProvider>
|
||||
|
||||
|
||||
@@ -3,18 +3,25 @@ import { dataListTitle } from '../../shared/listTitles';
|
||||
import { spDateOnlyToIso } from '../../shared/dateUtils';
|
||||
import { ICompany } from '../../shared/dictionary';
|
||||
import { IFieldChange, parseFieldChanges } from '../../shared/fieldChanges/fieldChanges';
|
||||
import { COMPANIES_JSON_FIELD, FORM_ID, IFormData, EMPTY_DATA } from './model';
|
||||
import {
|
||||
COMPANIES_JSON_FIELD,
|
||||
FURNITURE_JSON_FIELD,
|
||||
FURNITURE_OTHER_FIELD,
|
||||
FORM_ID,
|
||||
IFormData,
|
||||
EMPTY_DATA
|
||||
} from './model';
|
||||
import { APPROVER_CHANGES_FIELD } from './changeTracking';
|
||||
|
||||
/**
|
||||
* JSON выбранных компаний → массив. Пусто — заявка до мультивыбора (тогда
|
||||
* организации есть только текстом в Organization) или битый JSON.
|
||||
* JSON-колонка с массивом → массив. Пусто — колонка не заполнена (заявка создана
|
||||
* до появления поля: компании до мультивыбора, мебель до 2026-10-29) или битый JSON.
|
||||
*/
|
||||
function parseCompanies(raw: unknown): ICompany[] {
|
||||
function parseJsonArray<T>(raw: unknown): T[] {
|
||||
if (typeof raw !== 'string' || raw.trim() === '') return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? (parsed as ICompany[]) : [];
|
||||
return Array.isArray(parsed) ? (parsed as T[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -50,6 +57,8 @@ export async function loadWorkplaceRequest(itemId: number, listId?: string): Pro
|
||||
...SCALAR_FIELDS,
|
||||
'NeedsEquipment',
|
||||
COMPANIES_JSON_FIELD,
|
||||
FURNITURE_JSON_FIELD,
|
||||
FURNITURE_OTHER_FIELD,
|
||||
APPROVER_CHANGES_FIELD,
|
||||
'RequestStatus',
|
||||
'RequestUniqueCode'
|
||||
@@ -71,7 +80,9 @@ export async function loadWorkplaceRequest(itemId: number, listId?: string): Pro
|
||||
});
|
||||
|
||||
data.NeedsEquipment = item.NeedsEquipment === true;
|
||||
data.Companies = parseCompanies(item[COMPANIES_JSON_FIELD]);
|
||||
data.Companies = parseJsonArray<ICompany>(item[COMPANIES_JSON_FIELD]);
|
||||
data.Furniture = parseJsonArray<string>(item[FURNITURE_JSON_FIELD]);
|
||||
data.FurnitureOther = (item[FURNITURE_OTHER_FIELD] as string) || '';
|
||||
|
||||
return {
|
||||
data,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { RequestTypeId } from '../../models/types';
|
||||
import { ISelectOption } from '@tnsu/ui-kit';
|
||||
import { ICompany } from '../../shared/dictionary';
|
||||
|
||||
/**
|
||||
@@ -14,6 +15,22 @@ export const FORM_ID: RequestTypeId = 'it-workplace-setup';
|
||||
*/
|
||||
export const COMPANIES_JSON_FIELD = 'WorkplaceCompaniesJson';
|
||||
|
||||
/** Note-колонка с IFormData.Furniture в JSON. Префикс Workplace — по той же причине, что у компаний. */
|
||||
export const FURNITURE_JSON_FIELD = 'WorkplaceFurnitureJson';
|
||||
/** Text-колонка с IFormData.FurnitureOther. */
|
||||
export const FURNITURE_OTHER_FIELD = 'WorkplaceFurnitureOther';
|
||||
|
||||
/** Значение-триггер «Другое» в «Мебели»: при нём показываем поле ручного ввода. */
|
||||
export const FURNITURE_OTHER_VALUE = 'Другое';
|
||||
|
||||
export const FURNITURE_OPTIONS: ISelectOption[] = [
|
||||
{ value: 'Стол', label: 'Стол' },
|
||||
{ value: 'Кресло', label: 'Кресло' },
|
||||
{ value: 'Тумбочка', label: 'Тумбочка' },
|
||||
{ value: 'Канцелярские принадлежности', label: 'Канцелярские принадлежности' },
|
||||
{ value: FURNITURE_OTHER_VALUE, label: FURNITURE_OTHER_VALUE }
|
||||
];
|
||||
|
||||
/**
|
||||
* Данные формы «Организация рабочего места». Ключи = internalName колонок SP-списка
|
||||
* «Организация рабочего места — данные».
|
||||
@@ -57,6 +74,11 @@ export interface IFormData {
|
||||
NeedsEquipment: boolean;
|
||||
Software: string;
|
||||
AccessRights: string;
|
||||
// Мебель (необязательно; заполняется вручную)
|
||||
/** Выбранные пункты из FURNITURE_OPTIONS. В SP — JSON в колонке WorkplaceFurnitureJson. */
|
||||
Furniture: string[];
|
||||
/** Своё значение — только если выбрано «Другое», иначе ''. В SP — колонка WorkplaceFurnitureOther. */
|
||||
FurnitureOther: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,5 +118,7 @@ export const EMPTY_DATA: IFormData = {
|
||||
Organization: '',
|
||||
NeedsEquipment: false,
|
||||
Software: '',
|
||||
AccessRights: ''
|
||||
AccessRights: '',
|
||||
Furniture: [],
|
||||
FurnitureOther: ''
|
||||
};
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
{ "internalName": "NeedsEquipment", "title": "Нужна ли техника", "type": "Boolean" },
|
||||
{ "internalName": "Software", "title": "Необходимое ПО", "type": "Note" },
|
||||
{ "internalName": "AccessRights", "title": "Доступы", "type": "Note" },
|
||||
{ "internalName": "WorkplaceFurnitureJson", "title": "Мебель (JSON)", "type": "Note" },
|
||||
{ "internalName": "WorkplaceFurnitureOther", "title": "Мебель (другое)", "type": "Text" },
|
||||
{ "internalName": "WorkplaceApproverChangesJson", "title": "Изменения согласующего — рабочее место (JSON)", "type": "Note" },
|
||||
|
||||
{ "internalName": "SourceHire", "title": "Источник (заявка на прием)", "type": "Lookup", "lookupList": "Прием на работу — данные", "lookupShowField": "Title" }
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import {
|
||||
ApprovalListService,
|
||||
ApproverService,
|
||||
IApproverToWrite,
|
||||
IMatrixRow,
|
||||
IResolveFieldValue,
|
||||
getPlannedMatrix,
|
||||
planMissingExecutors
|
||||
} from '../../shared/approval';
|
||||
import { getCreatedBy } from '../../shared/createdBy';
|
||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||
import { IFormData } from './model';
|
||||
import { routeFieldValues } from './routeFields';
|
||||
|
||||
/** Окружение просмотра заявки, нужное для добавления исполнителей. */
|
||||
export interface IExecutorsSyncContext {
|
||||
context: WebPartContext;
|
||||
approval: ApprovalListService;
|
||||
approverService: ApproverService;
|
||||
/** Id типа заявки в approval-route; undefined — ещё не загружен. */
|
||||
requestTypeId: number | undefined;
|
||||
itemId: number;
|
||||
/** Title списка данных заявки — по нему читаем инициатора (getCreatedBy). */
|
||||
dataListTitle: string;
|
||||
}
|
||||
|
||||
export interface IExecutorsSyncResult {
|
||||
addedCount: number;
|
||||
/** Строки, ставшие активными, — им нужно оповещение. */
|
||||
activatedRowIds: number[];
|
||||
}
|
||||
|
||||
const NOTHING_ADDED: IExecutorsSyncResult = { addedCount: 0, activatedRowIds: [] };
|
||||
|
||||
function toWrite(row: IMatrixRow): IApproverToWrite {
|
||||
return {
|
||||
email: row.email,
|
||||
position: row.position,
|
||||
allowReassign: row.allowReassign,
|
||||
slaHours: row.slaHours,
|
||||
askOverdueReason: row.askOverdueReason,
|
||||
autoApprove: row.autoApprove
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Согласующий поправил «Мебель» или «Необходимое ПО» в поданной заявке — добавить
|
||||
* исполнителей, которых эти поля приводят по маршруту из админки (иначе, напр.,
|
||||
* мебель, отмеченная руководителем, осталась бы без исполнителя: маршрут
|
||||
* планировался при подаче).
|
||||
*
|
||||
* Строим два плана исполнения — со старыми и с новыми мебелью/ПО — и добавляем
|
||||
* разницу (planMissingExecutors). Остальные поля маршрута (организации, отдел)
|
||||
* в обоих планах берём новые: правка организаций полным правом исполнителей
|
||||
* не добавляет — такого не просили, а маршрут по ним уже был при подаче.
|
||||
*
|
||||
* Маршрут резолвится по инициатору (Автор элемента), как при подаче.
|
||||
* Ошибка — с пояснением, что поля уже сохранены: молча терять исполнителя нельзя.
|
||||
*/
|
||||
export async function addExecutorsForEditedFields(
|
||||
sync: IExecutorsSyncContext,
|
||||
before: IFormData,
|
||||
after: IFormData
|
||||
): Promise<IExecutorsSyncResult> {
|
||||
const fieldsBefore = routeFieldValues({ Companies: after.Companies, Furniture: before.Furniture }, before.Software);
|
||||
const fieldsAfter = routeFieldValues(after, after.Software);
|
||||
if (JSON.stringify(fieldsBefore) === JSON.stringify(fieldsAfter)) return NOTHING_ADDED;
|
||||
|
||||
try {
|
||||
if (!sync.requestTypeId) throw new Error('не загружен тип заявки');
|
||||
const initiator = await getCreatedBy(sync.dataListTitle, sync.itemId);
|
||||
if (!initiator?.email) throw new Error('не удалось определить инициатора заявки');
|
||||
|
||||
const planExecution = (fieldValues: IResolveFieldValue[]): Promise<IMatrixRow[]> =>
|
||||
getPlannedMatrix(
|
||||
sync.context,
|
||||
sync.requestTypeId as number,
|
||||
initiator.email,
|
||||
sync.approval,
|
||||
'Execution',
|
||||
after.DepartmentId,
|
||||
fieldValues
|
||||
);
|
||||
const [plannedBefore, plannedAfter, currentRows] = await Promise.all([
|
||||
planExecution(fieldsBefore),
|
||||
planExecution(fieldsAfter),
|
||||
sync.approval.getMatrixForRequest(sync.itemId)
|
||||
]);
|
||||
|
||||
const toAdd = planMissingExecutors(plannedBefore, plannedAfter, currentRows);
|
||||
if (toAdd.length === 0) return NOTHING_ADDED;
|
||||
const activatedRowIds = await sync.approverService.addMissingExecutors(sync.itemId, toAdd.map(toWrite));
|
||||
return { addedCount: toAdd.length, activatedRowIds };
|
||||
} catch (e) {
|
||||
throw new Error(`Изменения сохранены, но не удалось добавить исполнителей по маршруту: ${friendlyErrorMessage(e)}`);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { IResolveFieldValue } from '../../shared/approval';
|
||||
import { COMPANIES_JSON_FIELD, IFormData } from './model';
|
||||
import { COMPANIES_JSON_FIELD, FURNITURE_JSON_FIELD, IFormData } from './model';
|
||||
|
||||
/**
|
||||
* Значения полей заявки для условных шагов маршрута (fieldValues при resolve).
|
||||
@@ -11,12 +11,18 @@ import { COMPANIES_JSON_FIELD, IFormData } from './model';
|
||||
* строку (массив → 400), а «содержит» ищет подстроку. Тот же формат, что в
|
||||
* заявках на приём и подбор (см. RecruitingHireForm).
|
||||
*
|
||||
* Маршрут планируется при подаче, поэтому условие по Software сработает,
|
||||
* только если «Необходимое ПО» указал сам инициатор (руководитель заполняет
|
||||
* его позже, на своём шаге).
|
||||
* Мебель — «WorkplaceFurnitureJson содержит Стол» и т.п. (исполнитель по мебели):
|
||||
* пункты одной строкой через запятую — «содержит» ищет подстроку, разделитель
|
||||
* ему безразличен, а в диагностике админки так читается легче. Сортируем, чтобы
|
||||
* порядок кликов не менял строку и не перепланировал матрицу впустую.
|
||||
*
|
||||
* Маршрут планируется при подаче, поэтому условия по Software и мебели при
|
||||
* подаче сработают, только если их указал сам инициатор. Если их потом
|
||||
* заполнит руководитель, недостающих исполнителей добавляет просмотр заявки
|
||||
* при сохранении правок.
|
||||
*/
|
||||
export function routeFieldValues(
|
||||
data: Pick<IFormData, 'Companies'>,
|
||||
data: Pick<IFormData, 'Companies' | 'Furniture'>,
|
||||
software: string
|
||||
): IResolveFieldValue[] {
|
||||
const fieldValues: IResolveFieldValue[] = [];
|
||||
@@ -29,5 +35,9 @@ export function routeFieldValues(
|
||||
if (softwareText) {
|
||||
fieldValues.push({ fieldName: 'Software', value: softwareText });
|
||||
}
|
||||
const furnitureText = data.Furniture.slice().sort().join(', ');
|
||||
if (furnitureText) {
|
||||
fieldValues.push({ fieldName: FURNITURE_JSON_FIELD, value: furnitureText });
|
||||
}
|
||||
return fieldValues;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as React from 'react';
|
||||
import { CollapseSection, FormField, FormModeProvider, MultiChoiceField, TextInput } from '../../../shared/ui';
|
||||
import { FURNITURE_OPTIONS, FURNITURE_OTHER_VALUE, ISectionProps } from '../model';
|
||||
|
||||
interface IFurnitureSectionProps extends ISectionProps {
|
||||
/** Согласующий-руководитель на своём шаге может отметить мебель, даже когда
|
||||
* остальная форма read-only (как ПО и доступы в EquipmentSection). */
|
||||
approverEditable?: boolean;
|
||||
}
|
||||
|
||||
export const FurnitureSection: React.FC<IFurnitureSectionProps> = ({ data, set, approverEditable }) => {
|
||||
const hasOther = data.Furniture.indexOf(FURNITURE_OTHER_VALUE) !== -1;
|
||||
|
||||
const handleFurnitureChange = (values: string[]): void => {
|
||||
set('Furniture', values);
|
||||
// Сняли «Другое» — своё значение больше не относится к заявке, не храним его.
|
||||
if (values.indexOf(FURNITURE_OTHER_VALUE) === -1) set('FurnitureOther', '');
|
||||
};
|
||||
|
||||
const fields = (
|
||||
<>
|
||||
<FormField label="Мебель" fieldKey="Furniture" full>
|
||||
<MultiChoiceField
|
||||
value={data.Furniture}
|
||||
onChange={handleFurnitureChange}
|
||||
options={FURNITURE_OPTIONS}
|
||||
placeholder="Выберите, что нужно"
|
||||
/>
|
||||
</FormField>
|
||||
{hasOther && (
|
||||
<FormField label="Мебель (другое)" htmlFor="FurnitureOther" full>
|
||||
<TextInput id="FurnitureOther" value={data.FurnitureOther} onChange={(v) => set('FurnitureOther', v)} />
|
||||
</FormField>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<CollapseSection title="Мебель" icon="chair">
|
||||
{/* MultiChoiceField берёт режим только из контекста (своего readOnly у него нет),
|
||||
поэтому право руководителя даём вложенным провайдером, перекрывающим внешний. */}
|
||||
{approverEditable ? <FormModeProvider readOnly={false}>{fields}</FormModeProvider> : fields}
|
||||
</CollapseSection>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,14 @@
|
||||
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
||||
import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList';
|
||||
import { IFieldChange, serializeFieldChanges } from '../../shared/fieldChanges/fieldChanges';
|
||||
import { COMPANIES_JSON_FIELD, FORM_ID, IFormData, organizationText } from './model';
|
||||
import {
|
||||
COMPANIES_JSON_FIELD,
|
||||
FURNITURE_JSON_FIELD,
|
||||
FURNITURE_OTHER_FIELD,
|
||||
FORM_ID,
|
||||
IFormData,
|
||||
organizationText
|
||||
} from './model';
|
||||
import { APPROVER_CHANGES_FIELD } from './changeTracking';
|
||||
|
||||
/**
|
||||
@@ -13,22 +20,30 @@ export function workplaceApprovalService(listId?: string): ApprovalListService {
|
||||
return approvalServiceFor(FORM_ID, listId);
|
||||
}
|
||||
|
||||
/** Поля, которые заполняет согласующий-руководитель на своём шаге (см. updateReviewFields). */
|
||||
export type ReviewFields = Pick<IFormData, 'Software' | 'AccessRights' | 'Furniture' | 'FurnitureOther'>;
|
||||
|
||||
/** Колонки SP «Мебели»: массив — JSON-ом, пустые значения — null. */
|
||||
function furnitureFields(data: Pick<IFormData, 'Furniture' | 'FurnitureOther'>): Record<string, unknown> {
|
||||
return {
|
||||
[FURNITURE_JSON_FIELD]: data.Furniture.length > 0 ? JSON.stringify(data.Furniture) : null,
|
||||
[FURNITURE_OTHER_FIELD]: data.FurnitureOther === '' ? null : data.FurnitureOther
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить поля, которые заполняет согласующий-руководитель на этапе согласования
|
||||
* (инициатор часто не знает нужное ПО и доступы). Пишет только эти два поля.
|
||||
* (инициатор часто не знает нужное ПО, доступы и мебель). Пишет только эти поля.
|
||||
*
|
||||
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadWorkplaceRequest): элемент уже
|
||||
* существует, поэтому читаем/пишем напрямую по url его списка, если он задан.
|
||||
*/
|
||||
export async function updateReviewFields(
|
||||
id: number,
|
||||
fields: { Software: string; AccessRights: string },
|
||||
listId?: string
|
||||
): Promise<void> {
|
||||
export async function updateReviewFields(id: number, fields: ReviewFields, listId?: string): Promise<void> {
|
||||
const list = resolveDataList(FORM_ID, listId);
|
||||
await list.items.getById(id).update({
|
||||
Software: fields.Software === '' ? null : fields.Software,
|
||||
AccessRights: fields.AccessRights === '' ? null : fields.AccessRights,
|
||||
...furnitureFields(fields)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,6 +78,14 @@ function buildContentFields(data: IFormData): Record<string, unknown> {
|
||||
item[COMPANIES_JSON_FIELD] = data.Companies.length > 0 ? JSON.stringify(data.Companies) : null;
|
||||
return;
|
||||
}
|
||||
if (key === 'Furniture') {
|
||||
Object.assign(item, furnitureFields(data));
|
||||
return;
|
||||
}
|
||||
if (key === 'FurnitureOther') {
|
||||
// Пишется вместе с Furniture (см. выше).
|
||||
return;
|
||||
}
|
||||
if (key === 'Organization') {
|
||||
// Текстовая копия выбранных компаний — для печати и списка в SP (см. organizationText).
|
||||
const text = organizationText(data);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { effectiveUserEmail } from '../../shared/impersonation';
|
||||
import { FORM_ID } from './model';
|
||||
|
||||
export interface IApproverRights {
|
||||
/** Первый шаг согласования (руководитель) может заполнить «Необходимое ПО» и «Доступы». */
|
||||
/** Первый шаг согласования (руководитель) может заполнить «Необходимое ПО», «Доступы» и «Мебель». */
|
||||
canEditReviewFields: boolean;
|
||||
/** Участник маршрута из shared/approval/fullEditRights.ts правит все поля и вложения. */
|
||||
canEditAll: boolean;
|
||||
@@ -28,7 +28,7 @@ const FULL_EDIT_STATUSES: ReadonlyArray<string> = [REQUEST_STATUS.onApproval, RE
|
||||
|
||||
/**
|
||||
* Что текущий пользователь может править в поданной заявке.
|
||||
* - «ПО»/«Доступы» — только на СВОЁМ активном шаге согласования, если это первый шаг.
|
||||
* - «ПО»/«Доступы»/«Мебель» — только на СВОЁМ активном шаге согласования, если это первый шаг.
|
||||
* - Все поля — если пользователь есть в маршруте (согласование или исполнение)
|
||||
* НЕЗАВИСИМО от того, чья сейчас очередь, пока заявка в работе (решение
|
||||
* Тимура 2026-10-07: так в «Организации рабочего места» работает Ербол).
|
||||
|
||||
@@ -321,7 +321,8 @@ export class ApprovalListService {
|
||||
return this.resolveUserId(email);
|
||||
}
|
||||
|
||||
// Добавляет одну строку согласования с заданным Order (для делегата).
|
||||
// Добавляет одну строку согласования с заданным Order (для делегата, ручного
|
||||
// добавления участника и исполнителя, которого привела правка полей).
|
||||
// delegatedById — Id пользователя, который выполнил переназначение (не строки
|
||||
// из маршрута — у них делегата нет). Он сохраняет право переназначить эту
|
||||
// строку ещё раз, если делегат недоступен (см. ApproverService.delegate).
|
||||
@@ -342,6 +343,12 @@ export class ApprovalListService {
|
||||
[FIELDS.order]: order,
|
||||
[FIELDS.allowReassign]: approver.allowReassign,
|
||||
[FIELDS.phase]: phase,
|
||||
// SLA шага из маршрута админки — у исполнителя, добавленного по правке
|
||||
// полей (ApproverService.addMissingExecutors). У делегата их нет: пусто/Нет,
|
||||
// как и раньше. ?? null, а не || null: ноль часов — значимое значение.
|
||||
[FIELDS.slaHours]: approver.slaHours ?? null,
|
||||
[FIELDS.askOverdueReason]: approver.askOverdueReason ?? null,
|
||||
[FIELDS.autoApprove]: approver.autoApprove ?? false,
|
||||
...(delegatedById ? { [`${FIELDS.delegatedBy}Id`]: delegatedById } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@ import '@pnp/sp/webs';
|
||||
import '@pnp/sp/lists';
|
||||
import { IList } from '@pnp/sp/lists';
|
||||
import '@pnp/sp/items';
|
||||
import { ApprovalListService } from './approvalList.service';
|
||||
import { ApprovalListService, IApproverToWrite } from './approvalList.service';
|
||||
import { REQUEST_STATUS, APPROVAL_PHASE } from './models/statuses';
|
||||
import { IApprovalRow } from './models/IApprovalRow';
|
||||
import { ISiteUser } from './models/ISiteUser';
|
||||
import { emitRequestsChanged } from '../requestEvents';
|
||||
import { planQueuedStepMove, StepMoveDirection } from './queuedStepMove';
|
||||
import { planStepAdd } from './routeStepAdd';
|
||||
import { IStepAddRow, planStepAdd } from './routeStepAdd';
|
||||
|
||||
function isActiveStatus(status: string): boolean {
|
||||
return status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.onExecution;
|
||||
@@ -209,6 +209,40 @@ export class ApproverService {
|
||||
return rowsAfter.filter((r) => isActiveStatus(r.status) && !activeBefore.has(r.id)).map((r) => r.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавить в поданную заявку исполнителей, которых привела правка полей
|
||||
* маршрута согласующим (КОГО — решает planMissingExecutors). Каждый встаёт в
|
||||
* КОНЕЦ исполнения «Ждёт очереди», по тем же правилам, что addStep
|
||||
* (planStepAdd), но без права редактора маршрута: людей назначает маршрут из
|
||||
* админки, а не пользователь. SLA шага переносится из маршрута.
|
||||
*
|
||||
* Возвращает Id строк, ставших активными (обычно пусто); им нужно оповещение.
|
||||
*/
|
||||
public async addMissingExecutors(requestId: number, executors: IApproverToWrite[]): Promise<number[]> {
|
||||
if (executors.length === 0) return [];
|
||||
const rows = await this.approval.getForRequest(requestId);
|
||||
const activeBefore = new Set(rows.filter((r) => isActiveStatus(r.status)).map((r) => r.id));
|
||||
|
||||
// По одному и с локальным дописыванием rows: иначе второй исполнитель
|
||||
// получил бы тот же order, что первый, и стал бы параллельным шагом.
|
||||
const planned: IStepAddRow[] = rows.slice();
|
||||
for (const executor of executors) {
|
||||
const approverId = await this.approval.getUserIdByEmail(executor.email);
|
||||
const plan = planStepAdd(planned, APPROVAL_PHASE.execution, approverId);
|
||||
if (!plan.ok) throw new Error(plan.error);
|
||||
await this.approval.addApproverRow(requestId, executor, plan.order, REQUEST_STATUS.queued, APPROVAL_PHASE.execution);
|
||||
planned.push({ approverId, order: plan.order, status: REQUEST_STATUS.queued, phase: APPROVAL_PHASE.execution });
|
||||
}
|
||||
|
||||
await this.approval.activateNextQueued(requestId);
|
||||
const rowsAfter = await this.approval.getForRequest(requestId);
|
||||
|
||||
// Новым исполнителям — права на элемент данных (см. addStep).
|
||||
await this.touchRequest(requestId);
|
||||
emitRequestsChanged();
|
||||
return rowsAfter.filter((r) => isActiveStatus(r.status) && !activeBefore.has(r.id)).map((r) => r.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Шаг фазы «Исполнение», который ещё можно заменить или удалить: ждёт очереди
|
||||
* или уже активен («На исполнении»). Выполненный шаг — решение человека, его
|
||||
|
||||
@@ -81,6 +81,8 @@ export { canEditAllFields } from './fullEditRights';
|
||||
export { planQueuedStepMove, queuedStepMoveRule } from './queuedStepMove';
|
||||
export type { StepMoveDirection, IStepMoveRow, IStepOrderChange } from './queuedStepMove';
|
||||
export { useQueuedStepMove } from './useQueuedStepMove';
|
||||
export { planMissingExecutors } from './missingExecutors';
|
||||
export type { IPlannedPerson, ICurrentRoutePerson } from './missingExecutors';
|
||||
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
||||
export { ExecutorsMatrix } from './ui/ExecutorsMatrix';
|
||||
export { ApprovalHistory } from './history/ApprovalHistory';
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ICurrentRoutePerson, IPlannedPerson, planMissingExecutors } from './missingExecutors';
|
||||
|
||||
const APPROVAL = 'Согласование';
|
||||
const EXECUTION = 'Исполнение';
|
||||
|
||||
const IT = 'it@tnsu.kz';
|
||||
const FURNITURE = 'furniture@tnsu.kz';
|
||||
const MANAGER = 'manager@tnsu.kz';
|
||||
|
||||
function planned(email: string): IPlannedPerson {
|
||||
return { email };
|
||||
}
|
||||
|
||||
function current(email: string, phase = EXECUTION): ICurrentRoutePerson {
|
||||
return { email, phase };
|
||||
}
|
||||
|
||||
describe('planMissingExecutors', () => {
|
||||
it('добавляет исполнителя, которого привела правка', () => {
|
||||
const result = planMissingExecutors([planned(IT)], [planned(IT), planned(FURNITURE)], [current(IT)]);
|
||||
expect(result).toEqual([planned(FURNITURE)]);
|
||||
});
|
||||
|
||||
it('план не изменился — никого не добавляет', () => {
|
||||
expect(planMissingExecutors([planned(IT)], [planned(IT)], [current(IT)])).toEqual([]);
|
||||
});
|
||||
|
||||
it('не возвращает исполнителя, которого убрали вручную (он был в плане и до правки)', () => {
|
||||
expect(planMissingExecutors([planned(IT)], [planned(IT), planned(FURNITURE)], [current(FURNITURE)])).toEqual([]);
|
||||
});
|
||||
|
||||
it('не дублирует того, кто уже есть среди исполнителей (в любом статусе)', () => {
|
||||
expect(planMissingExecutors([], [planned(FURNITURE)], [current(FURNITURE)])).toEqual([]);
|
||||
});
|
||||
|
||||
it('согласующий с тем же email — не помеха: в исполнение добавляем', () => {
|
||||
expect(planMissingExecutors([], [planned(MANAGER)], [current(MANAGER, APPROVAL)])).toEqual([planned(MANAGER)]);
|
||||
});
|
||||
|
||||
it('сравнивает email без учёта регистра и пробелов', () => {
|
||||
expect(planMissingExecutors([], [planned(' Furniture@TNSU.kz ')], [current(FURNITURE)])).toEqual([]);
|
||||
expect(planMissingExecutors([planned('FURNITURE@tnsu.kz')], [planned(FURNITURE)], [])).toEqual([]);
|
||||
});
|
||||
|
||||
it('пропускает шаг без назначенного человека', () => {
|
||||
expect(planMissingExecutors([], [planned('')], [])).toEqual([]);
|
||||
});
|
||||
|
||||
it('один человек на двух шагах нового плана — добавляет один раз', () => {
|
||||
expect(planMissingExecutors([], [planned(FURNITURE), planned(FURNITURE)], [])).toEqual([planned(FURNITURE)]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { APPROVAL_PHASE } from './models/statuses';
|
||||
|
||||
/**
|
||||
* Каких исполнителей добавить в УЖЕ ПОДАННУЮ заявку, когда согласующий поправил
|
||||
* поля, от которых зависит маршрут (напр. «Мебель» в «Организации рабочего
|
||||
* места»: отметил её руководитель — нужен исполнитель по мебели).
|
||||
* Чистая функция без SharePoint: решает, КОГО добавить. Выполняет
|
||||
* ApproverService.addMissingExecutors.
|
||||
*
|
||||
* Правила (согласованы с владельцем 2026-10-29):
|
||||
* - добавляем только тех, кого привела ПРАВКА: есть в плане после неё и нет в
|
||||
* плане до неё. Так не вернётся исполнитель, которого вручную заменили или
|
||||
* удалили, и не подтянутся правки маршрута в админке, сделанные после подачи;
|
||||
* - не добавляем того, кто уже есть среди исполнителей заявки в ЛЮБОМ статусе
|
||||
* (ждёт, исполняет, выполнил, делегировал) — иначе дубль;
|
||||
* - никого не удаляем: сняли мебель — исполнитель по мебели остаётся;
|
||||
* - шаг без назначенного человека (пустой email) пропускаем: добавить некого.
|
||||
*/
|
||||
|
||||
/** То, что нужно о человеке в плане маршрута; подходит IMatrixRow. */
|
||||
export interface IPlannedPerson {
|
||||
email: string;
|
||||
}
|
||||
|
||||
/** То, что нужно о строке маршрута заявки; подходит IMatrixRow. */
|
||||
export interface ICurrentRoutePerson {
|
||||
email: string;
|
||||
phase?: string;
|
||||
}
|
||||
|
||||
// SharePoint и approval-route могут вернуть один и тот же email в разном регистре.
|
||||
function normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function planMissingExecutors<T extends IPlannedPerson>(
|
||||
plannedBefore: IPlannedPerson[],
|
||||
plannedAfter: T[],
|
||||
currentRows: ICurrentRoutePerson[]
|
||||
): T[] {
|
||||
const wasPlanned = new Set(plannedBefore.map((p) => normalizeEmail(p.email)));
|
||||
const alreadyInRoute = new Set(
|
||||
currentRows.filter((r) => r.phase === APPROVAL_PHASE.execution).map((r) => normalizeEmail(r.email))
|
||||
);
|
||||
|
||||
const toAdd: T[] = [];
|
||||
plannedAfter.forEach((person) => {
|
||||
const email = normalizeEmail(person.email);
|
||||
if (!email || wasPlanned.has(email) || alreadyInRoute.has(email)) return;
|
||||
// Один человек на нескольких шагах нового плана — добавляем один раз.
|
||||
alreadyInRoute.add(email);
|
||||
toAdd.push(person);
|
||||
});
|
||||
return toAdd;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from 'react';
|
||||
import { Button, Icon } from '@tnsu/ui-kit';
|
||||
import { printRoot } from './printRoot';
|
||||
|
||||
interface IPrintButtonProps {
|
||||
label?: string;
|
||||
/** Заголовок документа = имя PDF-файла (см. buildPrintTitle). Не задан — браузер назовёт сам. */
|
||||
documentTitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ВРЕМЕННО: локальная кнопка «Печать» вместо PrintButton из @tnsu/ui-kit — ради
|
||||
* documentTitle (см. printRoot.ts). Вид и поведение те же, что у кита.
|
||||
*/
|
||||
export const PrintButton: React.FC<IPrintButtonProps> = ({ label = 'Печать', documentTitle }) => (
|
||||
<Button variant="secondary" onClick={() => printRoot(undefined, documentTitle)}>
|
||||
<Icon name="document" size="sm" />
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* ВРЕМЕННО (2026-10-29): локальная копия printRoot из @tnsu/ui-kit с одним
|
||||
* отличием — заголовком окна печати (title). Браузер берёт его как имя файла при
|
||||
* «Сохранить как PDF»; у кита окно без <title>, и файл назывался как попало.
|
||||
* План: перенести параметр title в кит (сигнатура уже совместимая:
|
||||
* printRoot(selector, title)), затем удалить этот файл и вернуть импорт из кита.
|
||||
*/
|
||||
|
||||
// Значение поля для печати. Дату (input type=date, ISO yyyy-mm-dd) — в дд.мм.гггг.
|
||||
function formatValue(el: HTMLInputElement): string {
|
||||
if (el.type === 'date' && /^\d{4}-\d{2}-\d{2}$/.test(el.value)) {
|
||||
const [y, m, d] = el.value.split('-');
|
||||
return `${d}.${m}.${y}`;
|
||||
}
|
||||
return el.value;
|
||||
}
|
||||
|
||||
// Печатаем как ДАННЫЕ, а не как форму: каждое поле ввода в клоне заменяем на
|
||||
// обычный текст его значения (значения берём из живого DOM — React держит их как
|
||||
// свойства, в клонированном HTML их нет).
|
||||
function renderValuesAsText(source: Element, clone: Element): void {
|
||||
const put = (cloneEl: Element | undefined, text: string): void => {
|
||||
if (!cloneEl) return;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'rp-print-value';
|
||||
div.textContent = text && text.length > 0 ? text : '—';
|
||||
(cloneEl.closest('label') ?? cloneEl).replaceWith(div);
|
||||
};
|
||||
|
||||
const cInputs = clone.querySelectorAll('input');
|
||||
source.querySelectorAll('input').forEach((el, i) => {
|
||||
if (el.type === 'checkbox' || el.type === 'radio') {
|
||||
put(cInputs[i], el.checked ? 'Да' : 'Нет');
|
||||
} else {
|
||||
put(cInputs[i], formatValue(el));
|
||||
}
|
||||
});
|
||||
|
||||
const cAreas = clone.querySelectorAll('textarea');
|
||||
source.querySelectorAll('textarea').forEach((el, i) => put(cAreas[i], el.value));
|
||||
|
||||
const cSelects = clone.querySelectorAll('select');
|
||||
source.querySelectorAll('select').forEach((el, i) => {
|
||||
const opt = el.options[el.selectedIndex];
|
||||
put(cSelects[i], opt && opt.value ? opt.text : '');
|
||||
});
|
||||
}
|
||||
|
||||
// Стили печатного окна — как в ките (см. комментарии там).
|
||||
const PRINT_CSS =
|
||||
'html,body{margin:0;background:#fff;height:auto!important;min-height:0!important}' +
|
||||
'body{padding:24px;font-size:13px}' +
|
||||
'svg{display:none!important}' +
|
||||
'.ms-Persona-imageArea,.ms-Persona-image{display:none!important}' +
|
||||
'.rp-print-root>*{border:0!important;box-shadow:none!important;padding:0!important}' +
|
||||
'.rp-print-value{white-space:pre-wrap;color:#000;padding:1px 0 2px}';
|
||||
|
||||
/**
|
||||
* Запасной путь — печать самой страницы (нет корня печати или всплывающее окно
|
||||
* заблокировано). Заголовок страницы подменяем на время диалога, чтобы и тут
|
||||
* имя файла было осмысленным; window.print() ждёт закрытия диалога.
|
||||
*/
|
||||
function printCurrentPage(title?: string): void {
|
||||
if (!title) {
|
||||
window.print();
|
||||
return;
|
||||
}
|
||||
const pageTitle = document.title;
|
||||
document.title = title;
|
||||
window.print();
|
||||
document.title = pageTitle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Переносит документ (по умолчанию `.rp-print-root`) в отдельное чистое окно,
|
||||
* заменяя поля ввода на текст значений, и печатает его. title — заголовок окна,
|
||||
* он же имя файла при сохранении в PDF (см. buildPrintTitle).
|
||||
*/
|
||||
export function printRoot(selector = '.rp-print-root', title?: string): void {
|
||||
const source = document.querySelector(selector);
|
||||
if (!source) {
|
||||
printCurrentPage(title);
|
||||
return;
|
||||
}
|
||||
const win = window.open('', '_blank', 'width=980,height=1200');
|
||||
if (!win) {
|
||||
printCurrentPage(title);
|
||||
return;
|
||||
}
|
||||
|
||||
const clone = source.cloneNode(true) as Element;
|
||||
clone.querySelectorAll('.rp-no-print').forEach((el) => el.remove());
|
||||
renderValuesAsText(source, clone);
|
||||
|
||||
const head = Array.from(document.querySelectorAll('style, link[rel="stylesheet"]'))
|
||||
.map((n) => n.outerHTML)
|
||||
.join('\n');
|
||||
|
||||
win.document.open();
|
||||
win.document.write(
|
||||
`<!doctype html><html><head><meta charset="utf-8">${head}` +
|
||||
`<style>${PRINT_CSS}</style></head><body>${clone.outerHTML}</body></html>`
|
||||
);
|
||||
win.document.close();
|
||||
// Через свойство, а не тегом в разметке выше: так текст не надо экранировать.
|
||||
if (title) win.document.title = title;
|
||||
win.focus();
|
||||
|
||||
const done = (): void => {
|
||||
win.print();
|
||||
win.close();
|
||||
};
|
||||
if (win.document.readyState === 'complete') {
|
||||
window.setTimeout(done, 350);
|
||||
} else {
|
||||
win.onload = (): void => {
|
||||
window.setTimeout(done, 350);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface IPrintTitleParts {
|
||||
/** Название формы, как в шапке карточки. */
|
||||
formTitle: string;
|
||||
/** ФИО сотрудника (кандидата), о ком заявка; '' — пропускаем. Берётся как есть. */
|
||||
personName: string;
|
||||
/** Уникальный код заявки; '' у старых заявок — тогда номер элемента. */
|
||||
code: string;
|
||||
itemId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Имя печатного документа (и PDF-файла):
|
||||
* «<Название формы> — <ФИО> №<код>», напр. «Заявка на отпуск — Иванов Иван №HR-2610-45».
|
||||
* Номер — как в шапке карточки (код, а у старых заявок — номер элемента).
|
||||
*/
|
||||
export function buildPrintTitle({ formTitle, personName, code, itemId }: IPrintTitleParts): string {
|
||||
const name = personName.trim();
|
||||
const head = name ? `${formTitle} — ${name}` : formTitle;
|
||||
return `${head} №${code || itemId}`;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { DropdownButton, Icon, PrintButton, printRoot } from '@tnsu/ui-kit';
|
||||
import { DropdownButton, Icon } from '@tnsu/ui-kit';
|
||||
import { PrintButton } from '../PrintButton/PrintButton';
|
||||
import { printRoot } from '../PrintButton/printRoot';
|
||||
|
||||
interface IPrintSplitButtonProps {
|
||||
/** `\n`-joined «Компания — должность», как в data.Organization. */
|
||||
@@ -7,6 +9,8 @@ interface IPrintSplitButtonProps {
|
||||
selectedIndex: number;
|
||||
onChange: (index: number) => void;
|
||||
label?: string;
|
||||
/** Заголовок документа = имя PDF-файла (см. buildPrintTitle). */
|
||||
documentTitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,7 +23,8 @@ export const PrintSplitButton: React.FC<IPrintSplitButtonProps> = ({
|
||||
value,
|
||||
selectedIndex,
|
||||
onChange,
|
||||
label = 'Печать'
|
||||
label = 'Печать',
|
||||
documentTitle
|
||||
}) => {
|
||||
const [pendingPrint, setPendingPrint] = React.useState(false);
|
||||
const lines = value ? value.split('\n') : [];
|
||||
@@ -31,11 +36,11 @@ export const PrintSplitButton: React.FC<IPrintSplitButtonProps> = ({
|
||||
React.useEffect(() => {
|
||||
if (!pendingPrint) return;
|
||||
setPendingPrint(false);
|
||||
printRoot();
|
||||
}, [pendingPrint]);
|
||||
printRoot(undefined, documentTitle);
|
||||
}, [pendingPrint, documentTitle]);
|
||||
|
||||
if (lines.length <= 1) {
|
||||
return <PrintButton label={label} />;
|
||||
return <PrintButton label={label} documentTitle={documentTitle} />;
|
||||
}
|
||||
|
||||
const options = lines.map((line, i) => ({ value: String(i), label: line }));
|
||||
|
||||
@@ -24,8 +24,7 @@ export {
|
||||
Icon,
|
||||
ConfirmHost,
|
||||
confirmModal,
|
||||
RequestNumber,
|
||||
PrintButton
|
||||
RequestNumber
|
||||
} from '@tnsu/ui-kit';
|
||||
export type { ISelectOption, IConfirmOptions } from '@tnsu/ui-kit';
|
||||
|
||||
@@ -48,6 +47,9 @@ export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
|
||||
export { PeopleTextField } from './PeopleField/PeopleTextField';
|
||||
export { OrganizationField } from './OrganizationField/OrganizationField';
|
||||
export { OrganizationTable } from './OrganizationField/OrganizationTable';
|
||||
// PrintButton — ВРЕМЕННО локальный (заголовок = имя PDF), пока параметр title не перенесён в ui-kit.
|
||||
export { PrintButton } from './PrintButton/PrintButton';
|
||||
export { buildPrintTitle } from './PrintButton/printRoot';
|
||||
export { PrintSplitButton } from './PrintSplitButton/PrintSplitButton';
|
||||
export { OrgLetterhead } from './OrgLetterhead/OrgLetterhead';
|
||||
export { PrintSignature } from './PrintSignature/PrintSignature';
|
||||
|
||||
Reference in New Issue
Block a user