From 5ae9fbd64b1addb2e465b3fa08fea87c6d6ab508 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Wed, 19 Aug 2026 17:04:48 +0500 Subject: [PATCH] =?UTF-8?q?=D0=BC=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../approvalAdmin/components/app/AdminApp.tsx | 2 +- .../screens/ManageByUserDuplicateModal.tsx | 34 ++++ .../components/screens/ManageByUserScreen.tsx | 186 ++++++++++++++---- .../webparts/approvalAdmin/routing.test.ts | 18 +- .../src/webparts/approvalAdmin/routing.ts | 19 +- .../services/requestManageService.ts | 30 +++ frontend/src/webparts/approvalAdmin/texts.ts | 37 +++- 7 files changed, 273 insertions(+), 53 deletions(-) create mode 100644 frontend/src/webparts/approvalAdmin/components/screens/ManageByUserDuplicateModal.tsx diff --git a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx index 52b6bf5..df02a0e 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx +++ b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx @@ -153,7 +153,7 @@ export const AdminApp: React.FC = ({ api, dictionary, sharePoint )} {route.tab === 'manageByUser' && ( - + )} diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserDuplicateModal.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserDuplicateModal.tsx new file mode 100644 index 0000000..5348bb6 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserDuplicateModal.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { texts } from '../../texts'; +import { Button, Modal } from '../ui'; + +export type DuplicateChoice = 'delete' | 'skip'; + +export interface IManageByUserDuplicateModalProps { + title: string; + message: string; + onChoice: (choice: DuplicateChoice, forAll: boolean) => void; +} + +/** + * «Да / Да для всех / Нет / Нет для всех» — классический диалог разрешения + * конфликтов при пакетной операции (как в проводнике при копировании файлов). + * Закрытие крестиком/подложкой равносильно «Нет» (без «для всех» — иначе + * случайный клик мимо тихо остановил бы весь оставшийся список). + */ +export const ManageByUserDuplicateModal: React.FC = ({ title, message, onChoice }) => ( + onChoice('skip', false)} + footer={ + <> + + + + + + } + > +

{message}

