Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/23
@@ -16,7 +16,9 @@
|
|||||||
|
|
||||||
| Право | Кто | Как определяется | Где действует | Файл с проверкой |
|
| Право | Кто | Как определяется | Где действует | Файл с проверкой |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Удалять согласующих, которые ещё ждут очереди | Алтынай Бултурукова | email в коде | Подбор, Приём, Реферальная рекомендация: и при составлении, и в поданной заявке | `shared/approval/routeEditRights.ts` → `canEditRoute` |
|
| Удалять согласующих, которые ещё ждут очереди | Алтынай Бултурукова, Павел Горбенко | email в коде | Подбор, Приём, Реферальная рекомендация: и при составлении, и в поданной заявке | `shared/approval/routeEditRights.ts` → `canEditRoute` |
|
||||||
|
| Удалять любых согласующих **при составлении** заявки | те же (`canEditRoute`) | email в коде | Все остальные формы с маршрутом согласования (отпуск, увольнение, 1С и т. д.); в поданной заявке — нет | `shared/approval/routeEditRights.ts` → `composeRemoveRowRule` |
|
||||||
|
| Добавлять и удалять исполнителей **при составлении** заявки | те же (`canEditRoute`) | email в коде | Все формы, где в маршруте из админки есть фаза исполнения; в поданной заявке — нет | `shared/approval/routeEditRights.ts` → `canEditExecutors` |
|
||||||
| Заменять и удалять исполнителей, **в том числе активного** («На исполнении») | Алтынай Бултурукова | тот же email (`canEditRoute`) | **Только** Заявка на подбор, матрица «Исполнение» | `canEditRoute` + `forms/RecruitingSelection/RecruitingSelectionView.tsx` |
|
| Заменять и удалять исполнителей, **в том числе активного** («На исполнении») | Алтынай Бултурукова | тот же email (`canEditRoute`) | **Только** Заявка на подбор, матрица «Исполнение» | `canEditRoute` + `forms/RecruitingSelection/RecruitingSelectionView.tsx` |
|
||||||
| Править **все поля** заявки на своём активном шаге согласования (без вложений) | Алтынай Бултурукова | email в коде | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` |
|
| Править **все поля** заявки на своём активном шаге согласования (без вложений) | Алтынай Бултурукова | email в коде | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` |
|
||||||
| Править оклад на ИС, оклад после ИС, «Отдел/Участок» на своём шаге согласования | Начальник расчётной группы (сейчас Ким Анна) | должность шага: «начальник/руководитель» + «расчет» | Заявка на приём | `forms/RecruitingHire/approverRights.ts` → `canEditPayrollFields` |
|
| Править оклад на ИС, оклад после ИС, «Отдел/Участок» на своём шаге согласования | Начальник расчётной группы (сейчас Ким Анна) | должность шага: «начальник/руководитель» + «расчет» | Заявка на приём | `forms/RecruitingHire/approverRights.ts` → `canEditPayrollFields` |
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"@pnp/sp": "^4.20.0",
|
"@pnp/sp": "^4.20.0",
|
||||||
"@pnp/spfx-controls-react": "^3.25.0",
|
"@pnp/spfx-controls-react": "^3.25.0",
|
||||||
"@tnsu/ui-kit": "^0.12.0",
|
"@tnsu/ui-kit": "^0.12.0",
|
||||||
|
"jszip": "~3.8.0",
|
||||||
"react": "17.0.1",
|
"react": "17.0.1",
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "17.0.1",
|
||||||
"tslib": "2.3.1"
|
"tslib": "2.3.1"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"@pnp/sp": "^4.20.0",
|
"@pnp/sp": "^4.20.0",
|
||||||
"@pnp/spfx-controls-react": "^3.25.0",
|
"@pnp/spfx-controls-react": "^3.25.0",
|
||||||
"@tnsu/ui-kit": "^0.12.0",
|
"@tnsu/ui-kit": "^0.12.0",
|
||||||
|
"jszip": "~3.8.0",
|
||||||
"react": "17.0.1",
|
"react": "17.0.1",
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "17.0.1",
|
||||||
"tslib": "2.3.1"
|
"tslib": "2.3.1"
|
||||||
|
|||||||
@@ -16,6 +16,6 @@
|
|||||||
"title": { "default": "request-portal" },
|
"title": { "default": "request-portal" },
|
||||||
"description": { "default": "request-portal description" },
|
"description": { "default": "request-portal description" },
|
||||||
"officeFabricIconFontName": "Page",
|
"officeFabricIconFontName": "Page",
|
||||||
"properties": { "notificationsEnabled": true }
|
"properties": { "notificationsEnabled": true, "edoApiBaseUrl": "https://10.0.1.225:8443" }
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ import { setImpersonatedIdentity } from './shared/impersonation';
|
|||||||
import {
|
import {
|
||||||
APPROVAL_API_BASE_URL_LOCAL,
|
APPROVAL_API_BASE_URL_LOCAL,
|
||||||
APPROVAL_API_BASE_URL_PROD,
|
APPROVAL_API_BASE_URL_PROD,
|
||||||
|
EDO_API_BASE_URL_LOCAL,
|
||||||
|
EDO_API_BASE_URL_STAGE,
|
||||||
setApprovalApiBaseUrl,
|
setApprovalApiBaseUrl,
|
||||||
|
setEdoApiBaseUrl,
|
||||||
setNotificationSettings
|
setNotificationSettings
|
||||||
} from './shared/config';
|
} from './shared/config';
|
||||||
import { initListPartitions } from './shared/approval';
|
import { initListPartitions } from './shared/approval';
|
||||||
@@ -38,6 +41,11 @@ export interface IRequestPortalWebPartProps {
|
|||||||
debugUserDisplayName: string;
|
debugUserDisplayName: string;
|
||||||
/** Куда шлём запросы approval-route (маршруты согласования) — прод или локальный backend. */
|
/** Куда шлём запросы approval-route (маршруты согласования) — прод или локальный backend. */
|
||||||
approvalApiBaseUrl: string;
|
approvalApiBaseUrl: string;
|
||||||
|
/**
|
||||||
|
* С каким ЭДО работаем при отправке документов на подписание — локальным или
|
||||||
|
* стейджинговым. У веб-частей, добавленных до появления свойства, оно пустое — это стейдж.
|
||||||
|
*/
|
||||||
|
edoApiBaseUrl: string;
|
||||||
/**
|
/**
|
||||||
* Уведомления согласующим (поток Power Automate). По умолчанию включены:
|
* Уведомления согласующим (поток Power Automate). По умолчанию включены:
|
||||||
* у веб-частей, добавленных до появления свойства, оно undefined — это тоже «вкл».
|
* у веб-частей, добавленных до появления свойства, оно undefined — это тоже «вкл».
|
||||||
@@ -58,6 +66,8 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
|||||||
setImpersonatedIdentity(this.properties.debugUserQuery, this.properties.debugUserDisplayName);
|
setImpersonatedIdentity(this.properties.debugUserQuery, this.properties.debugUserDisplayName);
|
||||||
this.properties.approvalApiBaseUrl = this.properties.approvalApiBaseUrl || APPROVAL_API_BASE_URL_PROD;
|
this.properties.approvalApiBaseUrl = this.properties.approvalApiBaseUrl || APPROVAL_API_BASE_URL_PROD;
|
||||||
setApprovalApiBaseUrl(this.properties.approvalApiBaseUrl);
|
setApprovalApiBaseUrl(this.properties.approvalApiBaseUrl);
|
||||||
|
this.properties.edoApiBaseUrl = this.properties.edoApiBaseUrl || EDO_API_BASE_URL_STAGE;
|
||||||
|
setEdoApiBaseUrl(this.properties.edoApiBaseUrl);
|
||||||
this._applyNotificationSettings();
|
this._applyNotificationSettings();
|
||||||
|
|
||||||
const element: React.ReactElement = React.createElement(
|
const element: React.ReactElement = React.createElement(
|
||||||
@@ -89,6 +99,8 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
|||||||
// панели без перезапуска), но render случается позже onInit.
|
// панели без перезапуска), но render случается позже onInit.
|
||||||
this.properties.approvalApiBaseUrl = this.properties.approvalApiBaseUrl || APPROVAL_API_BASE_URL_PROD;
|
this.properties.approvalApiBaseUrl = this.properties.approvalApiBaseUrl || APPROVAL_API_BASE_URL_PROD;
|
||||||
setApprovalApiBaseUrl(this.properties.approvalApiBaseUrl);
|
setApprovalApiBaseUrl(this.properties.approvalApiBaseUrl);
|
||||||
|
this.properties.edoApiBaseUrl = this.properties.edoApiBaseUrl || EDO_API_BASE_URL_STAGE;
|
||||||
|
setEdoApiBaseUrl(this.properties.edoApiBaseUrl);
|
||||||
this._applyNotificationSettings();
|
this._applyNotificationSettings();
|
||||||
|
|
||||||
// Партиции грузим здесь, а не по требованию из форм: имена списков нужны
|
// Партиции грузим здесь, а не по требованию из форм: имена списков нужны
|
||||||
@@ -218,6 +230,19 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
|||||||
})
|
})
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
groupName: 'ЭДО (подписание)',
|
||||||
|
groupFields: [
|
||||||
|
PropertyPaneDropdown('edoApiBaseUrl', {
|
||||||
|
label: 'С каким ЭДО работать',
|
||||||
|
selectedKey: this.properties.edoApiBaseUrl || EDO_API_BASE_URL_STAGE,
|
||||||
|
options: [
|
||||||
|
{ key: EDO_API_BASE_URL_STAGE, text: `Стейдж (${EDO_API_BASE_URL_STAGE})` },
|
||||||
|
{ key: EDO_API_BASE_URL_LOCAL, text: `Локально (${EDO_API_BASE_URL_LOCAL})` }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
groupName: 'Уведомления',
|
groupName: 'Уведомления',
|
||||||
groupFields: [
|
groupFields: [
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 20 KiB |
@@ -13,6 +13,7 @@ import {
|
|||||||
useRequestSubmit
|
useRequestSubmit
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
|
composeRemoveRowRule,
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
APPROVAL_PHASE,
|
APPROVAL_PHASE,
|
||||||
unassignedStepMessage
|
unassignedStepMessage
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
|
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||||
import { IRequestFormProps } from '../../models/types';
|
import { IRequestFormProps } from '../../models/types';
|
||||||
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
|
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
@@ -327,7 +329,11 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({
|
|||||||
<RequestSection {...sectionProps} />
|
<RequestSection {...sectionProps} />
|
||||||
</FormModeProvider>
|
</FormModeProvider>
|
||||||
|
|
||||||
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
<ApprovalMatrix
|
||||||
|
editor={matrix}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Блок исполнителя показываем, только когда по маршруту реально есть
|
{/* Блок исполнителя показываем, только когда по маршруту реально есть
|
||||||
исполнители (условие «IsManager» и т.п. могло не назначить ни одного). */}
|
исполнители (условие «IsManager» и т.п. могло не назначить ни одного). */}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ RequestTypeId» в `docs/forms.md`). Тип уже зарегистрирова
|
|||||||
- **Сумма обучения** — `MoneyInput` (разделитель тысяч в UI, в списке — «сырое»
|
- **Сумма обучения** — `MoneyInput` (разделитель тысяч в UI, в списке — «сырое»
|
||||||
число), колонка `TrainingAmount` (Number, 2 знака), поле необязательное.
|
число), колонка `TrainingAmount` (Number, 2 знака), поле необязательное.
|
||||||
- **Дата расторжения договора** — обычный `input type=date`, без вычислений
|
- **Дата расторжения договора** — обычный `input type=date`, без вычислений
|
||||||
(в отличие от `EndDate` в «Заявке на отпуск», которая считается).
|
(в отличие от даты окончания отпуска, которой нет совсем).
|
||||||
- **Заголовок элемента** (`Title`): `Обходной лист — {ФИО} — дд.мм.гггг`.
|
- **Заголовок элемента** (`Title`): `Обходной лист — {ФИО} — дд.мм.гггг`.
|
||||||
|
|
||||||
## Связь с заявлением на увольнение
|
## Связь с заявлением на увольнение
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ import {
|
|||||||
useRequestSubmit
|
useRequestSubmit
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
|
composeRemoveRowRule,
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
ExecutorsMatrix,
|
||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
@@ -24,8 +26,13 @@ import {
|
|||||||
REQUEST_STATUS,
|
REQUEST_STATUS,
|
||||||
isEditableStatus,
|
isEditableStatus,
|
||||||
IApproverToWrite,
|
IApproverToWrite,
|
||||||
unassignedStepMessage
|
unassignedStepMessage,
|
||||||
|
useFormExecutors,
|
||||||
|
approvalPhaseRows,
|
||||||
|
executorsToWrite,
|
||||||
|
initialSubmitStatus
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
|
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||||
import { IRequestFormProps } from '../../models/types';
|
import { IRequestFormProps } from '../../models/types';
|
||||||
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
|
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
@@ -169,7 +176,7 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
|
|||||||
const loadMatrix = useCallback(
|
const loadMatrix = useCallback(
|
||||||
() =>
|
() =>
|
||||||
editId
|
editId
|
||||||
? approval.getMatrixForRequest(editId)
|
? approval.getMatrixForRequest(editId).then(approvalPhaseRows)
|
||||||
: getPlannedMatrix(context, requestTypeId as number, data.SiteChiefEmail, approval, 'Approval', null, undefined, projectId),
|
: getPlannedMatrix(context, requestTypeId as number, data.SiteChiefEmail, approval, 'Approval', null, undefined, projectId),
|
||||||
[context, approval, editId, data.SiteChiefEmail, requestTypeId, projectId]
|
[context, approval, editId, data.SiteChiefEmail, requestTypeId, projectId]
|
||||||
);
|
);
|
||||||
@@ -181,6 +188,18 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
|
|||||||
(login) => approval.getJobTitle(login)
|
(login) => approval.getJobTitle(login)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Исполнители — из маршрута админки (заявитель их не правит); те же параметры
|
||||||
|
// резолва, что и у согласующих.
|
||||||
|
const executors = useFormExecutors({
|
||||||
|
readOnly,
|
||||||
|
context,
|
||||||
|
approval,
|
||||||
|
requestTypeId,
|
||||||
|
userEmail: data.SiteChiefEmail,
|
||||||
|
editId,
|
||||||
|
projectId
|
||||||
|
});
|
||||||
|
|
||||||
const hasItems = data.Items.some((i) => i.mixClass.trim().length > 0 && i.volume.trim().length > 0);
|
const hasItems = data.Items.some((i) => i.mixClass.trim().length > 0 && i.volume.trim().length > 0);
|
||||||
const isValid = !!(data.RequestDate && data.SiteChiefEmail && data.Project.trim() && hasItems);
|
const isValid = !!(data.RequestDate && data.SiteChiefEmail && data.Project.trim() && hasItems);
|
||||||
const validate = (): string | undefined => {
|
const validate = (): string | undefined => {
|
||||||
@@ -222,6 +241,7 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
|
|||||||
rtId,
|
rtId,
|
||||||
data,
|
data,
|
||||||
toApprovers(matrix.rows),
|
toApprovers(matrix.rows),
|
||||||
|
executorsToWrite(executors.rows),
|
||||||
status,
|
status,
|
||||||
writeListId
|
writeListId
|
||||||
);
|
);
|
||||||
@@ -236,7 +256,7 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
|
|||||||
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
||||||
// Имена списков — той же партиции, куда фактически записана заявка (writeListId),
|
// Имена списков — той же партиции, куда фактически записана заявка (writeListId),
|
||||||
// не текущей: поток ищет строку маршрута именно в НЕЙ.
|
// не текущей: поток ищет строку маршрута именно в НЕЙ.
|
||||||
if (status === REQUEST_STATUS.onApproval) {
|
if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) {
|
||||||
await notifyActiveStep(context, approval, id, {
|
await notifyActiveStep(context, approval, id, {
|
||||||
requestTypeId: rtId,
|
requestTypeId: rtId,
|
||||||
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
||||||
@@ -258,11 +278,21 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
|
|||||||
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
||||||
);
|
);
|
||||||
if (unassignedError) return unassignedError;
|
if (unassignedError) return unassignedError;
|
||||||
|
const unassignedExecutorError = unassignedStepMessage(
|
||||||
|
executors.rows,
|
||||||
|
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
|
||||||
|
);
|
||||||
|
if (unassignedExecutorError) return unassignedExecutorError;
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
||||||
const handleSubmit = (): Promise<void> => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit);
|
// Без исполнителей — прежний статус «На согласовании»; с ними — как у рефералки
|
||||||
|
// (initialSubmitStatus учитывает маршрут без согласующих).
|
||||||
|
const submitStatus = executors.rows.length > 0
|
||||||
|
? initialSubmitStatus(matrix.rows.length, executors.rows.length)
|
||||||
|
: REQUEST_STATUS.onApproval;
|
||||||
|
const handleSubmit = (): Promise<void> => submit.run('submit', save(submitStatus), validateSubmit);
|
||||||
|
|
||||||
const sectionProps = {
|
const sectionProps = {
|
||||||
data,
|
data,
|
||||||
@@ -323,11 +353,14 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
|
|||||||
|
|
||||||
<ApprovalMatrix
|
<ApprovalMatrix
|
||||||
editor={matrix}
|
editor={matrix}
|
||||||
|
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||||
search={(q) => approval.searchUsers(q)}
|
search={(q) => approval.searchUsers(q)}
|
||||||
waiting={!matrixReady}
|
waiting={!matrixReady}
|
||||||
waitingText="Укажите начальника участка и проект, чтобы построить маршрут согласования."
|
waitingText="Укажите начальника участка и проект, чтобы построить маршрут согласования."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||||
|
|
||||||
{/* Автору на доработке важнее всего видеть, почему вернули или
|
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||||
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
||||||
{editId && (
|
{editId && (
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ import {
|
|||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
ExecutorsMatrix,
|
||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId
|
useRequestTypeId,
|
||||||
|
useViewExecutors,
|
||||||
|
approvalPhaseRows
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
import {
|
import {
|
||||||
@@ -71,10 +74,11 @@ const ConcreteRequestView: React.FC<IConcreteRequestViewProps> = ({ context, ite
|
|||||||
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
|
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
|
||||||
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
|
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
|
||||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||||
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]);
|
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]);
|
||||||
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||||
approval.getJobTitle(l)
|
approval.getJobTitle(l)
|
||||||
);
|
);
|
||||||
|
const executors = useViewExecutors(context, approval, itemId, decisionSeq);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -153,6 +157,7 @@ const ConcreteRequestView: React.FC<IConcreteRequestViewProps> = ({ context, ite
|
|||||||
</FormCard>
|
</FormCard>
|
||||||
|
|
||||||
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
||||||
|
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export type ISaveResult = ISaveItemResult;
|
|||||||
/**
|
/**
|
||||||
* Сохранить заявку на бетон: содержимое + маршрут согласования.
|
* Сохранить заявку на бетон: содержимое + маршрут согласования.
|
||||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||||
* Фазы исполнения нет — writeRoute вызывается с пустым списком исполнителей.
|
* Исполнители (фаза «Исполнение») приходят из маршрута админки и пишутся тем же writeRoute.
|
||||||
* Возвращает Id заявки (+ код при создании).
|
* Возвращает Id заявки (+ код при создании).
|
||||||
*
|
*
|
||||||
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadConcreteRequest). Имеет смысл
|
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadConcreteRequest). Имеет смысл
|
||||||
@@ -59,12 +59,13 @@ export async function saveConcreteRequest(
|
|||||||
requestTypeId: number,
|
requestTypeId: number,
|
||||||
data: IFormData,
|
data: IFormData,
|
||||||
approvers: IApproverToWrite[],
|
approvers: IApproverToWrite[],
|
||||||
|
executors: IApproverToWrite[],
|
||||||
status: string,
|
status: string,
|
||||||
listId?: string
|
listId?: string
|
||||||
): Promise<ISaveResult> {
|
): Promise<ISaveResult> {
|
||||||
const item = buildItem(data, status);
|
const item = buildItem(data, status);
|
||||||
|
|
||||||
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||||
await writeRoute(concreteRequestApprovalService(listId), result.id, approvers, [], status);
|
await writeRoute(concreteRequestApprovalService(listId), result.id, approvers, executors, status);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ import {
|
|||||||
useRequestSubmit
|
useRequestSubmit
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
|
composeRemoveRowRule,
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
ExecutorsMatrix,
|
||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
@@ -24,8 +26,13 @@ import {
|
|||||||
isEditableStatus,
|
isEditableStatus,
|
||||||
IApproverToWrite,
|
IApproverToWrite,
|
||||||
unassignedStepMessage,
|
unassignedStepMessage,
|
||||||
resolveWriteListId
|
resolveWriteListId,
|
||||||
|
approvalPhaseRows,
|
||||||
|
executorsToWrite,
|
||||||
|
initialSubmitStatus,
|
||||||
|
useFormExecutors
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
|
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||||
import { IRequestFormProps } from '../../models/types';
|
import { IRequestFormProps } from '../../models/types';
|
||||||
import { getEmployeeOrganizations, getCompanyDirectorEmail, IEmployeeOrganization } from '../../shared/dictionary';
|
import { getEmployeeOrganizations, getCompanyDirectorEmail, IEmployeeOrganization } from '../../shared/dictionary';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
@@ -157,7 +164,7 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
|||||||
const loadMatrix = useCallback(
|
const loadMatrix = useCallback(
|
||||||
() =>
|
() =>
|
||||||
editId
|
editId
|
||||||
? approval.getMatrixForRequest(editId)
|
? approval.getMatrixForRequest(editId).then(approvalPhaseRows)
|
||||||
: getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval', null, [
|
: getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval', null, [
|
||||||
{ fieldName: 'Position', value: primaryPosition(data.Organization) }
|
{ fieldName: 'Position', value: primaryPosition(data.Organization) }
|
||||||
]),
|
]),
|
||||||
@@ -171,6 +178,18 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
|||||||
(login) => approval.getJobTitle(login)
|
(login) => approval.getJobTitle(login)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Исполнители самого заявления (фаза «Исполнение» маршрута из админки) — по
|
||||||
|
// тому же сотруднику и тем же fieldValues, что и согласующие выше.
|
||||||
|
const executors = useFormExecutors({
|
||||||
|
readOnly,
|
||||||
|
context,
|
||||||
|
approval,
|
||||||
|
requestTypeId,
|
||||||
|
userEmail: data.Employee,
|
||||||
|
editId,
|
||||||
|
fieldValues: [{ fieldName: 'Position', value: primaryPosition(data.Organization) }]
|
||||||
|
});
|
||||||
|
|
||||||
// Убрать компанию из заявления (совместитель, реально не увольняется из
|
// Убрать компанию из заявления (совместитель, реально не увольняется из
|
||||||
// ВСЕХ) — см. OrganizationsTable. Снимает компанию из data.Organization И, по
|
// ВСЕХ) — см. OrganizationsTable. Снимает компанию из data.Organization И, по
|
||||||
// возможности, соответствующего согласующего (директора/должностного
|
// возможности, соответствующего согласующего (директора/должностного
|
||||||
@@ -243,6 +262,7 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
|||||||
rtId,
|
rtId,
|
||||||
data,
|
data,
|
||||||
toApprovers(matrix.rows),
|
toApprovers(matrix.rows),
|
||||||
|
executorsToWrite(executors.rows),
|
||||||
status,
|
status,
|
||||||
writeListId
|
writeListId
|
||||||
);
|
);
|
||||||
@@ -257,7 +277,7 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
|||||||
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
||||||
// Имена списков — той же партиции, куда фактически записана заявка (writeListId),
|
// Имена списков — той же партиции, куда фактически записана заявка (writeListId),
|
||||||
// не текущей: поток ищет строку маршрута именно в НЕЙ.
|
// не текущей: поток ищет строку маршрута именно в НЕЙ.
|
||||||
if (status === REQUEST_STATUS.onApproval) {
|
if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) {
|
||||||
await notifyActiveStep(context, approval, id, {
|
await notifyActiveStep(context, approval, id, {
|
||||||
requestTypeId: rtId,
|
requestTypeId: rtId,
|
||||||
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
||||||
@@ -279,11 +299,27 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
|||||||
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
||||||
);
|
);
|
||||||
if (unassignedError) return unassignedError;
|
if (unassignedError) return unassignedError;
|
||||||
|
const unassignedExecutorError = unassignedStepMessage(
|
||||||
|
executors.rows,
|
||||||
|
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
|
||||||
|
);
|
||||||
|
if (unassignedExecutorError) return unassignedExecutorError;
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
||||||
const handleSubmit = (): Promise<void> => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit);
|
// Исполнители в маршруте есть → статус стартует с учётом фаз (initialSubmitStatus:
|
||||||
|
// без согласующих сразу «Выполнение»); иначе прежний «На согласовании».
|
||||||
|
const handleSubmit = (): Promise<void> =>
|
||||||
|
submit.run(
|
||||||
|
'submit',
|
||||||
|
save(
|
||||||
|
executors.rows.length > 0
|
||||||
|
? initialSubmitStatus(matrix.rows.length, executors.rows.length)
|
||||||
|
: REQUEST_STATUS.onApproval
|
||||||
|
),
|
||||||
|
validateSubmit
|
||||||
|
);
|
||||||
|
|
||||||
const sectionProps = {
|
const sectionProps = {
|
||||||
data,
|
data,
|
||||||
@@ -340,7 +376,13 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
|||||||
<RequestSection {...sectionProps} />
|
<RequestSection {...sectionProps} />
|
||||||
</FormModeProvider>
|
</FormModeProvider>
|
||||||
|
|
||||||
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
<ApprovalMatrix
|
||||||
|
editor={matrix}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||||
|
|
||||||
{/* Автору на доработке важнее всего видеть, почему вернули или
|
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||||
отклонили, — поэтому история и в форме, а не только в просмотре.
|
отклонили, — поэтому история и в форме, а не только в просмотре.
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ import {
|
|||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
ExecutorsMatrix,
|
||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
REQUEST_STATUS
|
REQUEST_STATUS,
|
||||||
|
approvalPhaseRows,
|
||||||
|
useViewExecutors
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
import { getManagerEmail } from '../../shared/dictionary';
|
import { getManagerEmail } from '../../shared/dictionary';
|
||||||
@@ -87,10 +90,12 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
|||||||
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
|
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
|
||||||
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
|
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
|
||||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||||
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]);
|
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]);
|
||||||
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||||
approval.getJobTitle(l)
|
approval.getJobTitle(l)
|
||||||
);
|
);
|
||||||
|
// Исполнители — отдельная фаза тех же строк маршрута; перечитываются вместе с согласующими.
|
||||||
|
const executors = useViewExecutors(context, approval, itemId, decisionSeq);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -282,6 +287,8 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
|||||||
</FormCard>
|
</FormCard>
|
||||||
|
|
||||||
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
||||||
|
|
||||||
|
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||||
|
|||||||
@@ -96,7 +96,9 @@ export type ISaveResult = ISaveItemResult;
|
|||||||
/**
|
/**
|
||||||
* Сохранить заявление на увольнение: содержимое + маршрут согласования.
|
* Сохранить заявление на увольнение: содержимое + маршрут согласования.
|
||||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||||
* Фазы исполнения нет — writeRoute вызывается с пустым списком исполнителей.
|
* executors — исполнители самого заявления (фаза «Исполнение» маршрута из
|
||||||
|
* админки; пусто, если в маршруте их нет). Это не маршрут связанной заявки
|
||||||
|
* Clearance (см. createClearance.ts).
|
||||||
* Возвращает Id заявки (+ код при создании).
|
* Возвращает Id заявки (+ код при создании).
|
||||||
*
|
*
|
||||||
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadDismissalRequest). Имеет смысл
|
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadDismissalRequest). Имеет смысл
|
||||||
@@ -109,6 +111,7 @@ export async function saveDismissalRequest(
|
|||||||
requestTypeId: number,
|
requestTypeId: number,
|
||||||
data: IFormData,
|
data: IFormData,
|
||||||
approvers: IApproverToWrite[],
|
approvers: IApproverToWrite[],
|
||||||
|
executors: IApproverToWrite[],
|
||||||
status: string,
|
status: string,
|
||||||
listId?: string
|
listId?: string
|
||||||
): Promise<ISaveResult> {
|
): Promise<ISaveResult> {
|
||||||
@@ -116,7 +119,7 @@ export async function saveDismissalRequest(
|
|||||||
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee);
|
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee);
|
||||||
|
|
||||||
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||||
await writeRoute(dismissalApprovalService(listId), result.id, approvers, [], status);
|
await writeRoute(dismissalApprovalService(listId), result.id, approvers, executors, status);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,16 @@ import {
|
|||||||
useRequestSubmit
|
useRequestSubmit
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
|
composeRemoveRowRule,
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
|
useFormExecutors,
|
||||||
|
ExecutorsMatrix,
|
||||||
|
approvalPhaseRows,
|
||||||
|
executorsToWrite,
|
||||||
|
initialSubmitStatus,
|
||||||
getPlannedMatrix,
|
getPlannedMatrix,
|
||||||
resolveWriteListId,
|
resolveWriteListId,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
@@ -26,6 +32,7 @@ import {
|
|||||||
IApproverToWrite,
|
IApproverToWrite,
|
||||||
unassignedStepMessage
|
unassignedStepMessage
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
|
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||||
import { IRequestFormProps } from '../../models/types';
|
import { IRequestFormProps } from '../../models/types';
|
||||||
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
|
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
@@ -153,7 +160,7 @@ const EmployeeTransferForm: React.FC<IRequestFormProps> = ({
|
|||||||
const loadMatrix = useCallback(
|
const loadMatrix = useCallback(
|
||||||
() =>
|
() =>
|
||||||
editId
|
editId
|
||||||
? approval.getMatrixForRequest(editId)
|
? approval.getMatrixForRequest(editId).then(approvalPhaseRows)
|
||||||
: getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval'),
|
: getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval'),
|
||||||
[context, approval, editId, data.Employee, requestTypeId]
|
[context, approval, editId, data.Employee, requestTypeId]
|
||||||
);
|
);
|
||||||
@@ -165,6 +172,17 @@ const EmployeeTransferForm: React.FC<IRequestFormProps> = ({
|
|||||||
(login) => approval.getJobTitle(login)
|
(login) => approval.getJobTitle(login)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Исполнители — по тому же сотруднику, что и маршрут согласования. Только чтение:
|
||||||
|
// их назначает маршрут из админки, не заявитель.
|
||||||
|
const executors = useFormExecutors({
|
||||||
|
readOnly,
|
||||||
|
context,
|
||||||
|
approval,
|
||||||
|
requestTypeId,
|
||||||
|
userEmail: data.Employee,
|
||||||
|
editId
|
||||||
|
});
|
||||||
|
|
||||||
const isValid = !!(
|
const isValid = !!(
|
||||||
data.Employee &&
|
data.Employee &&
|
||||||
data.Position.trim() &&
|
data.Position.trim() &&
|
||||||
@@ -211,6 +229,7 @@ const EmployeeTransferForm: React.FC<IRequestFormProps> = ({
|
|||||||
rtId,
|
rtId,
|
||||||
data,
|
data,
|
||||||
toApprovers(matrix.rows),
|
toApprovers(matrix.rows),
|
||||||
|
executorsToWrite(executors.rows),
|
||||||
status,
|
status,
|
||||||
writeListId
|
writeListId
|
||||||
);
|
);
|
||||||
@@ -225,7 +244,7 @@ const EmployeeTransferForm: React.FC<IRequestFormProps> = ({
|
|||||||
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
||||||
// Имена списков — той же партиции, куда фактически записана заявка (writeListId),
|
// Имена списков — той же партиции, куда фактически записана заявка (writeListId),
|
||||||
// не текущей: поток ищет строку маршрута именно в НЕЙ.
|
// не текущей: поток ищет строку маршрута именно в НЕЙ.
|
||||||
if (status === REQUEST_STATUS.onApproval) {
|
if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) {
|
||||||
await notifyActiveStep(context, approval, id, {
|
await notifyActiveStep(context, approval, id, {
|
||||||
requestTypeId: rtId,
|
requestTypeId: rtId,
|
||||||
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
||||||
@@ -247,11 +266,27 @@ const EmployeeTransferForm: React.FC<IRequestFormProps> = ({
|
|||||||
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
||||||
);
|
);
|
||||||
if (unassignedError) return unassignedError;
|
if (unassignedError) return unassignedError;
|
||||||
|
const unassignedExecutorError = unassignedStepMessage(
|
||||||
|
executors.rows,
|
||||||
|
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
|
||||||
|
);
|
||||||
|
if (unassignedExecutorError) return unassignedExecutorError;
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
||||||
const handleSubmit = (): Promise<void> => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit);
|
// Есть исполнители — initialSubmitStatus учитывает маршрут без согласующих
|
||||||
|
// (сразу «Выполнение»); иначе прежний статус «На согласовании».
|
||||||
|
const handleSubmit = (): Promise<void> =>
|
||||||
|
submit.run(
|
||||||
|
'submit',
|
||||||
|
save(
|
||||||
|
executors.rows.length > 0
|
||||||
|
? initialSubmitStatus(matrix.rows.length, executors.rows.length)
|
||||||
|
: REQUEST_STATUS.onApproval
|
||||||
|
),
|
||||||
|
validateSubmit
|
||||||
|
);
|
||||||
|
|
||||||
const sectionProps = { data, set, context, onEmployeeChange: handleEmployeeChange };
|
const sectionProps = { data, set, context, onEmployeeChange: handleEmployeeChange };
|
||||||
|
|
||||||
@@ -302,7 +337,13 @@ const EmployeeTransferForm: React.FC<IRequestFormProps> = ({
|
|||||||
<RequestSection {...sectionProps} />
|
<RequestSection {...sectionProps} />
|
||||||
</FormModeProvider>
|
</FormModeProvider>
|
||||||
|
|
||||||
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
<ApprovalMatrix
|
||||||
|
editor={matrix}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||||
|
|
||||||
{/* Автору на доработке важнее всего видеть, почему вернули или
|
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||||
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import {
|
|||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
|
useViewExecutors,
|
||||||
|
ExecutorsMatrix,
|
||||||
|
approvalPhaseRows,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId
|
useRequestTypeId
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
@@ -70,10 +73,11 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
|
|||||||
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
|
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
|
||||||
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
|
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
|
||||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||||
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]);
|
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]);
|
||||||
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||||
approval.getJobTitle(l)
|
approval.getJobTitle(l)
|
||||||
);
|
);
|
||||||
|
const executors = useViewExecutors(context, approval, itemId, decisionSeq);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -150,6 +154,7 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
|
|||||||
</FormCard>
|
</FormCard>
|
||||||
|
|
||||||
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
||||||
|
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ export type ISaveResult = ISaveItemResult;
|
|||||||
/**
|
/**
|
||||||
* Сохранить заявку на перемещение: содержимое + маршрут согласования.
|
* Сохранить заявку на перемещение: содержимое + маршрут согласования.
|
||||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||||
* Фазы исполнения нет — writeRoute вызывается с пустым списком исполнителей.
|
* executors — исполнители из маршрута (фаза «Исполнение»); пустой список, если в
|
||||||
|
* маршруте их нет.
|
||||||
* Возвращает Id заявки (+ код при создании).
|
* Возвращает Id заявки (+ код при создании).
|
||||||
*
|
*
|
||||||
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadEmployeeTransferRequest). Имеет
|
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadEmployeeTransferRequest). Имеет
|
||||||
@@ -55,6 +56,7 @@ export async function saveEmployeeTransferRequest(
|
|||||||
requestTypeId: number,
|
requestTypeId: number,
|
||||||
data: IFormData,
|
data: IFormData,
|
||||||
approvers: IApproverToWrite[],
|
approvers: IApproverToWrite[],
|
||||||
|
executors: IApproverToWrite[],
|
||||||
status: string,
|
status: string,
|
||||||
listId?: string
|
listId?: string
|
||||||
): Promise<ISaveResult> {
|
): Promise<ISaveResult> {
|
||||||
@@ -62,6 +64,6 @@ export async function saveEmployeeTransferRequest(
|
|||||||
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee);
|
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee);
|
||||||
|
|
||||||
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||||
await writeRoute(employeeTransferApprovalService(listId), result.id, approvers, [], status);
|
await writeRoute(employeeTransferApprovalService(listId), result.id, approvers, executors, status);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
|
canEditExecutors,
|
||||||
getPlannedMatrix,
|
getPlannedMatrix,
|
||||||
resolveWriteListId,
|
resolveWriteListId,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
@@ -206,7 +207,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
|||||||
const executors = useMatrixEditor(
|
const executors = useMatrixEditor(
|
||||||
loadExecutors,
|
loadExecutors,
|
||||||
`${userEmail}-${editId ?? 'new'}-${projectId}-${companiesFieldValue}`,
|
`${userEmail}-${editId ?? 'new'}-${projectId}-${companiesFieldValue}`,
|
||||||
false,
|
canEditExecutors(context, readOnly),
|
||||||
matrixReady,
|
matrixReady,
|
||||||
(login) => approval.getJobTitle(login),
|
(login) => approval.getJobTitle(login),
|
||||||
resolveApproverOrgInfo(context)
|
resolveApproverOrgInfo(context)
|
||||||
@@ -448,12 +449,14 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
|||||||
canRemoveRow={canRemoveApprover ? () => true : undefined}
|
canRemoveRow={canRemoveApprover ? () => true : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!executors.loading && executors.rows.length > 0 && (
|
{!executors.loading && (executors.editable || executors.rows.length > 0) && (
|
||||||
<ApprovalMatrix
|
<ApprovalMatrix
|
||||||
editor={executors}
|
editor={executors}
|
||||||
search={(q) => approval.searchUsers(q)}
|
search={(q) => approval.searchUsers(q)}
|
||||||
title="Исполнение (после согласования)"
|
title="Исполнение (после согласования)"
|
||||||
roleLabel="Исполнитель"
|
roleLabel="Исполнитель"
|
||||||
|
canRemoveRow={executors.editable ? () => true : undefined}
|
||||||
|
pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…"
|
||||||
emptyText="Исполнители не заданы по маршруту."
|
emptyText="Исполнители не заданы по маршруту."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
|
canEditExecutors,
|
||||||
getPlannedMatrix,
|
getPlannedMatrix,
|
||||||
resolveWriteListId,
|
resolveWriteListId,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
@@ -236,7 +237,7 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
|
|||||||
const executors = useMatrixEditor(
|
const executors = useMatrixEditor(
|
||||||
loadExecutors,
|
loadExecutors,
|
||||||
`${userEmail}-${editId ?? 'new'}-${projectId}-${companiesFieldValue}`,
|
`${userEmail}-${editId ?? 'new'}-${projectId}-${companiesFieldValue}`,
|
||||||
false,
|
canEditExecutors(context, readOnly),
|
||||||
matrixReady,
|
matrixReady,
|
||||||
(login) => approval.getJobTitle(login),
|
(login) => approval.getJobTitle(login),
|
||||||
resolveApproverOrgInfo(context)
|
resolveApproverOrgInfo(context)
|
||||||
@@ -439,12 +440,14 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
|
|||||||
canRemoveRow={canRemoveApprover ? () => true : undefined}
|
canRemoveRow={canRemoveApprover ? () => true : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!executors.loading && executors.rows.length > 0 && (
|
{!executors.loading && (executors.editable || executors.rows.length > 0) && (
|
||||||
<ApprovalMatrix
|
<ApprovalMatrix
|
||||||
editor={executors}
|
editor={executors}
|
||||||
search={(q) => approval.searchUsers(q)}
|
search={(q) => approval.searchUsers(q)}
|
||||||
title="Исполнение (после согласования)"
|
title="Исполнение (после согласования)"
|
||||||
roleLabel="Исполнитель"
|
roleLabel="Исполнитель"
|
||||||
|
canRemoveRow={executors.editable ? () => true : undefined}
|
||||||
|
pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…"
|
||||||
emptyText="Исполнители не заданы по маршруту."
|
emptyText="Исполнители не заданы по маршруту."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
|
canEditExecutors,
|
||||||
getPlannedMatrix,
|
getPlannedMatrix,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
@@ -241,7 +242,7 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
|
|||||||
const executors = useMatrixEditor(
|
const executors = useMatrixEditor(
|
||||||
loadExecutors,
|
loadExecutors,
|
||||||
`${data.Referrer}-${editId ?? 'new'}`,
|
`${data.Referrer}-${editId ?? 'new'}`,
|
||||||
false,
|
canEditExecutors(context, readOnly),
|
||||||
!!editId || (!!data.Referrer && !!requestTypeId),
|
!!editId || (!!data.Referrer && !!requestTypeId),
|
||||||
(login) => approval.getJobTitle(login),
|
(login) => approval.getJobTitle(login),
|
||||||
resolveApproverOrgInfo(context)
|
resolveApproverOrgInfo(context)
|
||||||
@@ -470,12 +471,14 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
|
|||||||
canRemoveRow={canRemoveApprover ? () => true : undefined}
|
canRemoveRow={canRemoveApprover ? () => true : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!executors.loading && executors.rows.length > 0 && (
|
{!executors.loading && (executors.editable || executors.rows.length > 0) && (
|
||||||
<ApprovalMatrix
|
<ApprovalMatrix
|
||||||
editor={executors}
|
editor={executors}
|
||||||
search={(q) => approval.searchUsers(q)}
|
search={(q) => approval.searchUsers(q)}
|
||||||
title={noApprovalPhase ? 'Исполнение' : 'Исполнение (после согласования)'}
|
title={noApprovalPhase ? 'Исполнение' : 'Исполнение (после согласования)'}
|
||||||
roleLabel="Исполнитель"
|
roleLabel="Исполнитель"
|
||||||
|
canRemoveRow={executors.editable ? () => true : undefined}
|
||||||
|
pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…"
|
||||||
emptyText="Исполнители не заданы по маршруту."
|
emptyText="Исполнители не заданы по маршруту."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
useRequestSubmit
|
useRequestSubmit
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
|
composeRemoveRowRule,
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
@@ -24,7 +25,12 @@ import {
|
|||||||
isEditableStatus,
|
isEditableStatus,
|
||||||
IApproverToWrite,
|
IApproverToWrite,
|
||||||
unassignedStepMessage,
|
unassignedStepMessage,
|
||||||
resolveWriteListId
|
resolveWriteListId,
|
||||||
|
useFormExecutors,
|
||||||
|
ExecutorsMatrix,
|
||||||
|
approvalPhaseRows,
|
||||||
|
executorsToWrite,
|
||||||
|
initialSubmitStatus
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import {
|
import {
|
||||||
uploadAttachments,
|
uploadAttachments,
|
||||||
@@ -181,13 +187,24 @@ const ServiceRequestForm: React.FC<IRequestFormProps> = ({
|
|||||||
const loadMatrix = useCallback(
|
const loadMatrix = useCallback(
|
||||||
() =>
|
() =>
|
||||||
editId
|
editId
|
||||||
? approval.getMatrixForRequest(editId)
|
? approval.getMatrixForRequest(editId).then(approvalPhaseRows)
|
||||||
: getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, undefined, projectId),
|
: getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, undefined, projectId),
|
||||||
[context, userEmail, approval, editId, projectId, requestTypeId]
|
[context, userEmail, approval, editId, projectId, requestTypeId]
|
||||||
);
|
);
|
||||||
const matrix = useMatrixEditor(loadMatrix, `${userEmail}-${editId ?? 'new'}-${projectId}`, !readOnly, matrixReady, (login) =>
|
const matrix = useMatrixEditor(loadMatrix, `${userEmail}-${editId ?? 'new'}-${projectId}`, !readOnly, matrixReady, (login) =>
|
||||||
approval.getJobTitle(login)
|
approval.getJobTitle(login)
|
||||||
);
|
);
|
||||||
|
// Исполнители резолвятся тем же маршрутом и с тем же проектом, что и согласующие:
|
||||||
|
// пока проект не выбран, e-mail пустой — useFormExecutors не грузит маршрут (404).
|
||||||
|
const executors = useFormExecutors({
|
||||||
|
readOnly,
|
||||||
|
context,
|
||||||
|
approval,
|
||||||
|
requestTypeId,
|
||||||
|
userEmail: matrixReady ? userEmail : '',
|
||||||
|
editId,
|
||||||
|
projectId
|
||||||
|
});
|
||||||
|
|
||||||
const hasCosts = hasAnyCostItem(data.Costs);
|
const hasCosts = hasAnyCostItem(data.Costs);
|
||||||
const isValid =
|
const isValid =
|
||||||
@@ -273,7 +290,7 @@ const ServiceRequestForm: React.FC<IRequestFormProps> = ({
|
|||||||
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
||||||
}
|
}
|
||||||
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
|
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
|
||||||
const { id, code } = await saveServiceRequest(requestId, rtId, data, toApprovers(matrix.rows), status, writeListId);
|
const { id, code } = await saveServiceRequest(requestId, rtId, data, toApprovers(matrix.rows), executorsToWrite(executors.rows), status, writeListId);
|
||||||
setRequestId(id);
|
setRequestId(id);
|
||||||
setRequestStatus(status);
|
setRequestStatus(status);
|
||||||
if (code) {
|
if (code) {
|
||||||
@@ -294,7 +311,7 @@ const ServiceRequestForm: React.FC<IRequestFormProps> = ({
|
|||||||
if (onSaved) {
|
if (onSaved) {
|
||||||
onSaved();
|
onSaved();
|
||||||
}
|
}
|
||||||
if (status === REQUEST_STATUS.onApproval) {
|
if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) {
|
||||||
await notifyActiveStep(context, approval, id, {
|
await notifyActiveStep(context, approval, id, {
|
||||||
requestTypeId: rtId,
|
requestTypeId: rtId,
|
||||||
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
||||||
@@ -314,11 +331,26 @@ const ServiceRequestForm: React.FC<IRequestFormProps> = ({
|
|||||||
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
||||||
);
|
);
|
||||||
if (unassignedError) return unassignedError;
|
if (unassignedError) return unassignedError;
|
||||||
|
const unassignedExecutorError = unassignedStepMessage(
|
||||||
|
executors.rows,
|
||||||
|
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
|
||||||
|
);
|
||||||
|
if (unassignedExecutorError) return unassignedExecutorError;
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
||||||
const handleSubmit = (): Promise<void> => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit);
|
const handleSubmit = (): Promise<void> => submit.run(
|
||||||
|
'submit',
|
||||||
|
// Есть исполнители — initialSubmitStatus сам выбирает стартовый статус (согласующих
|
||||||
|
// может не быть вовсе), иначе прежнее «На согласовании».
|
||||||
|
save(
|
||||||
|
executors.rows.length > 0
|
||||||
|
? initialSubmitStatus(matrix.rows.length, executors.rows.length)
|
||||||
|
: REQUEST_STATUS.onApproval
|
||||||
|
),
|
||||||
|
validateSubmit
|
||||||
|
);
|
||||||
|
|
||||||
const sectionProps = {
|
const sectionProps = {
|
||||||
data,
|
data,
|
||||||
@@ -384,11 +416,14 @@ const ServiceRequestForm: React.FC<IRequestFormProps> = ({
|
|||||||
|
|
||||||
<ApprovalMatrix
|
<ApprovalMatrix
|
||||||
editor={matrix}
|
editor={matrix}
|
||||||
|
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||||
search={(q) => approval.searchUsers(q)}
|
search={(q) => approval.searchUsers(q)}
|
||||||
waiting={!matrixReady}
|
waiting={!matrixReady}
|
||||||
waitingText="Укажите заявителя и проект, чтобы построить маршрут согласования."
|
waitingText="Укажите заявителя и проект, чтобы построить маршрут согласования."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||||
|
|
||||||
{/* Автору на доработке важнее всего видеть, почему вернули или
|
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||||
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
||||||
{editId && (
|
{editId && (
|
||||||
|
|||||||
@@ -20,12 +20,16 @@ import {
|
|||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
|
useViewExecutors,
|
||||||
|
ExecutorsMatrix,
|
||||||
|
approvalPhaseRows,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
isEditableStatus
|
isEditableStatus
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
import {
|
import {
|
||||||
approvalListTitle,
|
approvalListTitle,
|
||||||
approvalListTitleForListId,
|
approvalListTitleForListId,
|
||||||
@@ -54,6 +58,9 @@ interface IServiceRequestViewProps {
|
|||||||
|
|
||||||
// Заявка «Заявка на услуги» только для просмотра (режим согласующего).
|
// Заявка «Заявка на услуги» только для просмотра (режим согласующего).
|
||||||
const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
|
// onCodeLoaded — колбэк родителя, его ссылка меняется на каждом рендере; в зависимостях эффекта
|
||||||
|
// загрузки идёт стабильная обёртка, иначе заявка перезапрашивалась бы при каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
@@ -77,10 +84,11 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
|
|||||||
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
|
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
|
||||||
|
|
||||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||||
const loadApprovers = React.useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]);
|
const loadApprovers = React.useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]);
|
||||||
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||||
approval.getJobTitle(l)
|
approval.getJobTitle(l)
|
||||||
);
|
);
|
||||||
|
const executors = useViewExecutors(context, approval, itemId, decisionSeq);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -91,7 +99,7 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
onCodeLoaded?.(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setAttachments(files);
|
setAttachments(files);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -106,7 +114,7 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [itemId, listId]);
|
}, [itemId, listId, notifyCode]);
|
||||||
|
|
||||||
// Ссылки на скачивание для файлов строк «Приложения» (см. AnnexesSection).
|
// Ссылки на скачивание для файлов строк «Приложения» (см. AnnexesSection).
|
||||||
// Считаем ДО ранних return'ов ниже: хук, вызванный только на части рендеров,
|
// Считаем ДО ранних return'ов ниже: хук, вызванный только на части рендеров,
|
||||||
@@ -180,6 +188,7 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
|
|||||||
</FormCard>
|
</FormCard>
|
||||||
|
|
||||||
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
||||||
|
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ function buildItem(data: IFormData, status: string): Record<string, unknown> {
|
|||||||
export type ISaveResult = ISaveItemResult;
|
export type ISaveResult = ISaveItemResult;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сохранить «Заявку на услуги»: контент + маршрут согласования.
|
* Сохранить «Заявку на услуги»: контент + маршрут согласования и исполнения.
|
||||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём
|
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём
|
||||||
* новую. Возвращает Id заявки (+ уникальный код при создании).
|
* новую. Возвращает Id заявки (+ уникальный код при создании).
|
||||||
*
|
*
|
||||||
@@ -56,14 +56,13 @@ export async function saveServiceRequest(
|
|||||||
requestTypeId: number,
|
requestTypeId: number,
|
||||||
data: IFormData,
|
data: IFormData,
|
||||||
approvers: IApproverToWrite[],
|
approvers: IApproverToWrite[],
|
||||||
|
executors: IApproverToWrite[],
|
||||||
status: string,
|
status: string,
|
||||||
listId?: string
|
listId?: string
|
||||||
): Promise<ISaveResult> {
|
): Promise<ISaveResult> {
|
||||||
const item = buildItem(data, status);
|
const item = buildItem(data, status);
|
||||||
|
|
||||||
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||||
// Фазы исполнения у заявки на услуги нет — договор дальше ведут вне портала,
|
await writeRoute(serviceRequestApprovalService(listId), result.id, approvers, executors, status);
|
||||||
// поэтому исполнителей не пишем (пустой массив).
|
|
||||||
await writeRoute(serviceRequestApprovalService(listId), result.id, approvers, [], status);
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ import {
|
|||||||
useRequestSubmit
|
useRequestSubmit
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
|
composeRemoveRowRule,
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
canEditExecutors,
|
||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
@@ -26,6 +28,7 @@ import {
|
|||||||
resolveWriteListId,
|
resolveWriteListId,
|
||||||
usePhasedRoute
|
usePhasedRoute
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
|
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||||
import { IRequestFormProps } from '../../models/types';
|
import { IRequestFormProps } from '../../models/types';
|
||||||
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
|
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
@@ -132,7 +135,8 @@ const SkudExplanationForm: React.FC<IRequestFormProps> = ({
|
|||||||
requestId: editId,
|
requestId: editId,
|
||||||
employeeEmail: data.Employee,
|
employeeEmail: data.Employee,
|
||||||
requestTypeId,
|
requestTypeId,
|
||||||
editable: !readOnly
|
editable: !readOnly,
|
||||||
|
executorsEditable: canEditExecutors(context, readOnly)
|
||||||
});
|
});
|
||||||
|
|
||||||
const validate = (): string | undefined => {
|
const validate = (): string | undefined => {
|
||||||
@@ -266,14 +270,20 @@ const SkudExplanationForm: React.FC<IRequestFormProps> = ({
|
|||||||
<ExplanationSection {...sectionProps} />
|
<ExplanationSection {...sectionProps} />
|
||||||
</FormModeProvider>
|
</FormModeProvider>
|
||||||
|
|
||||||
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
<ApprovalMatrix
|
||||||
|
editor={matrix}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||||
|
/>
|
||||||
|
|
||||||
{!executors.loading && executors.rows.length > 0 && (
|
{!executors.loading && (executors.editable || executors.rows.length > 0) && (
|
||||||
<ApprovalMatrix
|
<ApprovalMatrix
|
||||||
editor={executors}
|
editor={executors}
|
||||||
search={(q) => approval.searchUsers(q)}
|
search={(q) => approval.searchUsers(q)}
|
||||||
title="Исполнение (после согласования)"
|
title="Исполнение (после согласования)"
|
||||||
roleLabel="Исполнитель"
|
roleLabel="Исполнитель"
|
||||||
|
canRemoveRow={executors.editable ? () => true : undefined}
|
||||||
|
pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…"
|
||||||
emptyText="Исполнители не заданы по маршруту."
|
emptyText="Исполнители не заданы по маршруту."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -87,8 +87,8 @@ describe('cellKind / toMissedKind', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('месяц начинается с понедельника: 1 июля 2026 — среда, два пустых места', () => {
|
it('месяц начинается с понедельника: 1 июля 2026 — среда, два пустых места', () => {
|
||||||
expect(cells[0]).toBeNull();
|
expect(cells[0]).toBeUndefined();
|
||||||
expect(cells[1]).toBeNull();
|
expect(cells[1]).toBeUndefined();
|
||||||
expect(cells[2]?.day).toBe(1);
|
expect(cells[2]?.day).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -130,12 +130,12 @@ export function monthWorkedHours(byDay: Map<string, IDayInfo>): number {
|
|||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ячейки месяца: ведущие null — пустые места до первого числа (неделя с понедельника). */
|
/** Ячейки месяца: ведущие undefined — пустые места до первого числа (неделя с понедельника). */
|
||||||
export function buildMonthCells(year: number, month: number, byDay: Map<string, IDayInfo>): (IDayInfo | null)[] {
|
export function buildMonthCells(year: number, month: number, byDay: Map<string, IDayInfo>): (IDayInfo | undefined)[] {
|
||||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||||
const firstColumn = (new Date(year, month, 1).getDay() + 6) % DAYS_IN_WEEK;
|
const firstColumn = (new Date(year, month, 1).getDay() + 6) % DAYS_IN_WEEK;
|
||||||
const cells: (IDayInfo | null)[] = [];
|
const cells: (IDayInfo | undefined)[] = [];
|
||||||
for (let i = 0; i < firstColumn; i += 1) cells.push(null);
|
for (let i = 0; i < firstColumn; i += 1) cells.push(undefined);
|
||||||
for (let d = 1; d <= daysInMonth; d += 1) {
|
for (let d = 1; d <= daysInMonth; d += 1) {
|
||||||
const key = toDateKey(year, month, d);
|
const key = toDateKey(year, month, d);
|
||||||
cells.push(byDay.get(key) ?? { day: d, dateKey: key, events: [], first: '', last: '' });
|
cells.push(byDay.get(key) ?? { day: d, dateKey: key, events: [], first: '', last: '' });
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ import {
|
|||||||
isEditableStatus,
|
isEditableStatus,
|
||||||
IApproverToWrite,
|
IApproverToWrite,
|
||||||
unassignedStepMessage,
|
unassignedStepMessage,
|
||||||
APPROVAL_PHASE
|
APPROVAL_PHASE,
|
||||||
|
executorsToWrite
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import {
|
import {
|
||||||
uploadAttachments,
|
uploadAttachments,
|
||||||
@@ -306,7 +307,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
requestId,
|
requestId,
|
||||||
data,
|
data,
|
||||||
toApprovers(matrixRows),
|
toApprovers(matrixRows),
|
||||||
toApprovers(executors.rows),
|
executorsToWrite(executors.rows),
|
||||||
finalStatus,
|
finalStatus,
|
||||||
writeListId
|
writeListId
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ import {
|
|||||||
useRequestSubmit
|
useRequestSubmit
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
|
composeRemoveRowRule,
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
|
canEditExecutors,
|
||||||
getPlannedMatrix,
|
getPlannedMatrix,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
@@ -327,7 +329,7 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
|
|||||||
const executors = useMatrixEditor(
|
const executors = useMatrixEditor(
|
||||||
loadExecutors,
|
loadExecutors,
|
||||||
`${userEmail}-${editId ?? 'new'}-${projectId}-${targetProjectId}`,
|
`${userEmail}-${editId ?? 'new'}-${projectId}-${targetProjectId}`,
|
||||||
false,
|
canEditExecutors(context, readOnly),
|
||||||
executorsReady,
|
executorsReady,
|
||||||
(login) => approval.getJobTitle(login)
|
(login) => approval.getJobTitle(login)
|
||||||
);
|
);
|
||||||
@@ -512,6 +514,7 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
|
|||||||
|
|
||||||
<ApprovalMatrix
|
<ApprovalMatrix
|
||||||
editor={matrix}
|
editor={matrix}
|
||||||
|
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||||
search={(q) => approval.searchUsers(q)}
|
search={(q) => approval.searchUsers(q)}
|
||||||
waiting={!matrixReady}
|
waiting={!matrixReady}
|
||||||
waitingText="Укажите заявителя и проект-источник («Откуда»), чтобы построить маршрут согласования."
|
waitingText="Укажите заявителя и проект-источник («Откуда»), чтобы построить маршрут согласования."
|
||||||
@@ -519,12 +522,14 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
|
|||||||
|
|
||||||
{/* Блок исполнителя показываем, только когда по маршруту реально есть
|
{/* Блок исполнителя показываем, только когда по маршруту реально есть
|
||||||
исполнители (грузится по проектам, см. executorsReady). */}
|
исполнители (грузится по проектам, см. executorsReady). */}
|
||||||
{executorsReady && !executors.loading && executors.rows.length > 0 && (
|
{executorsReady && !executors.loading && (executors.editable || executors.rows.length > 0) && (
|
||||||
<ApprovalMatrix
|
<ApprovalMatrix
|
||||||
editor={executors}
|
editor={executors}
|
||||||
search={(q) => approval.searchUsers(q)}
|
search={(q) => approval.searchUsers(q)}
|
||||||
title="Исполнение (после согласования)"
|
title="Исполнение (после согласования)"
|
||||||
roleLabel="Исполнитель"
|
roleLabel="Исполнитель"
|
||||||
|
canRemoveRow={executors.editable ? () => true : undefined}
|
||||||
|
pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…"
|
||||||
emptyText="Исполнители не заданы по маршруту."
|
emptyText="Исполнители не заданы по маршруту."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -25,11 +25,11 @@
|
|||||||
- **Заголовок элемента** (`Title`): `Отпуск без содержания — {ФИО} — дд.мм.гггг`.
|
- **Заголовок элемента** (`Title`): `Отпуск без содержания — {ФИО} — дд.мм.гггг`.
|
||||||
|
|
||||||
## Отличия от «Заявка на отпуск» (hr-vacation)
|
## Отличия от «Заявка на отпуск» (hr-vacation)
|
||||||
- Даты с/по вместо «дата начала + количество дней».
|
- Те же «дата начала + количество дней»: даты окончания нет, праздники и выходные не считаются.
|
||||||
- Добавлены поля «В связи с» (`Reason`) и «Лицо замещающее» (`Substitute`).
|
- Добавлены поля «В связи с» (`Reason`) и «Лицо замещающее» (`Substitute`).
|
||||||
|
|
||||||
## Секции формы
|
## Секции формы
|
||||||
RequestSection (сотрудник · должность · дата начала · дата окончания · основание · замещающий)
|
RequestSection (сотрудник · должность · дата начала · количество дней · основание · замещающий)
|
||||||
|
|
||||||
## Точки входа
|
## Точки входа
|
||||||
- rptype: `hr-unpaid-leave`
|
- rptype: `hr-unpaid-leave`
|
||||||
|
|||||||
@@ -13,10 +13,16 @@ import {
|
|||||||
useRequestSubmit
|
useRequestSubmit
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
|
composeRemoveRowRule,
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
ExecutorsMatrix,
|
||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
|
useFormExecutors,
|
||||||
|
approvalPhaseRows,
|
||||||
|
executorsToWrite,
|
||||||
|
initialSubmitStatus,
|
||||||
getPlannedMatrix,
|
getPlannedMatrix,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
@@ -26,6 +32,7 @@ import {
|
|||||||
unassignedStepMessage,
|
unassignedStepMessage,
|
||||||
resolveWriteListId
|
resolveWriteListId
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
|
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||||
import { IRequestFormProps } from '../../models/types';
|
import { IRequestFormProps } from '../../models/types';
|
||||||
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
|
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
@@ -154,7 +161,7 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
const loadMatrix = useCallback(
|
const loadMatrix = useCallback(
|
||||||
() =>
|
() =>
|
||||||
editId
|
editId
|
||||||
? approval.getMatrixForRequest(editId)
|
? approval.getMatrixForRequest(editId).then(approvalPhaseRows)
|
||||||
: getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval'),
|
: getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval'),
|
||||||
[context, approval, editId, data.Employee, requestTypeId]
|
[context, approval, editId, data.Employee, requestTypeId]
|
||||||
);
|
);
|
||||||
@@ -166,8 +173,19 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
(login) => approval.getJobTitle(login)
|
(login) => approval.getJobTitle(login)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Даты корректны, если конец не раньше начала (обе обязательны).
|
// Исполнители фазы «Исполнение» — по маршруту из админки, только чтение.
|
||||||
const datesValid = !!(data.StartDate && data.EndDate && data.EndDate >= data.StartDate);
|
const executors = useFormExecutors({
|
||||||
|
readOnly,
|
||||||
|
context,
|
||||||
|
approval,
|
||||||
|
requestTypeId,
|
||||||
|
userEmail: data.Employee,
|
||||||
|
editId
|
||||||
|
});
|
||||||
|
|
||||||
|
// Продолжительность: целое положительное число календарных дней.
|
||||||
|
const daysNum = Number(data.DaysCount);
|
||||||
|
const daysValid = Number.isFinite(daysNum) && daysNum > 0;
|
||||||
// Заявку нужно подавать не позднее чем за MIN_NOTICE_DAYS дней до даты начала
|
// Заявку нужно подавать не позднее чем за MIN_NOTICE_DAYS дней до даты начала
|
||||||
// отпуска — календарь (min у input type=date, см. RequestSection) не даёт
|
// отпуска — календарь (min у input type=date, см. RequestSection) не даёт
|
||||||
// выбрать более раннюю дату, но проверяем и здесь на случай обхода UI.
|
// выбрать более раннюю дату, но проверяем и здесь на случай обхода UI.
|
||||||
@@ -176,7 +194,8 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
const isValid = !!(
|
const isValid = !!(
|
||||||
data.Employee &&
|
data.Employee &&
|
||||||
data.Position.trim() &&
|
data.Position.trim() &&
|
||||||
datesValid &&
|
data.StartDate &&
|
||||||
|
daysValid &&
|
||||||
isStartDateValid &&
|
isStartDateValid &&
|
||||||
data.Reason.trim()
|
data.Reason.trim()
|
||||||
);
|
);
|
||||||
@@ -184,10 +203,10 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
if (data.StartDate && !isStartDateValid) {
|
if (data.StartDate && !isStartDateValid) {
|
||||||
return `Заявку на отпуск нужно подать не позднее чем за ${MIN_NOTICE_DAYS} дней до даты начала — минимально доступная дата: ${formatIsoDate(minStartDate)}.`;
|
return `Заявку на отпуск нужно подать не позднее чем за ${MIN_NOTICE_DAYS} дней до даты начала — минимально доступная дата: ${formatIsoDate(minStartDate)}.`;
|
||||||
}
|
}
|
||||||
if (!data.Employee || !data.Position.trim() || !data.StartDate || !data.EndDate || !data.Reason.trim()) {
|
if (!data.Employee || !data.Position.trim() || !data.StartDate || !data.Reason.trim()) {
|
||||||
return 'Заполните обязательные поля: сотрудник, должность, дата начала, дата окончания, основание.';
|
return 'Заполните обязательные поля: сотрудник, должность, дата начала, основание.';
|
||||||
}
|
}
|
||||||
if (!datesValid) return 'Дата окончания не может быть раньше даты начала.';
|
if (!daysValid) return 'Количество дней должно быть больше нуля.';
|
||||||
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
||||||
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -223,6 +242,7 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
rtId,
|
rtId,
|
||||||
data,
|
data,
|
||||||
toApprovers(matrix.rows),
|
toApprovers(matrix.rows),
|
||||||
|
executorsToWrite(executors.rows),
|
||||||
status,
|
status,
|
||||||
writeListId
|
writeListId
|
||||||
);
|
);
|
||||||
@@ -237,7 +257,7 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
||||||
// Имена списков — той же партиции, куда фактически записана заявка (writeListId),
|
// Имена списков — той же партиции, куда фактически записана заявка (writeListId),
|
||||||
// не текущей: поток ищет строку маршрута именно в НЕЙ.
|
// не текущей: поток ищет строку маршрута именно в НЕЙ.
|
||||||
if (status === REQUEST_STATUS.onApproval) {
|
if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) {
|
||||||
await notifyActiveStep(context, approval, id, {
|
await notifyActiveStep(context, approval, id, {
|
||||||
requestTypeId: rtId,
|
requestTypeId: rtId,
|
||||||
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
||||||
@@ -259,11 +279,22 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
||||||
);
|
);
|
||||||
if (unassignedError) return unassignedError;
|
if (unassignedError) return unassignedError;
|
||||||
|
const unassignedExecutorError = unassignedStepMessage(
|
||||||
|
executors.rows,
|
||||||
|
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
|
||||||
|
);
|
||||||
|
if (unassignedExecutorError) return unassignedExecutorError;
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
||||||
const handleSubmit = (): Promise<void> => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit);
|
// Есть исполнители — initialSubmitStatus учитывает маршрут без согласующих.
|
||||||
|
const handleSubmit = (): Promise<void> =>
|
||||||
|
submit.run(
|
||||||
|
'submit',
|
||||||
|
save(executors.rows.length > 0 ? initialSubmitStatus(matrix.rows.length, executors.rows.length) : REQUEST_STATUS.onApproval),
|
||||||
|
validateSubmit
|
||||||
|
);
|
||||||
|
|
||||||
const sectionProps = { data, set, context, onEmployeeChange: handleEmployeeChange };
|
const sectionProps = { data, set, context, onEmployeeChange: handleEmployeeChange };
|
||||||
|
|
||||||
@@ -314,7 +345,13 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
<RequestSection {...sectionProps} />
|
<RequestSection {...sectionProps} />
|
||||||
</FormModeProvider>
|
</FormModeProvider>
|
||||||
|
|
||||||
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
<ApprovalMatrix
|
||||||
|
editor={matrix}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||||
|
|
||||||
{/* Автору на доработке важнее всего видеть, почему вернули или
|
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||||
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ import {
|
|||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
|
ExecutorsMatrix,
|
||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
|
useViewExecutors,
|
||||||
|
approvalPhaseRows,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId
|
useRequestTypeId
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
@@ -70,7 +73,8 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
|
|||||||
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
|
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
|
||||||
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
|
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
|
||||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||||
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]);
|
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]);
|
||||||
|
const executors = useViewExecutors(context, approval, itemId, decisionSeq);
|
||||||
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||||
approval.getJobTitle(l)
|
approval.getJobTitle(l)
|
||||||
);
|
);
|
||||||
@@ -150,6 +154,8 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
|
|||||||
</FormCard>
|
</FormCard>
|
||||||
|
|
||||||
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
||||||
|
|
||||||
|
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export async function loadUnpaidLeaveRequest(itemId: number, listId?: string): P
|
|||||||
'Id',
|
'Id',
|
||||||
'Position',
|
'Position',
|
||||||
'StartDate',
|
'StartDate',
|
||||||
'EndDate',
|
'DaysCount',
|
||||||
'Reason',
|
'Reason',
|
||||||
'Organization',
|
'Organization',
|
||||||
'Department',
|
'Department',
|
||||||
@@ -46,14 +46,14 @@ export async function loadUnpaidLeaveRequest(itemId: number, listId?: string): P
|
|||||||
const employee = item.Employee as { Title?: string; EMail?: string } | undefined;
|
const employee = item.Employee as { Title?: string; EMail?: string } | undefined;
|
||||||
const substitute = item.Substitute as { EMail?: string } | undefined;
|
const substitute = item.Substitute as { EMail?: string } | undefined;
|
||||||
const start = (item.StartDate as string) || '';
|
const start = (item.StartDate as string) || '';
|
||||||
const end = (item.EndDate as string) || '';
|
const days = item.DaysCount;
|
||||||
|
|
||||||
const data: IFormData = {
|
const data: IFormData = {
|
||||||
...EMPTY_DATA,
|
...EMPTY_DATA,
|
||||||
Employee: employee?.EMail || '',
|
Employee: employee?.EMail || '',
|
||||||
Position: (item.Position as string) || '',
|
Position: (item.Position as string) || '',
|
||||||
StartDate: spDateOnlyToIso(start),
|
StartDate: spDateOnlyToIso(start),
|
||||||
EndDate: spDateOnlyToIso(end),
|
DaysCount: days === null || days === undefined ? '' : String(days),
|
||||||
Reason: (item.Reason as string) || '',
|
Reason: (item.Reason as string) || '',
|
||||||
Substitute: substitute?.EMail || '',
|
Substitute: substitute?.EMail || '',
|
||||||
Organization: (item.Organization as string) || '',
|
Organization: (item.Organization as string) || '',
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ export const FORM_ID: RequestTypeId = 'hr-unpaid-leave';
|
|||||||
* по возможности = internalName колонок списка данных (см. shared/listTitles.ts).
|
* по возможности = internalName колонок списка данных (см. shared/listTitles.ts).
|
||||||
* Employee/Substitute хранят e-mail выбранного сотрудника (то, что отдаёт
|
* Employee/Substitute хранят e-mail выбранного сотрудника (то, что отдаёт
|
||||||
* PeopleField); по Employee резолвится маршрут согласования, поэтому заявку можно
|
* PeopleField); по Employee резолвится маршрут согласования, поэтому заявку можно
|
||||||
* подать и за другого сотрудника. В отличие от «Заявки на отпуск» здесь обе даты
|
* подать и за другого сотрудника. Как и в «Заявке на отпуск», даты окончания нет:
|
||||||
* (с/по) вводятся явно, а DaysCount считается при сохранении.
|
* отпуск задаётся началом и количеством дней, чтобы не считать праздники и выходные.
|
||||||
*/
|
*/
|
||||||
export interface IFormData {
|
export interface IFormData {
|
||||||
/** E-mail сотрудника, за которого заявка (People). Пусто = не выбран. */
|
/** E-mail сотрудника, за которого заявка (People). Пусто = не выбран. */
|
||||||
@@ -21,8 +21,8 @@ export interface IFormData {
|
|||||||
Position: string;
|
Position: string;
|
||||||
/** ISO YYYY-MM-DD (input type=date). */
|
/** ISO YYYY-MM-DD (input type=date). */
|
||||||
StartDate: string;
|
StartDate: string;
|
||||||
/** ISO YYYY-MM-DD (input type=date). */
|
/** Количество календарных дней (строка из input type=number). */
|
||||||
EndDate: string;
|
DaysCount: string;
|
||||||
/** Основание «В связи с …». */
|
/** Основание «В связи с …». */
|
||||||
Reason: string;
|
Reason: string;
|
||||||
/** E-mail замещающего на время отсутствия (People). Пусто = не выбран. */
|
/** E-mail замещающего на время отсутствия (People). Пусто = не выбран. */
|
||||||
@@ -68,7 +68,7 @@ export const EMPTY_DATA: IFormData = {
|
|||||||
Employee: '',
|
Employee: '',
|
||||||
Position: '',
|
Position: '',
|
||||||
StartDate: '',
|
StartDate: '',
|
||||||
EndDate: '',
|
DaysCount: '',
|
||||||
Reason: '',
|
Reason: '',
|
||||||
Substitute: '',
|
Substitute: '',
|
||||||
Organization: '',
|
Organization: '',
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
{ "internalName": "Employee", "title": "Сотрудник", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
|
{ "internalName": "Employee", "title": "Сотрудник", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
|
||||||
{ "internalName": "Position", "title": "Должность", "type": "Text" },
|
{ "internalName": "Position", "title": "Должность", "type": "Text" },
|
||||||
{ "internalName": "StartDate", "title": "Дата начала", "type": "DateTime", "dateOnly": true },
|
{ "internalName": "StartDate", "title": "Дата начала", "type": "DateTime", "dateOnly": true },
|
||||||
{ "internalName": "EndDate", "title": "Дата окончания", "type": "DateTime", "dateOnly": true },
|
|
||||||
{ "internalName": "DaysCount", "title": "Количество дней", "type": "Number" },
|
{ "internalName": "DaysCount", "title": "Количество дней", "type": "Number" },
|
||||||
{ "internalName": "Reason", "title": "Основание (в связи с)", "type": "Note" },
|
{ "internalName": "Reason", "title": "Основание (в связи с)", "type": "Note" },
|
||||||
{ "internalName": "Substitute", "title": "Лицо, замещающее на время отсутствия", "type": "User", "userSelectionMode": "PeopleOnly" },
|
{ "internalName": "Substitute", "title": "Лицо, замещающее на время отсутствия", "type": "User", "userSelectionMode": "PeopleOnly" },
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
|
|||||||
min={isoDatePlusDays(MIN_NOTICE_DAYS)}
|
min={isoDatePlusDays(MIN_NOTICE_DAYS)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Дата окончания" htmlFor="EndDate" required>
|
<FormField label="Количество дней" htmlFor="DaysCount" required>
|
||||||
<TextInput id="EndDate" type="date" value={data.EndDate} onChange={(v) => set('EndDate', v)} />
|
<TextInput id="DaysCount" type="number" value={data.DaysCount} onChange={(v) => set('DaysCount', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="В связи с" htmlFor="Reason" required full>
|
<FormField label="В связи с" htmlFor="Reason" required full>
|
||||||
<TextArea id="Reason" value={data.Reason} onChange={(v) => set('Reason', v)} />
|
<TextArea id="Reason" value={data.Reason} onChange={(v) => set('Reason', v)} />
|
||||||
|
|||||||
@@ -16,21 +16,13 @@ function pad2(n: number): string {
|
|||||||
return n < 10 ? `0${n}` : `${n}`;
|
return n < 10 ? `0${n}` : `${n}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Число дней отпуска включительно по датам с/по. Пусто/некорректно → пусто (null). */
|
|
||||||
function computeDays(startIso: string, endIso: string): number | undefined {
|
|
||||||
if (!startIso || !endIso) return undefined;
|
|
||||||
const diff = new Date(endIso).getTime() - new Date(startIso).getTime();
|
|
||||||
if (!Number.isFinite(diff) || diff < 0) return undefined;
|
|
||||||
return Math.round(diff / 86400000) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Осмысленный заголовок элемента (системная колонка Title). */
|
/** Осмысленный заголовок элемента (системная колонка Title). */
|
||||||
function buildTitle(employeeName: string): string {
|
function buildTitle(employeeName: string): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
return `Отпуск без содержания — ${employeeName} — ${pad2(now.getDate())}.${pad2(now.getMonth() + 1)}.${now.getFullYear()}`;
|
return `Отпуск без содержания — ${employeeName} — ${pad2(now.getDate())}.${pad2(now.getMonth() + 1)}.${now.getFullYear()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Поля элемента списка данных из модели формы (+ Employee/Substitute по Id, + вычисленный DaysCount). */
|
/** Поля элемента списка данных из модели формы (+ Employee/Substitute по Id). */
|
||||||
function buildItem(
|
function buildItem(
|
||||||
data: IFormData,
|
data: IFormData,
|
||||||
status: string,
|
status: string,
|
||||||
@@ -38,15 +30,14 @@ function buildItem(
|
|||||||
employeeName: string,
|
employeeName: string,
|
||||||
substituteId: number | undefined
|
substituteId: number | undefined
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
const days = computeDays(data.StartDate, data.EndDate);
|
const days = Number(data.DaysCount);
|
||||||
return {
|
return {
|
||||||
Title: buildTitle(employeeName),
|
Title: buildTitle(employeeName),
|
||||||
RequestStatus: status,
|
RequestStatus: status,
|
||||||
EmployeeId: employeeId,
|
EmployeeId: employeeId,
|
||||||
Position: data.Position || null,
|
Position: data.Position || null,
|
||||||
StartDate: data.StartDate || null,
|
StartDate: data.StartDate || null,
|
||||||
EndDate: data.EndDate || null,
|
DaysCount: Number.isFinite(days) && days > 0 ? days : null,
|
||||||
DaysCount: days ?? null,
|
|
||||||
Reason: data.Reason || null,
|
Reason: data.Reason || null,
|
||||||
SubstituteId: substituteId ?? null,
|
SubstituteId: substituteId ?? null,
|
||||||
Organization: data.Organization || null,
|
Organization: data.Organization || null,
|
||||||
@@ -62,7 +53,7 @@ export type ISaveResult = ISaveItemResult;
|
|||||||
/**
|
/**
|
||||||
* Сохранить заявку без содержания: содержимое + маршрут согласования.
|
* Сохранить заявку без содержания: содержимое + маршрут согласования.
|
||||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||||
* Фазы исполнения нет — writeRoute вызывается с пустым списком исполнителей.
|
* executors — исполнители фазы «Исполнение» из маршрута (пусто, если в маршруте их нет).
|
||||||
* Возвращает Id заявки (+ код при создании).
|
* Возвращает Id заявки (+ код при создании).
|
||||||
*
|
*
|
||||||
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadUnpaidLeaveRequest). Имеет смысл
|
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadUnpaidLeaveRequest). Имеет смысл
|
||||||
@@ -75,6 +66,7 @@ export async function saveUnpaidLeaveRequest(
|
|||||||
requestTypeId: number,
|
requestTypeId: number,
|
||||||
data: IFormData,
|
data: IFormData,
|
||||||
approvers: IApproverToWrite[],
|
approvers: IApproverToWrite[],
|
||||||
|
executors: IApproverToWrite[],
|
||||||
status: string,
|
status: string,
|
||||||
listId?: string
|
listId?: string
|
||||||
): Promise<ISaveResult> {
|
): Promise<ISaveResult> {
|
||||||
@@ -84,6 +76,6 @@ export async function saveUnpaidLeaveRequest(
|
|||||||
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee, substituteId);
|
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee, substituteId);
|
||||||
|
|
||||||
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||||
await writeRoute(unpaidLeaveApprovalService(listId), result.id, approvers, [], status);
|
await writeRoute(unpaidLeaveApprovalService(listId), result.id, approvers, executors, status);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,9 @@
|
|||||||
## Маршрут
|
## Маршрут
|
||||||
- Тип заявки в approval-route API (`/api/request-types`): id **11**, `pageUrl` «hr-vacation»,
|
- Тип заявки в approval-route API (`/api/request-types`): id **11**, `pageUrl` «hr-vacation»,
|
||||||
`listId` «/sites/requests/Lists/List5», section «Кадры».
|
`listId` «/sites/requests/Lists/List5», section «Кадры».
|
||||||
- Фазы: только согласование. Фазы исполнения нет — после согласования заявка
|
- Фазы: согласование, а при наличии исполнителей в маршруте (бывает: тип заявки может их
|
||||||
просто получает статус «Согласовано» (никакого Planner, в отличие от Ticket1C).
|
содержать) — ещё и исполнение. Без исполнителей после согласования заявка получает статус
|
||||||
|
«Согласовано», с исполнителем — сразу «Выполнение». Planner не используется (в отличие от Ticket1C).
|
||||||
- **Матрица редактируема заявителем** при новой заявке / черновике / возврате
|
- **Матрица редактируема заявителем** при новой заявке / черновике / возврате
|
||||||
(как RecruitingHire / WorkplaceSetup): `useMatrixEditor(editable = !readOnly)`.
|
(как RecruitingHire / WorkplaceSetup): `useMatrixEditor(editable = !readOnly)`.
|
||||||
Заявитель может добавлять/удалять/переставлять согласующих поверх планируемого
|
Заявитель может добавлять/удалять/переставлять согласующих поверх планируемого
|
||||||
@@ -24,10 +25,48 @@
|
|||||||
пользователем (email + должность из профиля `SPS-JobTitle` через
|
пользователем (email + должность из профиля `SPS-JobTitle` через
|
||||||
`approval.getJobTitle`). При выборе другого сотрудника его должность
|
`approval.getJobTitle`). При выборе другого сотрудника его должность
|
||||||
подтягивается так же. Ошибка профиля не мешает форме — поле просто остаётся как есть.
|
подтягивается так же. Ошибка профиля не мешает форме — поле просто остаётся как есть.
|
||||||
- **Дата окончания не вводится** — считается при сохранении: `начало + (дни − 1)`
|
- **Даты окончания нет**: отпуск задаётся началом и количеством дней, чтобы не считать
|
||||||
и пишется в колонку `EndDate` (обязательна в списке, но в UI её нет).
|
праздники и выходные. Колонка `EndDate` не используется: из провижининга, типа контента и представления она убрана (само системное поле SharePoint `FromBaseType` удалить нельзя).
|
||||||
- **Заголовок элемента** (`Title`): `Отпуск — {ФИО} — дд.мм.гггг`.
|
- **Заголовок элемента** (`Title`): `Отпуск — {ФИО} — дд.мм.гггг`.
|
||||||
|
|
||||||
|
## Подписание в ЭДО
|
||||||
|
Когда **последний согласующий** нажимает «Согласовать», заявление сначала уходит на подпись в TANSU ЭДО
|
||||||
|
(`VacationView` → `onBeforeDecide`), и только потом применяется решение. Последний — значит заявка
|
||||||
|
«На согласовании» и несогласованной осталась одна строка фазы согласования (`completesApprovalPhase`;
|
||||||
|
исполнители не считаются, параллельные согласующие учтены). **Не ушло в ЭДО — решение не сохраняется**:
|
||||||
|
заявка остаётся на согласовании, ошибка видна в панели решения, согласовать можно ещё раз. Иначе заявка
|
||||||
|
ушла бы дальше по маршруту, а подписывать было бы нечего. Повтор безопасен: DOCX собирается одинаково
|
||||||
|
(фиксированная дата записей архива), и уже принятые файлы ЭДО узнаёт по хэшу — ответ `duplicate_document`
|
||||||
|
считается успехом. В зелёном сообщении «Решение сохранено» появляется «Документы отправлены на подписание
|
||||||
|
в ЭДО». Кнопка «Повторить отправку на подпись» осталась для заявок, согласованных до этой схемы:
|
||||||
|
- **Кто подписывает** — сотрудник, указанный в заявке (поле «Сотрудник»); ему уходит приглашение.
|
||||||
|
**Инициатор** (`initiator_email`, письма о подписях) — исполнитель заявки, первый в фазе исполнения; нет
|
||||||
|
исполнителя — уведомлений о подписях нет.
|
||||||
|
- **Брендированная шапка** — логотип компании первой строкой документа (тот же, что при печати,
|
||||||
|
`shared/letterheadLogo.ts` → `shared/orgLogos.ts`). Логотипы вшиты в бандл, поэтому у компании, для
|
||||||
|
которой логотип есть, документ без шапки уйти не может. У компании без логотипа шапки нет — как и в печати.
|
||||||
|
- **Документ** — заявление, собранное в браузере как DOCX (`vacationDocument.ts`, JSZip); ЭДО сам
|
||||||
|
переводит его в PDF. Текст — рабочий вариант, финальную формулировку утверждает кадровая служба.
|
||||||
|
- **Совместители.** Заявку сотрудник заполняет одну, а подписывает заявление по каждой компании, где
|
||||||
|
работает: в ЭДО уходит по файлу на компанию (своя шапка — компания и должность в ней), БИН файлов — в
|
||||||
|
массиве `file_company_bins`, общий `company_bin` не передаётся. Подписант один — сам сотрудник, от
|
||||||
|
имени физического лица; БИН нужны ЭДО только чтобы выбрать сертификат Sigex (mTLS) нужной компании.
|
||||||
|
Запрос один на все файлы: ЭДО принимает его целиком или отклоняет целиком (например, если у какой-то
|
||||||
|
компании не распознан), поэтому повтор безопасен. Сертификат Sigex компании нужен ЭДО только для mTLS
|
||||||
|
(карточка документа): без него документ принимается и подписывается как обычно. Ответ 200 значит
|
||||||
|
«принято»: в зелёном сообщении просто «Документы отправлены на подписание в ЭДО».
|
||||||
|
- **ИИН** берётся из `GET /Employee/ByEmail` (поле `Iin`), **БИН компаний** — из `GET /Dictionary/Companies`
|
||||||
|
по `CompanyId` каждой записи сотрудника (`shared/edo/signingTarget.ts`). Компания без БИН в справочнике
|
||||||
|
пропускается и показывается в сообщении; остальные уходят.
|
||||||
|
- **С каким ЭДО работать** — свойство веб-части «С каким ЭДО работать» (`edoApiBaseUrl`): стейдж или
|
||||||
|
локальный бэкенд. По умолчанию стейдж. Токен берётся для того же AAD-приложения, что у approval-route.
|
||||||
|
- **Отпуск без содержания в ЭДО не подписывается** (`UnpaidLeave`): подписание подключено только к этой форме.
|
||||||
|
- **Неудача отправки** показывается сообщением над панелью решения и согласование не отменяет; рядом
|
||||||
|
кнопка «Повторить отправку на подпись». Признака «уже отправлено» в заявке нет, поэтому после
|
||||||
|
перезагрузки страницы повтор недоступен.
|
||||||
|
- **Ограничение:** срабатывает только если финальное решение принято в UI портала. Автосогласование по
|
||||||
|
SLA на бэкенде approval-route этот код не вызывает — такое заявление на подпись не уйдёт.
|
||||||
|
|
||||||
## Секции формы
|
## Секции формы
|
||||||
RequestSection (сотрудник · должность · дата начала · количество дней)
|
RequestSection (сотрудник · должность · дата начала · количество дней)
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,16 @@ import {
|
|||||||
useRequestSubmit
|
useRequestSubmit
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
|
composeRemoveRowRule,
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
|
useFormExecutors,
|
||||||
|
ExecutorsMatrix,
|
||||||
|
approvalPhaseRows,
|
||||||
|
executorsToWrite,
|
||||||
|
initialSubmitStatus,
|
||||||
getPlannedMatrix,
|
getPlannedMatrix,
|
||||||
resolveWriteListId,
|
resolveWriteListId,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
@@ -26,8 +32,9 @@ import {
|
|||||||
IApproverToWrite,
|
IApproverToWrite,
|
||||||
unassignedStepMessage
|
unassignedStepMessage
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
|
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||||
import { IRequestFormProps } from '../../models/types';
|
import { IRequestFormProps } from '../../models/types';
|
||||||
import { getEmployeeOrganizations, formatEmployeeOrganizations } 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 { isoDatePlusDays, formatIsoDate } from '../../shared/dateUtils';
|
||||||
import {
|
import {
|
||||||
@@ -102,6 +109,15 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
};
|
};
|
||||||
}, [editId, editListId, notifyCode, setSubmitError]);
|
}, [editId, editListId, notifyCode, setSubmitError]);
|
||||||
|
|
||||||
|
// Должность и подразделение больше не вводятся руками: берутся из ПЕРВОЙ (основной)
|
||||||
|
// записи Employee/ByEmail и пишутся в скалярные колонки Position/Department (их
|
||||||
|
// читают документ и ЭДО); по каждой организации то же видно в таблице (Organization).
|
||||||
|
const primaryOrgFields = (records: IEmployeeOrganization[]): Pick<IFormData, 'Position' | 'Department' | 'DepartmentId'> => ({
|
||||||
|
Position: records[0]?.position || '',
|
||||||
|
Department: records[0]?.section || '',
|
||||||
|
DepartmentId: records[0]?.departmentId || ''
|
||||||
|
});
|
||||||
|
|
||||||
// Новая заявка — по умолчанию на себя: подставляем текущего пользователя,
|
// Новая заявка — по умолчанию на себя: подставляем текущего пользователя,
|
||||||
// его должность и организации (Employee/ByEmail — см. shared/dictionary/
|
// его должность и организации (Employee/ByEmail — см. shared/dictionary/
|
||||||
// employeeOrganizations.service.ts; сотрудник может состоять сразу в
|
// employeeOrganizations.service.ts; сотрудник может состоять сразу в
|
||||||
@@ -118,8 +134,8 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
setData((prev) => ({
|
setData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
Position: prev.Position || records[0]?.position || prev.Position,
|
...primaryOrgFields(records),
|
||||||
Organization: formatEmployeeOrganizations(records)
|
Organization: formatEmployeeOrganizationsWithDepartment(records)
|
||||||
}));
|
}));
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
@@ -135,15 +151,15 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
const handleEmployeeChange = (login: string): void => {
|
const handleEmployeeChange = (login: string): void => {
|
||||||
setData((prev) => ({ ...prev, Employee: login }));
|
setData((prev) => ({ ...prev, Employee: login }));
|
||||||
if (!login) {
|
if (!login) {
|
||||||
setData((prev) => ({ ...prev, Organization: '' }));
|
setData((prev) => ({ ...prev, ...primaryOrgFields([]), Organization: '' }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
getEmployeeOrganizations(context, login)
|
getEmployeeOrganizations(context, login)
|
||||||
.then((records) =>
|
.then((records) =>
|
||||||
setData((prev) => ({
|
setData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
Position: records[0]?.position || prev.Position,
|
...primaryOrgFields(records),
|
||||||
Organization: formatEmployeeOrganizations(records)
|
Organization: formatEmployeeOrganizationsWithDepartment(records)
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
@@ -155,7 +171,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
const loadMatrix = useCallback(
|
const loadMatrix = useCallback(
|
||||||
() =>
|
() =>
|
||||||
editId
|
editId
|
||||||
? approval.getMatrixForRequest(editId)
|
? approval.getMatrixForRequest(editId).then(approvalPhaseRows)
|
||||||
: getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval'),
|
: getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval'),
|
||||||
[context, approval, editId, data.Employee, requestTypeId]
|
[context, approval, editId, data.Employee, requestTypeId]
|
||||||
);
|
);
|
||||||
@@ -167,6 +183,17 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
(login) => approval.getJobTitle(login)
|
(login) => approval.getJobTitle(login)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Исполнители из маршрута админки (фаза «Исполнение»): только чтение, таблица
|
||||||
|
// и запись в заявку — когда они в маршруте есть.
|
||||||
|
const executors = useFormExecutors({
|
||||||
|
readOnly,
|
||||||
|
context,
|
||||||
|
approval,
|
||||||
|
requestTypeId,
|
||||||
|
userEmail: data.Employee,
|
||||||
|
editId
|
||||||
|
});
|
||||||
|
|
||||||
const daysNum = Number(data.DaysCount);
|
const daysNum = Number(data.DaysCount);
|
||||||
// Заявку нужно подавать не позднее чем за MIN_NOTICE_DAYS дней до даты начала
|
// Заявку нужно подавать не позднее чем за MIN_NOTICE_DAYS дней до даты начала
|
||||||
// отпуска — календарь (min у input type=date, см. RequestSection) не даёт
|
// отпуска — календарь (min у input type=date, см. RequestSection) не даёт
|
||||||
@@ -175,7 +202,6 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
const isStartDateValid = !data.StartDate || data.StartDate >= minStartDate;
|
const isStartDateValid = !data.StartDate || data.StartDate >= minStartDate;
|
||||||
const isValid = !!(
|
const isValid = !!(
|
||||||
data.Employee &&
|
data.Employee &&
|
||||||
data.Position.trim() &&
|
|
||||||
data.StartDate &&
|
data.StartDate &&
|
||||||
isStartDateValid &&
|
isStartDateValid &&
|
||||||
Number.isFinite(daysNum) &&
|
Number.isFinite(daysNum) &&
|
||||||
@@ -185,7 +211,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
if (data.StartDate && !isStartDateValid) {
|
if (data.StartDate && !isStartDateValid) {
|
||||||
return `Заявку на отпуск нужно подать не позднее чем за ${MIN_NOTICE_DAYS} дней до даты начала — минимально доступная дата: ${formatIsoDate(minStartDate)}.`;
|
return `Заявку на отпуск нужно подать не позднее чем за ${MIN_NOTICE_DAYS} дней до даты начала — минимально доступная дата: ${formatIsoDate(minStartDate)}.`;
|
||||||
}
|
}
|
||||||
if (!isValid) return 'Заполните обязательные поля: сотрудник, должность, дата начала, количество дней (> 0).';
|
if (!isValid) return 'Заполните обязательные поля: сотрудник, дата начала, количество дней (> 0).';
|
||||||
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
||||||
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -221,6 +247,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
rtId,
|
rtId,
|
||||||
data,
|
data,
|
||||||
toApprovers(matrix.rows),
|
toApprovers(matrix.rows),
|
||||||
|
executorsToWrite(executors.rows),
|
||||||
status,
|
status,
|
||||||
writeListId
|
writeListId
|
||||||
);
|
);
|
||||||
@@ -236,7 +263,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
// Имена списков — той же партиции, куда фактически записали (writeListId),
|
// Имена списков — той же партиции, куда фактически записали (writeListId),
|
||||||
// не обязательно текущей на момент рендера формы: поток ищет строку
|
// не обязательно текущей на момент рендера формы: поток ищет строку
|
||||||
// маршрута именно в НЕЙ.
|
// маршрута именно в НЕЙ.
|
||||||
if (status === REQUEST_STATUS.onApproval) {
|
if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) {
|
||||||
await notifyActiveStep(context, approval, id, {
|
await notifyActiveStep(context, approval, id, {
|
||||||
requestTypeId: rtId,
|
requestTypeId: rtId,
|
||||||
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
||||||
@@ -258,11 +285,27 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
||||||
);
|
);
|
||||||
if (unassignedError) return unassignedError;
|
if (unassignedError) return unassignedError;
|
||||||
|
const unassignedExecutorError = unassignedStepMessage(
|
||||||
|
executors.rows,
|
||||||
|
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
|
||||||
|
);
|
||||||
|
if (unassignedExecutorError) return unassignedExecutorError;
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
||||||
const handleSubmit = (): Promise<void> => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit);
|
// Есть исполнители — initialSubmitStatus учтёт и маршрут без согласующих (сразу «Выполнение»);
|
||||||
|
// иначе прежнее поведение: «На согласовании».
|
||||||
|
const handleSubmit = (): Promise<void> =>
|
||||||
|
submit.run(
|
||||||
|
'submit',
|
||||||
|
save(
|
||||||
|
executors.rows.length > 0
|
||||||
|
? initialSubmitStatus(matrix.rows.length, executors.rows.length)
|
||||||
|
: REQUEST_STATUS.onApproval
|
||||||
|
),
|
||||||
|
validateSubmit
|
||||||
|
);
|
||||||
|
|
||||||
const sectionProps = { data, set, context, onEmployeeChange: handleEmployeeChange };
|
const sectionProps = { data, set, context, onEmployeeChange: handleEmployeeChange };
|
||||||
|
|
||||||
@@ -313,7 +356,13 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
<RequestSection {...sectionProps} />
|
<RequestSection {...sectionProps} />
|
||||||
</FormModeProvider>
|
</FormModeProvider>
|
||||||
|
|
||||||
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
<ApprovalMatrix
|
||||||
|
editor={matrix}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||||
|
|
||||||
{/* Автору на доработке важнее всего видеть, почему вернули или
|
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||||
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import styles from './VacationView.module.scss';
|
import styles from './VacationView.module.scss';
|
||||||
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
|
import { Alert, Button, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApproverService,
|
ApproverService,
|
||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
@@ -10,9 +10,16 @@ import {
|
|||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
|
useViewExecutors,
|
||||||
|
ExecutorsMatrix,
|
||||||
|
approvalPhaseRows,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId
|
useRequestTypeId
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
|
import { completesApprovalPhase } from '../../shared/approval/models/statuses';
|
||||||
|
import type { ApproverDecision } from '../../shared/approval';
|
||||||
|
import { isAlreadyAccepted } from '../../shared/edo/edoErrors';
|
||||||
|
import { executionPhaseRows } from '../../shared/approval/useRouteExecutors';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
import {
|
import {
|
||||||
approvalListTitle,
|
approvalListTitle,
|
||||||
@@ -23,6 +30,7 @@ import {
|
|||||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||||
import { loadVacationRequest } from './load';
|
import { loadVacationRequest } from './load';
|
||||||
import { vacationApprovalService } from './submit';
|
import { vacationApprovalService } from './submit';
|
||||||
|
import { IVacationSigningResult, sendVacationForSigning } from './sendToSign';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
import { useStableCallback } from '../../shared/useStableCallback';
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
@@ -52,11 +60,18 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
|||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
|
// Ф.И.О. из заявки (Employee/Title) — запасной вариант для документа на подпись.
|
||||||
|
const [employeeName, setEmployeeName] = useState<string>('');
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
// Какая из организаций сотрудника (см. OrganizationField) идёт в печать —
|
// Какая из организаций сотрудника (см. OrganizationField) идёт в печать —
|
||||||
// выбор в тулбаре через PrintSplitButton, по умолчанию первая.
|
// выбор в тулбаре через PrintSplitButton, по умолчанию первая.
|
||||||
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
|
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
|
||||||
|
// Итог отправки согласованного заявления на подпись в ЭДО (см. sendToSigning): по каким
|
||||||
|
// компаниям ушло, по каким нельзя, либо ошибка всего запроса.
|
||||||
|
const [signingResult, setSigningResult] = useState<IVacationSigningResult | undefined>(undefined);
|
||||||
|
const [signingError, setSigningError] = useState<string>('');
|
||||||
|
const [signingBusy, setSigningBusy] = useState<boolean>(false);
|
||||||
|
|
||||||
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
|
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
|
||||||
// vacationApprovalService можно звать не опасаясь исключения.
|
// vacationApprovalService можно звать не опасаясь исключения.
|
||||||
@@ -70,10 +85,14 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
|||||||
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
|
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
|
||||||
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
|
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
|
||||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||||
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]);
|
// Заявление ушло в ЭДО перед этим решением (см. sendBeforeFinalApproval) — сказать об этом в «Решение сохранено».
|
||||||
|
const sentBeforeDecision = useRef<boolean>(false);
|
||||||
|
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]);
|
||||||
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||||
approval.getJobTitle(l)
|
approval.getJobTitle(l)
|
||||||
);
|
);
|
||||||
|
// Исполнители (фаза «Исполнение») — отдельная таблица, если они есть в маршруте.
|
||||||
|
const executors = useViewExecutors(context, approval, itemId, decisionSeq);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -84,6 +103,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
|
setEmployeeName(loaded.employeeName);
|
||||||
notifyCode(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -125,6 +145,50 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
|||||||
}).catch(() => undefined);
|
}).catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Заявление уходит на подпись в ЭДО (по документу на каждую компанию сотрудника, одним запросом).
|
||||||
|
// Исполнитель — первый в фазе исполнения маршрута: он инициатор в ЭДО. Повтор безопасен: файл
|
||||||
|
// собирается одинаково, и уже принятое ЭДО узнаёт по хэшу (duplicate_document) — это тоже успех.
|
||||||
|
const submitToEdo = async (): Promise<void> => {
|
||||||
|
setSigningError('');
|
||||||
|
setSigningResult(undefined);
|
||||||
|
setSigningBusy(true);
|
||||||
|
try {
|
||||||
|
const rows = await approval.getMatrixForRequest(itemId);
|
||||||
|
const initiatorEmail = executionPhaseRows(rows).filter((r) => r.email)[0]?.email;
|
||||||
|
try {
|
||||||
|
setSigningResult(await sendVacationForSigning(context, { data, employeeName, itemId, code, initiatorEmail }));
|
||||||
|
} catch (e) {
|
||||||
|
if (!isAlreadyAccepted(e)) throw e;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSigningBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Решение последнего согласующего применяется, только когда заявление уже в ЭДО: иначе заявка
|
||||||
|
// ушла бы дальше по маршруту, а подписывать было бы нечего. Не ушло — решение не сохраняется,
|
||||||
|
// заявка остаётся на согласовании, и ошибку видно в панели решения; согласовать можно ещё раз.
|
||||||
|
const sendBeforeFinalApproval = async (decision: ApproverDecision): Promise<void> => {
|
||||||
|
sentBeforeDecision.current = false;
|
||||||
|
if (decision !== 'approve') return;
|
||||||
|
|
||||||
|
if (!completesApprovalPhase(status, await approval.getMatrixForRequest(itemId))) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await submitToEdo();
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`заявление не ушло на подпись в ЭДО, заявка осталась на согласовании — ${friendlyErrorMessage(e)}`);
|
||||||
|
}
|
||||||
|
sentBeforeDecision.current = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Для заявок, согласованных раньше, когда отправка шла после решения и могла не удаться.
|
||||||
|
const retrySigning = (): void => {
|
||||||
|
submitToEdo().catch((e) =>
|
||||||
|
setSigningError(`Заявка согласована, но отправить заявление на подпись не удалось: ${friendlyErrorMessage(e)}`)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={`${styles.toolbar} rp-no-print`}>
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
@@ -150,6 +214,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
|||||||
</FormCard>
|
</FormCard>
|
||||||
|
|
||||||
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
||||||
|
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||||
@@ -160,14 +225,30 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rp-no-print">
|
<div className="rp-no-print">
|
||||||
|
{signingBusy && <Alert variant="ok">Отправляем заявление на подпись в ЭДО…</Alert>}
|
||||||
|
{!signingBusy && signingResult && signingResult.skipped.length > 0 && (
|
||||||
|
<Alert variant="err">
|
||||||
|
{`Не отправлено по компаниям: ${signingResult.skipped.map((c) => `${c.name} — ${c.reason}`).join('; ')}.`}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{!signingBusy && signingError && (
|
||||||
|
<>
|
||||||
|
<Alert variant="err">{signingError}</Alert>
|
||||||
|
<Button variant="secondary" onClick={retrySigning}>
|
||||||
|
Повторить отправку на подпись
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<DecisionPanel
|
<DecisionPanel
|
||||||
service={approverService}
|
service={approverService}
|
||||||
requestId={itemId}
|
requestId={itemId}
|
||||||
search={(q) => approval.searchUsers(q)}
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
onBeforeDecide={sendBeforeFinalApproval}
|
||||||
onDecided={(newStatus) => {
|
onDecided={(newStatus) => {
|
||||||
setStatus(newStatus);
|
setStatus(newStatus);
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
notify();
|
notify();
|
||||||
|
return sentBeforeDecision.current ? 'Документы отправлены на подписание в ЭДО.' : undefined;
|
||||||
}}
|
}}
|
||||||
onDelegated={() => {
|
onDelegated={() => {
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/** ISO `YYYY-MM-DD` → `дд.мм.гггг`. Не похоже на дату → строка как есть (чтобы документ не падал из-за мусора). */
|
||||||
|
export function formatRuDate(iso: string): string {
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
|
||||||
|
return match ? `${match[3]}.${match[2]}.${match[1]}` : iso;
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ export const FORM_ID: RequestTypeId = 'hr-vacation';
|
|||||||
* списка данных этой формы (см. shared/listTitles.ts). Employee хранит e-mail выбранного
|
* списка данных этой формы (см. shared/listTitles.ts). Employee хранит e-mail выбранного
|
||||||
* сотрудника (то, что отдаёт PeopleField) — по нему же резолвится маршрут
|
* сотрудника (то, что отдаёт PeopleField) — по нему же резолвится маршрут
|
||||||
* согласования, поэтому заявку можно подать и за другого сотрудника.
|
* согласования, поэтому заявку можно подать и за другого сотрудника.
|
||||||
* EndDate в форме не вводится: считается при сохранении как начало + (дни − 1).
|
* Даты окончания нет: отпуск задаётся началом и количеством дней, праздники и выходные не считаются.
|
||||||
*/
|
*/
|
||||||
export interface IFormData {
|
export interface IFormData {
|
||||||
/** E-mail сотрудника, за которого заявка (People). Пусто = не выбран. */
|
/** E-mail сотрудника, за которого заявка (People). Пусто = не выбран. */
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
{ "internalName": "Employee", "title": "Сотрудник", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
|
{ "internalName": "Employee", "title": "Сотрудник", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
|
||||||
{ "internalName": "Position", "title": "Должность", "type": "Text" },
|
{ "internalName": "Position", "title": "Должность", "type": "Text" },
|
||||||
{ "internalName": "StartDate", "title": "Дата начала", "type": "DateTime", "dateOnly": true },
|
{ "internalName": "StartDate", "title": "Дата начала", "type": "DateTime", "dateOnly": true },
|
||||||
{ "internalName": "EndDate", "title": "Дата окончания", "type": "DateTime", "dateOnly": true },
|
|
||||||
{ "internalName": "DaysCount", "title": "Количество дней", "type": "Number" },
|
{ "internalName": "DaysCount", "title": "Количество дней", "type": "Number" },
|
||||||
{ "internalName": "Substitute", "title": "Лицо, замещающее на время отсутствия", "type": "User", "userSelectionMode": "PeopleOnly" },
|
{ "internalName": "Substitute", "title": "Лицо, замещающее на время отсутствия", "type": "User", "userSelectionMode": "PeopleOnly" },
|
||||||
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
|
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { FormGrid, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui';
|
import { FormGrid, FormField, TextInput, PeopleField, OrganizationTable } from '../../../shared/ui';
|
||||||
import { DepartmentPicker, ProjectPicker } from '../../../shared/dictionary';
|
import { ProjectPicker } from '../../../shared/dictionary';
|
||||||
import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS } from '../model';
|
import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS } from '../model';
|
||||||
import { isoDatePlusDays } from '../../../shared/dateUtils';
|
import { isoDatePlusDays } from '../../../shared/dateUtils';
|
||||||
|
|
||||||
/** Единственная секция формы «Заявка на отпуск»: сотрудник, должность, даты. */
|
/** Единственная секция формы «Заявка на отпуск»: сотрудник (должность и подразделение подтягиваются из справочника), даты. */
|
||||||
export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange, printOrgIndex }) => (
|
export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange, printOrgIndex }) => (
|
||||||
<FormGrid>
|
<FormGrid>
|
||||||
<FormField label="Сотрудник" htmlFor="Employee" required full>
|
<FormField label="Сотрудник" htmlFor="Employee" required full>
|
||||||
@@ -15,21 +15,7 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
|
|||||||
placeholder={EMPLOYEE_PLACEHOLDER}
|
placeholder={EMPLOYEE_PLACEHOLDER}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Должность" htmlFor="Position" required>
|
<OrganizationTable value={data.Organization} printIndex={printOrgIndex} />
|
||||||
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
|
|
||||||
</FormField>
|
|
||||||
<OrganizationField value={data.Organization} printIndex={printOrgIndex} />
|
|
||||||
<FormField label="Подразделение" htmlFor="Department">
|
|
||||||
<DepartmentPicker
|
|
||||||
id="Department"
|
|
||||||
context={context}
|
|
||||||
value={data.Department}
|
|
||||||
onChange={(name, id) => {
|
|
||||||
set('Department', name);
|
|
||||||
set('DepartmentId', id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Проект" htmlFor="Project">
|
<FormField label="Проект" htmlFor="Project">
|
||||||
<ProjectPicker
|
<ProjectPicker
|
||||||
id="Project"
|
id="Project"
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { letterheadLogoFor } from '../../shared/letterheadLogo';
|
||||||
|
import { getCompanies, getEmployeeOrganizations } from '../../shared/dictionary';
|
||||||
|
import { submitSigningRequest } from '../../shared/edo/edo.service';
|
||||||
|
import { ISigningCompany, ISkippedCompany, resolveSigningTargets } from '../../shared/edo/signingTarget';
|
||||||
|
import { formatRuDate } from './dates';
|
||||||
|
import { IFormData } from './model';
|
||||||
|
import { createVacationDocx, vacationFileName } from './vacationDocument';
|
||||||
|
|
||||||
|
/** Что нужно знать о согласованной заявке, чтобы отправить её на подпись. */
|
||||||
|
export interface IVacationToSign {
|
||||||
|
/** Данные заявки (прочитаны из списка). */
|
||||||
|
data: IFormData;
|
||||||
|
/** Ф.И.О. сотрудника из заявки (`Employee/Title`) — запасной вариант, если справочник пуст. */
|
||||||
|
employeeName: string;
|
||||||
|
/** Уникальный код заявки (`RequestUniqueCode`); пусто — берётся id. */
|
||||||
|
code: string;
|
||||||
|
itemId: number;
|
||||||
|
/**
|
||||||
|
* Почта исполнителя заявки (тот, кто идёт в маршруте после согласующих): он «инициатор» в ЭДО, и
|
||||||
|
* на его почту придёт сообщение о подписи. Нет исполнителя в маршруте — инициатора нет, и никто не уведомляется о подписях.
|
||||||
|
*/
|
||||||
|
initiatorEmail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Итог отправки: по каким компаниям документ ушёл и по каким нельзя. */
|
||||||
|
export interface IVacationSigningResult {
|
||||||
|
/** Названия компаний, по которым заявление отправлено на подпись. */
|
||||||
|
sent: string[];
|
||||||
|
/** Компании сотрудника, по которым документ не собрать (нет БИН в справочнике), и почему. */
|
||||||
|
skipped: ISkippedCompany[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сегодняшняя дата `дд.мм.гггг` по местному времени — дата составления заявления. */
|
||||||
|
function todayRu(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const pad = (n: number): string => (n < 10 ? `0${n}` : `${n}`);
|
||||||
|
return `${pad(now.getDate())}.${pad(now.getMonth() + 1)}.${now.getFullYear()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ф.И.О. замещающего по его e-mail; не нашли — сам адрес, чтобы заявление всё равно собралось. */
|
||||||
|
async function substituteName(context: WebPartContext, email: string): Promise<string> {
|
||||||
|
if (!email) return '';
|
||||||
|
try {
|
||||||
|
const records = await getEmployeeOrganizations(context, email);
|
||||||
|
return records[0]?.fio || email;
|
||||||
|
} catch {
|
||||||
|
// Имя замещающего — украшение документа: из-за него отправку не срываем.
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отправляет согласованное заявление на отпуск на подпись в ЭДО — отдельным файлом на каждую
|
||||||
|
* компанию, где работает сотрудник: заявку он заполняет одну, а подписывает заявление в каждой
|
||||||
|
* компании. У каждого файла своя шапка (компания, должность) и свой БИН в `file_company_bins`;
|
||||||
|
* подписант один — сам сотрудник, от имени физического лица. БИН нужны ЭДО только для выбора
|
||||||
|
* сертификата Sigex (mTLS) нужной компании.
|
||||||
|
*
|
||||||
|
* Запрос один на все файлы: ЭДО принимает его целиком или отклоняет целиком (например, если у
|
||||||
|
* одной из компаний нет сертификата), поэтому «часть ушла, часть нет» не бывает и повтор безопасен.
|
||||||
|
*
|
||||||
|
* Подписывает сотрудник, указанный в заявке: ему уходят приглашения. Инициатор — исполнитель заявки:
|
||||||
|
* на его почту ЭДО сообщит о подписи. ИИН и БИН берутся из справочников api.tnsu.kz.
|
||||||
|
*/
|
||||||
|
export async function sendVacationForSigning(
|
||||||
|
context: WebPartContext,
|
||||||
|
vacation: IVacationToSign
|
||||||
|
): Promise<IVacationSigningResult> {
|
||||||
|
const { data } = vacation;
|
||||||
|
if (!data.Employee) {
|
||||||
|
throw new Error('в заявке не указан сотрудник');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [records, companies] = await Promise.all([getEmployeeOrganizations(context, data.Employee), getCompanies(context)]);
|
||||||
|
const targets = resolveSigningTargets(records, companies);
|
||||||
|
|
||||||
|
if (targets.companies.length === 0) {
|
||||||
|
const reasons = targets.skipped.map((c) => `${c.name} — ${c.reason}`).join('; ');
|
||||||
|
throw new Error(`ни по одной компании сотрудника заявление не отправить: ${reasons}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = Number(data.DaysCount);
|
||||||
|
const documentDate = todayRu();
|
||||||
|
const employeeName =
|
||||||
|
[targets.name.lastName, targets.name.firstName, targets.name.middleName].filter((p) => p).join(' ') ||
|
||||||
|
vacation.employeeName;
|
||||||
|
const substitute = await substituteName(context, data.Substitute);
|
||||||
|
|
||||||
|
const files = await Promise.all(
|
||||||
|
targets.companies.map(async (company: ISigningCompany) => ({
|
||||||
|
name: vacationFileName(employeeName, company.name, documentDate),
|
||||||
|
companyBin: company.bin,
|
||||||
|
content: await createVacationDocx({
|
||||||
|
companyName: company.name,
|
||||||
|
// В каждой компании своя должность; нет её в справочнике — та, что в заявке.
|
||||||
|
position: company.position || data.Position,
|
||||||
|
employeeName,
|
||||||
|
startDate: formatRuDate(data.StartDate),
|
||||||
|
daysCount: days,
|
||||||
|
substituteName: substitute,
|
||||||
|
requestCode: vacation.code || String(vacation.itemId),
|
||||||
|
documentDate,
|
||||||
|
// Брендированная шапка — тот же логотип, что при печати; у компаний без логотипа её нет.
|
||||||
|
logo: letterheadLogoFor(company.name)
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
await submitSigningRequest(context, {
|
||||||
|
recipient: { iin: targets.iin, email: data.Employee, name: targets.name },
|
||||||
|
initiatorEmail: vacation.initiatorEmail,
|
||||||
|
files
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
sent: targets.companies.map((c) => c.name),
|
||||||
|
skipped: targets.skipped
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -16,20 +16,13 @@ function pad2(n: number): string {
|
|||||||
return n < 10 ? `0${n}` : `${n}`;
|
return n < 10 ? `0${n}` : `${n}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** «Дата окончания» в форме не вводится: начало + (дни − 1). Пусто → ''. */
|
|
||||||
function computeEndDate(startIso: string, days: number): string {
|
|
||||||
if (!startIso || !Number.isFinite(days) || days <= 0) return '';
|
|
||||||
const endMs = new Date(startIso).getTime() + (days - 1) * 86400000;
|
|
||||||
return new Date(endMs).toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Осмысленный заголовок элемента (системная колонка Title). */
|
/** Осмысленный заголовок элемента (системная колонка Title). */
|
||||||
function buildTitle(employeeName: string): string {
|
function buildTitle(employeeName: string): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
return `Отпуск — ${employeeName} — ${pad2(now.getDate())}.${pad2(now.getMonth() + 1)}.${now.getFullYear()}`;
|
return `Отпуск — ${employeeName} — ${pad2(now.getDate())}.${pad2(now.getMonth() + 1)}.${now.getFullYear()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Поля элемента списка данных из модели формы (+ Employee/Substitute по Id, + вычисленная EndDate). */
|
/** Поля элемента списка данных из модели формы (+ Employee/Substitute по Id). */
|
||||||
function buildItem(
|
function buildItem(
|
||||||
data: IFormData,
|
data: IFormData,
|
||||||
status: string,
|
status: string,
|
||||||
@@ -45,7 +38,6 @@ function buildItem(
|
|||||||
Position: data.Position || null,
|
Position: data.Position || null,
|
||||||
StartDate: data.StartDate || null,
|
StartDate: data.StartDate || null,
|
||||||
DaysCount: Number.isFinite(days) && days > 0 ? days : null,
|
DaysCount: Number.isFinite(days) && days > 0 ? days : null,
|
||||||
EndDate: computeEndDate(data.StartDate, days) || null,
|
|
||||||
SubstituteId: substituteId ?? null,
|
SubstituteId: substituteId ?? null,
|
||||||
Organization: data.Organization || null,
|
Organization: data.Organization || null,
|
||||||
Department: data.Department || null,
|
Department: data.Department || null,
|
||||||
@@ -60,7 +52,7 @@ export type ISaveResult = ISaveItemResult;
|
|||||||
/**
|
/**
|
||||||
* Сохранить заявку на отпуск: содержимое + маршрут согласования.
|
* Сохранить заявку на отпуск: содержимое + маршрут согласования.
|
||||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||||
* Фазы исполнения у отпуска нет — writeRoute вызывается с пустым списком исполнителей.
|
* executors — исполнители фазы «Исполнение» из маршрута админки (пусто, если в маршруте их нет).
|
||||||
* Возвращает Id заявки (+ код при создании).
|
* Возвращает Id заявки (+ код при создании).
|
||||||
*
|
*
|
||||||
* listId — партиция, в которую фактически пишем: при редактировании — партиция
|
* listId — партиция, в которую фактически пишем: при редактировании — партиция
|
||||||
@@ -76,6 +68,7 @@ export async function saveVacationRequest(
|
|||||||
requestTypeId: number,
|
requestTypeId: number,
|
||||||
data: IFormData,
|
data: IFormData,
|
||||||
approvers: IApproverToWrite[],
|
approvers: IApproverToWrite[],
|
||||||
|
executors: IApproverToWrite[],
|
||||||
status: string,
|
status: string,
|
||||||
listId?: string
|
listId?: string
|
||||||
): Promise<ISaveResult> {
|
): Promise<ISaveResult> {
|
||||||
@@ -85,6 +78,6 @@ export async function saveVacationRequest(
|
|||||||
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee, substituteId);
|
const item = buildItem(data, status, employee.Id, employee.Title || data.Employee, substituteId);
|
||||||
|
|
||||||
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||||
await writeRoute(vacationApprovalService(listId), result.id, approvers, [], status);
|
await writeRoute(vacationApprovalService(listId), result.id, approvers, executors, status);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import JSZip from 'jszip';
|
||||||
|
import { formatRuDate } from './dates';
|
||||||
|
import {
|
||||||
|
IVacationDocumentInput,
|
||||||
|
buildDocumentXml,
|
||||||
|
createVacationDocx,
|
||||||
|
escapeXml,
|
||||||
|
vacationFileName
|
||||||
|
} from './vacationDocument';
|
||||||
|
import { letterheadLogoFor, pngSize } from '../../shared/letterheadLogo';
|
||||||
|
|
||||||
|
const INPUT: IVacationDocumentInput = {
|
||||||
|
companyName: 'ТОО TANSU Construction',
|
||||||
|
position: 'Инженер',
|
||||||
|
employeeName: 'Иванов Иван Иванович',
|
||||||
|
startDate: '01.12.2026',
|
||||||
|
daysCount: 14,
|
||||||
|
substituteName: 'Петров Пётр Петрович',
|
||||||
|
requestCode: 'HR-0042',
|
||||||
|
documentDate: '30.09.2026',
|
||||||
|
logo: letterheadLogoFor('ТОО TANSU Construction')
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Blob → байты через FileReader: в окружении Jest (jsdom) нет ни Response, ни blob.arrayBuffer(). */
|
||||||
|
function readBlob(blob: Blob): Promise<ArrayBuffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result as ArrayBuffer);
|
||||||
|
reader.onerror = () => reject(reader.error);
|
||||||
|
reader.readAsArrayBuffer(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('даты отпуска', () => {
|
||||||
|
it('формат дд.мм.гггг, мусор остаётся как есть', () => {
|
||||||
|
expect(formatRuDate('2026-12-01')).toBe('01.12.2026');
|
||||||
|
expect(formatRuDate('завтра')).toBe('завтра');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('заявление на отпуск', () => {
|
||||||
|
it('в тексте есть все данные заявки', () => {
|
||||||
|
const xml = buildDocumentXml(INPUT);
|
||||||
|
|
||||||
|
for (const part of [
|
||||||
|
'ТОО TANSU Construction',
|
||||||
|
'Инженер',
|
||||||
|
'Иванов Иван Иванович',
|
||||||
|
'ЗАЯВЛЕНИЕ',
|
||||||
|
'14 календарных дней, начиная с 01.12.2026',
|
||||||
|
'Петров Пётр Петрович',
|
||||||
|
'HR-0042',
|
||||||
|
'30.09.2026'
|
||||||
|
]) {
|
||||||
|
expect(xml).toContain(part);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без замещающего абзаца о замещении нет', () => {
|
||||||
|
expect(buildDocumentXml({ ...INPUT, substituteName: '' })).not.toContain('исполнение обязанностей');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('специальные символы в данных экранируются, разметка не ломается', () => {
|
||||||
|
const xml = buildDocumentXml({ ...INPUT, companyName: 'ТОО "Рога & Копыта" <филиал>' });
|
||||||
|
|
||||||
|
expect(xml).toContain('ТОО "Рога & Копыта" <филиал>');
|
||||||
|
expect(xml).not.toContain('Рога & Копыта');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('экранирование', () => {
|
||||||
|
expect(escapeXml(`<a href="x">&'</a>`)).toBe('<a href="x">&'</a>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('имя файла без недопустимых символов', () => {
|
||||||
|
expect(vacationFileName('Иванов И.И.', 'ТОО TANSU Construction', '30.09.2026')).toBe(
|
||||||
|
'Заявление на отпуск — Иванов И.И. — ТОО TANSU Construction — 30.09.2026.docx'
|
||||||
|
);
|
||||||
|
expect(vacationFileName('A/B:C', 'ТОО "X"', '01.01.2026')).not.toMatch(/[\\/:*?"<>|]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DOCX — это zip с обязательными частями пакета', async () => {
|
||||||
|
const blob = await createVacationDocx(INPUT);
|
||||||
|
const zip = await JSZip.loadAsync(await readBlob(blob));
|
||||||
|
|
||||||
|
expect(Object.keys(zip.files).sort()).toEqual([
|
||||||
|
'[Content_Types].xml',
|
||||||
|
'_rels/.rels',
|
||||||
|
'word/_rels/document.xml.rels',
|
||||||
|
'word/document.xml',
|
||||||
|
'word/media/logo.png'
|
||||||
|
]);
|
||||||
|
const document = await zip.file('word/document.xml')!.async('string');
|
||||||
|
expect(document).toContain('ЗАЯВЛЕНИЕ');
|
||||||
|
// Шапка — первая в документе, раньше текста.
|
||||||
|
expect(document.indexOf('r:embed="rIdLogo"')).toBeLessThan(document.indexOf('ЗАЯВЛЕНИЕ'));
|
||||||
|
expect(await zip.file('word/media/logo.png')!.async('base64')).toBe(INPUT.logo!.base64);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('одно и то же заявление собирается байт в байт одинаково — ЭДО узнаёт повтор', async () => {
|
||||||
|
const [first, second] = await Promise.all([createVacationDocx(INPUT), createVacationDocx(INPUT)]);
|
||||||
|
|
||||||
|
expect(new Uint8Array(await readBlob(first))).toEqual(new Uint8Array(await readBlob(second)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('у каждой компании с печатной шапкой есть логотип для документа, размеры из PNG', () => {
|
||||||
|
for (const company of ['ТОО TANSU Construction', 'ТОО АСТЕЛ-К', 'ТОО СТ Global', 'ТОО KazPromService', 'ТОО QAZGEOMAX']) {
|
||||||
|
const logo = letterheadLogoFor(company);
|
||||||
|
expect(logo?.width).toBeGreaterThan(0);
|
||||||
|
expect(logo?.height).toBe(300);
|
||||||
|
}
|
||||||
|
expect(pngSize(INPUT.logo!.base64)).toEqual({ width: 212, height: 300 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('у компании без логотипа документ собирается без шапки', async () => {
|
||||||
|
expect(letterheadLogoFor('ТОО Монтажник-Т')).toBeUndefined();
|
||||||
|
|
||||||
|
const zip = await JSZip.loadAsync(await readBlob(await createVacationDocx({ ...INPUT, logo: undefined })));
|
||||||
|
|
||||||
|
expect(Object.keys(zip.files).sort()).toEqual(['[Content_Types].xml', '_rels/.rels', 'word/document.xml']);
|
||||||
|
expect(await zip.file('word/document.xml')!.async('string')).not.toContain('rIdLogo');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import JSZip from 'jszip';
|
||||||
|
import type { ILetterheadLogo } from '../../shared/letterheadLogo';
|
||||||
|
|
||||||
|
/** Данные заявления на отпуск. Всё, что идёт в текст, уже подготовлено вызывающим. */
|
||||||
|
export interface IVacationDocumentInput {
|
||||||
|
/** Организация, на которую пишется заявление («ТОО TANSU Construction»). */
|
||||||
|
companyName: string;
|
||||||
|
/** Должность заявителя; пусто — строка «должность» не выводится. */
|
||||||
|
position: string;
|
||||||
|
/** Ф.И.О. заявителя. */
|
||||||
|
employeeName: string;
|
||||||
|
/** Начало отпуска, ISO `YYYY-MM-DD`. */
|
||||||
|
startDate: string;
|
||||||
|
/** Сколько календарных дней длится отпуск. Конца нет: праздники и выходные считает кадровая служба. */
|
||||||
|
daysCount: number;
|
||||||
|
/** Ф.И.О. замещающего; пусто — абзац о замещении не выводится. */
|
||||||
|
substituteName: string;
|
||||||
|
/** Номер заявки в портале. */
|
||||||
|
requestCode: string;
|
||||||
|
/** Дата составления заявления, `дд.мм.гггг`. */
|
||||||
|
documentDate: string;
|
||||||
|
/** Логотип компании — брендированная шапка документа; нет у компании логотипа — без шапки. */
|
||||||
|
logo?: ILetterheadLogo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DOCX_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||||
|
|
||||||
|
/** Экранирование для XML: имена и должности приходят из справочников и могут содержать `&`, `<`, кавычки. */
|
||||||
|
export function escapeXml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
type Align = 'left' | 'right' | 'center' | 'both';
|
||||||
|
|
||||||
|
interface IParagraph {
|
||||||
|
text: string;
|
||||||
|
align?: Align;
|
||||||
|
bold?: boolean;
|
||||||
|
/** Отступ после абзаца, twips (20 twips = 1 пт). */
|
||||||
|
after?: number;
|
||||||
|
/** Отступ первой строки, twips. */
|
||||||
|
firstLine?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Times New Roman 14 пт — привычный вид официального заявления. Размер в «половинках» пункта.
|
||||||
|
const FONT = 'Times New Roman';
|
||||||
|
const FONT_SIZE_HALF_POINTS = 28;
|
||||||
|
|
||||||
|
function paragraph({ text, align = 'left', bold = false, after = 0, firstLine = 0 }: IParagraph): string {
|
||||||
|
const indent = firstLine > 0 ? `<w:ind w:firstLine="${firstLine}"/>` : '';
|
||||||
|
const run =
|
||||||
|
`<w:rPr><w:rFonts w:ascii="${FONT}" w:hAnsi="${FONT}" w:cs="${FONT}"/>` +
|
||||||
|
`${bold ? '<w:b/>' : ''}<w:sz w:val="${FONT_SIZE_HALF_POINTS}"/><w:szCs w:val="${FONT_SIZE_HALF_POINTS}"/></w:rPr>`;
|
||||||
|
return (
|
||||||
|
`<w:p><w:pPr><w:spacing w:after="${after}" w:line="276" w:lineRule="auto"/>${indent}<w:jc w:val="${align}"/></w:pPr>` +
|
||||||
|
`<w:r>${run}<w:t xml:space="preserve">${escapeXml(text)}</w:t></w:r></w:p>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Абзацы заявления. Формулировка — рабочий вариант, финальный текст утверждает кадровая служба. */
|
||||||
|
export function buildVacationParagraphs(input: IVacationDocumentInput): IParagraph[] {
|
||||||
|
const paragraphs: IParagraph[] = [
|
||||||
|
// Шапка «кому/от кого»: должность в именительном падеже, поэтому без предлога «от».
|
||||||
|
{ text: input.companyName, align: 'right' },
|
||||||
|
...(input.position ? [{ text: input.position, align: 'right' as const }] : []),
|
||||||
|
{ text: input.employeeName, align: 'right', after: 480 },
|
||||||
|
{ text: 'ЗАЯВЛЕНИЕ', align: 'center', bold: true, after: 360 },
|
||||||
|
{
|
||||||
|
text:
|
||||||
|
`Прошу предоставить мне ежегодный оплачиваемый трудовой отпуск продолжительностью ${input.daysCount} ` +
|
||||||
|
`календарных дней, начиная с ${input.startDate}.`,
|
||||||
|
align: 'both',
|
||||||
|
firstLine: 709,
|
||||||
|
after: 240
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
if (input.substituteName) {
|
||||||
|
paragraphs.push({
|
||||||
|
text: `На период моего отсутствия исполнение обязанностей прошу возложить на ${input.substituteName}.`,
|
||||||
|
align: 'both',
|
||||||
|
firstLine: 709,
|
||||||
|
after: 240
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
paragraphs.push(
|
||||||
|
{ text: `Дата: ${input.documentDate}`, after: 120 },
|
||||||
|
{ text: `Заявка в портале №${input.requestCode}`, after: 360 },
|
||||||
|
{ text: `${input.employeeName} ____________` }
|
||||||
|
);
|
||||||
|
|
||||||
|
return paragraphs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Логотип в шапке — высота 2,5 см, ширина по пропорции картинки. EMU: 1 см = 360 000. */
|
||||||
|
const LOGO_HEIGHT_EMU = 900000;
|
||||||
|
const LOGO_REL_ID = 'rIdLogo';
|
||||||
|
|
||||||
|
function logoParagraph(logo: ILetterheadLogo): string {
|
||||||
|
const cy = LOGO_HEIGHT_EMU;
|
||||||
|
const cx = Math.round((cy * logo.width) / logo.height);
|
||||||
|
return (
|
||||||
|
'<w:p><w:pPr><w:spacing w:after="360"/><w:jc w:val="left"/></w:pPr><w:r><w:drawing>' +
|
||||||
|
'<wp:inline distT="0" distB="0" distL="0" distR="0">' +
|
||||||
|
`<wp:extent cx="${cx}" cy="${cy}"/><wp:docPr id="1" name="Логотип"/>` +
|
||||||
|
'<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic>' +
|
||||||
|
'<pic:nvPicPr><pic:cNvPr id="0" name="logo.png"/><pic:cNvPicPr/></pic:nvPicPr>' +
|
||||||
|
`<pic:blipFill><a:blip r:embed="${LOGO_REL_ID}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill>` +
|
||||||
|
`<pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm>` +
|
||||||
|
'<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr>' +
|
||||||
|
'</pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Тело `word/document.xml`. Отдельно от упаковки в zip — чтобы проверять разметку без архива. */
|
||||||
|
export function buildDocumentXml(input: IVacationDocumentInput): string {
|
||||||
|
const body = (input.logo ? logoParagraph(input.logo) : '') + buildVacationParagraphs(input).map(paragraph).join('');
|
||||||
|
// A4, поля 2 см сверху/снизу/слева и 1,5 см справа — как у канцелярских бланков.
|
||||||
|
const section =
|
||||||
|
'<w:sectPr><w:pgSz w:w="11906" w:h="16838"/>' +
|
||||||
|
'<w:pgMar w:top="1134" w:right="850" w:bottom="1134" w:left="1701" w:header="709" w:footer="709" w:gutter="0"/></w:sectPr>';
|
||||||
|
return (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||||
|
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"' +
|
||||||
|
' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"' +
|
||||||
|
' xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"' +
|
||||||
|
' xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"' +
|
||||||
|
' xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">' +
|
||||||
|
`<w:body>${body}${section}</w:body></w:document>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTENT_TYPES_XML =
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||||
|
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
|
||||||
|
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
|
||||||
|
'<Default Extension="xml" ContentType="application/xml"/>' +
|
||||||
|
'<Default Extension="png" ContentType="image/png"/>' +
|
||||||
|
'<Override PartName="/word/document.xml" ' +
|
||||||
|
'ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>' +
|
||||||
|
'</Types>';
|
||||||
|
|
||||||
|
const ROOT_RELS_XML =
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||||
|
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
||||||
|
'<Relationship Id="rId1" ' +
|
||||||
|
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" ' +
|
||||||
|
'Target="word/document.xml"/></Relationships>';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Собирает заявление как DOCX. Файл строится прямо в браузере — отдельного сервиса
|
||||||
|
* документов нет, а ЭДО сам переводит DOCX в PDF перед подписанием.
|
||||||
|
*/
|
||||||
|
const DOCUMENT_RELS_XML =
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||||
|
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
||||||
|
`<Relationship Id="${LOGO_REL_ID}" ` +
|
||||||
|
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" ' +
|
||||||
|
'Target="media/logo.png"/></Relationships>';
|
||||||
|
|
||||||
|
const FIXED_ZIP_DATE = new Date(Date.UTC(2020, 0, 1));
|
||||||
|
|
||||||
|
export async function createVacationDocx(input: IVacationDocumentInput): Promise<Blob> {
|
||||||
|
const zip = new JSZip();
|
||||||
|
// Без записей каталогов (_rels/, word/): в файлах, собранных Word, их нет, и строгие
|
||||||
|
// читатели пакета (в том числе конвертер ЭДО) не должны на них спотыкаться.
|
||||||
|
// Дата записей в архиве фиксирована: иначе JSZip ставит «сейчас», и одно и то же заявление при
|
||||||
|
// повторной отправке давало бы другой файл — ЭДО не узнал бы повтор и завёл бы второй документ.
|
||||||
|
const options = { createFolders: false, date: FIXED_ZIP_DATE };
|
||||||
|
zip.file('[Content_Types].xml', CONTENT_TYPES_XML, options);
|
||||||
|
zip.file('_rels/.rels', ROOT_RELS_XML, options);
|
||||||
|
zip.file('word/document.xml', buildDocumentXml(input), options);
|
||||||
|
if (input.logo) {
|
||||||
|
zip.file('word/_rels/document.xml.rels', DOCUMENT_RELS_XML, options);
|
||||||
|
zip.file('word/media/logo.png', input.logo.base64, { ...options, base64: true });
|
||||||
|
}
|
||||||
|
return zip.generateAsync({ type: 'blob', mimeType: DOCX_CONTENT_TYPE, compression: 'DEFLATE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Имя файла для ЭДО: по нему человек узнает документ в письме и кабинете — и отличит компании. */
|
||||||
|
export function vacationFileName(employeeName: string, companyName: string, documentDate: string): string {
|
||||||
|
// Недопустимые в имени файла символы заменяем, а не удаляем: ФИО и компания остаются читаемыми.
|
||||||
|
const safe = (text: string): string => text.replace(/[\\/:*?"<>|]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
return `Заявление на отпуск — ${safe(employeeName)} — ${safe(companyName)} — ${documentDate}.docx`;
|
||||||
|
}
|
||||||
@@ -17,10 +17,12 @@ import {
|
|||||||
useRequestSubmit
|
useRequestSubmit
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
|
composeRemoveRowRule,
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
|
canEditExecutors,
|
||||||
getPlannedMatrix,
|
getPlannedMatrix,
|
||||||
resolveWriteListId,
|
resolveWriteListId,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
@@ -212,7 +214,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
|||||||
const executors = useMatrixEditor(
|
const executors = useMatrixEditor(
|
||||||
loadExecutors,
|
loadExecutors,
|
||||||
`${userEmail}-${editId ?? 'new'}-${data.DepartmentId}`,
|
`${userEmail}-${editId ?? 'new'}-${data.DepartmentId}`,
|
||||||
false,
|
canEditExecutors(context, readOnly),
|
||||||
!!editId || !!requestTypeId,
|
!!editId || !!requestTypeId,
|
||||||
(login) => approval.getJobTitle(login)
|
(login) => approval.getJobTitle(login)
|
||||||
);
|
);
|
||||||
@@ -391,14 +393,20 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
|||||||
</CollapseSection>
|
</CollapseSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
<ApprovalMatrix
|
||||||
|
editor={matrix}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||||
|
/>
|
||||||
|
|
||||||
{!executors.loading && executors.rows.length > 0 && (
|
{!executors.loading && (executors.editable || executors.rows.length > 0) && (
|
||||||
<ApprovalMatrix
|
<ApprovalMatrix
|
||||||
editor={executors}
|
editor={executors}
|
||||||
search={(q) => approval.searchUsers(q)}
|
search={(q) => approval.searchUsers(q)}
|
||||||
title="Исполнение (после согласования)"
|
title="Исполнение (после согласования)"
|
||||||
roleLabel="Исполнитель"
|
roleLabel="Исполнитель"
|
||||||
|
canRemoveRow={executors.editable ? () => true : undefined}
|
||||||
|
pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…"
|
||||||
emptyText="Исполнители не заданы по маршруту."
|
emptyText="Исполнители не заданы по маршруту."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export interface IApproverOrgInfo {
|
|||||||
* email не найден в кадровом справочнике (404) или пуст — getEmployeeOrganizations
|
* email не найден в кадровом справочнике (404) или пуст — getEmployeeOrganizations
|
||||||
* отдаёт [], здесь это просто пустые отдел/организация, не ошибка матрицы.
|
* отдаёт [], здесь это просто пустые отдел/организация, не ошибка матрицы.
|
||||||
*/
|
*/
|
||||||
export function resolveApproverOrgInfo(context: WebPartContext) {
|
export function resolveApproverOrgInfo(context: WebPartContext): (email: string) => Promise<IApproverOrgInfo> {
|
||||||
return async (email: string): Promise<IApproverOrgInfo> => {
|
return async (email: string): Promise<IApproverOrgInfo> => {
|
||||||
const records = await getEmployeeOrganizations(context, email);
|
const records = await getEmployeeOrganizations(context, email);
|
||||||
const first = records[0];
|
const first = records[0];
|
||||||
|
|||||||
@@ -63,13 +63,21 @@ export { notifyActiveStep } from './notify.service';
|
|||||||
export type { INotifyTarget } from './notify.service';
|
export type { INotifyTarget } from './notify.service';
|
||||||
export { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor';
|
export { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor';
|
||||||
export type { IUseMatrixEditor } from './useMatrixEditor';
|
export type { IUseMatrixEditor } from './useMatrixEditor';
|
||||||
|
export {
|
||||||
|
approvalPhaseRows,
|
||||||
|
executionPhaseRows,
|
||||||
|
executorsToWrite,
|
||||||
|
useFormExecutors,
|
||||||
|
useViewExecutors
|
||||||
|
} from './useRouteExecutors';
|
||||||
export { usePhasedRoute } from './usePhasedRoute';
|
export { usePhasedRoute } from './usePhasedRoute';
|
||||||
export type { IPhasedRoute } from './usePhasedRoute';
|
export type { IPhasedRoute } from './usePhasedRoute';
|
||||||
export { resolveApproverOrgInfo } from './approverOrgInfo';
|
export { resolveApproverOrgInfo } from './approverOrgInfo';
|
||||||
export type { IApproverOrgInfo } from './approverOrgInfo';
|
export type { IApproverOrgInfo } from './approverOrgInfo';
|
||||||
export { canEditRoute } from './routeEditRights';
|
export { canEditRoute, canEditExecutors, composeRemoveRowRule } from './routeEditRights';
|
||||||
export { canEditAllFields } from './fullEditRights';
|
export { canEditAllFields } from './fullEditRights';
|
||||||
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
||||||
|
export { ExecutorsMatrix } from './ui/ExecutorsMatrix';
|
||||||
export { ApprovalHistory } from './history/ApprovalHistory';
|
export { ApprovalHistory } from './history/ApprovalHistory';
|
||||||
export { ApprovalHistoryFeed } from './history/ApprovalHistoryFeed';
|
export { ApprovalHistoryFeed } from './history/ApprovalHistoryFeed';
|
||||||
export { HISTORY_TEXT } from './history/historyTexts';
|
export { HISTORY_TEXT } from './history/historyTexts';
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
REQUEST_STATUS,
|
REQUEST_STATUS,
|
||||||
REQUEST_STATUS_CHOICES,
|
REQUEST_STATUS_CHOICES,
|
||||||
initialSubmitStatus,
|
initialSubmitStatus,
|
||||||
|
completesApprovalPhase,
|
||||||
isEditableStatus,
|
isEditableStatus,
|
||||||
isSubmittedStatus,
|
isSubmittedStatus,
|
||||||
} from './statuses';
|
} from './statuses';
|
||||||
@@ -54,6 +55,32 @@ describe('initialSubmitStatus', () => {
|
|||||||
// возвращает initialSubmitStatus, ОБЯЗАН считаться отправкой: иначе строки
|
// возвращает initialSubmitStatus, ОБЯЗАН считаться отправкой: иначе строки
|
||||||
// маршрута получают недопустимый статус, а активации не происходит вовсе —
|
// маршрута получают недопустимый статус, а активации не происходит вовсе —
|
||||||
// именно так заявка без согласующих перестала попадать в очередь исполнения.
|
// именно так заявка без согласующих перестала попадать в очередь исполнения.
|
||||||
|
describe('completesApprovalPhase', () => {
|
||||||
|
const row = (status: string, phase: string = APPROVAL_PHASE.approval): { status: string; phase: string } => ({ status, phase });
|
||||||
|
|
||||||
|
it('осталась одна несогласованная строка — это решение завершит согласование', () => {
|
||||||
|
expect(completesApprovalPhase(REQUEST_STATUS.onApproval, [row(REQUEST_STATUS.approved), row(REQUEST_STATUS.onApproval)])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Исполнитель в маршруте не мешает: его строка фазы «Исполнение».
|
||||||
|
it('строки исполнителей не считаются', () => {
|
||||||
|
expect(
|
||||||
|
completesApprovalPhase(REQUEST_STATUS.onApproval, [row(REQUEST_STATUS.onApproval), row('Ждёт очереди', APPROVAL_PHASE.execution)])
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('впереди ещё согласующие (в том числе параллельные) — не завершит', () => {
|
||||||
|
expect(completesApprovalPhase(REQUEST_STATUS.onApproval, [row(REQUEST_STATUS.onApproval), row(REQUEST_STATUS.onApproval)])).toBe(false);
|
||||||
|
expect(completesApprovalPhase(REQUEST_STATUS.onApproval, [row(REQUEST_STATUS.onApproval), row('Ждёт очереди')])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Решение исполнителя не должно второй раз запускать то, что делается по итогам согласования.
|
||||||
|
it('заявка уже не на согласовании (исполнение, черновик) — нет', () => {
|
||||||
|
expect(completesApprovalPhase(REQUEST_STATUS.execution, [row(REQUEST_STATUS.onApproval)])).toBe(false);
|
||||||
|
expect(completesApprovalPhase(undefined, [row(REQUEST_STATUS.onApproval)])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('isSubmittedStatus', () => {
|
describe('isSubmittedStatus', () => {
|
||||||
it('любой результат initialSubmitStatus считается отправкой', () => {
|
it('любой результат initialSubmitStatus считается отправкой', () => {
|
||||||
[initialSubmitStatus(1, 0), initialSubmitStatus(0, 1), initialSubmitStatus(0, 0)].forEach((s) =>
|
[initialSubmitStatus(1, 0), initialSubmitStatus(0, 1), initialSubmitStatus(0, 0)].forEach((s) =>
|
||||||
|
|||||||
@@ -152,3 +152,21 @@ export const APPROVAL_PHASE_CHOICES: string[] = [
|
|||||||
APPROVAL_PHASE.approval,
|
APPROVAL_PHASE.approval,
|
||||||
APPROVAL_PHASE.execution,
|
APPROVAL_PHASE.execution,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Решение «Согласовать» сейчас завершит фазу СОГЛАСОВАНИЯ: заявка «На согласовании», и среди строк
|
||||||
|
* согласования (не исполнения) несогласованной осталась одна — та, по которой решает текущий
|
||||||
|
* согласующий. Проверяется ДО решения: то, что должно случиться по итогам согласования (например,
|
||||||
|
* отправка заявления в ЭДО), можно сделать заранее и при неудаче не применять решение вовсе.
|
||||||
|
*
|
||||||
|
* Параллельные согласующие (одинаковый Order) учтены: пока кто-то из них не согласовал, строк
|
||||||
|
* остаётся больше одной. Исполнители не считаются — их строки фазы «Исполнение».
|
||||||
|
*/
|
||||||
|
export function completesApprovalPhase(
|
||||||
|
currentStatus: string | undefined,
|
||||||
|
rows: ReadonlyArray<{ status: string; phase?: string }>
|
||||||
|
): boolean {
|
||||||
|
if (currentStatus !== REQUEST_STATUS.onApproval) return false;
|
||||||
|
const pending = rows.filter((r) => r.phase !== APPROVAL_PHASE.execution && r.status !== REQUEST_STATUS.approved);
|
||||||
|
return pending.length === 1;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { effectiveUserEmail } from '../impersonation';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Кто может удалять согласующих из маршрута (и при составлении заявки, и на
|
* Кто может удалять согласующих из маршрута (и при составлении заявки, и на
|
||||||
* своём шаге согласования) — сверх обычных прав инициатора/согласующего.
|
* своём шаге согласования) — сверх обычных прав инициатора/согласующего.
|
||||||
@@ -12,10 +15,33 @@
|
|||||||
* canRemoveRow в ApprovalMatrix; здесь только сама проверка личности.
|
* canRemoveRow в ApprovalMatrix; здесь только сама проверка личности.
|
||||||
*/
|
*/
|
||||||
const ROUTE_EDITOR_EMAILS: ReadonlySet<string> = new Set([
|
const ROUTE_EDITOR_EMAILS: ReadonlySet<string> = new Set([
|
||||||
'a.bulturukova@tnsu.kz' // Алтынай Бултурукова
|
'a.bulturukova@tnsu.kz', // Алтынай Бултурукова
|
||||||
|
'gorbenko.pavel@tnsu.kz' // Павел Горбенко — составляет свой маршрут при подаче (обкатка форм)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** Может ли этот email удалять согласующих из маршрута (см. описание выше). */
|
/** Может ли этот email удалять согласующих из маршрута (см. описание выше). */
|
||||||
export function canEditRoute(email: string | undefined): boolean {
|
export function canEditRoute(email: string | undefined): boolean {
|
||||||
return !!email && ROUTE_EDITOR_EMAILS.has(email.trim().toLowerCase());
|
return !!email && ROUTE_EDITOR_EMAILS.has(email.trim().toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Правило для ApprovalMatrix.canRemoveRow при СОСТАВЛЕНИИ заявки: редактор маршрута удаляет
|
||||||
|
* любого согласующего, остальным кнопки удаления нет. Удаление тут чисто локальное — при
|
||||||
|
* отправке маршрут целиком пишется из строк матрицы, поэтому записи на сервер не нужно.
|
||||||
|
* В поданной заявке так нельзя: там своя реализация (см. RecruitingHireView).
|
||||||
|
*/
|
||||||
|
export function composeRemoveRowRule(email: string | undefined): (() => boolean) | undefined {
|
||||||
|
return canEditRoute(email) ? () => true : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Может ли текущий пользователь править состав ИСПОЛНИТЕЛЕЙ (добавлять и удалять)
|
||||||
|
* при составлении заявки: те же редакторы маршрута, что и для согласующих.
|
||||||
|
* Исполнителей по умолчанию назначает маршрут из админки, заявителю они только
|
||||||
|
* показываются; редактору маршрута матрица «Исполнение» становится редактируемой.
|
||||||
|
* Учитывает подмену личности (effectiveUserEmail). readOnly — заявка уже не
|
||||||
|
* редактируется (отправлена), тогда править нельзя никому.
|
||||||
|
*/
|
||||||
|
export function canEditExecutors(context: WebPartContext, readOnly: boolean): boolean {
|
||||||
|
return !readOnly && canEditRoute(effectiveUserEmail(context));
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,8 +23,12 @@ interface IDecisionPanelProps {
|
|||||||
* Отказ (reject) панель НЕ считает провалом решения — решение к этому моменту
|
* Отказ (reject) панель НЕ считает провалом решения — решение к этому моменту
|
||||||
* уже применено на сервере; сообщить о проблеме побочного действия должен сам
|
* уже применено на сервере; сообщить о проблеме побочного действия должен сам
|
||||||
* обработчик (у него для этого есть своя область экрана).
|
* обработчик (у него для этого есть своя область экрана).
|
||||||
|
*
|
||||||
|
* Может вернуть строку — её панель допишет к зелёному сообщению «Решение сохранено…»
|
||||||
|
* (например, «Документы отправлены на подписание в ЭДО»). Нет побочного действия или оно
|
||||||
|
* не удалось — не возвращайте ничего, сообщение останется прежним.
|
||||||
*/
|
*/
|
||||||
onDecided?: (status: string) => void | Promise<void>;
|
onDecided?: (status: string) => void | string | undefined | Promise<void | string | undefined>;
|
||||||
/** Выполнить ПЕРЕД применением решения (напр. сохранить поля, заполненные
|
/** Выполнить ПЕРЕД применением решения (напр. сохранить поля, заполненные
|
||||||
* согласующим). Если бросит — решение не применяется, показывается ошибка.
|
* согласующим). Если бросит — решение не применяется, показывается ошибка.
|
||||||
* decision — какое решение применяется (проверки, нужные только при
|
* decision — какое решение применяется (проверки, нужные только при
|
||||||
@@ -66,6 +70,8 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
|
|||||||
const [comment, setComment] = useState<string>('');
|
const [comment, setComment] = useState<string>('');
|
||||||
const [submitting, setSubmitting] = useState<boolean>(false);
|
const [submitting, setSubmitting] = useState<boolean>(false);
|
||||||
const [resultStatus, setResultStatus] = useState<string | undefined>(undefined);
|
const [resultStatus, setResultStatus] = useState<string | undefined>(undefined);
|
||||||
|
// Что обработчик onDecided велел дописать к сообщению об успехе.
|
||||||
|
const [resultNote, setResultNote] = useState<string | undefined>(undefined);
|
||||||
const [delegated, setDelegated] = useState<number>(0);
|
const [delegated, setDelegated] = useState<number>(0);
|
||||||
const [showDelegate, setShowDelegate] = useState<boolean>(false);
|
const [showDelegate, setShowDelegate] = useState<boolean>(false);
|
||||||
// Выбранные делеганты копятся здесь и пишутся одной пачкой по кнопке — иначе
|
// Выбранные делеганты копятся здесь и пишутся одной пачкой по кнопке — иначе
|
||||||
@@ -119,13 +125,16 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
|
|||||||
// отработал, кнопки остаются заблокированными (submitting), и пользователь не
|
// отработал, кнопки остаются заблокированными (submitting), и пользователь не
|
||||||
// видит успеха раньше, чем всё сделано. Провал onDecided на статус решения не
|
// видит успеха раньше, чем всё сделано. Провал onDecided на статус решения не
|
||||||
// влияет — оно уже применено, поэтому сообщение показываем всё равно.
|
// влияет — оно уже применено, поэтому сообщение показываем всё равно.
|
||||||
|
let note: string | undefined;
|
||||||
if (onDecided) {
|
if (onDecided) {
|
||||||
try {
|
try {
|
||||||
await onDecided(status);
|
const returned = await onDecided(status);
|
||||||
|
note = typeof returned === 'string' && returned ? returned : undefined;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Побочное действие после решения не выполнилось', e);
|
console.error('Побочное действие после решения не выполнилось', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
setResultNote(note);
|
||||||
setResultStatus(status);
|
setResultStatus(status);
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -205,7 +214,7 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
|
|||||||
<div className={styles.panel}>
|
<div className={styles.panel}>
|
||||||
<Alert variant="ok">
|
<Alert variant="ok">
|
||||||
{isExecution ? 'Отмечено как выполнено' : 'Решение сохранено'}. Статус заявки: «
|
{isExecution ? 'Отмечено как выполнено' : 'Решение сохранено'}. Статус заявки: «
|
||||||
{resultStatus}».
|
{resultStatus}».{resultNote ? ` ${resultNote}` : ''}
|
||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { ISiteUser } from '../models/ISiteUser';
|
||||||
|
import { IUseMatrixEditor } from '../useMatrixEditor';
|
||||||
|
import { ApprovalMatrix } from './ApprovalMatrix';
|
||||||
|
|
||||||
|
interface IExecutorsMatrixProps {
|
||||||
|
editor: IUseMatrixEditor;
|
||||||
|
search: (query: string) => Promise<ISiteUser[]>;
|
||||||
|
/** Заголовок; по умолчанию «Исполнение». */
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Таблица исполнителей — рисуется только когда в маршруте из админки они есть:
|
||||||
|
* у форм без фазы исполнения блока нет вовсе (кроме редактора маршрута, см. canEditExecutors).
|
||||||
|
*/
|
||||||
|
export const ExecutorsMatrix: React.FC<IExecutorsMatrixProps> = ({ editor, search, title = 'Исполнение' }) => {
|
||||||
|
// Редактор маршрута видит блок и пустым — иначе исполнителя некуда добавить.
|
||||||
|
if (editor.loading || (editor.rows.length === 0 && !editor.editable)) return null;
|
||||||
|
return (
|
||||||
|
<ApprovalMatrix
|
||||||
|
editor={editor}
|
||||||
|
search={search}
|
||||||
|
title={title}
|
||||||
|
roleLabel="Исполнитель"
|
||||||
|
canRemoveRow={editor.editable ? () => true : undefined}
|
||||||
|
pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…"
|
||||||
|
emptyText="Исполнители не назначены."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -12,7 +12,7 @@ type MatrixEditor = ReturnType<typeof useMatrixEditor>;
|
|||||||
export interface IPhasedRoute {
|
export interface IPhasedRoute {
|
||||||
/** Согласующие — редактируемы заявителем в новой заявке / черновике / доработке. */
|
/** Согласующие — редактируемы заявителем в новой заявке / черновике / доработке. */
|
||||||
approvers: MatrixEditor;
|
approvers: MatrixEditor;
|
||||||
/** Исполнители по маршруту (фаза «Исполнение») — только чтение. */
|
/** Исполнители по маршруту (фаза «Исполнение») — только чтение, кроме редактора маршрута. */
|
||||||
executors: MatrixEditor;
|
executors: MatrixEditor;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +26,8 @@ interface IRouteSource {
|
|||||||
requestTypeId: number | undefined;
|
requestTypeId: number | undefined;
|
||||||
/** Можно ли править согласующих. */
|
/** Можно ли править согласующих. */
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
|
/** Можно ли править исполнителей (только редактору маршрута, см. canEditExecutors). */
|
||||||
|
executorsEditable?: boolean;
|
||||||
/** Меняется после решения согласующего — перечитать строки. */
|
/** Меняется после решения согласующего — перечитать строки. */
|
||||||
reloadSeq?: number;
|
reloadSeq?: number;
|
||||||
}
|
}
|
||||||
@@ -42,7 +44,7 @@ const byPhase =
|
|||||||
* approval-route, форма его только получает.
|
* approval-route, форма его только получает.
|
||||||
*/
|
*/
|
||||||
export function usePhasedRoute(source: IRouteSource): IPhasedRoute {
|
export function usePhasedRoute(source: IRouteSource): IPhasedRoute {
|
||||||
const { context, approval, requestId, employeeEmail, requestTypeId, editable, reloadSeq = 0 } = source;
|
const { context, approval, requestId, employeeEmail, requestTypeId, editable, executorsEditable = false, reloadSeq = 0 } = source;
|
||||||
const canLoad = !!requestId || (!!employeeEmail && !!requestTypeId);
|
const canLoad = !!requestId || (!!employeeEmail && !!requestTypeId);
|
||||||
const reloadKey = `${employeeEmail}-${requestId ?? 'new'}-${reloadSeq}`;
|
const reloadKey = `${employeeEmail}-${requestId ?? 'new'}-${reloadSeq}`;
|
||||||
|
|
||||||
@@ -64,6 +66,6 @@ export function usePhasedRoute(source: IRouteSource): IPhasedRoute {
|
|||||||
const jobTitle = (login: string): Promise<string> => approval.getJobTitle(login);
|
const jobTitle = (login: string): Promise<string> => approval.getJobTitle(login);
|
||||||
|
|
||||||
const approvers = useMatrixEditor(loadApprovers, reloadKey, editable, canLoad, jobTitle, resolveApproverOrgInfo(context));
|
const approvers = useMatrixEditor(loadApprovers, reloadKey, editable, canLoad, jobTitle, resolveApproverOrgInfo(context));
|
||||||
const executors = useMatrixEditor(loadExecutors, reloadKey, false, canLoad, jobTitle, resolveApproverOrgInfo(context));
|
const executors = useMatrixEditor(loadExecutors, reloadKey, executorsEditable, canLoad, jobTitle, resolveApproverOrgInfo(context));
|
||||||
return { approvers, executors };
|
return { approvers, executors };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { ApprovalListService, IApproverToWrite } from './approvalList.service';
|
||||||
|
import { getPlannedMatrix } from './approvalSubmission.service';
|
||||||
|
import { resolveApproverOrgInfo } from './approverOrgInfo';
|
||||||
|
import { IResolveFieldValue } from './models/IApprovalRouteApi';
|
||||||
|
import { IMatrixRow } from './models/IMatrixRow';
|
||||||
|
import { APPROVAL_PHASE } from './models/statuses';
|
||||||
|
import { canEditExecutors } from './routeEditRights';
|
||||||
|
import { IUseMatrixEditor, useMatrixEditor } from './useMatrixEditor';
|
||||||
|
|
||||||
|
/** Строки фазы согласования (всё, что не «Исполнение») — для таблицы согласующих. */
|
||||||
|
export const approvalPhaseRows = (rows: IMatrixRow[]): IMatrixRow[] =>
|
||||||
|
rows.filter((r) => r.phase !== APPROVAL_PHASE.execution);
|
||||||
|
|
||||||
|
/** Строки фазы исполнения — для таблицы исполнителей. */
|
||||||
|
export const executionPhaseRows = (rows: IMatrixRow[]): IMatrixRow[] =>
|
||||||
|
rows.filter((r) => r.phase === APPROVAL_PHASE.execution);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Строки матрицы исполнителей → то, что пишет writeRoute. Номер шага
|
||||||
|
* нормализуется к 1..N внутри фазы: у уже записанных строк он сквозной (со
|
||||||
|
* смещением на согласующих), а writeRoute смещает фазу сам.
|
||||||
|
*/
|
||||||
|
export const executorsToWrite = (rows: IMatrixRow[]): IApproverToWrite[] => {
|
||||||
|
const base = rows.reduce((min, r) => Math.min(min, r.order), Number.MAX_SAFE_INTEGER);
|
||||||
|
return rows.map((r) => ({
|
||||||
|
email: r.email,
|
||||||
|
position: r.position,
|
||||||
|
allowReassign: r.allowReassign,
|
||||||
|
order: r.order - base + 1,
|
||||||
|
slaHours: r.slaHours,
|
||||||
|
askOverdueReason: r.askOverdueReason,
|
||||||
|
autoApprove: r.autoApprove
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IFormExecutorsSource {
|
||||||
|
context: WebPartContext;
|
||||||
|
approval: ApprovalListService;
|
||||||
|
requestTypeId: number | undefined;
|
||||||
|
/** E-mail, по которому резолвится маршрут (инициатор/выбранный сотрудник). */
|
||||||
|
userEmail: string;
|
||||||
|
/** Редактируемая заявка — исполнители читаются из её строк, а не планируются заново. */
|
||||||
|
editId: number | undefined;
|
||||||
|
/** Те же параметры, что форма отдаёт getPlannedMatrix для согласующих. */
|
||||||
|
departmentId?: string | null; // eslint-disable-line @rushstack/no-new-null -- транзитом в resolveRoute
|
||||||
|
fieldValues?: IResolveFieldValue[];
|
||||||
|
projectId?: string | null; // eslint-disable-line @rushstack/no-new-null -- транзитом в resolveRoute
|
||||||
|
/** Заявка не редактируется — правка состава исполнителей закрыта для всех. */
|
||||||
|
readOnly?: boolean;
|
||||||
|
/** Дополнительный ключ перезагрузки (значение поля, влияющее на маршрут). */
|
||||||
|
reloadSuffix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Исполнители формы заполнения (фаза «Исполнение»): планируемые по маршруту из
|
||||||
|
* админки для новой заявки, из записанных строк — для черновика/доработки.
|
||||||
|
* Заявителю — только чтение: исполнителя назначает маршрут. Редактор маршрута
|
||||||
|
* (см. canEditExecutors) может добавлять и удалять исполнителей.
|
||||||
|
*/
|
||||||
|
export function useFormExecutors(source: IFormExecutorsSource): IUseMatrixEditor {
|
||||||
|
const { context, approval, requestTypeId, userEmail, editId, departmentId, fieldValues, projectId, reloadSuffix = '', readOnly = false } = source;
|
||||||
|
const fieldValuesKey = JSON.stringify(fieldValues ?? []);
|
||||||
|
const load = useCallback(
|
||||||
|
() =>
|
||||||
|
editId
|
||||||
|
? approval.getMatrixForRequest(editId).then(executionPhaseRows)
|
||||||
|
: getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Execution', departmentId, fieldValues, projectId),
|
||||||
|
// fieldValues сравниваем по содержимому: форма пересоздаёт массив на каждый рендер.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[context, approval, editId, requestTypeId, userEmail, departmentId, fieldValuesKey, projectId]
|
||||||
|
);
|
||||||
|
return useMatrixEditor(
|
||||||
|
load,
|
||||||
|
`x-${userEmail}-${editId ?? 'new'}-${departmentId ?? ''}-${projectId ?? ''}-${fieldValuesKey}-${reloadSuffix}`,
|
||||||
|
canEditExecutors(context, readOnly),
|
||||||
|
!!editId || (!!userEmail && !!requestTypeId),
|
||||||
|
(login) => approval.getJobTitle(login),
|
||||||
|
resolveApproverOrgInfo(context)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Исполнители вьюхи просмотра — из фактических строк заявки. decisionSeq в
|
||||||
|
* ключе: после решения/замены строки на сервере меняются — перечитываем.
|
||||||
|
*/
|
||||||
|
export function useViewExecutors(
|
||||||
|
context: WebPartContext,
|
||||||
|
approval: ApprovalListService,
|
||||||
|
itemId: number,
|
||||||
|
decisionSeq: number
|
||||||
|
): IUseMatrixEditor {
|
||||||
|
const load = useCallback(
|
||||||
|
() => approval.getMatrixForRequest(itemId).then(executionPhaseRows),
|
||||||
|
[approval, itemId]
|
||||||
|
);
|
||||||
|
return useMatrixEditor(
|
||||||
|
load,
|
||||||
|
`e-${itemId}-${decisionSeq}`,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
(l) => approval.getJobTitle(l),
|
||||||
|
resolveApproverOrgInfo(context)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -52,6 +52,33 @@ export function getApprovalApiBaseUrl(): string {
|
|||||||
return _approvalApiBaseUrl;
|
return _approvalApiBaseUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ЭДО (TANSU ЭДО) — подписание документов. Токен берётся для того же AAD-приложения, что и
|
||||||
|
* у approval-route и справочников: бэкенд ЭДО принимает токены сотрудников этого приложения
|
||||||
|
* (Auth:Entra:Audience). Если ЭДО переедет на своё приложение — поменять только эту константу
|
||||||
|
* и объявить доступ в package-solution.json (webApiPermissionRequests).
|
||||||
|
*/
|
||||||
|
export const EDO_API_CLIENT_ID = APPROVAL_API_CLIENT_ID;
|
||||||
|
|
||||||
|
/** Локальный бэкенд ЭДО — порт из launchSettings.json (https). */
|
||||||
|
export const EDO_API_BASE_URL_LOCAL = 'https://localhost:7016';
|
||||||
|
/** Стейджинг ЭДО. Сертификат самоподписанный: браузер должен ему доверять, иначе запрос не уйдёт. */
|
||||||
|
export const EDO_API_BASE_URL_STAGE = 'https://10.0.1.225:8443';
|
||||||
|
|
||||||
|
// Настраивается свойством веб-части edoApiBaseUrl (селект в панели свойств) — тот же
|
||||||
|
// модульный синглтон, что и approvalApiBaseUrl выше.
|
||||||
|
let _edoApiBaseUrl = EDO_API_BASE_URL_STAGE;
|
||||||
|
|
||||||
|
export function setEdoApiBaseUrl(url: string): void {
|
||||||
|
// Хвостовые слэши отбрасываем: адрес склеивается с путём «/api/...», а в панели
|
||||||
|
// свойств его могли вписать с «/» на конце.
|
||||||
|
_edoApiBaseUrl = (url || EDO_API_BASE_URL_STAGE).trim().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEdoApiBaseUrl(): string {
|
||||||
|
return _edoApiBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
// Уведомления согласующим (поток Power Automate, см. approval/notify.service.ts).
|
// Уведомления согласующим (поток Power Automate, см. approval/notify.service.ts).
|
||||||
// Настраивается свойствами веб-части notificationsEnabled / notifyOnlyTo — тот
|
// Настраивается свойствами веб-части notificationsEnabled / notifyOnlyTo — тот
|
||||||
// же модульный синглтон, что и у approvalApiBaseUrl выше.
|
// же модульный синглтон, что и у approvalApiBaseUrl выше.
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../config';
|
import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../config';
|
||||||
|
import { IStorageLike, readCachedCompanies, writeCachedCompanies } from './companiesCache';
|
||||||
import { ICompany } from './models/ICompany';
|
import { ICompany } from './models/ICompany';
|
||||||
|
|
||||||
// GET /Dictionary/Companies — справочник компаний, ответ — плоский массив
|
// GET /Dictionary/Companies — справочник компаний, ответ — плоский массив
|
||||||
// { Id, Name } (без обёртки, как /Dictionary/Departments).
|
// { Id, Name, Bin } (без обёртки, как /Dictionary/Departments).
|
||||||
async function fetchCompanies(context: WebPartContext): Promise<ICompany[]> {
|
async function fetchCompanies(context: WebPartContext): Promise<ICompany[]> {
|
||||||
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
|
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
|
||||||
const response: HttpClientResponse = await client.get(
|
const response: HttpClientResponse = await client.get(
|
||||||
@@ -17,12 +18,33 @@ async function fetchCompanies(context: WebPartContext): Promise<ICompany[]> {
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Справочник в рамках сессии не меняется — грузим один раз и отдаём тот же
|
/** localStorage, если он есть: в некоторых режимах браузера обращение к нему бросает исключение. */
|
||||||
// промис повторным вызовам (как в departments.service).
|
function browserStorage(): IStorageLike | undefined {
|
||||||
|
try {
|
||||||
|
return window.localStorage;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCompanies(context: WebPartContext): Promise<ICompany[]> {
|
||||||
|
const storage = browserStorage();
|
||||||
|
const cached = storage ? readCachedCompanies(storage, Date.now()) : undefined;
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const fresh = await fetchCompanies(context);
|
||||||
|
if (storage) writeCachedCompanies(storage, fresh, Date.now());
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Справочник живёт в localStorage 4 часа (см. companiesCache.ts) — между перезагрузками страницы
|
||||||
|
// к API не ходим. Поверх этого один промис на сессию: параллельные вызовы делят один запрос и не
|
||||||
|
// читают хранилище заново (как в departments.service). Ошибку не кешируем — следующий вызов
|
||||||
|
// попробует ещё раз.
|
||||||
let cache: Promise<ICompany[]> | undefined;
|
let cache: Promise<ICompany[]> | undefined;
|
||||||
export function getCompanies(context: WebPartContext): Promise<ICompany[]> {
|
export function getCompanies(context: WebPartContext): Promise<ICompany[]> {
|
||||||
if (!cache) {
|
if (!cache) {
|
||||||
cache = fetchCompanies(context).catch((e) => {
|
cache = loadCompanies(context).catch((e) => {
|
||||||
cache = undefined;
|
cache = undefined;
|
||||||
throw e;
|
throw e;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import {
|
||||||
|
COMPANIES_CACHE_KEY,
|
||||||
|
COMPANIES_CACHE_TTL_MS,
|
||||||
|
IStorageLike,
|
||||||
|
readCachedCompanies,
|
||||||
|
writeCachedCompanies
|
||||||
|
} from './companiesCache';
|
||||||
|
import { ICompany } from './models/ICompany';
|
||||||
|
|
||||||
|
class MemoryStorage implements IStorageLike {
|
||||||
|
public readonly data = new Map<string, string>();
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null
|
||||||
|
public getItem(key: string): string | null {
|
||||||
|
return this.data.has(key) ? (this.data.get(key) as string) : null;
|
||||||
|
}
|
||||||
|
public setItem(key: string, value: string): void {
|
||||||
|
this.data.set(key, value);
|
||||||
|
}
|
||||||
|
public removeItem(key: string): void {
|
||||||
|
this.data.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMPANIES: ICompany[] = [
|
||||||
|
{ Id: '9de064a4-44c8-88ef-11ec-8284930fad96', Name: 'ТОО TANSU Construction', Bin: '021040005165' },
|
||||||
|
{ Id: '0a1b2c3d-0000-4000-8000-000000000002', Name: 'ТОО TANSU Строй' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const NOW = 1_800_000_000_000;
|
||||||
|
|
||||||
|
describe('кэш справочника компаний', () => {
|
||||||
|
it('в пустом хранилище кэша нет', () => {
|
||||||
|
expect(readCachedCompanies(new MemoryStorage(), NOW)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('записанное читается обратно, пока не прошло 4 часа', () => {
|
||||||
|
const storage = new MemoryStorage();
|
||||||
|
writeCachedCompanies(storage, COMPANIES, NOW);
|
||||||
|
|
||||||
|
expect(readCachedCompanies(storage, NOW + 1000)).toEqual(COMPANIES);
|
||||||
|
expect(readCachedCompanies(storage, NOW + COMPANIES_CACHE_TTL_MS - 1)).toEqual(COMPANIES);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('через 4 часа кэш устаревает и удаляется', () => {
|
||||||
|
const storage = new MemoryStorage();
|
||||||
|
writeCachedCompanies(storage, COMPANIES, NOW);
|
||||||
|
|
||||||
|
expect(readCachedCompanies(storage, NOW + COMPANIES_CACHE_TTL_MS)).toBeUndefined();
|
||||||
|
expect(storage.data.has(COMPANIES_CACHE_KEY)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('срок — ровно 4 часа', () => {
|
||||||
|
expect(COMPANIES_CACHE_TTL_MS).toBe(14_400_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('запись из будущего (часы переведены назад) не доверяется', () => {
|
||||||
|
const storage = new MemoryStorage();
|
||||||
|
writeCachedCompanies(storage, COMPANIES, NOW + 60_000);
|
||||||
|
|
||||||
|
expect(readCachedCompanies(storage, NOW)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('битый JSON и неожиданная форма — как отсутствующий кэш', () => {
|
||||||
|
const broken = new MemoryStorage();
|
||||||
|
broken.setItem(COMPANIES_CACHE_KEY, '{не json');
|
||||||
|
expect(readCachedCompanies(broken, NOW)).toBeUndefined();
|
||||||
|
expect(broken.data.has(COMPANIES_CACHE_KEY)).toBe(false);
|
||||||
|
|
||||||
|
const wrongShape = new MemoryStorage();
|
||||||
|
wrongShape.setItem(COMPANIES_CACHE_KEY, JSON.stringify({ savedAt: NOW, items: [{ Id: 1 }] }));
|
||||||
|
expect(readCachedCompanies(wrongShape, NOW)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('недоступное хранилище не ломает ни чтение, ни запись', () => {
|
||||||
|
const throwing: IStorageLike = {
|
||||||
|
getItem: () => {
|
||||||
|
throw new Error('запрещено');
|
||||||
|
},
|
||||||
|
setItem: () => {
|
||||||
|
throw new Error('переполнено');
|
||||||
|
},
|
||||||
|
removeItem: () => {
|
||||||
|
throw new Error('запрещено');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(readCachedCompanies(throwing, NOW)).toBeUndefined();
|
||||||
|
expect(() => writeCachedCompanies(throwing, COMPANIES, NOW)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { ICompany } from './models/ICompany';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ключ в localStorage. Версия в имени: поменяется форма записи — старые кэши просто перестанут
|
||||||
|
* читаться, а не сломают разбор.
|
||||||
|
*/
|
||||||
|
export const COMPANIES_CACHE_KEY = 'rp.dictionary.companies.v1';
|
||||||
|
|
||||||
|
/** Справочник компаний меняется редко, поэтому живёт в кэше 4 часа. */
|
||||||
|
export const COMPANIES_CACHE_TTL_MS = 4 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** Тот минимум localStorage, который нужен кэшу — для подмены в тестах. */
|
||||||
|
export interface IStorageLike {
|
||||||
|
// Сигнатура повторяет DOM-интерфейс Storage, где «нет значения» — это null.
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null
|
||||||
|
getItem(key: string): string | null;
|
||||||
|
setItem(key: string, value: string): void;
|
||||||
|
removeItem(key: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ICachedCompanies {
|
||||||
|
savedAt: number;
|
||||||
|
items: ICompany[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCompany(value: unknown): value is ICompany {
|
||||||
|
const company = value as Partial<ICompany> | undefined;
|
||||||
|
return !!company && typeof company.Id === 'string' && typeof company.Name === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Свежий кэш или undefined. Просроченный, битый или в неожиданной форме кэш считается отсутствующим
|
||||||
|
* и удаляется: лучше лишний раз сходить в справочник, чем показать мусор.
|
||||||
|
*/
|
||||||
|
export function readCachedCompanies(storage: IStorageLike, now: number): ICompany[] | undefined {
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null
|
||||||
|
let raw: string | null;
|
||||||
|
try {
|
||||||
|
raw = storage.getItem(COMPANIES_CACHE_KEY);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!raw) return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Partial<ICachedCompanies>;
|
||||||
|
const fresh =
|
||||||
|
typeof parsed.savedAt === 'number' &&
|
||||||
|
now - parsed.savedAt >= 0 &&
|
||||||
|
now - parsed.savedAt < COMPANIES_CACHE_TTL_MS &&
|
||||||
|
Array.isArray(parsed.items) &&
|
||||||
|
parsed.items.every(isCompany);
|
||||||
|
if (fresh) return parsed.items as ICompany[];
|
||||||
|
} catch {
|
||||||
|
// битый JSON — как отсутствующий кэш
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
storage.removeItem(COMPANIES_CACHE_KEY);
|
||||||
|
} catch {
|
||||||
|
// хранилище недоступно — удалять нечего
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Кладёт справочник в кэш. Переполнение или запрет хранилища (режим инкогнито) кэш не ломают — просто его не будет. */
|
||||||
|
export function writeCachedCompanies(storage: IStorageLike, items: ICompany[], now: number): void {
|
||||||
|
try {
|
||||||
|
storage.setItem(COMPANIES_CACHE_KEY, JSON.stringify({ savedAt: now, items } as ICachedCompanies));
|
||||||
|
} catch {
|
||||||
|
// кэш — оптимизация, а не источник данных
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ async function fetchEmployeeOrganizations(context: WebPartContext, email: string
|
|||||||
id: (r.Id as string) || '',
|
id: (r.Id as string) || '',
|
||||||
fio: (r.Fio as string) || '',
|
fio: (r.Fio as string) || '',
|
||||||
email: (r.Email as string) || '',
|
email: (r.Email as string) || '',
|
||||||
|
iin: (r.Iin as string) || '',
|
||||||
company: (r.Company as string) || '',
|
company: (r.Company as string) || '',
|
||||||
companyId: (r.CompanyId as string) || '',
|
companyId: (r.CompanyId as string) || '',
|
||||||
section: (r.Section as string) || '',
|
section: (r.Section as string) || '',
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { IEmployeeOrganization } from './models/IEmployeeOrganization';
|
||||||
|
import { formatEmployeeOrganizationsWithDepartment, parseOrganizationLine } from './organizationLines';
|
||||||
|
|
||||||
|
const rec = (p: Partial<IEmployeeOrganization>): IEmployeeOrganization => ({
|
||||||
|
id: '', fio: '', email: '', iin: '', company: 'ТОО А', companyId: '', section: '', departmentId: '', position: '', mobile: '', ...p
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('организации с подразделением', () => {
|
||||||
|
it('на каждую организацию своё подразделение', () => {
|
||||||
|
const text = formatEmployeeOrganizationsWithDepartment([
|
||||||
|
rec({ company: 'ТОО А', position: 'Директор', section: 'Дирекция' }),
|
||||||
|
rec({ company: 'ТОО Б', position: 'Казначей', section: 'Финансы' })
|
||||||
|
]);
|
||||||
|
expect(text.split('\n').map(parseOrganizationLine)).toEqual([
|
||||||
|
{ company: 'ТОО А', position: 'Директор', department: 'Дирекция' },
|
||||||
|
{ company: 'ТОО Б', position: 'Казначей', department: 'Финансы' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без подразделения формат прежний «Компания — должность»', () => {
|
||||||
|
expect(formatEmployeeOrganizationsWithDepartment([rec({ position: 'Директор' })])).toBe('ТОО А — Директор');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('старые строки разбираются с пустым подразделением', () => {
|
||||||
|
expect(parseOrganizationLine('ТОО А — Директор')).toEqual({ company: 'ТОО А', position: 'Директор', department: '' });
|
||||||
|
expect(parseOrganizationLine('ТОО А')).toEqual({ company: 'ТОО А', position: '', department: '' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,3 +28,5 @@ export {
|
|||||||
getCompanyDirectorEmail,
|
getCompanyDirectorEmail,
|
||||||
getManagerEmail
|
getManagerEmail
|
||||||
} from './employeeLookup.service';
|
} from './employeeLookup.service';
|
||||||
|
export { formatEmployeeOrganizationsWithDepartment, parseOrganizationLine } from './organizationLines';
|
||||||
|
export type { IOrganizationLine } from './organizationLines';
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
// GET https://api.tnsu.kz/Dictionary/Companies → плоский массив { Id, Name }
|
// GET https://api.tnsu.kz/Dictionary/Companies → плоский массив { Id, Name, Bin }
|
||||||
// (PascalCase, без обёртки — как /Dictionary/Departments).
|
// (PascalCase, без обёртки — как /Dictionary/Departments).
|
||||||
export interface ICompany {
|
export interface ICompany {
|
||||||
/** GUID компании — стабильный идентификатор. */
|
/** GUID компании — стабильный идентификатор. */
|
||||||
Id: string;
|
Id: string;
|
||||||
/** Наименование (напр. «ТОО AFL Engineering») — то, что видит пользователь. */
|
/** Наименование (напр. «ТОО AFL Engineering») — то, что видит пользователь. */
|
||||||
Name: string;
|
Name: string;
|
||||||
|
/** БИН компании, 12 цифр. Нужен для ЭДО (от имени какой компании подписывается документ). */
|
||||||
|
Bin?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export interface IEmployeeOrganization {
|
|||||||
id: string;
|
id: string;
|
||||||
fio: string;
|
fio: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
/** ИИН сотрудника, 12 цифр. Нужен для ЭДО (по нему там заводится подписант). */
|
||||||
|
iin: string;
|
||||||
company: string;
|
company: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
section: string;
|
section: string;
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { IEmployeeOrganization } from './models/IEmployeeOrganization';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Организации сотрудника с подразделением: «Компания — должность — подразделение»
|
||||||
|
* на строку. Нужен формам, что показывают организации таблицей (см.
|
||||||
|
* OrganizationTable). Первый сегмент по-прежнему компания — остальные разборщики
|
||||||
|
* (OrgLetterhead и др.) берут только его и не ломаются. Нет подразделения —
|
||||||
|
* формат прежний, как у formatEmployeeOrganizations.
|
||||||
|
*/
|
||||||
|
export function formatEmployeeOrganizationsWithDepartment(records: IEmployeeOrganization[]): string {
|
||||||
|
return records
|
||||||
|
.map((r) => {
|
||||||
|
if (r.section) return [r.company, r.position, r.section].join(' — ');
|
||||||
|
return r.position ? `${r.company} — ${r.position}` : r.company;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IOrganizationLine {
|
||||||
|
company: string;
|
||||||
|
position: string;
|
||||||
|
department: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Разбор строки «Компания — должность[ — подразделение]»; старые строки без подразделения дают пустое. */
|
||||||
|
export function parseOrganizationLine(line: string): IOrganizationLine {
|
||||||
|
const [company = '', position = '', department = ''] = line.split(' — ').map((p) => p.trim());
|
||||||
|
return { company, position, department };
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { EDO_API_CLIENT_ID, getEdoApiBaseUrl } from '../config';
|
||||||
|
import { EdoRequestError } from './edoErrors';
|
||||||
|
import { IFioParts } from './fio';
|
||||||
|
|
||||||
|
/** Путь приёма документов на подпись от интеграций (TANSU ЭДО, multipart/form-data). */
|
||||||
|
const SIGNING_REQUESTS_PATH = '/api/v1/integration/signing-requests';
|
||||||
|
|
||||||
|
/** Файл на подпись и компания, от имени которой он идёт (по её сертификату Sigex в ЭДО). */
|
||||||
|
/** Как портал называет себя в ЭДО. */
|
||||||
|
export const EDO_SOURCE_NAME = 'Портал заявок';
|
||||||
|
|
||||||
|
export interface IEdoSigningFile {
|
||||||
|
name: string;
|
||||||
|
content: Blob;
|
||||||
|
/** БИН компании файла. У сотрудника-совместителя файлы одного запроса идут от разных компаний. */
|
||||||
|
companyBin: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Что отправляем в ЭДО: одного получателя, он же подписант (физлицо), и файлы. */
|
||||||
|
export interface IEdoSigningRequest {
|
||||||
|
/** Подписывающий: ЭДО заводит его по ИИН, если нет, и зовёт подписать письмом. */
|
||||||
|
recipient: { iin: string; email: string; name: IFioParts };
|
||||||
|
/** Кому сообщать о подписи; пусто — никому. */
|
||||||
|
initiatorEmail?: string;
|
||||||
|
files: IEdoSigningFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ответ ЭДО: то, что нужно показать пользователю. */
|
||||||
|
export interface IEdoSigningResult {
|
||||||
|
/** Кабинет получателя без ключа: войти по ЭЦП или через Microsoft. */
|
||||||
|
cabinetUrl: string;
|
||||||
|
documents: Array<{
|
||||||
|
id: string;
|
||||||
|
fileName: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ответ ЭДО в snake_case — только поля, которые читаем. */
|
||||||
|
interface IEdoResponseBody {
|
||||||
|
cabinet_url?: string;
|
||||||
|
documents?: Array<{ id?: string; file_name?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отправляет документы на подпись в ЭДО одним запросом: либо принимаются все, либо ни один.
|
||||||
|
*
|
||||||
|
* Токен берётся для приложения ЭДО текущего пользователя (AadHttpClient), поэтому в ЭДО
|
||||||
|
* запрос приходит от того, кто нажал «Согласовать», — он заводится там автоматически при
|
||||||
|
* первом обращении. Адрес ЭДО — свойство веб-части edoApiBaseUrl.
|
||||||
|
*/
|
||||||
|
export async function submitSigningRequest(
|
||||||
|
context: WebPartContext,
|
||||||
|
request: IEdoSigningRequest
|
||||||
|
): Promise<IEdoSigningResult> {
|
||||||
|
const baseUrl = getEdoApiBaseUrl();
|
||||||
|
const client = await context.aadHttpClientFactory.getClient(EDO_API_CLIENT_ID);
|
||||||
|
|
||||||
|
// Content-Type не задаём: браузер сам допишет boundary для multipart.
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('iin', request.recipient.iin);
|
||||||
|
form.append('email', request.recipient.email);
|
||||||
|
form.append('last_name', request.recipient.name.lastName);
|
||||||
|
form.append('first_name', request.recipient.name.firstName);
|
||||||
|
if (request.recipient.name.middleName) form.append('middle_name', request.recipient.name.middleName);
|
||||||
|
if (request.initiatorEmail) form.append('initiator_email', request.initiatorEmail);
|
||||||
|
// Так ЭДО подпишет документ в колонке «Кто отправил», а не техническим client_id.
|
||||||
|
form.append('service_initiator_name', EDO_SOURCE_NAME);
|
||||||
|
// БИН файлов — одним полем JSON-массивом строк, по порядку файлов: общий company_bin не нужен,
|
||||||
|
// подписант один (физлицо). Повторяющиеся поля ЭДО не принимает (400 invalid_file_company_bins_format).
|
||||||
|
for (const file of request.files) {
|
||||||
|
form.append('files', file.content, file.name);
|
||||||
|
}
|
||||||
|
form.append('file_company_bins', JSON.stringify(request.files.map((file) => file.companyBin)));
|
||||||
|
|
||||||
|
let response: HttpClientResponse;
|
||||||
|
try {
|
||||||
|
response = await client.post(`${baseUrl}${SIGNING_REQUESTS_PATH}`, AadHttpClient.configurations.v1, { body: form });
|
||||||
|
} catch (e) {
|
||||||
|
// Сеть/сертификат: у браузера для таких случаев одно сообщение «Failed to fetch»,
|
||||||
|
// а причина (недоверенный сертификат, ЭДО выключен, адрес не тот) видна только в консоли.
|
||||||
|
console.error('ЭДО недоступен', e);
|
||||||
|
throw new Error(
|
||||||
|
`ЭДО (${baseUrl}) недоступен. Проверьте, что сервер запущен и браузер доверяет его сертификату`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new EdoRequestError(response.status, await response.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await response.json()) as IEdoResponseBody;
|
||||||
|
return {
|
||||||
|
cabinetUrl: body.cabinet_url || '',
|
||||||
|
documents: (body.documents || []).map((d) => ({
|
||||||
|
id: d.id || '',
|
||||||
|
fileName: d.file_name || ''
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { ICompany } from '../dictionary/models/ICompany';
|
||||||
|
import { IEmployeeOrganization } from '../dictionary/models/IEmployeeOrganization';
|
||||||
|
import { describeEdoError } from './edoErrors';
|
||||||
|
import { splitFio } from './fio';
|
||||||
|
import { resolveSigningTargets } from './signingTarget';
|
||||||
|
|
||||||
|
const TANSU_ID = '9de064a4-44c8-88ef-11ec-8284930fad96';
|
||||||
|
|
||||||
|
function record(overrides: Partial<IEmployeeOrganization> = {}): IEmployeeOrganization {
|
||||||
|
return {
|
||||||
|
id: 'e1',
|
||||||
|
fio: 'Иванов Иван Иванович',
|
||||||
|
email: 'ivanov@tnsu.kz',
|
||||||
|
iin: '950515123452',
|
||||||
|
company: 'ТОО TANSU Construction',
|
||||||
|
companyId: TANSU_ID,
|
||||||
|
section: '',
|
||||||
|
departmentId: '',
|
||||||
|
position: 'Инженер',
|
||||||
|
mobile: '',
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMPANIES: ICompany[] = [
|
||||||
|
{ Id: TANSU_ID, Name: 'ТОО TANSU Construction', Bin: '021040005165' },
|
||||||
|
{ Id: '0a1b2c3d-0000-4000-8000-000000000002', Name: 'ТОО Без БИН', Bin: '' }
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('Ф.И.О. по частям для ЭДО', () => {
|
||||||
|
it('три слова', () => {
|
||||||
|
expect(splitFio('Иванов Иван Иванович')).toEqual({ lastName: 'Иванов', firstName: 'Иван', middleName: 'Иванович' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без отчества', () => {
|
||||||
|
expect(splitFio('Иванов Иван')).toEqual({ lastName: 'Иванов', firstName: 'Иван', middleName: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('лишние пробелы и «оглы» не теряются', () => {
|
||||||
|
expect(splitFio(' Алиев Тимур Расул оглы ')).toEqual({
|
||||||
|
lastName: 'Алиев',
|
||||||
|
firstName: 'Тимур',
|
||||||
|
middleName: 'Расул оглы'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('одно слово или пусто — не разобрать', () => {
|
||||||
|
expect(splitFio('Иванов')).toBeUndefined();
|
||||||
|
expect(splitFio('')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const STROY_ID = '0a1b2c3d-0000-4000-8000-000000000003';
|
||||||
|
const NO_BIN_ID = '0a1b2c3d-0000-4000-8000-000000000002';
|
||||||
|
|
||||||
|
describe('кому и по каким компаниям отправлять на подпись', () => {
|
||||||
|
const companies: ICompany[] = [...COMPANIES, { Id: STROY_ID, Name: 'ТОО TANSU Строй', Bin: '021040005166' }];
|
||||||
|
|
||||||
|
it('ИИН из справочника сотрудников, БИН из справочника компаний', () => {
|
||||||
|
const targets = resolveSigningTargets([record()], companies);
|
||||||
|
|
||||||
|
expect(targets.iin).toBe('950515123452');
|
||||||
|
expect(targets.name.lastName).toBe('Иванов');
|
||||||
|
expect(targets.companies).toEqual([{ bin: '021040005165', name: 'ТОО TANSU Construction', position: 'Инженер' }]);
|
||||||
|
expect(targets.skipped).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Заявку заполняют одну, а подписывают по каждой компании, где человек работает.
|
||||||
|
it('по документу на каждую компанию, со своей должностью', () => {
|
||||||
|
const targets = resolveSigningTargets(
|
||||||
|
[record(), record({ company: 'ТОО TANSU Строй', companyId: STROY_ID, position: 'Главный инженер' })],
|
||||||
|
companies
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(targets.companies).toEqual([
|
||||||
|
{ bin: '021040005165', name: 'ТОО TANSU Construction', position: 'Инженер' },
|
||||||
|
{ bin: '021040005166', name: 'ТОО TANSU Строй', position: 'Главный инженер' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('одна и та же компания дважды не попадает', () => {
|
||||||
|
const targets = resolveSigningTargets([record(), record({ position: 'Совместитель' })], companies);
|
||||||
|
|
||||||
|
expect(targets.companies).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('идентификаторы компаний сравниваются без учёта регистра', () => {
|
||||||
|
expect(resolveSigningTargets([record({ companyId: TANSU_ID.toUpperCase() })], companies).companies[0].bin).toBe(
|
||||||
|
'021040005165'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('компания без БИН не мешает остальным — уходит в пропущенные с причиной', () => {
|
||||||
|
const targets = resolveSigningTargets(
|
||||||
|
[record({ company: 'ТОО Без БИН', companyId: NO_BIN_ID }), record()],
|
||||||
|
companies
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(targets.companies.map((c) => c.bin)).toEqual(['021040005165']);
|
||||||
|
expect(targets.skipped).toEqual([{ name: 'ТОО Без БИН', reason: 'в справочнике компаний нет БИН' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('компании нет в справочнике — тоже пропущена', () => {
|
||||||
|
const targets = resolveSigningTargets(
|
||||||
|
[record({ company: 'ТОО Неизвестная', companyId: 'ffffffff-0000-4000-8000-000000000000' })],
|
||||||
|
companies
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(targets.companies).toEqual([]);
|
||||||
|
expect(targets.skipped[0].name).toBe('ТОО Неизвестная');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('нет записи о сотруднике — понятная причина', () => {
|
||||||
|
expect(() => resolveSigningTargets([], companies)).toThrow('нет в кадровом справочнике');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('нет ИИН в карточке', () => {
|
||||||
|
expect(() => resolveSigningTargets([record({ iin: ' ' })], companies)).toThrow('не указан ИИН');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ФИО из одного слова не годится', () => {
|
||||||
|
expect(() => resolveSigningTargets([record({ fio: 'Иванов' })], companies)).toThrow('Ф.И.О.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ни в одной записи нет компании', () => {
|
||||||
|
expect(() => resolveSigningTargets([record({ companyId: '' })], companies)).toThrow('не указана компания');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('текст ошибки ЭДО', () => {
|
||||||
|
it('берёт русский detail из ProblemDetails', () => {
|
||||||
|
const body = JSON.stringify({ title: 'Bad Request', detail: 'Для компании «X» не загружен сертификат Sigex.' });
|
||||||
|
|
||||||
|
expect(describeEdoError(400, body)).toBe('Для компании «X» не загружен сертификат Sigex.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('title, если detail нет', () => {
|
||||||
|
expect(describeEdoError(400, JSON.stringify({ title: 'Подписант не найден' }))).toBe('Подписант не найден');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ошибки проверки собираются в одну строку', () => {
|
||||||
|
const body = JSON.stringify({ errors: { Iin: ['ИИН неверный'], Email: ['Email неверный', 'Слишком длинный'] } });
|
||||||
|
|
||||||
|
expect(describeEdoError(400, body)).toBe('ИИН неверный; Email неверный; Слишком длинный');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не JSON — по статусу', () => {
|
||||||
|
expect(describeEdoError(502, '<html>Bad gateway</html>')).toBe('ЭДО ответил 502');
|
||||||
|
expect(describeEdoError(401, '')).toContain('401');
|
||||||
|
expect(describeEdoError(403, '')).toContain('403');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/** Ответ ЭДО с ошибкой: текст для человека и машинный код из ProblemDetails (`code`). */
|
||||||
|
export class EdoRequestError extends Error {
|
||||||
|
public readonly status: number;
|
||||||
|
public readonly code: string | undefined;
|
||||||
|
|
||||||
|
public constructor(status: number, bodyText: string) {
|
||||||
|
super(describeEdoError(status, bodyText));
|
||||||
|
this.name = 'EdoRequestError';
|
||||||
|
this.status = status;
|
||||||
|
this.code = edoErrorCode(bodyText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Машинный код ошибки ЭДО (`code` в ProblemDetails) или undefined. */
|
||||||
|
export function edoErrorCode(bodyText: string): string | undefined {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(bodyText) as { code?: unknown };
|
||||||
|
return typeof parsed.code === 'string' ? parsed.code : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ЭДО уже принял эти файлы раньше (повтор запроса): для отправителя это успех, а не ошибка. */
|
||||||
|
export const isAlreadyAccepted = (e: unknown): boolean => e instanceof EdoRequestError && e.code === 'duplicate_document';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Текст ошибки из ответа ЭДО. Бэкенд отвечает ProblemDetails (`title`, `detail`, `errors`, `code`),
|
||||||
|
* и именно `title`/`detail` написаны по-русски для человека. Тело не JSON (прокси, 502) — отдаём
|
||||||
|
* статус: лучше короткое «ЭДО ответил 502», чем кусок HTML в интерфейсе.
|
||||||
|
*/
|
||||||
|
export function describeEdoError(status: number, bodyText: string): string {
|
||||||
|
let parsed: { title?: unknown; detail?: unknown; errors?: unknown } = {};
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(bodyText) as typeof parsed;
|
||||||
|
} catch {
|
||||||
|
// не JSON — разберём по статусу ниже
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = flattenValidationErrors(parsed.errors);
|
||||||
|
if (validation) return validation;
|
||||||
|
|
||||||
|
const text = [parsed.detail, parsed.title].find((v): v is string => typeof v === 'string' && v.trim().length > 0);
|
||||||
|
if (text) return text.trim();
|
||||||
|
|
||||||
|
if (status === 401) return 'ЭДО не принял токен: вход под этой учётной записью в ЭДО невозможен (401)';
|
||||||
|
if (status === 403) return 'ЭДО отказал в доступе (403)';
|
||||||
|
return `ЭДО ответил ${status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ошибки проверки формата `{ поле: [сообщения] }` → одна строка. */
|
||||||
|
function flattenValidationErrors(errors: unknown): string {
|
||||||
|
if (!errors || typeof errors !== 'object') return '';
|
||||||
|
const messages: string[] = [];
|
||||||
|
for (const value of Object.values(errors as Record<string, unknown>)) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const message of value) {
|
||||||
|
if (typeof message === 'string') messages.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return messages.join('; ');
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/** Ф.И.О. по частям — так их принимает ЭДО. */
|
||||||
|
export interface IFioParts {
|
||||||
|
lastName: string;
|
||||||
|
firstName: string;
|
||||||
|
middleName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Иванов Иван Иванович» → фамилия, имя, отчество. Справочник отдаёт ФИО одной строкой.
|
||||||
|
* Два слова — отчества нет; больше трёх (например, «оглы», двойные фамилии) — всё после
|
||||||
|
* имени уходит в отчество, чтобы ничего не потерять. Одно слово → ничего не определить:
|
||||||
|
* ЭДО требует и фамилию, и имя.
|
||||||
|
*/
|
||||||
|
export function splitFio(fio: string): IFioParts | undefined {
|
||||||
|
const words = (fio || '').split(/\s+/).filter((w) => w.length > 0);
|
||||||
|
if (words.length < 2) return undefined;
|
||||||
|
const [lastName, firstName, ...rest] = words;
|
||||||
|
return { lastName, firstName, middleName: rest.join(' ') };
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { ICompany } from '../dictionary/models/ICompany';
|
||||||
|
import { IEmployeeOrganization } from '../dictionary/models/IEmployeeOrganization';
|
||||||
|
import { IFioParts, splitFio } from './fio';
|
||||||
|
|
||||||
|
/** Одна компания, от имени которой подписывается документ. */
|
||||||
|
export interface ISigningCompany {
|
||||||
|
/** БИН компании: от её имени документ идёт в Sigex. */
|
||||||
|
bin: string;
|
||||||
|
name: string;
|
||||||
|
/** Должность сотрудника именно в этой компании. */
|
||||||
|
position: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Компания сотрудника, по которой документ отправить нельзя, и почему. */
|
||||||
|
export interface ISkippedCompany {
|
||||||
|
name: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Кто подписывает и по каким компаниям. */
|
||||||
|
export interface ISigningTargets {
|
||||||
|
iin: string;
|
||||||
|
name: IFioParts;
|
||||||
|
/** По документу на каждую компанию, где у сотрудника есть БИН в справочнике. */
|
||||||
|
companies: ISigningCompany[];
|
||||||
|
/** Компании, по которым документ не отправить (нет БИН в справочнике и т. п.). */
|
||||||
|
skipped: ISkippedCompany[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Определяет подписанта и компании по справочникам api.tnsu.kz: ИИН и ФИО — из Employee/ByEmail,
|
||||||
|
* БИН — из Dictionary/Companies по CompanyId.
|
||||||
|
*
|
||||||
|
* Сотрудник может работать в нескольких компаниях (на один e-mail — несколько записей), и заявление
|
||||||
|
* подписывается по каждой из них отдельно: у каждой компании свой документ со своей должностью.
|
||||||
|
* Одна и та же компания дважды не попадает. Компания без БИН не роняет остальные — она уходит в
|
||||||
|
* skipped с причиной. Ошибка бросается, только если подписать нельзя вовсе: нет человека, ИИН, ФИО
|
||||||
|
* или ни одной компании.
|
||||||
|
*/
|
||||||
|
export function resolveSigningTargets(records: IEmployeeOrganization[], companies: ICompany[]): ISigningTargets {
|
||||||
|
if (records.length === 0) {
|
||||||
|
throw new Error('сотрудника нет в кадровом справочнике — ИИН для подписания взять неоткуда');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Человек один на все записи: ИИН и ФИО берём из первой, где они есть.
|
||||||
|
const iin = (records.find((r) => (r.iin || '').trim())?.iin || '').trim();
|
||||||
|
if (!iin) {
|
||||||
|
throw new Error('в кадровом справочнике у сотрудника не указан ИИН');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fio = records.find((r) => (r.fio || '').trim())?.fio || '';
|
||||||
|
const name = splitFio(fio);
|
||||||
|
if (!name) {
|
||||||
|
throw new Error(`не удалось разобрать Ф.И.О. «${fio}» на фамилию и имя`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const byId = new Map(companies.map((c) => [c.Id.toLowerCase(), c]));
|
||||||
|
const result: ISigningCompany[] = [];
|
||||||
|
const skipped: ISkippedCompany[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
if (!record.companyId) continue;
|
||||||
|
|
||||||
|
const company = byId.get(record.companyId.toLowerCase());
|
||||||
|
const bin = (company?.Bin || '').trim();
|
||||||
|
const companyName = company?.Name || record.company;
|
||||||
|
|
||||||
|
if (!bin) {
|
||||||
|
if (!seen.has(`name:${companyName}`)) {
|
||||||
|
seen.add(`name:${companyName}`);
|
||||||
|
skipped.push({ name: companyName, reason: 'в справочнике компаний нет БИН' });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seen.has(bin)) continue;
|
||||||
|
seen.add(bin);
|
||||||
|
result.push({ bin, name: companyName, position: record.position });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.length === 0 && skipped.length === 0) {
|
||||||
|
throw new Error('в карточке сотрудника не указана компания');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { iin, name, companies: result, skipped };
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { ORG_LOGOS } from './orgLogos';
|
||||||
|
|
||||||
|
/** Логотип для шапки документа: PNG в base64 и его размеры в пикселях (по ним считается пропорция). */
|
||||||
|
export interface ILetterheadLogo {
|
||||||
|
base64: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ширина и высота PNG из заголовка IHDR (байты 16–23). */
|
||||||
|
export function pngSize(base64: string): { width: number; height: number } {
|
||||||
|
const head = atob(base64.slice(0, 44));
|
||||||
|
const read = (offset: number): number =>
|
||||||
|
((head.charCodeAt(offset) << 24) |
|
||||||
|
(head.charCodeAt(offset + 1) << 16) |
|
||||||
|
(head.charCodeAt(offset + 2) << 8) |
|
||||||
|
head.charCodeAt(offset + 3)) >>>
|
||||||
|
0;
|
||||||
|
return { width: read(16), height: read(20) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Логотип компании — тот же, что в печатной шапке (shared/ui/OrgLetterhead). Логотипы вшиты в бандл,
|
||||||
|
* поэтому у компании, для которой он есть, шапка в документе будет всегда. Нет логотипа у компании
|
||||||
|
* вовсе — undefined, документ уходит без шапки, как и печатается.
|
||||||
|
*/
|
||||||
|
export function letterheadLogoFor(company: string, logos: Record<string, string> = ORG_LOGOS): ILetterheadLogo | undefined {
|
||||||
|
const dataUri = logos[company];
|
||||||
|
const prefix = 'data:image/png;base64,';
|
||||||
|
if (!dataUri?.startsWith(prefix)) return undefined;
|
||||||
|
const base64 = dataUri.slice(prefix.length);
|
||||||
|
return { base64, ...pngSize(base64) };
|
||||||
|
}
|
||||||
@@ -1,8 +1,4 @@
|
|||||||
import tansuConstruction from '../assets/logos/tansu-construction.png';
|
import { tansuConstruction, astelK, ctGlobal, kazpromService, qazgeomax } from '../assets/logos/inlineLogos';
|
||||||
import astelK from '../assets/logos/astel-k.png';
|
|
||||||
import ctGlobal from '../assets/logos/ctglobal.png';
|
|
||||||
import kazpromService from '../assets/logos/kazprom-service.png';
|
|
||||||
import qazgeomax from '../assets/logos/qazgeomax.png';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Логотипы организаций для брендированной шапки печати (см. shared/ui/OrgLetterhead).
|
* Логотипы организаций для брендированной шапки печати (см. shared/ui/OrgLetterhead).
|
||||||
@@ -13,6 +9,11 @@ import qazgeomax from '../assets/logos/qazgeomax.png';
|
|||||||
* Не для каждой компании из справочника есть логотип — тогда OrgLetterhead просто
|
* Не для каждой компании из справочника есть логотип — тогда OrgLetterhead просто
|
||||||
* не рендерится (lookup возвращает undefined). Новая компания → положить PNG в
|
* не рендерится (lookup возвращает undefined). Новая компания → положить PNG в
|
||||||
* ../assets/logos и добавить сюда запись.
|
* ../assets/logos и добавить сюда запись.
|
||||||
|
*
|
||||||
|
* Логотипы вшиты в бандл data-URI (assets/logos/inlineLogos.ts), а не лежат отдельными файлами: иначе
|
||||||
|
* при первом заходе картинка догружается по сети, и шапка то не видна на экране, то не успевает в окно
|
||||||
|
* печати (оно открывается заново, без кэша страницы). Исходники — PNG высотой 300px рядом; после замены
|
||||||
|
* картинки пересобрать inlineLogos.ts (base64 содержимого файлов).
|
||||||
*/
|
*/
|
||||||
export const ORG_LOGOS: Record<string, string> = {
|
export const ORG_LOGOS: Record<string, string> = {
|
||||||
'ТОО TANSU Construction': tansuConstruction,
|
'ТОО TANSU Construction': tansuConstruction,
|
||||||
|
|||||||
@@ -19,3 +19,39 @@
|
|||||||
font-size: 13.5px;
|
font-size: 13.5px;
|
||||||
color: $gray-700;
|
color: $gray-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Блок таблицы на всю ширину сетки формы (без FormField — его подпись дублировала бы заголовок колонки).
|
||||||
|
.tableWrap {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Таблица организаций: те же цвета, что у .line — «подставлено автоматически».
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $gray-50;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: $gray-700;
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 9px 13px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
color: $gray-500;
|
||||||
|
border-bottom: 1px solid $gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr + tr td {
|
||||||
|
border-top: 1px solid $gray-200;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './OrganizationField.module.scss';
|
||||||
|
import { parseOrganizationLine } from '../../dictionary/organizationLines';
|
||||||
|
|
||||||
|
interface IOrganizationTableProps {
|
||||||
|
/** `\n`-joined «Компания — должность — подразделение» на организацию (см. formatEmployeeOrganizationsWithDepartment). */
|
||||||
|
value: string;
|
||||||
|
/** Индекс строки, остающейся в печати (см. OrganizationField.printIndex); остальные — `rp-no-print`. */
|
||||||
|
printIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Организации сотрудника таблицей «Организация · Должность · Подразделение» —
|
||||||
|
* read-only, подтягивается из Employee/ByEmail. На каждую организацию своё
|
||||||
|
* подразделение (сотрудник может состоять в нескольких). Без внешней подписи: её роль
|
||||||
|
* играет заголовок колонки «Организация». Нет данных — ничего не рендерим.
|
||||||
|
*/
|
||||||
|
export const OrganizationTable: React.FC<IOrganizationTableProps> = ({ value, printIndex }) => {
|
||||||
|
const rows = value ? value.split('\n').map(parseOrganizationLine) : [];
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.tableWrap}>
|
||||||
|
<table className={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Организация</th>
|
||||||
|
<th>Должность</th>
|
||||||
|
<th>Подразделение</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<tr key={i} className={printIndex !== undefined && i !== printIndex ? 'rp-no-print' : undefined}>
|
||||||
|
<td>{r.company}</td>
|
||||||
|
<td>{r.position || '—'}</td>
|
||||||
|
<td>{r.department || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { trimPickerQuery } from './pickerContext';
|
||||||
|
|
||||||
|
const body = (q: unknown): string => JSON.stringify({ queryParams: { QueryString: q, PrincipalType: 1 } });
|
||||||
|
const query = (b: unknown): unknown => (JSON.parse(b as string) as { queryParams: { QueryString: unknown } }).queryParams.QueryString;
|
||||||
|
|
||||||
|
describe('trimPickerQuery', () => {
|
||||||
|
it('убирает пробел в конце и в начале', () => {
|
||||||
|
expect(query(trimPickerQuery(body('Иван ')))).toBe('Иван');
|
||||||
|
expect(query(trimPickerQuery(body(' Иван Петров ')))).toBe('Иван Петров');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('схлопывает двойные пробелы внутри', () => {
|
||||||
|
expect(query(trimPickerQuery(body('Иван Петров')))).toBe('Иван Петров');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не трогает остальные поля запроса', () => {
|
||||||
|
const parsed = JSON.parse(trimPickerQuery(body('a ')) as string) as { queryParams: { PrincipalType: number } };
|
||||||
|
expect(parsed.queryParams.PrincipalType).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('чужие тела возвращает как есть', () => {
|
||||||
|
expect(trimPickerQuery('не json QueryString')).toBe('не json QueryString');
|
||||||
|
expect(trimPickerQuery(undefined)).toBeUndefined();
|
||||||
|
const other = JSON.stringify({ a: 1 });
|
||||||
|
expect(trimPickerQuery(other)).toBe(other);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,43 @@ export function pickedLogin(user: IPickedUser): string {
|
|||||||
return user.secondaryText || user.loginName || user.id || '';
|
return user.secondaryText || user.loginName || user.id || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Убирает пробелы по краям (и схлопывает внутренние) у QueryString в теле запроса
|
||||||
|
* поиска сотрудника. PeoplePicker из @pnp/spfx-controls-react шлёт введённый
|
||||||
|
* текст как есть, а SharePoint с «Иван » (пробел в конце, обычное дело при вводе
|
||||||
|
* «имя фамилия») ничего не находит. Тело не JSON или без QueryString — возвращается как было.
|
||||||
|
*/
|
||||||
|
export function trimPickerQuery(body: unknown): unknown {
|
||||||
|
if (typeof body !== 'string' || body.indexOf('QueryString') < 0) return body;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(body) as { queryParams?: { QueryString?: unknown } };
|
||||||
|
const query = parsed.queryParams?.QueryString;
|
||||||
|
if (typeof query !== 'string') return body;
|
||||||
|
parsed.queryParams!.QueryString = query.trim().replace(/\s+/g, ' ');
|
||||||
|
return JSON.stringify(parsed);
|
||||||
|
} catch {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* spHttpClient с подчисткой запроса поиска (см. trimPickerQuery): прокси, чтобы
|
||||||
|
* остальные методы клиента работали как прежде, подменяется только post.
|
||||||
|
*/
|
||||||
|
function withTrimmedSearch(client: WebPartContext['spHttpClient']): WebPartContext['spHttpClient'] {
|
||||||
|
return new Proxy(client, {
|
||||||
|
get(target, prop, receiver) {
|
||||||
|
if (prop !== 'post') return Reflect.get(target, prop, receiver);
|
||||||
|
return (url: string, config: unknown, options?: { body?: unknown }) =>
|
||||||
|
(target.post as (u: string, c: unknown, o?: unknown) => unknown)(
|
||||||
|
url,
|
||||||
|
config,
|
||||||
|
options ? { ...options, body: trimPickerQuery(options.body) } : options
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Контекст для PeoplePicker из WebPartContext.
|
* Контекст для PeoplePicker из WebPartContext.
|
||||||
* as unknown: @pnp/spfx-controls-react тянет свою копию sp-http-msgraph,
|
* as unknown: @pnp/spfx-controls-react тянет свою копию sp-http-msgraph,
|
||||||
@@ -27,6 +64,6 @@ export function buildPickerContext(context: WebPartContext): IPeoplePickerContex
|
|||||||
return {
|
return {
|
||||||
absoluteUrl: context.pageContext.web.absoluteUrl,
|
absoluteUrl: context.pageContext.web.absoluteUrl,
|
||||||
msGraphClientFactory: context.msGraphClientFactory,
|
msGraphClientFactory: context.msGraphClientFactory,
|
||||||
spHttpClient: context.spHttpClient
|
spHttpClient: withTrimmedSearch(context.spHttpClient)
|
||||||
} as unknown as IPeoplePickerContext;
|
} as unknown as IPeoplePickerContext;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export const SubmitActions: React.FC<ISubmitActionsProps> = ({
|
|||||||
onDelete,
|
onDelete,
|
||||||
}) => {
|
}) => {
|
||||||
const { saving, kind } = submit;
|
const { saving, kind } = submit;
|
||||||
const unfrozenForm = formId != null && UNFROZEN_FORMS.has(formId);
|
const unfrozenForm = formId !== undefined && UNFROZEN_FORMS.has(formId);
|
||||||
// false, пока не пришёл ответ о членстве в группе, — безопасный дефолт: не
|
// false, пока не пришёл ответ о членстве в группе, — безопасный дефолт: не
|
||||||
// мигать разблокированными кнопками до подтверждения, что это тестировщик.
|
// мигать разблокированными кнопками до подтверждения, что это тестировщик.
|
||||||
const [isTester, setIsTester] = React.useState<boolean>(false);
|
const [isTester, setIsTester] = React.useState<boolean>(false);
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export { PeopleField } from './PeopleField/PeopleField';
|
|||||||
export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
|
export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
|
||||||
export { PeopleTextField } from './PeopleField/PeopleTextField';
|
export { PeopleTextField } from './PeopleField/PeopleTextField';
|
||||||
export { OrganizationField } from './OrganizationField/OrganizationField';
|
export { OrganizationField } from './OrganizationField/OrganizationField';
|
||||||
|
export { OrganizationTable } from './OrganizationField/OrganizationTable';
|
||||||
export { PrintSplitButton } from './PrintSplitButton/PrintSplitButton';
|
export { PrintSplitButton } from './PrintSplitButton/PrintSplitButton';
|
||||||
export { OrgLetterhead } from './OrgLetterhead/OrgLetterhead';
|
export { OrgLetterhead } from './OrgLetterhead/OrgLetterhead';
|
||||||
export { LinkField } from './LinkField/LinkField';
|
export { LinkField } from './LinkField/LinkField';
|
||||||
|
|||||||