From 419aaac60a373448a9067724b9dd7f8be8a00e9f Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 7 Aug 2026 13:46:06 +0500 Subject: [PATCH] fix bug --- .../screens/ManageRequestsPhaseTable.tsx | 11 +- .../screens/ManageRequestsScreen.tsx | 138 ++++++++++++------ .../services/requestManageService.ts | 21 ++- 3 files changed, 118 insertions(+), 52 deletions(-) diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx index 4ae328c..2ed6500 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx @@ -13,7 +13,6 @@ export interface IManageRequestsPhaseTableProps { rows: ManagedApprovalRow[]; /** Заявка целиком только для просмотра (закрыта) — контролов нет вообще. */ readOnly: boolean; - busy: boolean; statusVariant: (status: string) => BadgeVariant; onReassign: (row: ManagedApprovalRow) => void; onRemove: (row: ManagedApprovalRow) => void; @@ -56,7 +55,6 @@ export const ManageRequestsPhaseTable: React.FC title, rows, readOnly, - busy, statusVariant, onReassign, onRemove, @@ -181,14 +179,14 @@ export const ManageRequestsPhaseTable: React.FC icon="chevron-down" className={`${styles.iconBtnVisible} ${styles.flipY}`} label={texts.manageRequests.moveUp} - disabled={busy || isGhost || !movable || blockIndex <= lastLockedBlockIndex + 1} + disabled={isGhost || !movable || blockIndex <= lastLockedBlockIndex + 1} onClick={() => moveBlockUp(blockIndex)} /> = blocks.length - 1} + disabled={isGhost || !movable || blockIndex >= blocks.length - 1} onClick={() => moveBlockDown(blockIndex)} /> @@ -204,14 +202,13 @@ export const ManageRequestsPhaseTable: React.FC icon="people" className={r.parallelWithPrev ? styles.iconBtnActive : styles.iconBtnVisible} label={r.parallelWithPrev ? texts.manageRequests.splitFromPrev : texts.manageRequests.mergeWithPrev} - disabled={busy || !canToggle} + disabled={!canToggle} onClick={() => toggleParallel(i)} /> onReassign(r)} /> className={styles.iconBtnVisible} label={texts.manageRequests.remove} danger - disabled={busy} onClick={() => onRemove(r)} /> @@ -254,7 +250,6 @@ export const ManageRequestsPhaseTable: React.FC options={employees.map(toEmployeeOption)} value="" loading={employeesLoading} - disabled={busy} placeholder={addPlaceholder} searchPlaceholder={texts.select.employeeSearchPlaceholder} emptyMessage={texts.select.employeesEmpty} diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx index 831cb4d..c44b034 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx @@ -84,6 +84,44 @@ function renumberPhase(phaseRows: ManagedApprovalRow[]): Array<{ id: number; ord .map(r => ({ id: r.id, order: r.order })); } +// Строки в одном из этих статусов ещё «в игре» — решение по ним не принято. +// approved/executed/rejected/returned сюда не входят: это уже принятые решения +// (в т.ч. «Возвращена на доработку» — согласующий своё слово сказал, просто мы +// разрешили его потом переназначить; в очереди на активацию он не участвует). +const PENDING_STATUSES: string[] = [REQUEST_STATUS.queued, REQUEST_STATUS.onApproval, REQUEST_STATUS.onExecution]; + +// Пересчитывает «чья сейчас очередь» в фазе — 1:1 activateNextQueued из +// request-portal (shared/approval/approvalList.service.ts): среди ещё не +// решённых строк у той, что с МИНИМАЛЬНЫМ order, выставляется активный статус +// фазы («На согласовании»/«На исполнении»), у всех остальных — «Ждёт очереди». +// Несколько строк с одинаковым order (параллельный шаг) активируются вместе — +// в этом и смысл параллельности. Вызывается после ЛЮБОГО изменения, которое +// могло поменять, кто в фазе «первый»: перестановка, удаление, добавление. +function computeActivationUpdates(phaseRows: ManagedApprovalRow[], phase: string): Array<{ id: number; status: string }> { + const activeStatus = phase === APPROVAL_PHASE.execution ? REQUEST_STATUS.onExecution : REQUEST_STATUS.onApproval; + const pending = phaseRows.filter(r => PENDING_STATUSES.indexOf(r.status) !== -1); + if (pending.length === 0) return []; + const minOrder = Math.min(...pending.map(r => r.order)); + const updates: Array<{ id: number; status: string }> = []; + pending.forEach(r => { + const desired = r.order === minOrder ? activeStatus : REQUEST_STATUS.queued; + if (r.status !== desired) updates.push({ id: r.id, status: desired }); + }); + return updates; +} + +// Сливает изменения order и status по одному и тому же id в один объект — иначе +// пришлось бы отправлять на одну и ту же строку два отдельных PATCH-запроса. +function mergeRowUpdates( + orderUpdates: Array<{ id: number; order: number }>, + statusUpdates: Array<{ id: number; status: string }> +): Array<{ id: number; order?: number; status?: string }> { + const byId = new Map(); + orderUpdates.forEach(u => byId.set(u.id, { ...(byId.get(u.id) || { id: u.id }), order: u.order })); + statusUpdates.forEach(u => byId.set(u.id, { ...(byId.get(u.id) || { id: u.id }), status: u.status })); + return Array.from(byId.values()); +} + // Что выбрано в комбобоксах «Тип заявки»/«Заявка» — переживает перезагрузку // страницы (localStorage), чтобы не искать одну и ту же заявку заново после // каждого F5. selectedRequest хранится целиком (не только id), чтобы сразу @@ -144,12 +182,6 @@ export const ManageRequestsScreen: React.FC = ({ api // (и настоящий Id из SharePoint) ещё не долетел. Реальные Id списка всегда // положительные, поэтому пересечься эти диапазоны не могут. const nextGhostId = React.useRef(-1); - // Идёт фоновая запись (перестановка/переключение параллельности) — блокирует - // ТОЛЬКО повторную перестановку, чтобы не отправить второй reorder поверх ещё - // не завершённого (порядок мог бы разъехаться при гонке двух запросов). - // Переназначение/удаление/добавление такой блокировки не требуют: они не - // трогают order соседних строк, поэтому гонок между собой не создают. - const [reordering, setReordering] = React.useState(false); const requestType = requestTypes.items.filter(rt => rt.id === Number(requestTypeId))[0]; // Только типы с привязанным списком SharePoint — без list_id/site_url искать @@ -246,23 +278,36 @@ export const ManageRequestsScreen: React.FC = ({ api const remainingPhaseRows = matrix.rows.filter(r => r.phase === row.phase && r.id !== row.id); const renumberUpdates = renumberPhase(remainingPhaseRows); const orderById = new Map(renumberUpdates.map(u => [u.id, u.order])); + const renumberedPhaseRows = remainingPhaseRows.map(r => (orderById.has(r.id) ? { ...r, order: orderById.get(r.id)! } : r)); + + // Удалили как раз ту строку, что была активной («На согласовании» и т.п.) — + // очередь надо продвинуть на следующую по order, иначе фаза так и останется + // без единой активной строки. + const activationUpdates = computeActivationUpdates(renumberedPhaseRows, row.phase); + const statusById = new Map(activationUpdates.map(u => [u.id, u.status])); + const writeUpdates = mergeRowUpdates(renumberUpdates, activationUpdates); setMatrix({ ...matrix, rows: matrix.rows .filter(r => r.id !== row.id) - .map(r => (orderById.has(r.id) ? { ...r, order: orderById.get(r.id)! } : r)) + .map(r => { + const order = orderById.get(r.id); + const status = statusById.get(r.id); + return order !== undefined || status !== undefined ? { ...r, ...(order !== undefined && { order }), ...(status !== undefined && { status }) } : r; + }) }); requestManage .removeRow(matrix, row.id) .then(() => { - if (renumberUpdates.length === 0) return; - // Удаление уже прошло — откатывать нечего, даже если это пересохранение - // номеров не удастся. Просто сообщаем об ошибке: строка гарантированно - // удалена, а нумерация в SharePoint в худшем случае останется с дыркой - // (что чинится обычной перестановкой любого шага той же фазы). - return requestManage.reorderRows(matrix, renumberUpdates).catch((e: Error) => setSaveError(e.message)); + if (writeUpdates.length === 0) return; + // Удаление уже прошло — откатывать нечего, даже если пересохранение + // номеров/статусов не удастся. Просто сообщаем об ошибке: строка + // гарантированно удалена, а нумерация/активация в SharePoint в худшем + // случае разойдётся с тем, что видно на экране (починится обычной + // перестановкой любого шага той же фазы). + return requestManage.reorderRows(matrix, writeUpdates).catch((e: Error) => setSaveError(e.message)); }) .catch((e: Error) => { setMatrix(previous); @@ -321,13 +366,16 @@ export const ManageRequestsScreen: React.FC = ({ api const order = nextOrder(phaseRows); const ghostId = nextGhostId.current--; - // Новая строка всегда «Ждёт очереди» — request-portal переводит её в «На - // согласовании»/«На исполнении» через activateNextQueued при следующем - // решении согласующего или повторной отправке формы (см. - // request-portal/shared/approval/approvalList.service.ts). Если это ПЕРВЫЙ - // шаг фазы (например, добавили исполнителя в заявку, где фазы исполнения - // раньше не было вовсе) — activateNextQueued не сработает сам по себе, пока - // кто-то не примет решение по заявке; шаг до этого момента просто ждёт. + // Новая строка встаёт ПОСЛЕДНЕЙ по order (см. nextOrder) — значит, стать + // активной сразу она может, только если во всей фазе вообще нет ни одной + // ожидающей строки (например, первый исполнитель, добавленный в заявку, где + // до этого фазы исполнения не было вовсе): тогда «первый по order среди + // ожидающих» — она и есть. Если в фазе уже кто-то ждёт своей очереди, та + // строка всегда раньше по order — новая просто встаёт в конец «Ждёт очереди». + const hasPending = phaseRows.some(r => PENDING_STATUSES.indexOf(r.status) !== -1); + const activeStatus = phase === APPROVAL_PHASE.execution ? REQUEST_STATUS.onExecution : REQUEST_STATUS.onApproval; + const initialStatus = hasPending ? REQUEST_STATUS.queued : activeStatus; + const ghostRow: ManagedApprovalRow = { id: ghostId, ticketId: matrix.request.id, @@ -335,7 +383,7 @@ export const ManageRequestsScreen: React.FC = ({ api approverName: found?.fio || email, approverEmail: email, position, - status: REQUEST_STATUS.queued, + status: initialStatus, date: '', comment: '', order, @@ -347,7 +395,7 @@ export const ManageRequestsScreen: React.FC = ({ api setMatrix({ ...matrix, rows: [...matrix.rows, ghostRow] }); requestManage - .addRow(matrix, matrix.request.id, phase, order, REQUEST_STATUS.queued, email, position, false) + .addRow(matrix, matrix.request.id, phase, order, initialStatus, email, position, false) .then(() => reloadMatrix(true)) .catch((e: Error) => { setMatrix(previous); @@ -355,28 +403,38 @@ export const ManageRequestsScreen: React.FC = ({ api }); }; - // Перестановка/параллельность — оптимистично: order из ManageRequestsPhaseTable - // применяется к матрице СРАЗУ (клик по стрелке/значку виден мгновенно), запрос - // на запись уходит фоном. Если он упадёт — откатываем к состоянию до клика и - // показываем ошибку; если пройдёт — тихо перечитываем матрицу (не меняя - // видимого состояния, если её никто больше не менял, но подхватит, если менял). + // Перестановка/параллельность — оптимистично и БЕЗ блокировки: order из + // ManageRequestsPhaseTable применяется к матрице СРАЗУ (каждый клик по + // стрелке/значку виден мгновенно, в т.ч. при быстрых повторных кликах — + // предыдущий фоновый запрос ничего не блокирует), запрос на запись уходит + // фоном. При ошибке — не откатываем к «состоянию до клика»: если кликов было + // несколько подряд, такой откат затёр бы более поздние, ещё не сохранённые + // изменения. Вместо этого просто перечитываем матрицу с сервера — она сама + // покажет, что в итоге реально сохранилось. const handleReorder = (updates: Array<{ id: number; order: number }>): void => { - if (!matrix || reordering) return; + if (!matrix || updates.length === 0) return; setSaveError(undefined); - const previous = matrix; const orderById = new Map(updates.map(u => [u.id, u.order])); - setMatrix({ ...matrix, rows: matrix.rows.map(r => (orderById.has(r.id) ? { ...r, order: orderById.get(r.id)! } : r)) }); + const nextRows = matrix.rows.map(r => (orderById.has(r.id) ? { ...r, order: orderById.get(r.id)! } : r)); - setReordering(true); - requestManage - .reorderRows(matrix, updates) - .then(() => reloadMatrix(true)) - .catch((e: Error) => { - setMatrix(previous); - setSaveError(e.message); - }) - .finally(() => setReordering(false)); + // order-обновления всегда относятся к ОДНОЙ фазе (перестановка идёт внутри + // одной таблицы — см. ManageRequestsPhaseTable), поэтому фазу для пересчёта + // активации достаточно взять у любой из задетых строк. + const changedPhase = nextRows.filter(r => orderById.has(r.id))[0].phase; + const activationUpdates = computeActivationUpdates(nextRows.filter(r => r.phase === changedPhase), changedPhase); + const statusById = new Map(activationUpdates.map(u => [u.id, u.status])); + + setMatrix({ + ...matrix, + rows: nextRows.map(r => (statusById.has(r.id) ? { ...r, status: statusById.get(r.id)! } : r)) + }); + + const writeUpdates = mergeRowUpdates(updates, activationUpdates); + requestManage.reorderRows(matrix, writeUpdates).catch((e: Error) => { + setSaveError(e.message); + reloadMatrix(true); + }); }; return ( @@ -439,7 +497,6 @@ export const ManageRequestsScreen: React.FC = ({ api title={texts.manageRequests.approvalPhaseTitle} rows={approvalRows} readOnly={readOnly} - busy={reordering} statusVariant={statusVariant} onReassign={openReassign} onRemove={askDelete} @@ -453,7 +510,6 @@ export const ManageRequestsScreen: React.FC = ({ api title={texts.manageRequests.executionPhaseTitle} rows={executionRows} readOnly={readOnly} - busy={reordering} statusVariant={statusVariant} onReassign={openReassign} onRemove={askDelete} diff --git a/frontend/src/webparts/approvalAdmin/services/requestManageService.ts b/frontend/src/webparts/approvalAdmin/services/requestManageService.ts index a4d5727..44d8bf6 100644 --- a/frontend/src/webparts/approvalAdmin/services/requestManageService.ts +++ b/frontend/src/webparts/approvalAdmin/services/requestManageService.ts @@ -281,13 +281,28 @@ export class RequestManageService { }); } - /** Переставить StepOrder нескольких строк разом (перестановка шагов местами). */ + /** + * Переставить StepOrder нескольких строк разом (перестановка шагов местами) и, + * при необходимости, статус той же строки — см. ManageRequestsScreen/ + * computeActivationUpdates: после любой перестановки строка, ставшая первой + * нерешённой в очереди фазы, должна получить «На согласовании»/«На + * исполнении» вместо «Ждёт очереди» (и наоборот — та, что раньше была первой, + * а теперь нет). Оба поля пишутся одним update, чтобы не плодить лишний + * запрос на строку. + */ public async reorderRows( matrix: Pick, - updates: Array<{ id: number; order: number }> + updates: Array<{ id: number; order?: number; status?: string }> ): Promise { const sp = this.clientFor(matrix.siteUrl); const list = sp.web.lists.getByTitle(matrix.approvalListTitle); - await Promise.all(updates.map(u => list.items.getById(u.id).update({ [FIELDS.order]: u.order }))); + await Promise.all( + updates.map(u => { + const patch: Record = {}; + if (u.order !== undefined) patch[FIELDS.order] = u.order; + if (u.status !== undefined) patch[FIELDS.status] = u.status; + return list.items.getById(u.id).update(patch); + }) + ); } }