+
+); diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserScreen.tsx index e5f3877..95b9622 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageByUserScreen.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import styles from './ManageByUserScreen.module.scss'; import { ManageByUserReplaceModal } from './ManageByUserReplaceModal'; +import { DuplicateChoice, ManageByUserDuplicateModal } from './ManageByUserDuplicateModal'; import { ApprovalRouteApi } from '../../services/api'; import { PortalDictionaryClient } from '../../services/portalDictionary'; import { RequestManageService } from '../../services/requestManageService'; @@ -11,6 +12,8 @@ import { shortGuid } from '../../utils/guid'; import { describeStepCondition } from '../../utils/stepExecutor'; import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee'; import { reportError } from '../../utils/errors'; +import { IManageByUserRouteParams, replaceRoute } from '../../routing'; +import { useLocalStorageState } from '../../hooks/useLocalStorageState'; import { useRequestTypes } from '../../hooks/useRequestTypes'; import { useRequestTypeListsAll } from '../../hooks/useRequestTypeListsAll'; import { useTemplateRouteStepsByExecutor } from '../../hooks/useTemplateRouteStepsByExecutor'; @@ -42,21 +45,27 @@ export interface IManageByUserScreenProps { /** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */ dictionary?: PortalDictionaryClient; requestManage: RequestManageService; + /** Заполнение из URL (глубокая ссылка на конкретного сотрудника) — см. routing.ts. */ + route?: IManageByUserRouteParams; } +// Выбранный сотрудник переживает перезагрузку страницы — как и выбор заявки в +// ManageRequestsScreen (тот же приём, отдельный ключ). +const SELECTION_STORAGE_KEY = 'approvalAdmin.manageByUserEmail'; + // Активные статусы строки для этого экрана — сознательно уже, чем // isEditableRowStatus (models/requestManage.ts): «Возвращена на доработку» и // черновики сюда не входят (согласовано отдельно, см. план реализации). const ACTIVE_STATUSES: string[] = [REQUEST_STATUS.queued, REQUEST_STATUS.onApproval, REQUEST_STATUS.onExecution]; -type Settled = { ok: true; value: T } | { ok: false; error: unknown }; - -// Promise.allSettled недоступен в целевом lib этого проекта (tsconfig.json — -// es5/es2015.*, без es2020.promise) — свой аналог вместо него: массовая замена -// не должна падать целиком из-за одной сломанной строки/шага (см. -// bulkReplaceLive/bulkReplaceTemplates ниже). -function settle(p: Promise): Promise> { - return p.then((value): Settled => ({ ok: true, value })).catch((error: unknown): Settled => ({ ok: false, error })); +// Итог пакетной операции по одной панели. +interface IBulkOutcome { + ok: number; + fail: number; + /** Дубли, снятые удалением старой строки/шага (пользователь выбрал «удалить»). */ + deleted: number; + /** Дубли, оставленные как есть (пользователь выбрал «пропустить»). */ + skipped: number; } // Та же цветовая группировка статуса заявки, что и в ManageRequestsScreen @@ -76,13 +85,31 @@ interface IReplaceScope { templates: UserTemplateStepRow[]; } -export const ManageByUserScreen: React.FC = ({ api, dictionary, requestManage }) => { +export const ManageByUserScreen: React.FC = ({ api, dictionary, requestManage, route }) => { const requestTypes = useRequestTypes(api); const listSeries = useRequestTypeListsAll(api); const employees = usePortalEmployees(dictionary); const dictionaries = usePortalDictionaries(dictionary); - const [employeeEmail, setEmployeeEmail] = React.useState(''); + const [employeeEmail, setEmployeeEmail] = useLocalStorageState(SELECTION_STORAGE_KEY, ''); + + // Открыли по глубокой ссылке (aabuser в URL) — перекрывает то, что было в + // localStorage. Только на монтировании: route меняется при каждом собственном + // replaceRoute ниже (тот же приём, что в ManageRequestsScreen/ResolveScreen). + React.useEffect(() => { + if (route?.userEmail) setEmployeeEmail(route.userEmail); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Держим URL синхронным с выбором — открытый экран можно скопировать и + // прислать коллеге. replaceRoute, а не navigate: смена сотрудника — правка + // состояния текущего экрана, а не отдельный шаг в истории браузера. + React.useEffect(() => { + replaceRoute({ + tab: 'manageByUser', + manageByUser: employeeEmail ? { userEmail: employeeEmail } : undefined + }); + }, [employeeEmail]); const partitionsByType = React.useMemo(() => { const map = new Map(); @@ -168,52 +195,123 @@ export const ManageByUserScreen: React.FC = ({ api, di const [replaceScope, setReplaceScope] = React.useState(); const [resultToast, setResultToast] = React.useState<{ message: string; kind: 'ok' | 'err' } | undefined>(); - async function bulkReplaceLive(rows: UserApprovalRow[], email: string, position: string): Promise<{ ok: number; fail: number }> { - const settled = await Promise.all(rows.map(r => settle(requestManage.reassignApprover(r, r.id, email, position)))); - const ok = settled.filter(s => s.ok).length; - return { ok, fail: settled.length - ok }; + // Открытый диалог разрешения дубля + то, чем разбудить ожидающий его цикл + // замены (см. askDuplicate). Пакетная обработка идёт ПОСЛЕДОВАТЕЛЬНО именно + // из-за этого диалога: параллельные ветки открыли бы N окон разом. + const [duplicatePrompt, setDuplicatePrompt] = + React.useState<{ message: string; resolve: (choice: DuplicateChoice) => void } | undefined>(); + // «…для всех» на время ОДНОЙ пакетной операции — ref, а не state: значение + // читается внутри уже запущенного цикла, и перерисовка ему не нужна. + const duplicatePolicy = React.useRef(undefined); + + const askDuplicate = (message: string): Promise => { + if (duplicatePolicy.current) return Promise.resolve(duplicatePolicy.current); + return new Promise(resolve => setDuplicatePrompt({ message, resolve })); + }; + + const answerDuplicate = (choice: DuplicateChoice, forAll: boolean): void => { + if (forAll) duplicatePolicy.current = choice; + const pending = duplicatePrompt; + setDuplicatePrompt(undefined); + pending?.resolve(choice); + }; + + /** + * Замена в живых заявках. Дубль (тот же человек уже стоит в этой же фазе этой + * же заявки) SharePoint сам не запрещает — проверяем до записи, иначе в + * маршруте появились бы две строки на одного человека, и та же заявка ушла бы + * ему на согласование дважды. + */ + async function bulkReplaceLive(rows: UserApprovalRow[], email: string, position: string): Promise { + const outcome: IBulkOutcome = { ok: 0, fail: 0, deleted: 0, skipped: 0 }; + + for (const row of rows) { + try { + const duplicate = await requestManage.hasApproverInSamePhase(row, email); + if (duplicate) { + const label = `${row.requestUniqueCode || `#${row.ticketId}`} ${row.requestTitle}`; + const choice = await askDuplicate(texts.manageByUser.duplicateLiveMessage(label, row.position, email)); + if (choice === 'skip') { + outcome.skipped += 1; + continue; + } + await requestManage.removeRow(row, row.id); + outcome.deleted += 1; + continue; + } + await requestManage.reassignApprover(row, row.id, email, position); + outcome.ok += 1; + } catch (e) { + reportError(e, 'ManageByUserScreen: bulkReplaceLive'); + outcome.fail += 1; + } + } + + return outcome; } - async function bulkReplaceTemplates( - rows: UserTemplateStepRow[], - email: string - ): Promise<{ ok: number; fail: number; conflicts: string[] }> { - const settled = await Promise.all( - rows.map(r => settle(api.templateRouteSteps.patch(r.routeId, r.id, { executorEmail: email }))) - ); - let ok = 0; - const conflicts: string[] = []; - settled.forEach((s, i) => { - if (s.ok) { - ok += 1; - return; + /** + * Замена в шаблонах маршрутов. Здесь дубль ловит СЕРВЕР (HasDuplicateAsync в + * TemplateRouteStepService → 409) — своей предварительной проверки не делаем, + * его правило шире email'а (совпадать должны ещё статус и условие появления), + * и дублировать это на клиенте значило бы разъехаться с ним при первой же + * правке. + */ + async function bulkReplaceTemplates(rows: UserTemplateStepRow[], email: string): Promise { + const outcome: IBulkOutcome = { ok: 0, fail: 0, deleted: 0, skipped: 0 }; + + for (const row of rows) { + try { + await api.templateRouteSteps.patch(row.routeId, row.id, { executorEmail: email }); + outcome.ok += 1; + } catch (e) { + if ((e as ApiClientError)?.status !== 409) { + reportError(e, 'ManageByUserScreen: bulkReplaceTemplates'); + outcome.fail += 1; + continue; + } + const label = `${row.requestTypeName} — ${row.companyName}`; + const choice = await askDuplicate(texts.manageByUser.duplicateTemplateMessage(label, row.orderNo, email)); + if (choice === 'skip') { + outcome.skipped += 1; + continue; + } + try { + await api.templateRouteSteps.remove(row.routeId, row.id); + outcome.deleted += 1; + } catch (removeError) { + reportError(removeError, 'ManageByUserScreen: bulkReplaceTemplates/remove'); + outcome.fail += 1; + } } - const reason = s.error as ApiClientError; - if (reason?.status === 409) { - const row = rows[i]; - conflicts.push(texts.manageByUser.templateConflict(`${row.requestTypeName} — ${row.companyName}`, row.orderNo)); - } - }); - return { ok, fail: settled.length - ok, conflicts }; + } + + return outcome; } const handleReplaceConfirm = async (email: string): Promise => { if (!replaceScope) return; const position = findEmployeeByEmail(employees.items, email)?.position || ''; const messages: string[] = []; + // Политика «…для всех» живёт ровно одну операцию — иначе следующая замена + // молча унаследовала бы решение, принятое для совсем другого списка строк. + duplicatePolicy.current = undefined; + + // Модалку выбора нового согласующего закрываем сразу: дальше могут + // открываться диалоги дублей, и держать два окна одновременно незачем. + setReplaceScope(undefined); if (replaceScope.live.length > 0) { - const { ok, fail } = await bulkReplaceLive(replaceScope.live, email, position); - messages.push(texts.manageByUser.liveResult(ok, fail)); + const r = await bulkReplaceLive(replaceScope.live, email, position); + messages.push(texts.manageByUser.liveResult(r.ok, r.fail, r.deleted, r.skipped)); } if (replaceScope.templates.length > 0) { - const { ok, fail, conflicts } = await bulkReplaceTemplates(replaceScope.templates, email); - messages.push(texts.manageByUser.templatesResult(ok, fail)); - messages.push(...conflicts); + const r = await bulkReplaceTemplates(replaceScope.templates, email); + messages.push(texts.manageByUser.templatesResult(r.ok, r.fail, r.deleted, r.skipped)); } + duplicatePolicy.current = undefined; setResultToast({ message: messages.join(' '), kind: 'ok' }); - setReplaceScope(undefined); setCheckedLiveKeys(new Set()); setCheckedTemplateIds(new Set()); reloadLiveRows(); @@ -362,6 +460,14 @@ export const ManageByUserScreen: React.FC = ({ api, di /> )} + {duplicatePrompt && ( + + )} + {resultToast && ( setResultToast(undefined)} /> )} diff --git a/frontend/src/webparts/approvalAdmin/routing.test.ts b/frontend/src/webparts/approvalAdmin/routing.test.ts index 9eb0a52..efabd81 100644 --- a/frontend/src/webparts/approvalAdmin/routing.test.ts +++ b/frontend/src/webparts/approvalAdmin/routing.test.ts @@ -9,7 +9,13 @@ beforeEach(() => { describe('parseRoute', () => { it('без aatab — вкладка по умолчанию dashboard, всё остальное пусто', () => { - expect(parseRoute('')).toEqual({ tab: 'dashboard', routeId: undefined, resolve: undefined, manage: undefined }); + expect(parseRoute('')).toEqual({ + tab: 'dashboard', + routeId: undefined, + resolve: undefined, + manage: undefined, + manageByUser: undefined + }); }); it('незнакомое значение aatab тоже откатывается на dashboard', () => { @@ -32,6 +38,16 @@ describe('parseRoute', () => { expect(route.manage).toBeUndefined(); }); + it('manageByUser с aabuser — email сотрудника читается в manageByUser', () => { + const route = parseRoute('?aatab=manageByUser&aabuser=ivanov%40tnsu.kz'); + expect(route.tab).toBe('manageByUser'); + expect(route.manageByUser).toEqual({ userEmail: 'ivanov@tnsu.kz' }); + }); + + it('aabuser учитывается только при tab=manageByUser', () => { + expect(parseRoute('?aatab=dashboard&aabuser=ivanov%40tnsu.kz').manageByUser).toBeUndefined(); + }); + it('aaroute учитывается только при tab=routes', () => { expect(parseRoute('?aatab=routes&aaroute=7').routeId).toBe(7); expect(parseRoute('?aatab=manageRequests&aaroute=7').routeId).toBeUndefined(); diff --git a/frontend/src/webparts/approvalAdmin/routing.ts b/frontend/src/webparts/approvalAdmin/routing.ts index d8ac489..5ce28e1 100644 --- a/frontend/src/webparts/approvalAdmin/routing.ts +++ b/frontend/src/webparts/approvalAdmin/routing.ts @@ -33,6 +33,11 @@ export interface IResolveRouteParams { departmentId: string; } +export interface IManageByUserRouteParams { + /** Email выбранного сотрудника — он однозначно определяет весь экран (обе панели). */ + userEmail: string; +} + export interface IManageRequestRouteParams { requestTypeId: string; requestId: string; @@ -61,6 +66,8 @@ export interface IAdminRoute { resolve?: IResolveRouteParams; /** Задан только при tab='manageRequests' и непустых requestTypeId+requestId. */ manage?: IManageRequestRouteParams; + /** Задан только при tab='manageByUser' и непустом userEmail. */ + manageByUser?: IManageByUserRouteParams; } const ROUTE_CHANGE_EVENT = 'aa-route-change'; @@ -72,7 +79,8 @@ const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'ma const PARAM_KEYS = [ 'aatab', 'aaroute', 'aarfilter', 'aartype', 'aauser', 'aaproject', 'aaprojectname', 'aastatus', 'aafields', 'aadept', - 'aamtype', 'aamreq', 'aamlist' + 'aamtype', 'aamreq', 'aamlist', + 'aabuser' ]; // raw — результат URLSearchParams.get() (DOM API, возвращает string | null). @@ -125,12 +133,15 @@ export function parseRoute(search: string = window.location.search): IAdminRoute ? { requestTypeId: manageTypeId, requestId: manageReqId, listId: params.get('aamlist') || '' } : undefined; + const byUserEmail = tab === 'manageByUser' ? params.get('aabuser') : null; + return { tab, routeId: routeId && !isNaN(routeId) ? routeId : undefined, routesFilter: (tab === 'routes' ? params.get('aarfilter') : null) || undefined, resolve, - manage + manage, + manageByUser: byUserEmail ? { userEmail: byUserEmail } : undefined }; } @@ -163,6 +174,10 @@ function buildQuery(route: IAdminRoute): string { if (route.manage.listId) params.set('aamlist', route.manage.listId); } + if (route.tab === 'manageByUser' && route.manageByUser?.userEmail) { + params.set('aabuser', route.manageByUser.userEmail); + } + const query = params.toString(); return `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`; } diff --git a/frontend/src/webparts/approvalAdmin/services/requestManageService.ts b/frontend/src/webparts/approvalAdmin/services/requestManageService.ts index 890e908..cd183d6 100644 --- a/frontend/src/webparts/approvalAdmin/services/requestManageService.ts +++ b/frontend/src/webparts/approvalAdmin/services/requestManageService.ts @@ -486,6 +486,36 @@ export class RequestManageService { return rows; } + /** + * Появится ли в фазе этой заявки ВТОРОЙ согласующий с данным email, если строку + * row переназначить на него — та же проверка, что ManageRequestsScreen делает + * через excludedEmails при ручном переназначении (там она бесплатна: вся + * матрица заявки уже загружена), но здесь массовая замена бьёт по многим + * заявкам разом и своей матрицы для каждой не держит, поэтому запрашивается + * отдельно. Как и excludedEmails, смотрит на ВСЕ строки фазы независимо от + * статуса (в т.ч. уже решённые) — правило «один человек не может стоять в + * фазе дважды» не знает исключений по статусу. + */ + public async hasApproverInSamePhase( + row: Pick, + email: string + ): Promise { + const sp = this.clientFor(row.siteUrl); + const rows = (await sp.web.lists + .getByTitle(row.approvalListTitle) + .items.filter(`${FIELDS.ticket}Id eq ${row.ticketId} and ${FIELDS.phase} eq '${row.phase}'`) + .select('Id', `${FIELDS.approver}/EMail`) + .expand(FIELDS.approver) + .top(200)()) as Array>; + + const target = email.toLowerCase(); + return rows.some(r => { + if ((r.Id as number) === row.id) return false; + const approver = r[FIELDS.approver] as { EMail?: string } | undefined; + return (approver?.EMail || '').toLowerCase() === target; + }); + } + /** Переназначить согласующего/исполнителя строки. Вызывающая сторона обязана * проверить isEditableRowStatus(row.status) — сервис это не перепроверяет. */ public async reassignApprover( diff --git a/frontend/src/webparts/approvalAdmin/texts.ts b/frontend/src/webparts/approvalAdmin/texts.ts index 9482992..389dcbe 100644 --- a/frontend/src/webparts/approvalAdmin/texts.ts +++ b/frontend/src/webparts/approvalAdmin/texts.ts @@ -78,7 +78,7 @@ export const texts = { routes: 'Маршруты', resolve: 'Проверка маршрута', manageRequests: 'Редактирование заявок', - manageByUser: 'Заявки по согласующему' + manageByUser: 'Заявки по участнику' }, app: { @@ -442,9 +442,9 @@ export const texts = { }, manageByUser: { - breadcrumb: 'Заявки по согласующему', + breadcrumb: 'Заявки по участнику', subtitle: 'Массовая замена согласующего/исполнителя', - title: 'Заявки по согласующему', + title: 'Заявки по участнику', employeeLabel: 'Сотрудник', employeePlaceholder: 'Выберите сотрудника', employeeHint: 'Покажет все его активные заявки (по всем типам и партициям) и все шаблонные шаги, где он назначен лично.', @@ -471,12 +471,31 @@ export const texts = { newEmployeeLabel: 'Сотрудник', newEmployeePlaceholder: 'Выберите нового согласующего', validationEmployee: 'Выберите сотрудника.', - liveResult: (ok: number, fail: number): string => - fail > 0 ? `Заявки: заменено ${ok}, не удалось ${fail}.` : `Заявки: заменено ${ok}.`, - templatesResult: (ok: number, fail: number): string => - fail > 0 ? `Шаблоны: заменено ${ok}, не удалось ${fail}.` : `Шаблоны: заменено ${ok}.`, - templateConflict: (routeLabel: string, orderNo: number): string => - `В маршруте «${routeLabel}» уже есть шаг с этим исполнителем — шаг №${orderNo} не изменён.` + liveResult: (ok: number, fail: number, deleted: number, skipped: number): string => { + const parts = [`заменено ${ok}`]; + if (deleted > 0) parts.push(`удалено дублей ${deleted}`); + if (skipped > 0) parts.push(`пропущено ${skipped}`); + if (fail > 0) parts.push(`не удалось ${fail}`); + return `Заявки: ${parts.join(', ')}.`; + }, + templatesResult: (ok: number, fail: number, deleted: number, skipped: number): string => { + const parts = [`заменено ${ok}`]; + if (deleted > 0) parts.push(`удалено дублей ${deleted}`); + if (skipped > 0) parts.push(`пропущено ${skipped}`); + if (fail > 0) parts.push(`не удалось ${fail}`); + return `Шаблоны: ${parts.join(', ')}.`; + }, + duplicateModalTitle: 'Уже назначен на этом же шаге', + duplicateLiveMessage: (requestLabel: string, oldPosition: string, newEmail: string): string => + `В заявке ${requestLabel} сотрудник ${newEmail} уже согласует/исполняет этот же шаг. ` + + `Заменить строку («${oldPosition || 'без должности'}») тоже на него нельзя — появится дубль. Удалить эту строку?`, + duplicateTemplateMessage: (routeLabel: string, orderNo: number, newEmail: string): string => + `В маршруте «${routeLabel}» сотрудник ${newEmail} уже назначен на шаг с такими же параметрами. ` + + `Заменить шаг №${orderNo} на него нельзя — сервер вернул конфликт. Удалить этот шаг?`, + duplicateDelete: 'Да, удалить', + duplicateDeleteAll: 'Да для всех', + duplicateSkip: 'Нет, пропустить', + duplicateSkipAll: 'Нет для всех' }, // Значения по умолчанию для переиспользуемых контролов выбора.