diff --git a/.eslintrc.js b/.eslintrc.js index a790d60..cb42e16 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -295,6 +295,13 @@ module.exports = { '@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 // defaults specified in the extended configurations, as well as above. diff --git a/src/webparts/requestPortal/RequestPortalWebPart.manifest.json b/src/webparts/requestPortal/RequestPortalWebPart.manifest.json index 594ae8a..33195e2 100644 --- a/src/webparts/requestPortal/RequestPortalWebPart.manifest.json +++ b/src/webparts/requestPortal/RequestPortalWebPart.manifest.json @@ -3,27 +3,19 @@ "id": "caca4ff9-050c-4643-936c-d0f29a0f9532", "alias": "RequestPortalWebPart", "componentType": "WebPart", - - // The "*" signifies that the version should be taken from the package.json "version": "*", "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, "supportedHosts": ["SharePointWebPart", "TeamsPersonalApp", "TeamsTab", "SharePointFullPage"], "supportsThemeVariants": true, "supportsFullBleed": true, "preconfiguredEntries": [{ - "groupId": "5c03119e-3074-46fd-976b-c60198311f70", // Advanced + "groupId": "5c03119e-3074-46fd-976b-c60198311f70", "group": { "default": "Advanced" }, "title": { "default": "request-portal" }, "description": { "default": "request-portal description" }, "officeFabricIconFontName": "Page", - "properties": { - "description": "request-portal" - } + "properties": {} }] } diff --git a/src/webparts/requestPortal/RequestPortalWebPart.ts b/src/webparts/requestPortal/RequestPortalWebPart.ts index 34789f4..cda9ef0 100644 --- a/src/webparts/requestPortal/RequestPortalWebPart.ts +++ b/src/webparts/requestPortal/RequestPortalWebPart.ts @@ -1,10 +1,7 @@ import * as React from 'react'; import * as ReactDom from 'react-dom'; import { Version } from '@microsoft/sp-core-library'; -import { - type IPropertyPaneConfiguration, - PropertyPaneTextField -} from '@microsoft/sp-property-pane'; +import { type IPropertyPaneConfiguration } from '@microsoft/sp-property-pane'; import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base'; import { IReadonlyTheme } from '@microsoft/sp-component-base'; @@ -13,13 +10,15 @@ import RequestPortal from './components/RequestPortal'; import { IRequestPortalProps } from './components/IRequestPortalProps'; import { initSP } from './shared/pnp/pnpjs'; import { installErrorLogging } from './shared/diagnostics'; -import { setImpersonatedEmail } from './shared/impersonation'; +import { setImpersonatedIdentity } from './shared/impersonation'; import { ErrorOverlay } from './shared/ui'; +import { PropertyPaneDebugUser } from './propertyPane/PropertyPaneDebugUser'; export interface IRequestPortalWebPartProps { - description: string; /** Отладочная подмена личности — см. shared/impersonation.ts. На бою пусто. */ + debugUserQuery: string; debugUserEmail: string; + debugUserDisplayName: string; } export default class RequestPortalWebPart extends BaseClientSideWebPart { @@ -30,13 +29,17 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart(RequestPortal, { - description: this.properties.description, + // key на подменённом e-mail: смена подмены в property pane должна + // пересобрать раздел заново — иначе счётчики в боковом меню и открытые + // таблицы остались бы от прежнего пользователя (их эффекты завязаны на + // монтирование/context, а не на identity подмены). + key: this.properties.debugUserEmail || '__self__', isDarkTheme: this._isDarkTheme, environmentMessage: this._environmentMessage, hasTeamsContext: !!this.context.sdks.microsoftTeams, @@ -62,7 +65,7 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart { - let environmentMessage: string = ''; + let environmentMessage: string; switch (context.app.host.name) { case 'Office': // running in Office environmentMessage = this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentOffice : strings.AppOfficeEnvironment; @@ -122,13 +125,17 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart { + this.properties.debugUserQuery = query; + this.properties.debugUserEmail = email; + this.properties.debugUserDisplayName = displayName; + this.render(); + } }) ] } diff --git a/src/webparts/requestPortal/components/IRequestPortalProps.ts b/src/webparts/requestPortal/components/IRequestPortalProps.ts index b18b06d..0d71bb6 100644 --- a/src/webparts/requestPortal/components/IRequestPortalProps.ts +++ b/src/webparts/requestPortal/components/IRequestPortalProps.ts @@ -1,7 +1,6 @@ import { WebPartContext } from '@microsoft/sp-webpart-base'; export interface IRequestPortalProps { - description: string; isDarkTheme: boolean; environmentMessage: string; hasTeamsContext: boolean; diff --git a/src/webparts/requestPortal/components/RequestPortal.module.scss b/src/webparts/requestPortal/components/RequestPortal.module.scss index b34cbb5..9aa1ad4 100644 --- a/src/webparts/requestPortal/components/RequestPortal.module.scss +++ b/src/webparts/requestPortal/components/RequestPortal.module.scss @@ -50,15 +50,26 @@ // Раскладка: боковой рейл (side) на всю высоту + основная область (main). .layout { display: flex; - align-items: stretch; // рейл тянется на высоту контента + align-items: flex-start; // рейл больше НЕ тянется по контенту — см. .side width: 100%; min-height: 78vh; // «присутствие» рейла даже когда контента мало } -// Рейл-колонка: сам Sidebar тянется по высоте (flex-стретч). +// Рейл-колонка: раньше тянулась по высоте .main (align-items: stretch), из-за +// чего высота рейла плавала вместе с содержимым соседней колонки — например, у +// вкладки «Инициированные мной» появляется доп. фильтр по статусу, .main +// становится чуть выше, и градиентный фон Sidebar (посчитанный от своей же +// высоты) заметно менял оттенок. Прибиваем рейл к высоте окна (sticky + 100vh): +// теперь он не зависит от того, что показано рядом. На мобильном брейкпоинте +// сам Sidebar уходит в position:fixed (см. @tnsu/ui-kit) — там эти правила уже +// не действуют. .side { flex: 0 0 auto; display: flex; + position: sticky; + top: 0; + align-self: flex-start; + height: 100vh; } .main { diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index 19621f1..f98c872 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -20,7 +20,7 @@ import { OwnTab, } from '../shared/routing'; 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 { Drawer } from './Drawer/Drawer'; import { CategoryTabs } from './CategoryTabs/CategoryTabs'; @@ -62,6 +62,7 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context // (setImpersonatedEmail в render) до создания этого элемента, и меняется оно // только вместе с новым render всей веб-части. const impersonatedEmail = getImpersonatedEmail(); + const impersonatedDisplayName = getImpersonatedDisplayName(); const route = useRoute(); const [navCollapsed, setNavCollapsed] = React.useState(false); @@ -133,7 +134,7 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context // загрузки в таблице не перезапустится сам. Шлём событие обновления — // таблица на него подписана и перечитает данные фоном, не убирая с экрана // текущие строки (см. RequestsTableView/SearchView). - if (nav === route.nav && route.mode === 'submit') emitRequestsChanged(); + if (nav === route.nav && route.mode === 'submit') emitRequestsChanged(true); // Всё восстанавливаемое состояние раздела передаём ЯВНО, из запомненного. // parseRoute подставил бы его и сам, но тогда оно осталось бы только в // состоянии и не попало бы в адрес: ссылка на раздел вела бы на первую @@ -276,8 +277,11 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context закрыть: она исчезает только когда свойство веб-части очищено. */} {impersonatedEmail && (
- Включена отладочная подмена: портал работает от имени {impersonatedEmail}. - Очистите поле в свойствах веб-части, чтобы вернуться к своей учётной записи. + Включена отладочная подмена: портал работает от имени{' '} + + {impersonatedDisplayName ? `${impersonatedDisplayName} (${impersonatedEmail})` : impersonatedEmail} + + . Очистите поле в свойствах веб-части, чтобы вернуться к своей учётной записи.
)}
diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx index 2a96cf0..9ec124b 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -416,7 +416,14 @@ export const RequestsTableView: React.FC = ({ }; load().catch(() => undefined); // ошибки уже обработаны внутри load через setError - const unsubscribe = onRequestsChanged(() => { + const unsubscribe = onRequestsChanged((force) => { + // Явный повторный клик по уже открытому пункту меню — пользователь ждёт + // видимого обновления, а не молчаливой подмены данных за спиной. Прежние + // строки прячем сразу; свежие подставит load(). + if (force) { + setRows(undefined); + setLoadedKey(''); + } load().catch(() => undefined); }); // Пока вкладка браузера скрыта, обновляться незачем — при возврате к ней diff --git a/src/webparts/requestPortal/features/myRequests/SearchView.tsx b/src/webparts/requestPortal/features/myRequests/SearchView.tsx index 7038d4a..75bdca8 100644 --- a/src/webparts/requestPortal/features/myRequests/SearchView.tsx +++ b/src/webparts/requestPortal/features/myRequests/SearchView.tsx @@ -200,7 +200,8 @@ export const SearchView: React.FC = ({ }; load().catch(() => undefined); - const unsubscribe = onRequestsChanged(() => { + const unsubscribe = onRequestsChanged((force) => { + if (force) setRows(undefined); load().catch(() => undefined); }); const timer = window.setInterval(() => { diff --git a/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx b/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx index 4417739..0249ab0 100644 --- a/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx +++ b/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx @@ -21,6 +21,7 @@ import { REQUEST_STATUS, isEditableStatus, IApproverToWrite, + APPROVAL_PHASE, findUnassignedStep } from '../../shared/approval'; import { IRequestFormProps } from '../../models/types'; @@ -106,17 +107,42 @@ const ClearanceForm: React.FC = ({ requestType, context, onCa const loadMatrix = useCallback( () => 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, [ { fieldName: 'Position', value: data.Position } ]), [context, approval, editId, data.Employee, data.Position, requestTypeId] ); + const matrixReady = !!editId || (!!data.Employee && !!requestTypeId); const matrix = useMatrixEditor( loadMatrix, `${data.Employee}-${editId ?? 'new'}-${data.Position}`, !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) ); @@ -148,7 +174,14 @@ const ClearanceForm: React.FC = ({ requestType, context, onCa if (!rtId) { 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); setRequestStatus(status); if (code) { @@ -179,6 +212,10 @@ const ClearanceForm: React.FC = ({ requestType, context, onCa if (unassigned) { return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`; } + const unassignedExecutor = findUnassignedStep(executors.rows); + if (unassignedExecutor) { + return `Шаг ${unassignedExecutor.order} исполнения («${unassignedExecutor.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.`; + } return undefined; }; @@ -220,6 +257,18 @@ const ClearanceForm: React.FC = ({ requestType, context, onCa approval.searchUsers(q)} /> + {/* Блок исполнителя показываем, только когда по маршруту реально есть + исполнители (условие «IsManager» и т.п. могло не назначить ни одного). */} + {matrixReady && !executors.loading && executors.rows.length > 0 && ( + approval.searchUsers(q)} + title="Исполнение (после согласования)" + roleLabel="Исполнитель" + emptyText="Исполнители не заданы по маршруту." + /> + )} + = ({ context, itemId, formKey [approval] ); - // Матрица маршрута из фактических строк заявки (read-only). decisionSeq в - // reloadKey: после решения строки на сервере меняются — перечитываем матрицу. + // Матрица маршрута из фактических строк заявки (read-only), раздельно по + // фазам: согласование и исполнение — два отдельных блока в UI (см. ниже). + // decisionSeq в reloadKey: после решения строки на сервере меняются — + // перечитываем матрицу. const [decisionSeq, setDecisionSeq] = useState(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) => 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(() => { let alive = true; @@ -196,6 +214,16 @@ const ClearanceView: React.FC = ({ context, itemId, formKey approval.searchUsers(q)} /> + + {!executors.loading && executors.rows.length > 0 && ( + approval.searchUsers(q)} + title="Исполнение" + roleLabel="Исполнитель" + emptyText="Исполнители не назначены." + /> + )}
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} diff --git a/src/webparts/requestPortal/forms/Clearance/sections/RequestSection.tsx b/src/webparts/requestPortal/forms/Clearance/sections/RequestSection.tsx index 28ae2d1..9e45ea6 100644 --- a/src/webparts/requestPortal/forms/Clearance/sections/RequestSection.tsx +++ b/src/webparts/requestPortal/forms/Clearance/sections/RequestSection.tsx @@ -2,9 +2,9 @@ import * as React from 'react'; import { CollapseSection, FormField, TextInput, MoneyInput, Toggle, PeopleField } from '../../../shared/ui'; import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model'; -// Должность/подразделение подтягиваются автоматически при выборе сотрудника -// (см. ClearanceForm.handleEmployeeChange — SharePoint User Profile) — -// свободный ввод здесь намеренно убран, чтобы не расходиться с профилем. +// Подразделение подтягивается автоматически при выборе сотрудника (см. +// ClearanceForm.handleEmployeeChange — SharePoint User Profile) — свободный +// ввод здесь намеренно убран, чтобы не расходиться с профилем. const NOOP = (): void => undefined; interface IRequestSectionProps extends ISectionProps { @@ -32,12 +32,16 @@ export const RequestSection: React.FC = ({ placeholder={EMPLOYEE_PLACEHOLDER} /> + {/* Подтягивается из профиля при выборе сотрудника, но поле остаётся + редактируемым: если профиль не синхронизирован (SPS-JobTitle пуст), + HR должен суметь ввести должность вручную — иначе маршрут + согласования (условие «IsManager» резолвится именно по ней) не + соберётся, и список исполнителей ниже останется пустым. */} set('Position', v)} placeholder="Подставится автоматически по выбранному сотруднику" /> diff --git a/src/webparts/requestPortal/forms/Clearance/submit.ts b/src/webparts/requestPortal/forms/Clearance/submit.ts index fe5c512..5d786f2 100644 --- a/src/webparts/requestPortal/forms/Clearance/submit.ts +++ b/src/webparts/requestPortal/forms/Clearance/submit.ts @@ -76,9 +76,8 @@ export interface ISaveResult { } /** - * Сохранить обходной лист: содержимое + маршрут согласования. + * Сохранить обходной лист: содержимое + маршрут (согласование + исполнение). * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. - * Фазы исполнения у обходного листа нет — writeRoute вызывается с пустым списком исполнителей. * Возвращает Id заявки (+ код при создании). */ export async function saveClearanceRequest( @@ -86,6 +85,7 @@ export async function saveClearanceRequest( requestTypeId: number, data: IFormData, approvers: IApproverToWrite[], + executors: IApproverToWrite[], status: string ): Promise { 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 writeRoute(clearanceApprovalService(), id, approvers, [], status); + await writeRoute(clearanceApprovalService(), id, approvers, executors, status); return { id, code }; } diff --git a/src/webparts/requestPortal/loc/en-us.js b/src/webparts/requestPortal/loc/en-us.js index 6dbeb61..6b25431 100644 --- a/src/webparts/requestPortal/loc/en-us.js +++ b/src/webparts/requestPortal/loc/en-us.js @@ -2,9 +2,8 @@ define([], function() { return { "PropertyPaneDescription": "Description", "BasicGroupName": "Group Name", - "DescriptionFieldLabel": "Description Field", - "DebugUserEmailFieldLabel": "Отладка: работать от имени сотрудника", - "DebugUserEmailFieldDescription": "Только для отладки маршрутов. Портал будет считать текущим пользователем указанный e-mail: по нему планируется маршрут, отбираются «свои» заявки и очередь согласования. Прав это не даёт — запись в списки всё равно идёт под вашей учёткой. На боевой странице оставьте пустым.", + "DebugUserFieldLabel": "Отладка: работать от имени сотрудника", + "DebugUserFieldDescription": "Только для отладки маршрутов. Введите фамилию и имя сотрудника (можно в обратном порядке, отчество не обязательно) — портал найдёт его e-mail сам, найденное отобразится над полем. Можно ввести e-mail напрямую. По этому e-mail планируется маршрут, отбираются «свои» заявки и очередь согласования. Прав это не даёт — запись в списки всё равно идёт под вашей учёткой. На боевой странице оставьте пустым.", "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", "AppLocalEnvironmentOffice": "The app is running on your local environment in office.com", diff --git a/src/webparts/requestPortal/loc/mystrings.d.ts b/src/webparts/requestPortal/loc/mystrings.d.ts index 8535e51..86c1a93 100644 --- a/src/webparts/requestPortal/loc/mystrings.d.ts +++ b/src/webparts/requestPortal/loc/mystrings.d.ts @@ -1,9 +1,8 @@ declare interface IRequestPortalWebPartStrings { PropertyPaneDescription: string; BasicGroupName: string; - DescriptionFieldLabel: string; - DebugUserEmailFieldLabel: string; - DebugUserEmailFieldDescription: string; + DebugUserFieldLabel: string; + DebugUserFieldDescription: string; AppLocalEnvironmentSharePoint: string; AppLocalEnvironmentTeams: string; AppLocalEnvironmentOffice: string; diff --git a/src/webparts/requestPortal/propertyPane/DebugUserField.tsx b/src/webparts/requestPortal/propertyPane/DebugUserField.tsx new file mode 100644 index 0000000..c83b3c9 --- /dev/null +++ b/src/webparts/requestPortal/propertyPane/DebugUserField.tsx @@ -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 = ({ label, description, context, initialQuery, onResolved }) => { + const [query, setQuery] = React.useState(initialQuery || ''); + const [status, setStatus] = React.useState('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 ( +
+
{label}
+ setQuery(e.target.value)} + placeholder="Иванов Иван Иванович, Иван Иванов или e-mail" + style={{ width: '100%', boxSizing: 'border-box', padding: '5px 8px', fontSize: 14 }} + /> +
+ {status === 'loading' && 'Ищем сотрудника…'} + {status === 'found' && found && `Найдено: ${found.displayName} — ${found.email}`} + {status === 'notfound' && 'Сотрудник не найден — подмена работать не будет'} + {status === 'email' && + (found + ? `Используется как e-mail напрямую: ${query.trim()} (${found.displayName})` + : `Используется как e-mail напрямую: ${query.trim()}`)} +
+
{description}
+
+ ); +}; diff --git a/src/webparts/requestPortal/propertyPane/PropertyPaneDebugUser.tsx b/src/webparts/requestPortal/propertyPane/PropertyPaneDebugUser.tsx new file mode 100644 index 0000000..81a1c99 --- /dev/null +++ b/src/webparts/requestPortal/propertyPane/PropertyPaneDebugUser.tsx @@ -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 { + 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( + , + elem + ); + } + + private onDispose(elem: HTMLElement): void { + ReactDom.unmountComponentAtNode(elem); + } +} + +/** Кастомное поле property pane: ФИО/e-mail сотрудника с резолвом в e-mail на лету. */ +export function PropertyPaneDebugUser( + targetProperty: string, + properties: IPropertyPaneDebugUserProps +): IPropertyPaneField { + return new PropertyPaneDebugUserBuilder(targetProperty, properties); +} diff --git a/src/webparts/requestPortal/shared/approval/approvalList.service.ts b/src/webparts/requestPortal/shared/approval/approvalList.service.ts index 4c115f0..6f038b8 100644 --- a/src/webparts/requestPortal/shared/approval/approvalList.service.ts +++ b/src/webparts/requestPortal/shared/approval/approvalList.service.ts @@ -129,6 +129,7 @@ export class ApprovalListService { toWrite.forEach(({ a, order }, i) => { // void: промис от add() на batched-инстансе нарочно не ждём поштучно — // он разрешится вместе со всеми остальными после execute() ниже. + // eslint-disable-next-line no-void -- без void это «висячий» промис, а no-floating-promises здесь уровня error void batchedList.items.add({ Title: a.position || a.email, [`${FIELDS.approver}Id`]: approverIds[i], diff --git a/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx b/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx index f84a6d0..85f70b1 100644 --- a/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx +++ b/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx @@ -51,7 +51,10 @@ export const ApprovalMatrix: React.FC = ({ // подсвечивается пол-таблицы и непонятно, что именно переехало. Запоминаем // ключи блока ДО перестановки: после неё индексы уже другие. const [activeKeys, setActiveKeys] = React.useState([]); - const registerRow = useFlipRows(activeKeys, rowKeys); + // Обёртка таблицы: её высоту useFlipRows анимирует вместе с переездом строк, + // чтобы пикер и следующая карточка не подпрыгивали при удалении шага. + const scrollRef = React.useRef(null); + const registerRow = useFlipRows(activeKeys, rowKeys, scrollRef); // Строка → шаг, которому она принадлежит. Нужно, чтобы отрисовать ячейку // «Шаг» один раз на весь шаг (rowSpan) и понять, можно ли двигать его выше/ниже. @@ -110,7 +113,7 @@ export const ApprovalMatrix: React.FC = ({
{emptyText}
)} {!waiting && !loading && !error && rows.length > 0 && ( -
+
diff --git a/src/webparts/requestPortal/shared/approval/ui/approval.module.scss b/src/webparts/requestPortal/shared/approval/ui/approval.module.scss index e3c8415..0b5b8c5 100644 --- a/src/webparts/requestPortal/shared/approval/ui/approval.module.scss +++ b/src/webparts/requestPortal/shared/approval/ui/approval.module.scss @@ -95,6 +95,19 @@ // сплющивания колонок. На телефоне таблица становится карточками (медиазапрос ниже). .matrixScroll { 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; } diff --git a/src/webparts/requestPortal/shared/approval/ui/useFlipRows.ts b/src/webparts/requestPortal/shared/approval/ui/useFlipRows.ts index 9fc8145..aca8d59 100644 --- a/src/webparts/requestPortal/shared/approval/ui/useFlipRows.ts +++ b/src/webparts/requestPortal/shared/approval/ui/useFlipRows.ts @@ -22,8 +22,9 @@ import * as React from 'react'; // центра разрывало блок визуально. Плюс строка становилась шире контейнера // и .matrixScroll (overflow-x: auto) на время анимации показывал полосу // горизонтальной прокрутки. -// • пружинистый перелёт (cubic-bezier с >1): при overflow-x: auto вторая ось -// тоже становится auto, и вылет за край таблицы строку бы обрезало. +// • пружинистый перелёт (cubic-bezier с >1): вылет за край .matrixScroll +// обрезается (там overflow-y: hidden — см. approval.module.scss), так что +// перелёт был бы виден только как срезанная строка. // • подсветка ВСЕХ переехавших строк: при обмене двух шагов двигаются оба, и // жёлтым заливало пол-таблицы. Подсвечиваем только тот блок, который двигал // пользователь (activeKeys). @@ -108,10 +109,16 @@ function measureTops(nodes: Map): Map // eslint-disable-next-line @rushstack/no-new-null -- null здесь диктует React: ref-колбэк получает именно null при отвязке узла ): (key: string) => (el: HTMLTableRowElement | null) => void { const nodes = React.useRef(new Map()); @@ -121,6 +128,10 @@ export function useFlipRows( const prevTops = React.useRef(new Map()); // Порядок строк на прошлый коммит — см. проверку orderChanged ниже. const prevRowKeys = React.useRef([]); + // Высота обёртки на прошлый коммит. Нужна именно запомненная: layout-эффект + // работает УЖЕ ПОСЛЕ коммита, и к этому моменту высота на экране новая — + // «откуда ехать» из текущей раскладки не узнать. + const prevHeight = React.useRef(undefined); // Свои анимации держим сами, чтобы отменять ИМЕННО их: el.getAnimations() // вернул бы заодно чужие (CSS-переходы и т.п.). const running = React.useRef([]); @@ -156,10 +167,13 @@ export function useFlipRows( rowKeys.length !== prevRowKeys.current.length || rowKeys.some((k, i) => k !== prevRowKeys.current[i]); prevRowKeys.current = rowKeys; + const container = containerRef ? containerRef.current : null; + if (!orderChanged) { // Порядок тот же — просто обновляем базовую геометрию для следующего // ДЕЙСТВИТЕЛЬНОГО сравнения, без единой анимации на этом проходе. prevTops.current = measureTops(nodes.current); + prevHeight.current = container ? container.getBoundingClientRect().height : undefined; return; } @@ -167,6 +181,10 @@ export function useFlipRows( // она в этот момент ещё летит, и новая анимация обязана стартовать от её // текущего положения на экране, иначе будет рывок. const visualTops = measureTops(nodes.current); + // То же и для высоты обёртки: если анимация высоты ещё идёт, здесь мы + // получим её промежуточное значение (после cancel ниже высота схлопнется к + // натуральной, и «откуда ехать» было бы уже не восстановить). + const visualHeight = container ? container.getBoundingClientRect().height : undefined; running.current.forEach((a) => a.cancel()); running.current = []; @@ -195,7 +213,38 @@ export function useFlipRows( 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 }) => { const isActive = activeKeys.indexOf(key) !== -1; diff --git a/src/webparts/requestPortal/shared/impersonation.ts b/src/webparts/requestPortal/shared/impersonation.ts index ddc95fd..4f4a950 100644 --- a/src/webparts/requestPortal/shared/impersonation.ts +++ b/src/webparts/requestPortal/shared/impersonation.ts @@ -1,4 +1,5 @@ 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 impersonatedDisplayName = ''; /** Вызывается веб-частью на каждый render — свойство могли поменять в панели. */ -export function setImpersonatedEmail(email: string | undefined): void { +export function setImpersonatedIdentity(email: string | undefined, displayName?: string): void { impersonatedEmail = (email || '').trim(); + impersonatedDisplayName = (displayName || '').trim(); } /** Заданная подмена ('' — подмены нет). Для предупреждающей полосы в интерфейсе. */ @@ -33,7 +36,89 @@ export function getImpersonatedEmail(): string { return impersonatedEmail; } +/** ФИО подменённого сотрудника, если его удалось найти поиском по имени ('' — для e-mail, введённого напрямую). */ +export function getImpersonatedDisplayName(): string { + return impersonatedDisplayName; +} + /** Email, под которым портал работает: подменённый, если задан, иначе — из токена. */ export function effectiveUserEmail(context: WebPartContext): string { 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 { + 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 { + 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]; +} diff --git a/src/webparts/requestPortal/shared/requestEvents.ts b/src/webparts/requestPortal/shared/requestEvents.ts index 80ee54a..8a9beea 100644 --- a/src/webparts/requestPortal/shared/requestEvents.ts +++ b/src/webparts/requestPortal/shared/requestEvents.ts @@ -4,12 +4,21 @@ // достаточно для нечастых событий (сохранение формы, решение согласующего). 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 эффекта). */ -export function onRequestsChanged(handler: () => void): () => void { - window.addEventListener(EVENT_NAME, handler); - return () => window.removeEventListener(EVENT_NAME, handler); +export function onRequestsChanged(handler: (force?: boolean) => void): () => void { + const listener = (e: Event): void => handler((e as CustomEvent<{ force?: boolean }>).detail?.force); + window.addEventListener(EVENT_NAME, listener); + return () => window.removeEventListener(EVENT_NAME, listener); }