Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/8
This commit was merged in pull request #8.
This commit is contained in:
@@ -295,6 +295,13 @@ module.exports = {
|
|||||||
'@microsoft/spfx/no-require-ensure': 2,
|
'@microsoft/spfx/no-require-ensure': 2,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Файлы локализации SPFx (loc/*.js) — это AMD-модули: их подключает
|
||||||
|
// загрузчик страницы через define(), а не бандлер. Без объявления среды
|
||||||
|
// no-undef считает define необъявленной переменной.
|
||||||
|
files: ['**/loc/*.js'],
|
||||||
|
env: { amd: true }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// For unit tests, we can be a little bit less strict. The settings below revise the
|
// For unit tests, we can be a little bit less strict. The settings below revise the
|
||||||
// defaults specified in the extended configurations, as well as above.
|
// defaults specified in the extended configurations, as well as above.
|
||||||
|
|||||||
@@ -3,27 +3,19 @@
|
|||||||
"id": "caca4ff9-050c-4643-936c-d0f29a0f9532",
|
"id": "caca4ff9-050c-4643-936c-d0f29a0f9532",
|
||||||
"alias": "RequestPortalWebPart",
|
"alias": "RequestPortalWebPart",
|
||||||
"componentType": "WebPart",
|
"componentType": "WebPart",
|
||||||
|
|
||||||
// The "*" signifies that the version should be taken from the package.json
|
|
||||||
"version": "*",
|
"version": "*",
|
||||||
"manifestVersion": 2,
|
"manifestVersion": 2,
|
||||||
|
|
||||||
// If true, the component can only be installed on sites where Custom Script is allowed.
|
|
||||||
// Components that allow authors to embed arbitrary script code should set this to true.
|
|
||||||
// https://support.office.com/en-us/article/Turn-scripting-capabilities-on-or-off-1f2c515f-5d7e-448a-9fd7-835da935584f
|
|
||||||
"requiresCustomScript": false,
|
"requiresCustomScript": false,
|
||||||
"supportedHosts": ["SharePointWebPart", "TeamsPersonalApp", "TeamsTab", "SharePointFullPage"],
|
"supportedHosts": ["SharePointWebPart", "TeamsPersonalApp", "TeamsTab", "SharePointFullPage"],
|
||||||
"supportsThemeVariants": true,
|
"supportsThemeVariants": true,
|
||||||
"supportsFullBleed": true,
|
"supportsFullBleed": true,
|
||||||
|
|
||||||
"preconfiguredEntries": [{
|
"preconfiguredEntries": [{
|
||||||
"groupId": "5c03119e-3074-46fd-976b-c60198311f70", // Advanced
|
"groupId": "5c03119e-3074-46fd-976b-c60198311f70",
|
||||||
"group": { "default": "Advanced" },
|
"group": { "default": "Advanced" },
|
||||||
"title": { "default": "request-portal" },
|
"title": { "default": "request-portal" },
|
||||||
"description": { "default": "request-portal description" },
|
"description": { "default": "request-portal description" },
|
||||||
"officeFabricIconFontName": "Page",
|
"officeFabricIconFontName": "Page",
|
||||||
"properties": {
|
"properties": {}
|
||||||
"description": "request-portal"
|
|
||||||
}
|
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import * as ReactDom from 'react-dom';
|
import * as ReactDom from 'react-dom';
|
||||||
import { Version } from '@microsoft/sp-core-library';
|
import { Version } from '@microsoft/sp-core-library';
|
||||||
import {
|
import { type IPropertyPaneConfiguration } from '@microsoft/sp-property-pane';
|
||||||
type IPropertyPaneConfiguration,
|
|
||||||
PropertyPaneTextField
|
|
||||||
} from '@microsoft/sp-property-pane';
|
|
||||||
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
|
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
|
||||||
import { IReadonlyTheme } from '@microsoft/sp-component-base';
|
import { IReadonlyTheme } from '@microsoft/sp-component-base';
|
||||||
|
|
||||||
@@ -13,13 +10,15 @@ import RequestPortal from './components/RequestPortal';
|
|||||||
import { IRequestPortalProps } from './components/IRequestPortalProps';
|
import { IRequestPortalProps } from './components/IRequestPortalProps';
|
||||||
import { initSP } from './shared/pnp/pnpjs';
|
import { initSP } from './shared/pnp/pnpjs';
|
||||||
import { installErrorLogging } from './shared/diagnostics';
|
import { installErrorLogging } from './shared/diagnostics';
|
||||||
import { setImpersonatedEmail } from './shared/impersonation';
|
import { setImpersonatedIdentity } from './shared/impersonation';
|
||||||
import { ErrorOverlay } from './shared/ui';
|
import { ErrorOverlay } from './shared/ui';
|
||||||
|
import { PropertyPaneDebugUser } from './propertyPane/PropertyPaneDebugUser';
|
||||||
|
|
||||||
export interface IRequestPortalWebPartProps {
|
export interface IRequestPortalWebPartProps {
|
||||||
description: string;
|
|
||||||
/** Отладочная подмена личности — см. shared/impersonation.ts. На бою пусто. */
|
/** Отладочная подмена личности — см. shared/impersonation.ts. На бою пусто. */
|
||||||
|
debugUserQuery: string;
|
||||||
debugUserEmail: string;
|
debugUserEmail: string;
|
||||||
|
debugUserDisplayName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequestPortalWebPartProps> {
|
export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequestPortalWebPartProps> {
|
||||||
@@ -30,13 +29,17 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
|||||||
public render(): void {
|
public render(): void {
|
||||||
// Именно здесь, а не в onInit: свойство меняется в панели без перезапуска
|
// Именно здесь, а не в onInit: свойство меняется в панели без перезапуска
|
||||||
// веб-части, и render вызывается на каждое такое изменение.
|
// веб-части, и render вызывается на каждое такое изменение.
|
||||||
setImpersonatedEmail(this.properties.debugUserEmail);
|
setImpersonatedIdentity(this.properties.debugUserEmail, this.properties.debugUserDisplayName);
|
||||||
|
|
||||||
const element: React.ReactElement = React.createElement(
|
const element: React.ReactElement = React.createElement(
|
||||||
ErrorOverlay,
|
ErrorOverlay,
|
||||||
undefined,
|
undefined,
|
||||||
React.createElement<IRequestPortalProps>(RequestPortal, {
|
React.createElement<IRequestPortalProps>(RequestPortal, {
|
||||||
description: this.properties.description,
|
// key на подменённом e-mail: смена подмены в property pane должна
|
||||||
|
// пересобрать раздел заново — иначе счётчики в боковом меню и открытые
|
||||||
|
// таблицы остались бы от прежнего пользователя (их эффекты завязаны на
|
||||||
|
// монтирование/context, а не на identity подмены).
|
||||||
|
key: this.properties.debugUserEmail || '__self__',
|
||||||
isDarkTheme: this._isDarkTheme,
|
isDarkTheme: this._isDarkTheme,
|
||||||
environmentMessage: this._environmentMessage,
|
environmentMessage: this._environmentMessage,
|
||||||
hasTeamsContext: !!this.context.sdks.microsoftTeams,
|
hasTeamsContext: !!this.context.sdks.microsoftTeams,
|
||||||
@@ -62,7 +65,7 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
|||||||
if (!!this.context.sdks.microsoftTeams) { // running in Teams, office.com or Outlook
|
if (!!this.context.sdks.microsoftTeams) { // running in Teams, office.com or Outlook
|
||||||
return this.context.sdks.microsoftTeams.teamsJs.app.getContext()
|
return this.context.sdks.microsoftTeams.teamsJs.app.getContext()
|
||||||
.then(context => {
|
.then(context => {
|
||||||
let environmentMessage: string = '';
|
let environmentMessage: string;
|
||||||
switch (context.app.host.name) {
|
switch (context.app.host.name) {
|
||||||
case 'Office': // running in Office
|
case 'Office': // running in Office
|
||||||
environmentMessage = this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentOffice : strings.AppOfficeEnvironment;
|
environmentMessage = this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentOffice : strings.AppOfficeEnvironment;
|
||||||
@@ -122,13 +125,17 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
|||||||
{
|
{
|
||||||
groupName: strings.BasicGroupName,
|
groupName: strings.BasicGroupName,
|
||||||
groupFields: [
|
groupFields: [
|
||||||
PropertyPaneTextField('description', {
|
PropertyPaneDebugUser('debugUserQuery', {
|
||||||
label: strings.DescriptionFieldLabel
|
label: strings.DebugUserFieldLabel,
|
||||||
}),
|
description: strings.DebugUserFieldDescription,
|
||||||
PropertyPaneTextField('debugUserEmail', {
|
context: this.context,
|
||||||
label: strings.DebugUserEmailFieldLabel,
|
initialQuery: this.properties.debugUserQuery,
|
||||||
description: strings.DebugUserEmailFieldDescription,
|
onResolved: (query, email, displayName) => {
|
||||||
placeholder: 'employee@tnsu.kz'
|
this.properties.debugUserQuery = query;
|
||||||
|
this.properties.debugUserEmail = email;
|
||||||
|
this.properties.debugUserDisplayName = displayName;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
|
||||||
export interface IRequestPortalProps {
|
export interface IRequestPortalProps {
|
||||||
description: string;
|
|
||||||
isDarkTheme: boolean;
|
isDarkTheme: boolean;
|
||||||
environmentMessage: string;
|
environmentMessage: string;
|
||||||
hasTeamsContext: boolean;
|
hasTeamsContext: boolean;
|
||||||
|
|||||||
@@ -50,15 +50,26 @@
|
|||||||
// Раскладка: боковой рейл (side) на всю высоту + основная область (main).
|
// Раскладка: боковой рейл (side) на всю высоту + основная область (main).
|
||||||
.layout {
|
.layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch; // рейл тянется на высоту контента
|
align-items: flex-start; // рейл больше НЕ тянется по контенту — см. .side
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 78vh; // «присутствие» рейла даже когда контента мало
|
min-height: 78vh; // «присутствие» рейла даже когда контента мало
|
||||||
}
|
}
|
||||||
|
|
||||||
// Рейл-колонка: сам Sidebar тянется по высоте (flex-стретч).
|
// Рейл-колонка: раньше тянулась по высоте .main (align-items: stretch), из-за
|
||||||
|
// чего высота рейла плавала вместе с содержимым соседней колонки — например, у
|
||||||
|
// вкладки «Инициированные мной» появляется доп. фильтр по статусу, .main
|
||||||
|
// становится чуть выше, и градиентный фон Sidebar (посчитанный от своей же
|
||||||
|
// высоты) заметно менял оттенок. Прибиваем рейл к высоте окна (sticky + 100vh):
|
||||||
|
// теперь он не зависит от того, что показано рядом. На мобильном брейкпоинте
|
||||||
|
// сам Sidebar уходит в position:fixed (см. @tnsu/ui-kit) — там эти правила уже
|
||||||
|
// не действуют.
|
||||||
.side {
|
.side {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
align-self: flex-start;
|
||||||
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
OwnTab,
|
OwnTab,
|
||||||
} from '../shared/routing';
|
} from '../shared/routing';
|
||||||
import { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents';
|
import { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents';
|
||||||
import { getImpersonatedEmail } from '../shared/impersonation';
|
import { getImpersonatedEmail, getImpersonatedDisplayName } from '../shared/impersonation';
|
||||||
import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges';
|
import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges';
|
||||||
import { Drawer } from './Drawer/Drawer';
|
import { Drawer } from './Drawer/Drawer';
|
||||||
import { CategoryTabs } from './CategoryTabs/CategoryTabs';
|
import { CategoryTabs } from './CategoryTabs/CategoryTabs';
|
||||||
@@ -62,6 +62,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
// (setImpersonatedEmail в render) до создания этого элемента, и меняется оно
|
// (setImpersonatedEmail в render) до создания этого элемента, и меняется оно
|
||||||
// только вместе с новым render всей веб-части.
|
// только вместе с новым render всей веб-части.
|
||||||
const impersonatedEmail = getImpersonatedEmail();
|
const impersonatedEmail = getImpersonatedEmail();
|
||||||
|
const impersonatedDisplayName = getImpersonatedDisplayName();
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const [navCollapsed, setNavCollapsed] = React.useState<boolean>(false);
|
const [navCollapsed, setNavCollapsed] = React.useState<boolean>(false);
|
||||||
@@ -133,7 +134,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
// загрузки в таблице не перезапустится сам. Шлём событие обновления —
|
// загрузки в таблице не перезапустится сам. Шлём событие обновления —
|
||||||
// таблица на него подписана и перечитает данные фоном, не убирая с экрана
|
// таблица на него подписана и перечитает данные фоном, не убирая с экрана
|
||||||
// текущие строки (см. RequestsTableView/SearchView).
|
// текущие строки (см. RequestsTableView/SearchView).
|
||||||
if (nav === route.nav && route.mode === 'submit') emitRequestsChanged();
|
if (nav === route.nav && route.mode === 'submit') emitRequestsChanged(true);
|
||||||
// Всё восстанавливаемое состояние раздела передаём ЯВНО, из запомненного.
|
// Всё восстанавливаемое состояние раздела передаём ЯВНО, из запомненного.
|
||||||
// parseRoute подставил бы его и сам, но тогда оно осталось бы только в
|
// parseRoute подставил бы его и сам, но тогда оно осталось бы только в
|
||||||
// состоянии и не попало бы в адрес: ссылка на раздел вела бы на первую
|
// состоянии и не попало бы в адрес: ссылка на раздел вела бы на первую
|
||||||
@@ -276,8 +277,11 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
закрыть: она исчезает только когда свойство веб-части очищено. */}
|
закрыть: она исчезает только когда свойство веб-части очищено. */}
|
||||||
{impersonatedEmail && (
|
{impersonatedEmail && (
|
||||||
<div className={styles.impersonationBar}>
|
<div className={styles.impersonationBar}>
|
||||||
Включена отладочная подмена: портал работает от имени <strong>{impersonatedEmail}</strong>.
|
Включена отладочная подмена: портал работает от имени{' '}
|
||||||
Очистите поле в свойствах веб-части, чтобы вернуться к своей учётной записи.
|
<strong>
|
||||||
|
{impersonatedDisplayName ? `${impersonatedDisplayName} (${impersonatedEmail})` : impersonatedEmail}
|
||||||
|
</strong>
|
||||||
|
. Очистите поле в свойствах веб-части, чтобы вернуться к своей учётной записи.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={styles.layout}>
|
<div className={styles.layout}>
|
||||||
|
|||||||
@@ -416,7 +416,14 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
load().catch(() => undefined); // ошибки уже обработаны внутри load через setError
|
load().catch(() => undefined); // ошибки уже обработаны внутри load через setError
|
||||||
const unsubscribe = onRequestsChanged(() => {
|
const unsubscribe = onRequestsChanged((force) => {
|
||||||
|
// Явный повторный клик по уже открытому пункту меню — пользователь ждёт
|
||||||
|
// видимого обновления, а не молчаливой подмены данных за спиной. Прежние
|
||||||
|
// строки прячем сразу; свежие подставит load().
|
||||||
|
if (force) {
|
||||||
|
setRows(undefined);
|
||||||
|
setLoadedKey('');
|
||||||
|
}
|
||||||
load().catch(() => undefined);
|
load().catch(() => undefined);
|
||||||
});
|
});
|
||||||
// Пока вкладка браузера скрыта, обновляться незачем — при возврате к ней
|
// Пока вкладка браузера скрыта, обновляться незачем — при возврате к ней
|
||||||
|
|||||||
@@ -200,7 +200,8 @@ export const SearchView: React.FC<ISearchViewProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
load().catch(() => undefined);
|
load().catch(() => undefined);
|
||||||
const unsubscribe = onRequestsChanged(() => {
|
const unsubscribe = onRequestsChanged((force) => {
|
||||||
|
if (force) setRows(undefined);
|
||||||
load().catch(() => undefined);
|
load().catch(() => undefined);
|
||||||
});
|
});
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
REQUEST_STATUS,
|
REQUEST_STATUS,
|
||||||
isEditableStatus,
|
isEditableStatus,
|
||||||
IApproverToWrite,
|
IApproverToWrite,
|
||||||
|
APPROVAL_PHASE,
|
||||||
findUnassignedStep
|
findUnassignedStep
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { IRequestFormProps } from '../../models/types';
|
import { IRequestFormProps } from '../../models/types';
|
||||||
@@ -106,17 +107,42 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({ requestType, context, onCa
|
|||||||
const loadMatrix = useCallback(
|
const loadMatrix = useCallback(
|
||||||
() =>
|
() =>
|
||||||
editId
|
editId
|
||||||
? approval.getMatrixForRequest(editId)
|
? approval
|
||||||
|
.getMatrixForRequest(editId)
|
||||||
|
.then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution))
|
||||||
: getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval', null, [
|
: getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval', null, [
|
||||||
{ fieldName: 'Position', value: data.Position }
|
{ fieldName: 'Position', value: data.Position }
|
||||||
]),
|
]),
|
||||||
[context, approval, editId, data.Employee, data.Position, requestTypeId]
|
[context, approval, editId, data.Employee, data.Position, requestTypeId]
|
||||||
);
|
);
|
||||||
|
const matrixReady = !!editId || (!!data.Employee && !!requestTypeId);
|
||||||
const matrix = useMatrixEditor(
|
const matrix = useMatrixEditor(
|
||||||
loadMatrix,
|
loadMatrix,
|
||||||
`${data.Employee}-${editId ?? 'new'}-${data.Position}`,
|
`${data.Employee}-${editId ?? 'new'}-${data.Position}`,
|
||||||
!readOnly,
|
!readOnly,
|
||||||
!!editId || (!!data.Employee && !!requestTypeId),
|
matrixReady,
|
||||||
|
(login) => approval.getJobTitle(login)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Фаза исполнения: тот же маршрут (условие «IsManager» по data.Position — см.
|
||||||
|
// комментарий у loadMatrix выше), только 'Execution' вместо 'Approval'. У
|
||||||
|
// открытой на редактирование заявки — строки уже записаны, читаем как есть.
|
||||||
|
const loadExecutors = useCallback(
|
||||||
|
() =>
|
||||||
|
editId
|
||||||
|
? approval
|
||||||
|
.getMatrixForRequest(editId)
|
||||||
|
.then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution))
|
||||||
|
: getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Execution', null, [
|
||||||
|
{ fieldName: 'Position', value: data.Position }
|
||||||
|
]),
|
||||||
|
[context, approval, editId, data.Employee, data.Position, requestTypeId]
|
||||||
|
);
|
||||||
|
const executors = useMatrixEditor(
|
||||||
|
loadExecutors,
|
||||||
|
`e-${data.Employee}-${editId ?? 'new'}-${data.Position}`,
|
||||||
|
!readOnly,
|
||||||
|
matrixReady,
|
||||||
(login) => approval.getJobTitle(login)
|
(login) => approval.getJobTitle(login)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -148,7 +174,14 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({ requestType, context, onCa
|
|||||||
if (!rtId) {
|
if (!rtId) {
|
||||||
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
||||||
}
|
}
|
||||||
const { id, code } = await saveClearanceRequest(requestId, rtId, data, toApprovers(matrix.rows), status);
|
const { id, code } = await saveClearanceRequest(
|
||||||
|
requestId,
|
||||||
|
rtId,
|
||||||
|
data,
|
||||||
|
toApprovers(matrix.rows),
|
||||||
|
toApprovers(executors.rows),
|
||||||
|
status
|
||||||
|
);
|
||||||
setRequestId(id);
|
setRequestId(id);
|
||||||
setRequestStatus(status);
|
setRequestStatus(status);
|
||||||
if (code) {
|
if (code) {
|
||||||
@@ -179,6 +212,10 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({ requestType, context, onCa
|
|||||||
if (unassigned) {
|
if (unassigned) {
|
||||||
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`;
|
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`;
|
||||||
}
|
}
|
||||||
|
const unassignedExecutor = findUnassignedStep(executors.rows);
|
||||||
|
if (unassignedExecutor) {
|
||||||
|
return `Шаг ${unassignedExecutor.order} исполнения («${unassignedExecutor.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.`;
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -220,6 +257,18 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({ requestType, context, onCa
|
|||||||
|
|
||||||
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
||||||
|
|
||||||
|
{/* Блок исполнителя показываем, только когда по маршруту реально есть
|
||||||
|
исполнители (условие «IsManager» и т.п. могло не назначить ни одного). */}
|
||||||
|
{matrixReady && !executors.loading && executors.rows.length > 0 && (
|
||||||
|
<ApprovalMatrix
|
||||||
|
editor={executors}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
title="Исполнение (после согласования)"
|
||||||
|
roleLabel="Исполнитель"
|
||||||
|
emptyText="Исполнители не заданы по маршруту."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<SubmitActions
|
<SubmitActions
|
||||||
submit={submit}
|
submit={submit}
|
||||||
isValid={isValid}
|
isValid={isValid}
|
||||||
|
|||||||
@@ -55,13 +55,31 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
|||||||
[approval]
|
[approval]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
|
// Матрица маршрута из фактических строк заявки (read-only), раздельно по
|
||||||
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
|
// фазам: согласование и исполнение — два отдельных блока в UI (см. ниже).
|
||||||
|
// decisionSeq в 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((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution)),
|
||||||
|
[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 loadExecutors = useCallback(
|
||||||
|
() =>
|
||||||
|
approval
|
||||||
|
.getMatrixForRequest(itemId)
|
||||||
|
.then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution)),
|
||||||
|
[approval, itemId]
|
||||||
|
);
|
||||||
|
const executors = useMatrixEditor(loadExecutors, `e-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||||
|
approval.getJobTitle(l)
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -196,6 +214,16 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
|||||||
</FormCard>
|
</FormCard>
|
||||||
|
|
||||||
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
||||||
|
|
||||||
|
{!executors.loading && executors.rows.length > 0 && (
|
||||||
|
<ApprovalMatrix
|
||||||
|
editor={executors}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
title="Исполнение"
|
||||||
|
roleLabel="Исполнитель"
|
||||||
|
emptyText="Исполнители не назначены."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import * as React from 'react';
|
|||||||
import { CollapseSection, FormField, TextInput, MoneyInput, Toggle, PeopleField } from '../../../shared/ui';
|
import { CollapseSection, FormField, TextInput, MoneyInput, Toggle, PeopleField } from '../../../shared/ui';
|
||||||
import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model';
|
import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model';
|
||||||
|
|
||||||
// Должность/подразделение подтягиваются автоматически при выборе сотрудника
|
// Подразделение подтягивается автоматически при выборе сотрудника (см.
|
||||||
// (см. ClearanceForm.handleEmployeeChange — SharePoint User Profile) —
|
// ClearanceForm.handleEmployeeChange — SharePoint User Profile) — свободный
|
||||||
// свободный ввод здесь намеренно убран, чтобы не расходиться с профилем.
|
// ввод здесь намеренно убран, чтобы не расходиться с профилем.
|
||||||
const NOOP = (): void => undefined;
|
const NOOP = (): void => undefined;
|
||||||
|
|
||||||
interface IRequestSectionProps extends ISectionProps {
|
interface IRequestSectionProps extends ISectionProps {
|
||||||
@@ -32,12 +32,16 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
|
|||||||
placeholder={EMPLOYEE_PLACEHOLDER}
|
placeholder={EMPLOYEE_PLACEHOLDER}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
{/* Подтягивается из профиля при выборе сотрудника, но поле остаётся
|
||||||
|
редактируемым: если профиль не синхронизирован (SPS-JobTitle пуст),
|
||||||
|
HR должен суметь ввести должность вручную — иначе маршрут
|
||||||
|
согласования (условие «IsManager» резолвится именно по ней) не
|
||||||
|
соберётся, и список исполнителей ниже останется пустым. */}
|
||||||
<FormField label="Должность" htmlFor="Position" required>
|
<FormField label="Должность" htmlFor="Position" required>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="Position"
|
id="Position"
|
||||||
value={data.Position}
|
value={data.Position}
|
||||||
onChange={NOOP}
|
onChange={(v) => set('Position', v)}
|
||||||
readOnly
|
|
||||||
placeholder="Подставится автоматически по выбранному сотруднику"
|
placeholder="Подставится автоматически по выбранному сотруднику"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|||||||
@@ -76,9 +76,8 @@ export interface ISaveResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сохранить обходной лист: содержимое + маршрут согласования.
|
* Сохранить обходной лист: содержимое + маршрут (согласование + исполнение).
|
||||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||||
* Фазы исполнения у обходного листа нет — writeRoute вызывается с пустым списком исполнителей.
|
|
||||||
* Возвращает Id заявки (+ код при создании).
|
* Возвращает Id заявки (+ код при создании).
|
||||||
*/
|
*/
|
||||||
export async function saveClearanceRequest(
|
export async function saveClearanceRequest(
|
||||||
@@ -86,6 +85,7 @@ export async function saveClearanceRequest(
|
|||||||
requestTypeId: number,
|
requestTypeId: number,
|
||||||
data: IFormData,
|
data: IFormData,
|
||||||
approvers: IApproverToWrite[],
|
approvers: IApproverToWrite[],
|
||||||
|
executors: IApproverToWrite[],
|
||||||
status: string
|
status: string
|
||||||
): Promise<ISaveResult> {
|
): Promise<ISaveResult> {
|
||||||
const list = getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
const list = getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||||
@@ -105,6 +105,6 @@ export async function saveClearanceRequest(
|
|||||||
await list.items.getById(id).update({ RequestUniqueCode: code });
|
await list.items.getById(id).update({ RequestUniqueCode: code });
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeRoute(clearanceApprovalService(), id, approvers, [], status);
|
await writeRoute(clearanceApprovalService(), id, approvers, executors, status);
|
||||||
return { id, code };
|
return { id, code };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ define([], function() {
|
|||||||
return {
|
return {
|
||||||
"PropertyPaneDescription": "Description",
|
"PropertyPaneDescription": "Description",
|
||||||
"BasicGroupName": "Group Name",
|
"BasicGroupName": "Group Name",
|
||||||
"DescriptionFieldLabel": "Description Field",
|
"DebugUserFieldLabel": "Отладка: работать от имени сотрудника",
|
||||||
"DebugUserEmailFieldLabel": "Отладка: работать от имени сотрудника",
|
"DebugUserFieldDescription": "Только для отладки маршрутов. Введите фамилию и имя сотрудника (можно в обратном порядке, отчество не обязательно) — портал найдёт его e-mail сам, найденное отобразится над полем. Можно ввести e-mail напрямую. По этому e-mail планируется маршрут, отбираются «свои» заявки и очередь согласования. Прав это не даёт — запись в списки всё равно идёт под вашей учёткой. На боевой странице оставьте пустым.",
|
||||||
"DebugUserEmailFieldDescription": "Только для отладки маршрутов. Портал будет считать текущим пользователем указанный e-mail: по нему планируется маршрут, отбираются «свои» заявки и очередь согласования. Прав это не даёт — запись в списки всё равно идёт под вашей учёткой. На боевой странице оставьте пустым.",
|
|
||||||
"AppLocalEnvironmentSharePoint": "The app is running on your local environment as SharePoint web part",
|
"AppLocalEnvironmentSharePoint": "The app is running on your local environment as SharePoint web part",
|
||||||
"AppLocalEnvironmentTeams": "The app is running on your local environment as Microsoft Teams app",
|
"AppLocalEnvironmentTeams": "The app is running on your local environment as Microsoft Teams app",
|
||||||
"AppLocalEnvironmentOffice": "The app is running on your local environment in office.com",
|
"AppLocalEnvironmentOffice": "The app is running on your local environment in office.com",
|
||||||
|
|||||||
+2
-3
@@ -1,9 +1,8 @@
|
|||||||
declare interface IRequestPortalWebPartStrings {
|
declare interface IRequestPortalWebPartStrings {
|
||||||
PropertyPaneDescription: string;
|
PropertyPaneDescription: string;
|
||||||
BasicGroupName: string;
|
BasicGroupName: string;
|
||||||
DescriptionFieldLabel: string;
|
DebugUserFieldLabel: string;
|
||||||
DebugUserEmailFieldLabel: string;
|
DebugUserFieldDescription: string;
|
||||||
DebugUserEmailFieldDescription: string;
|
|
||||||
AppLocalEnvironmentSharePoint: string;
|
AppLocalEnvironmentSharePoint: string;
|
||||||
AppLocalEnvironmentTeams: string;
|
AppLocalEnvironmentTeams: string;
|
||||||
AppLocalEnvironmentOffice: string;
|
AppLocalEnvironmentOffice: string;
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { resolveDebugUser } from '../shared/impersonation';
|
||||||
|
|
||||||
|
export interface IDebugUserFieldProps {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
context: WebPartContext;
|
||||||
|
initialQuery: string;
|
||||||
|
/** Зовётся при каждом изменении: query — как введено, email/displayName — итог поиска ('' если не найдено). */
|
||||||
|
onResolved: (query: string, email: string, displayName: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Status = 'idle' | 'loading' | 'found' | 'notfound' | 'email';
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 400;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поле свойств веб-части: ввод «Фамилия Имя [Отчество]» (в любом порядке) или
|
||||||
|
* e-mail напрямую. Сам резолвит e-mail через SharePoint people search — см.
|
||||||
|
* shared/impersonation.ts (resolveDebugUser) — и показывает найденное тут же,
|
||||||
|
* чтобы не гадать, кого именно подставили.
|
||||||
|
*/
|
||||||
|
export const DebugUserField: React.FC<IDebugUserFieldProps> = ({ label, description, context, initialQuery, onResolved }) => {
|
||||||
|
const [query, setQuery] = React.useState(initialQuery || '');
|
||||||
|
const [status, setStatus] = React.useState<Status>('idle');
|
||||||
|
const [found, setFound] = React.useState<{ email: string; displayName: string } | undefined>(undefined);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const text = query.trim();
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
setStatus('idle');
|
||||||
|
setFound(undefined);
|
||||||
|
onResolved('', '', '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// E-mail введён напрямую — используем его как есть, но всё равно ищем ФИО
|
||||||
|
// (для красной полосы в интерфейсе и для проверки, что такой сотрудник есть).
|
||||||
|
const isEmail = text.indexOf('@') >= 0;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setStatus(isEmail ? 'email' : 'loading');
|
||||||
|
if (isEmail) setFound(undefined);
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
resolveDebugUser(context, text)
|
||||||
|
.then((hit) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (isEmail) {
|
||||||
|
setFound(hit);
|
||||||
|
onResolved(text, text, hit ? hit.displayName : '');
|
||||||
|
} else if (hit) {
|
||||||
|
setStatus('found');
|
||||||
|
setFound(hit);
|
||||||
|
onResolved(text, hit.email, hit.displayName);
|
||||||
|
} else {
|
||||||
|
setStatus('notfound');
|
||||||
|
setFound(undefined);
|
||||||
|
onResolved(text, '', '');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (isEmail) {
|
||||||
|
onResolved(text, text, '');
|
||||||
|
} else {
|
||||||
|
setStatus('notfound');
|
||||||
|
setFound(undefined);
|
||||||
|
onResolved(text, '', '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 4 }}>{label}</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Иванов Иван Иванович, Иван Иванов или e-mail"
|
||||||
|
style={{ width: '100%', boxSizing: 'border-box', padding: '5px 8px', fontSize: 14 }}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 4, fontSize: 12, lineHeight: 1.4, color: '#a4262c', minHeight: 16 }}>
|
||||||
|
{status === 'loading' && 'Ищем сотрудника…'}
|
||||||
|
{status === 'found' && found && `Найдено: ${found.displayName} — ${found.email}`}
|
||||||
|
{status === 'notfound' && 'Сотрудник не найден — подмена работать не будет'}
|
||||||
|
{status === 'email' &&
|
||||||
|
(found
|
||||||
|
? `Используется как e-mail напрямую: ${query.trim()} (${found.displayName})`
|
||||||
|
: `Используется как e-mail напрямую: ${query.trim()}`)}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 6, fontSize: 12, lineHeight: 1.4, color: '#605e5c' }}>{description}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as ReactDom from 'react-dom';
|
||||||
|
import {
|
||||||
|
IPropertyPaneField,
|
||||||
|
PropertyPaneFieldType,
|
||||||
|
IPropertyPaneCustomFieldProps
|
||||||
|
} from '@microsoft/sp-property-pane';
|
||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { DebugUserField } from './DebugUserField';
|
||||||
|
|
||||||
|
export interface IPropertyPaneDebugUserProps {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
context: WebPartContext;
|
||||||
|
initialQuery: string;
|
||||||
|
onResolved: (query: string, email: string, displayName: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PropertyPaneDebugUserBuilder
|
||||||
|
implements IPropertyPaneField<IPropertyPaneCustomFieldProps & IPropertyPaneDebugUserProps> {
|
||||||
|
public type: PropertyPaneFieldType = PropertyPaneFieldType.Custom;
|
||||||
|
public targetProperty: string;
|
||||||
|
public properties: IPropertyPaneCustomFieldProps & IPropertyPaneDebugUserProps;
|
||||||
|
|
||||||
|
constructor(targetProperty: string, properties: IPropertyPaneDebugUserProps) {
|
||||||
|
this.targetProperty = targetProperty;
|
||||||
|
this.properties = {
|
||||||
|
key: targetProperty,
|
||||||
|
onRender: this.onRender.bind(this),
|
||||||
|
onDispose: this.onDispose.bind(this),
|
||||||
|
...properties
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private onRender(elem: HTMLElement): void {
|
||||||
|
ReactDom.render(
|
||||||
|
<DebugUserField
|
||||||
|
label={this.properties.label}
|
||||||
|
description={this.properties.description}
|
||||||
|
context={this.properties.context}
|
||||||
|
initialQuery={this.properties.initialQuery}
|
||||||
|
onResolved={this.properties.onResolved}
|
||||||
|
/>,
|
||||||
|
elem
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onDispose(elem: HTMLElement): void {
|
||||||
|
ReactDom.unmountComponentAtNode(elem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Кастомное поле property pane: ФИО/e-mail сотрудника с резолвом в e-mail на лету. */
|
||||||
|
export function PropertyPaneDebugUser(
|
||||||
|
targetProperty: string,
|
||||||
|
properties: IPropertyPaneDebugUserProps
|
||||||
|
): IPropertyPaneField<IPropertyPaneCustomFieldProps & IPropertyPaneDebugUserProps> {
|
||||||
|
return new PropertyPaneDebugUserBuilder(targetProperty, properties);
|
||||||
|
}
|
||||||
@@ -129,6 +129,7 @@ export class ApprovalListService {
|
|||||||
toWrite.forEach(({ a, order }, i) => {
|
toWrite.forEach(({ a, order }, i) => {
|
||||||
// void: промис от add() на batched-инстансе нарочно не ждём поштучно —
|
// void: промис от add() на batched-инстансе нарочно не ждём поштучно —
|
||||||
// он разрешится вместе со всеми остальными после execute() ниже.
|
// он разрешится вместе со всеми остальными после execute() ниже.
|
||||||
|
// eslint-disable-next-line no-void -- без void это «висячий» промис, а no-floating-promises здесь уровня error
|
||||||
void batchedList.items.add({
|
void batchedList.items.add({
|
||||||
Title: a.position || a.email,
|
Title: a.position || a.email,
|
||||||
[`${FIELDS.approver}Id`]: approverIds[i],
|
[`${FIELDS.approver}Id`]: approverIds[i],
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
// подсвечивается пол-таблицы и непонятно, что именно переехало. Запоминаем
|
// подсвечивается пол-таблицы и непонятно, что именно переехало. Запоминаем
|
||||||
// ключи блока ДО перестановки: после неё индексы уже другие.
|
// ключи блока ДО перестановки: после неё индексы уже другие.
|
||||||
const [activeKeys, setActiveKeys] = React.useState<string[]>([]);
|
const [activeKeys, setActiveKeys] = React.useState<string[]>([]);
|
||||||
const registerRow = useFlipRows(activeKeys, rowKeys);
|
// Обёртка таблицы: её высоту useFlipRows анимирует вместе с переездом строк,
|
||||||
|
// чтобы пикер и следующая карточка не подпрыгивали при удалении шага.
|
||||||
|
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const registerRow = useFlipRows(activeKeys, rowKeys, scrollRef);
|
||||||
|
|
||||||
// Строка → шаг, которому она принадлежит. Нужно, чтобы отрисовать ячейку
|
// Строка → шаг, которому она принадлежит. Нужно, чтобы отрисовать ячейку
|
||||||
// «Шаг» один раз на весь шаг (rowSpan) и понять, можно ли двигать его выше/ниже.
|
// «Шаг» один раз на весь шаг (rowSpan) и понять, можно ли двигать его выше/ниже.
|
||||||
@@ -110,7 +113,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
<div className={styles.matrixNote}>{emptyText}</div>
|
<div className={styles.matrixNote}>{emptyText}</div>
|
||||||
)}
|
)}
|
||||||
{!waiting && !loading && !error && rows.length > 0 && (
|
{!waiting && !loading && !error && rows.length > 0 && (
|
||||||
<div className={styles.matrixScroll}>
|
<div className={styles.matrixScroll} ref={scrollRef}>
|
||||||
<table className={styles.matrix}>
|
<table className={styles.matrix}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -95,6 +95,19 @@
|
|||||||
// сплющивания колонок. На телефоне таблица становится карточками (медиазапрос ниже).
|
// сплющивания колонок. На телефоне таблица становится карточками (медиазапрос ниже).
|
||||||
.matrixScroll {
|
.matrixScroll {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
// overflow-y ОБЯЗАН быть задан явно. По спецификации, если одна ось получает
|
||||||
|
// прокрутку, вторая с `visible` вычисляется в `auto` — то есть один
|
||||||
|
// overflow-x: auto включал прокрутку и по вертикали тоже. А анимация
|
||||||
|
// перестановки (см. ui/useFlipRows) стартует с translateY на высоту строки:
|
||||||
|
// при удалении строки нижние сначала отрисованы СМЕЩЁННЫМИ ВНИЗ, вылезают за
|
||||||
|
// нижнюю границу контейнера — на кадр появляется вертикальная полоса
|
||||||
|
// прокрутки, из-за неё контейнер сужается, текст в колонках перевёрстывается,
|
||||||
|
// строки съезжают, а через 380 мс всё это откатывается назад. Отсюда и
|
||||||
|
// дрожание интерфейса на каждое удаление позиции.
|
||||||
|
//
|
||||||
|
// hidden, а не clip: `clip` не создаёт scroll container, и тогда вертикальный
|
||||||
|
// вылет анимации утёк бы наружу, прокручивая уже саму страницу.
|
||||||
|
overflow-y: hidden;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ import * as React from 'react';
|
|||||||
// центра разрывало блок визуально. Плюс строка становилась шире контейнера
|
// центра разрывало блок визуально. Плюс строка становилась шире контейнера
|
||||||
// и .matrixScroll (overflow-x: auto) на время анимации показывал полосу
|
// и .matrixScroll (overflow-x: auto) на время анимации показывал полосу
|
||||||
// горизонтальной прокрутки.
|
// горизонтальной прокрутки.
|
||||||
// • пружинистый перелёт (cubic-bezier с >1): при overflow-x: auto вторая ось
|
// • пружинистый перелёт (cubic-bezier с >1): вылет за край .matrixScroll
|
||||||
// тоже становится auto, и вылет за край таблицы строку бы обрезало.
|
// обрезается (там overflow-y: hidden — см. approval.module.scss), так что
|
||||||
|
// перелёт был бы виден только как срезанная строка.
|
||||||
// • подсветка ВСЕХ переехавших строк: при обмене двух шагов двигаются оба, и
|
// • подсветка ВСЕХ переехавших строк: при обмене двух шагов двигаются оба, и
|
||||||
// жёлтым заливало пол-таблицы. Подсвечиваем только тот блок, который двигал
|
// жёлтым заливало пол-таблицы. Подсвечиваем только тот блок, который двигал
|
||||||
// пользователь (activeKeys).
|
// пользователь (activeKeys).
|
||||||
@@ -108,10 +109,16 @@ function measureTops(nodes: Map<string, HTMLTableRowElement>): Map<string, numbe
|
|||||||
* @param rowKeys ключи строк в ТЕКУЩЕМ порядке — единственный сигнал, по
|
* @param rowKeys ключи строк в ТЕКУЩЕМ порядке — единственный сигнал, по
|
||||||
* которому решаем, действительно ли что-то переставили (см. ниже, почему
|
* которому решаем, действительно ли что-то переставили (см. ниже, почему
|
||||||
* геометрии для этого недостаточно).
|
* геометрии для этого недостаточно).
|
||||||
|
* @param containerRef прокручиваемая обёртка таблицы (.matrixScroll). Её высоту
|
||||||
|
* анимируем в такт переезду строк: без этого React схлопывает высоту таблицы
|
||||||
|
* мгновенно, а строки едут 380 мс — и всё, что стоит НИЖЕ маршрута (пикер
|
||||||
|
* «Добавить согласующего», следующая карточка), рывком подпрыгивает на высоту
|
||||||
|
* удалённой строки. Необязателен: без него анимируются только строки.
|
||||||
*/
|
*/
|
||||||
export function useFlipRows(
|
export function useFlipRows(
|
||||||
activeKeys: string[],
|
activeKeys: string[],
|
||||||
rowKeys: string[]
|
rowKeys: string[],
|
||||||
|
containerRef?: React.RefObject<HTMLElement>
|
||||||
// eslint-disable-next-line @rushstack/no-new-null -- null здесь диктует React: ref-колбэк получает именно null при отвязке узла
|
// eslint-disable-next-line @rushstack/no-new-null -- null здесь диктует React: ref-колбэк получает именно null при отвязке узла
|
||||||
): (key: string) => (el: HTMLTableRowElement | null) => void {
|
): (key: string) => (el: HTMLTableRowElement | null) => void {
|
||||||
const nodes = React.useRef(new Map<string, HTMLTableRowElement>());
|
const nodes = React.useRef(new Map<string, HTMLTableRowElement>());
|
||||||
@@ -121,6 +128,10 @@ export function useFlipRows(
|
|||||||
const prevTops = React.useRef(new Map<string, number>());
|
const prevTops = React.useRef(new Map<string, number>());
|
||||||
// Порядок строк на прошлый коммит — см. проверку orderChanged ниже.
|
// Порядок строк на прошлый коммит — см. проверку orderChanged ниже.
|
||||||
const prevRowKeys = React.useRef<string[]>([]);
|
const prevRowKeys = React.useRef<string[]>([]);
|
||||||
|
// Высота обёртки на прошлый коммит. Нужна именно запомненная: layout-эффект
|
||||||
|
// работает УЖЕ ПОСЛЕ коммита, и к этому моменту высота на экране новая —
|
||||||
|
// «откуда ехать» из текущей раскладки не узнать.
|
||||||
|
const prevHeight = React.useRef<number | undefined>(undefined);
|
||||||
// Свои анимации держим сами, чтобы отменять ИМЕННО их: el.getAnimations()
|
// Свои анимации держим сами, чтобы отменять ИМЕННО их: el.getAnimations()
|
||||||
// вернул бы заодно чужие (CSS-переходы и т.п.).
|
// вернул бы заодно чужие (CSS-переходы и т.п.).
|
||||||
const running = React.useRef<Animation[]>([]);
|
const running = React.useRef<Animation[]>([]);
|
||||||
@@ -156,10 +167,13 @@ export function useFlipRows(
|
|||||||
rowKeys.length !== prevRowKeys.current.length || rowKeys.some((k, i) => k !== prevRowKeys.current[i]);
|
rowKeys.length !== prevRowKeys.current.length || rowKeys.some((k, i) => k !== prevRowKeys.current[i]);
|
||||||
prevRowKeys.current = rowKeys;
|
prevRowKeys.current = rowKeys;
|
||||||
|
|
||||||
|
const container = containerRef ? containerRef.current : null;
|
||||||
|
|
||||||
if (!orderChanged) {
|
if (!orderChanged) {
|
||||||
// Порядок тот же — просто обновляем базовую геометрию для следующего
|
// Порядок тот же — просто обновляем базовую геометрию для следующего
|
||||||
// ДЕЙСТВИТЕЛЬНОГО сравнения, без единой анимации на этом проходе.
|
// ДЕЙСТВИТЕЛЬНОГО сравнения, без единой анимации на этом проходе.
|
||||||
prevTops.current = measureTops(nodes.current);
|
prevTops.current = measureTops(nodes.current);
|
||||||
|
prevHeight.current = container ? container.getBoundingClientRect().height : undefined;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +181,10 @@ export function useFlipRows(
|
|||||||
// она в этот момент ещё летит, и новая анимация обязана стартовать от её
|
// она в этот момент ещё летит, и новая анимация обязана стартовать от её
|
||||||
// текущего положения на экране, иначе будет рывок.
|
// текущего положения на экране, иначе будет рывок.
|
||||||
const visualTops = measureTops(nodes.current);
|
const visualTops = measureTops(nodes.current);
|
||||||
|
// То же и для высоты обёртки: если анимация высоты ещё идёт, здесь мы
|
||||||
|
// получим её промежуточное значение (после cancel ниже высота схлопнется к
|
||||||
|
// натуральной, и «откуда ехать» было бы уже не восстановить).
|
||||||
|
const visualHeight = container ? container.getBoundingClientRect().height : undefined;
|
||||||
|
|
||||||
running.current.forEach((a) => a.cancel());
|
running.current.forEach((a) => a.cancel());
|
||||||
running.current = [];
|
running.current = [];
|
||||||
@@ -195,7 +213,38 @@ export function useFlipRows(
|
|||||||
|
|
||||||
prevTops.current = current;
|
prevTops.current = current;
|
||||||
|
|
||||||
if (prefersReducedMotion() || (moved.length === 0 && entered.length === 0)) return;
|
// Высота: «куда» — натуральная (анимации уже отменены). «Откуда» — то, что
|
||||||
|
// было видно: промежуточное значение недоигранной анимации, если она шла
|
||||||
|
// (visualHeight разошёлся с натуральным), иначе высота прошлого коммита.
|
||||||
|
const heightTo = container ? container.getBoundingClientRect().height : undefined;
|
||||||
|
const heightFrom =
|
||||||
|
visualHeight !== undefined && heightTo !== undefined && Math.abs(visualHeight - heightTo) >= MIN_SHIFT_PX
|
||||||
|
? visualHeight
|
||||||
|
: prevHeight.current;
|
||||||
|
prevHeight.current = heightTo;
|
||||||
|
const heightChanged =
|
||||||
|
!!container &&
|
||||||
|
heightFrom !== undefined &&
|
||||||
|
heightTo !== undefined &&
|
||||||
|
Math.abs(heightFrom - heightTo) >= MIN_SHIFT_PX;
|
||||||
|
|
||||||
|
// heightChanged в условии — отдельным слагаемым: при удалении ПОСЛЕДНЕЙ
|
||||||
|
// строки ехать некому (moved и entered пусты), но высота всё равно
|
||||||
|
// меняется, и без анимации нижняя карточка снова подпрыгнула бы.
|
||||||
|
if (prefersReducedMotion() || (moved.length === 0 && entered.length === 0 && !heightChanged)) return;
|
||||||
|
|
||||||
|
if (heightChanged && container) {
|
||||||
|
// Та же длительность и easing, что у переезда строк: нижняя граница
|
||||||
|
// обёртки должна идти ровно за последней строкой, иначе рассинхрон видно.
|
||||||
|
// Обрезку лишнего на время анимации берёт на себя overflow-y: hidden
|
||||||
|
// (см. .matrixScroll в approval.module.scss).
|
||||||
|
running.current.push(
|
||||||
|
container.animate([{ height: `${heightFrom as number}px` }, { height: `${heightTo as number}px` }], {
|
||||||
|
duration: MOVE_MS,
|
||||||
|
easing: MOVE_EASING,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
moved.forEach(({ el, key, shift }) => {
|
moved.forEach(({ el, key, shift }) => {
|
||||||
const isActive = activeKeys.indexOf(key) !== -1;
|
const isActive = activeKeys.indexOf(key) !== -1;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { SPHttpClient } from '@microsoft/sp-http';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ОТЛАДОЧНАЯ подмена «текущего пользователя»: портал ведёт себя так, будто им
|
* ОТЛАДОЧНАЯ подмена «текущего пользователя»: портал ведёт себя так, будто им
|
||||||
@@ -22,10 +23,12 @@ import { WebPartContext } from '@microsoft/sp-webpart-base';
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
let impersonatedEmail = '';
|
let impersonatedEmail = '';
|
||||||
|
let impersonatedDisplayName = '';
|
||||||
|
|
||||||
/** Вызывается веб-частью на каждый render — свойство могли поменять в панели. */
|
/** Вызывается веб-частью на каждый render — свойство могли поменять в панели. */
|
||||||
export function setImpersonatedEmail(email: string | undefined): void {
|
export function setImpersonatedIdentity(email: string | undefined, displayName?: string): void {
|
||||||
impersonatedEmail = (email || '').trim();
|
impersonatedEmail = (email || '').trim();
|
||||||
|
impersonatedDisplayName = (displayName || '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Заданная подмена ('' — подмены нет). Для предупреждающей полосы в интерфейсе. */
|
/** Заданная подмена ('' — подмены нет). Для предупреждающей полосы в интерфейсе. */
|
||||||
@@ -33,7 +36,89 @@ export function getImpersonatedEmail(): string {
|
|||||||
return impersonatedEmail;
|
return impersonatedEmail;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ФИО подменённого сотрудника, если его удалось найти поиском по имени ('' — для e-mail, введённого напрямую). */
|
||||||
|
export function getImpersonatedDisplayName(): string {
|
||||||
|
return impersonatedDisplayName;
|
||||||
|
}
|
||||||
|
|
||||||
/** Email, под которым портал работает: подменённый, если задан, иначе — из токена. */
|
/** Email, под которым портал работает: подменённый, если задан, иначе — из токена. */
|
||||||
export function effectiveUserEmail(context: WebPartContext): string {
|
export function effectiveUserEmail(context: WebPartContext): string {
|
||||||
return impersonatedEmail || context.pageContext.user.email || '';
|
return impersonatedEmail || context.pageContext.user.email || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IResolvedDebugUser {
|
||||||
|
email: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IPeopleSearchHit {
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сырой ответ ClientPeoplePickerSearchUser — value внутри двойного JSON. */
|
||||||
|
interface IPeoplePickerEntity {
|
||||||
|
DisplayText?: string;
|
||||||
|
Description?: string;
|
||||||
|
EntityData?: { Email?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchPeopleByText(context: WebPartContext, queryText: string): Promise<IPeopleSearchHit[]> {
|
||||||
|
const url = `${context.pageContext.web.absoluteUrl}/_api/SP.UI.ApplicationPages.ClientPeoplePickerWebServiceInterface.clientPeoplePickerSearchUser`;
|
||||||
|
const body = JSON.stringify({
|
||||||
|
queryParams: {
|
||||||
|
AllowEmailAddresses: true,
|
||||||
|
AllowMultipleEntities: false,
|
||||||
|
MaximumEntitySuggestions: 5,
|
||||||
|
PrincipalSource: 15, // All
|
||||||
|
PrincipalType: 1, // User
|
||||||
|
QueryString: queryText
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await context.spHttpClient.post(url, SPHttpClient.configurations.v1, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json;odata=nometadata',
|
||||||
|
'Content-Type': 'application/json;odata=nometadata'
|
||||||
|
},
|
||||||
|
body
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) return [];
|
||||||
|
|
||||||
|
const json: { value?: string } = await response.json();
|
||||||
|
if (!json.value) return [];
|
||||||
|
|
||||||
|
let entries: IPeoplePickerEntity[];
|
||||||
|
try {
|
||||||
|
entries = JSON.parse(json.value);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
.map((e) => ({ displayName: e.DisplayText || '', email: e.EntityData?.Email || e.Description || '' }))
|
||||||
|
.filter((e) => !!e.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ищет сотрудника по имени для отладочной подмены (см. property pane —
|
||||||
|
* PropertyPaneDebugUser/DebugUserField). Порядок «Фамилия Имя» или «Имя
|
||||||
|
* Фамилия» не важен: если прямой запрос ничего не находит, пробуем с
|
||||||
|
* переставленными первыми двумя словами.
|
||||||
|
*/
|
||||||
|
export async function resolveDebugUser(context: WebPartContext, nameQuery: string): Promise<IResolvedDebugUser | undefined> {
|
||||||
|
const text = nameQuery.trim();
|
||||||
|
if (!text) return undefined;
|
||||||
|
|
||||||
|
let hits = await searchPeopleByText(context, text);
|
||||||
|
if (hits.length === 0) {
|
||||||
|
const tokens = text.split(/\s+/).filter(Boolean);
|
||||||
|
if (tokens.length >= 2) {
|
||||||
|
const swapped = [tokens[1], tokens[0], ...tokens.slice(2)].join(' ');
|
||||||
|
hits = await searchPeopleByText(context, swapped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hits[0];
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,12 +4,21 @@
|
|||||||
// достаточно для нечастых событий (сохранение формы, решение согласующего).
|
// достаточно для нечастых событий (сохранение формы, решение согласующего).
|
||||||
const EVENT_NAME = 'rp-requests-changed';
|
const EVENT_NAME = 'rp-requests-changed';
|
||||||
|
|
||||||
export function emitRequestsChanged(): void {
|
/**
|
||||||
window.dispatchEvent(new Event(EVENT_NAME));
|
* force=true — повторный клик по УЖЕ открытому пункту меню (см. goto в
|
||||||
|
* RequestPortal.tsx): пользователь явно просит обновить список, поэтому таблица
|
||||||
|
* прячет прежние строки и показывает «Загрузка…», пока не придёт свежий ответ.
|
||||||
|
* force=false/не задан — фоновые обновления (создание заявки, решение
|
||||||
|
* согласующего, десятиминутный автообновление): строки остаются на экране, чтобы
|
||||||
|
* не моргать, если данные не изменились.
|
||||||
|
*/
|
||||||
|
export function emitRequestsChanged(force?: boolean): void {
|
||||||
|
window.dispatchEvent(new CustomEvent<{ force?: boolean }>(EVENT_NAME, { detail: { force } }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Подписка на изменения. Возвращает функцию отписки (вызвать в cleanup эффекта). */
|
/** Подписка на изменения. Возвращает функцию отписки (вызвать в cleanup эффекта). */
|
||||||
export function onRequestsChanged(handler: () => void): () => void {
|
export function onRequestsChanged(handler: (force?: boolean) => void): () => void {
|
||||||
window.addEventListener(EVENT_NAME, handler);
|
const listener = (e: Event): void => handler((e as CustomEvent<{ force?: boolean }>).detail?.force);
|
||||||
return () => window.removeEventListener(EVENT_NAME, handler);
|
window.addEventListener(EVENT_NAME, listener);
|
||||||
|
return () => window.removeEventListener(EVENT_NAME, listener);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user