From cfe97dc67e0040da65dd43ebabbc13f52ad7cb33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Thu, 8 Oct 2026 12:39:10 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=B6=D0=B4=D1=83=D1=89?= =?UTF-8?q?=D0=B8=D1=85=20=D1=88=D0=B0=D0=B3=D0=BE=D0=B2=20=D0=BC=D0=B0?= =?UTF-8?q?=D1=80=D1=88=D1=80=D1=83=D1=82=D0=B0=20=D0=B2=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D0=B7=D0=B0=D1=8F=D0=B2?= =?UTF-8?q?=D0=BA=D0=B0=D1=85=20=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=B4=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=20=D0=B8=20=D0=BF=D1=80=D0=B8=D1=91=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Алтынай Бултурукова (право canEditRoute) просила менять порядок участников уже поданной заявки. Стрелки у шагов согласования и исполнения: меняются местами только соседние шаги одной фазы, оба «Ждут очереди» — активный и пройденные шаги не трогаются, поэтому никто не теряет заявку и оповещать некого. Строки перечитываются перед записью, StepOrder пишется одним батчем. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/permissions.md | 1 + .../RecruitingHire/RecruitingHireView.tsx | 17 +++- .../RecruitingSelectionView.tsx | 17 +++- .../shared/approval/approvalList.service.ts | 16 +++ .../approval/approverDecision.service.ts | 24 +++++ .../requestPortal/shared/approval/index.ts | 3 + .../shared/approval/queuedStepMove.test.ts | 99 +++++++++++++++++++ .../shared/approval/queuedStepMove.ts | 90 +++++++++++++++++ .../shared/approval/routeEditRights.ts | 2 + .../shared/approval/ui/ApprovalMatrix.tsx | 76 ++++++++++---- .../shared/approval/useQueuedStepMove.ts | 35 +++++++ 11 files changed, 357 insertions(+), 23 deletions(-) create mode 100644 src/webparts/requestPortal/shared/approval/queuedStepMove.test.ts create mode 100644 src/webparts/requestPortal/shared/approval/queuedStepMove.ts create mode 100644 src/webparts/requestPortal/shared/approval/useQueuedStepMove.ts diff --git a/docs/permissions.md b/docs/permissions.md index d9933a5..d049d6a 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -19,6 +19,7 @@ | Удалять согласующих, которые ещё ждут очереди | Алтынай Бултурукова, Павел Горбенко | email в коде | Подбор, Приём, Реферальная рекомендация: и при составлении, и в поданной заявке | `shared/approval/routeEditRights.ts` → `canEditRoute` | | Удалять любых согласующих **при составлении** заявки | те же (`canEditRoute`) | email в коде | Все остальные формы с маршрутом согласования (отпуск, увольнение, 1С и т. д.); в поданной заявке — нет | `shared/approval/routeEditRights.ts` → `composeRemoveRowRule` | | Добавлять и удалять исполнителей **при составлении** заявки | те же (`canEditRoute`) | email в коде | Все формы, где в маршруте из админки есть фаза исполнения; в поданной заявке — нет | `shared/approval/routeEditRights.ts` → `canEditExecutors` | +| Менять местами шаги **в поданной заявке** (и согласующих, и исполнителей): только соседние шаги одной фазы, оба «Ждут очереди»; активный и пройденные шаги не двигаются | те же (`canEditRoute`) | email в коде | **Только** Подбор и Приём | `canEditRoute` + `shared/approval/queuedStepMove.ts` (правило) + `RecruitingSelectionView.tsx` / `RecruitingHireView.tsx` | | Заменять и удалять исполнителей, **в том числе активного** («На исполнении») | Алтынай Бултурукова | тот же email (`canEditRoute`) | **Только** Заявка на подбор, матрица «Исполнение» | `canEditRoute` + `forms/RecruitingSelection/RecruitingSelectionView.tsx` | | То же в поданной заявке: заменять и удалять невыполненных исполнителей, **в том числе активного и себя** | Исполнители заявки, у которых свой шаг исполнения ещё не выполнен | роль в маршруте (фаза «Исполнение»), без списка email | **Только** Заявка в отдел 1С | `forms/Ticket1C/useExecutorEditing.ts` | | Править **все поля и вложения** заявки (добавлять и удалять файлы) на своём активном шаге согласования | Алтынай Бултурукова | email в коде (список по каждой форме) | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` | diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx index e487cb1..1fe3623 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx @@ -32,7 +32,9 @@ import { canEditAllFields, REQUEST_STATUS, IMatrixRow, - ApproverDecision + ApproverDecision, + queuedStepMoveRule, + useQueuedStepMove } from '../../shared/approval'; import { navigate } from '../../shared/routing'; import { effectiveUserEmail } from '../../shared/impersonation'; @@ -267,6 +269,15 @@ const RecruitingHireView: React.FC = ({ context, itemI .catch((e) => setRouteError(friendlyErrorMessage(e))); }; + // Поменять местами ждущие шаги (согласующих или исполнителей) — то же право + // routeEditRights.ts; правило «только среди ждущих» — см. queuedStepMove.ts. + const handleMoveStep = useQueuedStepMove( + approverService, + itemId, + () => setDecisionSeq((n) => n + 1), + setRouteError + ); + useEffect(() => { let alive = true; setLoading(true); @@ -385,6 +396,8 @@ const RecruitingHireView: React.FC = ({ context, itemI search={(q) => approval.searchUsers(q)} canRemoveRow={canRemoveApprover ? (row) => row.status === REQUEST_STATUS.queued : undefined} onRemoveRow={handleRemoveApprover} + canMoveRow={canRemoveApprover ? queuedStepMoveRule(approvers.rows) : undefined} + onMoveRow={handleMoveStep} /> {!executors.loading && executors.rows.length > 0 && ( @@ -394,6 +407,8 @@ const RecruitingHireView: React.FC = ({ context, itemI title="Исполнение" roleLabel="Исполнитель" emptyText="Исполнители не назначены." + canMoveRow={canRemoveApprover ? queuedStepMoveRule(executors.rows) : undefined} + onMoveRow={handleMoveStep} /> )} diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx index 949a78b..b151f62 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx @@ -31,7 +31,9 @@ import { IMatrixRow, ISiteUser, APPROVAL_PHASE, - ApproverDecision + ApproverDecision, + queuedStepMoveRule, + useQueuedStepMove } from '../../shared/approval'; import { getAttachments } from '../../shared/attachments/attachments.service'; import { useAttachmentEditor } from '../../shared/attachments/useAttachmentEditor'; @@ -256,6 +258,15 @@ const RecruitingSelectionView: React.FC = ({ cont .catch((e) => setRouteError(friendlyErrorMessage(e))); }; + // Поменять местами ждущие шаги (согласующих или исполнителей) — то же право + // routeEditRights.ts; правило «только среди ждущих» — см. queuedStepMove.ts. + const handleMoveStep = useQueuedStepMove( + approverService, + itemId, + () => setDecisionSeq((n) => n + 1), + setRouteError + ); + useEffect(() => { let alive = true; setLoading(true); @@ -358,6 +369,8 @@ const RecruitingSelectionView: React.FC = ({ cont search={(q) => approval.searchUsers(q)} canRemoveRow={canRemoveApprover ? (row) => row.status === REQUEST_STATUS.queued : undefined} onRemoveRow={handleRemoveApprover} + canMoveRow={canRemoveApprover ? queuedStepMoveRule(approvers.rows) : undefined} + onMoveRow={handleMoveStep} /> {!executors.loading && executors.rows.length > 0 && ( @@ -373,6 +386,8 @@ const RecruitingSelectionView: React.FC = ({ cont canRemoveApprover ? (row) => isPendingExecutor(row) && pendingExecutorsCount > 1 : undefined } onRemoveRow={handleRemoveExecutor} + canMoveRow={canRemoveApprover ? queuedStepMoveRule(executors.rows) : undefined} + onMoveRow={handleMoveStep} /> )} diff --git a/src/webparts/requestPortal/shared/approval/approvalList.service.ts b/src/webparts/requestPortal/shared/approval/approvalList.service.ts index 6b3fc91..65b2d38 100644 --- a/src/webparts/requestPortal/shared/approval/approvalList.service.ts +++ b/src/webparts/requestPortal/shared/approval/approvalList.service.ts @@ -13,6 +13,7 @@ import { ISiteUser } from './models/ISiteUser'; import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses'; import { userSearchVariants } from './userSearchVariants'; import { planRoutePatch } from './routePatchPlan'; +import { IStepOrderChange } from './queuedStepMove'; // Внутренние имена колонок списка согласования (создаются провижинингом). const FIELDS = { @@ -304,6 +305,21 @@ export class ApprovalListService { }); } + // Новые StepOrder для нескольких строк — ОДНИМ батчем (см. createSteps): при + // обмене двух шагов местами половинчатая запись дала бы два шага с одним + // order, то есть внезапный параллельный шаг. См. ApproverService.moveQueuedStep. + public async setStepOrders(changes: IStepOrderChange[]): Promise { + if (changes.length === 0) return; + const [batchedSP, execute] = getSP().batched(); + const batchedList = batchedSP.web.lists.getByTitle(this.listTitle); + /* eslint-disable no-void -- промисы батча разрешатся вместе после execute(), см. createSteps */ + changes.forEach(({ rowId, order }) => { + void batchedList.items.getById(rowId).update({ [FIELDS.order]: order }); + }); + /* eslint-enable no-void */ + await execute(); + } + // Добавляет одну строку согласования с заданным Order (для делегата). // delegatedById — Id пользователя, который выполнил переназначение (не строки // из маршрута — у них делегата нет). Он сохраняет право переназначить эту diff --git a/src/webparts/requestPortal/shared/approval/approverDecision.service.ts b/src/webparts/requestPortal/shared/approval/approverDecision.service.ts index 8a72b0a..f8d5d94 100644 --- a/src/webparts/requestPortal/shared/approval/approverDecision.service.ts +++ b/src/webparts/requestPortal/shared/approval/approverDecision.service.ts @@ -8,6 +8,7 @@ import { REQUEST_STATUS, APPROVAL_PHASE } from './models/statuses'; import { IApprovalRow } from './models/IApprovalRow'; import { ISiteUser } from './models/ISiteUser'; import { emitRequestsChanged } from '../requestEvents'; +import { planQueuedStepMove, StepMoveDirection } from './queuedStepMove'; // approve/return/reject — согласующий; execute — исполнитель отметил «Выполнено». export type ApproverDecision = 'approve' | 'return' | 'reject' | 'execute'; @@ -141,6 +142,29 @@ export class ApproverService { emitRequestsChanged(); } + /** + * Поменять местами ещё не начатый шаг маршрута с соседним ждущим шагом той же + * фазы (согласование или исполнение) — право см. routeEditRights.ts, правило + * см. queuedStepMove.ts. + * + * Строки перечитываются с сервера прямо перед записью: пока на экране открыт + * маршрут, кто-то мог уже согласовать, и сосед стал активным, — тогда + * перестановка отменяется, а не ломает очередь. + * + * Участники не меняются, поэтому заявку не «трогаем» (см. touchRequest): права + * на элемент данных пересчитывать незачем, оповещать тоже некого — активный + * шаг остаётся прежним. + */ + public async moveQueuedStep(requestId: number, rowId: number, direction: StepMoveDirection): Promise { + const rows = await this.approval.getForRequest(requestId); + const changes = planQueuedStepMove(rows, rowId, direction); + if (!changes) { + throw new Error('Маршрут уже изменился (шаг взят в работу или решён) — обновите заявку.'); + } + await this.approval.setStepOrders(changes); + emitRequestsChanged(); + } + /** * Шаг фазы «Исполнение», который ещё можно заменить или удалить: ждёт очереди * или уже активен («На исполнении»). Выполненный шаг — решение человека, его diff --git a/src/webparts/requestPortal/shared/approval/index.ts b/src/webparts/requestPortal/shared/approval/index.ts index 00d0f2f..9d0dbf2 100644 --- a/src/webparts/requestPortal/shared/approval/index.ts +++ b/src/webparts/requestPortal/shared/approval/index.ts @@ -76,6 +76,9 @@ export { resolveApproverOrgInfo } from './approverOrgInfo'; export type { IApproverOrgInfo } from './approverOrgInfo'; export { canEditRoute, canEditExecutors, composeRemoveRowRule } from './routeEditRights'; export { canEditAllFields } from './fullEditRights'; +export { planQueuedStepMove, queuedStepMoveRule } from './queuedStepMove'; +export type { StepMoveDirection, IStepMoveRow, IStepOrderChange } from './queuedStepMove'; +export { useQueuedStepMove } from './useQueuedStepMove'; export { ApprovalMatrix } from './ui/ApprovalMatrix'; export { ExecutorsMatrix } from './ui/ExecutorsMatrix'; export { ApprovalHistory } from './history/ApprovalHistory'; diff --git a/src/webparts/requestPortal/shared/approval/queuedStepMove.test.ts b/src/webparts/requestPortal/shared/approval/queuedStepMove.test.ts new file mode 100644 index 0000000..9a63d4d --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/queuedStepMove.test.ts @@ -0,0 +1,99 @@ +import { IStepMoveRow, planQueuedStepMove, queuedStepMoveRule } from './queuedStepMove'; + +const APPROVAL = 'Согласование'; +const EXECUTION = 'Исполнение'; +const QUEUED = 'Ждёт очереди'; +const ON_APPROVAL = 'На согласовании'; +const APPROVED = 'Согласовано'; + +function row(id: number, order: number, status = QUEUED, phase = APPROVAL): IStepMoveRow { + return { id, order, status, phase }; +} + +describe('planQueuedStepMove', () => { + it('два ждущих соседа меняются значениями StepOrder', () => { + const rows = [row(1, 1, APPROVED), row(2, 2, ON_APPROVAL), row(3, 3), row(4, 4)]; + expect(planQueuedStepMove(rows, 4, 'up')).toEqual([ + { rowId: 4, order: 3 }, + { rowId: 3, order: 4 }, + ]); + expect(planQueuedStepMove(rows, 3, 'down')).toEqual([ + { rowId: 3, order: 4 }, + { rowId: 4, order: 3 }, + ]); + }); + + it('нельзя поставить ждущий шаг перед активным', () => { + const rows = [row(1, 1, ON_APPROVAL), row(2, 2)]; + expect(planQueuedStepMove(rows, 2, 'up')).toBeUndefined(); + }); + + it('активный и пройденный шаги не двигаются', () => { + const rows = [row(1, 1, APPROVED), row(2, 2, ON_APPROVAL), row(3, 3)]; + expect(planQueuedStepMove(rows, 2, 'down')).toBeUndefined(); + expect(planQueuedStepMove(rows, 1, 'down')).toBeUndefined(); + }); + + it('крайний шаг фазы дальше не двигается', () => { + const rows = [row(1, 1), row(2, 2)]; + expect(planQueuedStepMove(rows, 1, 'up')).toBeUndefined(); + expect(planQueuedStepMove(rows, 2, 'down')).toBeUndefined(); + }); + + it('граница фаз не пересекается: последний согласующий не уходит к исполнителям', () => { + const rows = [row(1, 1), row(2, 2), row(3, 3, QUEUED, EXECUTION), row(4, 4, QUEUED, EXECUTION)]; + expect(planQueuedStepMove(rows, 2, 'down')).toBeUndefined(); + expect(planQueuedStepMove(rows, 3, 'up')).toBeUndefined(); + expect(planQueuedStepMove(rows, 3, 'down')).toEqual([ + { rowId: 3, order: 4 }, + { rowId: 4, order: 3 }, + ]); + }); + + it('параллельный шаг (общий order) переезжает целиком', () => { + const rows = [row(1, 1, ON_APPROVAL), row(2, 2), row(3, 2), row(4, 3)]; + expect(planQueuedStepMove(rows, 3, 'down')).toEqual([ + { rowId: 2, order: 3 }, + { rowId: 3, order: 3 }, + { rowId: 4, order: 2 }, + ]); + }); + + it('дробный order делегата сохраняет относительный порядок', () => { + // Делегат (1.5) активен, исходный согласующий (2) ждёт после него. + const rows = [row(1, 1.5, ON_APPROVAL), row(2, 2), row(3, 3)]; + expect(planQueuedStepMove(rows, 2, 'up')).toBeUndefined(); + expect(planQueuedStepMove(rows, 2, 'down')).toEqual([ + { rowId: 2, order: 3 }, + { rowId: 3, order: 2 }, + ]); + }); + + it('строка без id (ещё не записана) не двигается', () => { + const rows: IStepMoveRow[] = [{ order: 1, status: QUEUED, phase: APPROVAL }, row(2, 2)]; + expect(planQueuedStepMove(rows, 2, 'up')).toBeUndefined(); + }); + + it('пустая фаза у старых строк считается согласованием', () => { + const rows: IStepMoveRow[] = [{ id: 1, order: 1, status: QUEUED }, row(2, 2)]; + expect(planQueuedStepMove(rows, 2, 'up')).toEqual([ + { rowId: 2, order: 1 }, + { rowId: 1, order: 2 }, + ]); + }); + + it('неизвестная строка — undefined', () => { + expect(planQueuedStepMove([row(1, 1)], 99, 'up')).toBeUndefined(); + }); +}); + +describe('queuedStepMoveRule', () => { + it('разрешает только допустимые направления', () => { + const rows = [row(1, 1, ON_APPROVAL), row(2, 2), row(3, 3)]; + const canMove = queuedStepMoveRule(rows); + expect(canMove(rows[1], 'up')).toBe(false); + expect(canMove(rows[1], 'down')).toBe(true); + expect(canMove(rows[2], 'up')).toBe(true); + expect(canMove(rows[2], 'down')).toBe(false); + }); +}); diff --git a/src/webparts/requestPortal/shared/approval/queuedStepMove.ts b/src/webparts/requestPortal/shared/approval/queuedStepMove.ts new file mode 100644 index 0000000..7f0063c --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/queuedStepMove.ts @@ -0,0 +1,90 @@ +import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses'; + +/** + * Перестановка ЕЩЁ НЕ НАЧАТЫХ шагов уже поданного маршрута (право — см. + * routeEditRights.ts). Чистые функции без SharePoint: решают, можно ли + * сдвинуть шаг, и какие StepOrder записать. Выполняет ApproverService.moveQueuedStep. + * + * Безопасное правило (согласовано с владельцем): меняются местами только два + * СОСЕДНИХ шага одной фазы, и ОБА целиком «Ждут очереди». Активный и пройденные + * шаги не двигаются и не перепрыгиваются — тот, у кого заявка сейчас, её не + * теряет, история решений остаётся верной, оповещать никого не нужно. + * + * Шаг — это все строки с одинаковым StepOrder (параллельный шаг двигается + * целиком). Обмен — значениями StepOrder, а не перенумерацией: относительный + * порядок со всеми остальными строками (в т.ч. с дробными order делегатов) + * сохраняется сам собой. + */ + +export type StepMoveDirection = 'up' | 'down'; + +/** То, что нужно о строке маршрута: подходит и IApprovalRow, и IMatrixRow. */ +export interface IStepMoveRow { + /** Id строки списка согласования; у планируемых (ещё не записанных) строк нет. */ + id?: number; + status: string; + order: number; + phase?: string; +} + +/** Новый StepOrder для строки. */ +export interface IStepOrderChange { + rowId: number; + order: number; +} + +// Пустая фаза у старых строк трактуется как «Согласование» (см. getForRequest). +function phaseOf(row: IStepMoveRow): string { + return row.phase || APPROVAL_PHASE.approval; +} + +// Строки фазы → шаги (группы с одинаковым order), по возрастанию order. +function stepsOf(rows: IStepMoveRow[]): IStepMoveRow[][] { + const sorted = [...rows].sort((a, b) => a.order - b.order); + const steps: IStepMoveRow[][] = []; + sorted.forEach((row) => { + const last = steps[steps.length - 1]; + if (last && last[0].order === row.order) last.push(row); + else steps.push([row]); + }); + return steps; +} + +function isMovableStep(step: IStepMoveRow[]): boolean { + return step.every((r) => r.id !== undefined && r.status === REQUEST_STATUS.queued); +} + +/** + * Какие StepOrder записать, чтобы сдвинуть шаг строки rowId на одну позицию. + * undefined — так сдвинуть нельзя (строки нет, шаг или сосед не «Ждут очереди», + * шаг уже крайний в своей фазе). + */ +export function planQueuedStepMove( + rows: IStepMoveRow[], + rowId: number, + direction: StepMoveDirection +): IStepOrderChange[] | undefined { + const target = rows.filter((r) => r.id === rowId)[0]; + if (!target) return undefined; + + const phase = phaseOf(target); + const steps = stepsOf(rows.filter((r) => phaseOf(r) === phase)); + const index = steps.findIndex((step) => step.some((r) => r.id === rowId)); + const neighbor = steps[direction === 'up' ? index - 1 : index + 1]; + const current = steps[index]; + if (!neighbor || !isMovableStep(current) || !isMovableStep(neighbor)) return undefined; + + const currentOrder = current[0].order; + const neighborOrder = neighbor[0].order; + return [ + ...current.map((r) => ({ rowId: r.id as number, order: neighborOrder })), + ...neighbor.map((r) => ({ rowId: r.id as number, order: currentOrder })), + ]; +} + +/** Правило для ApprovalMatrix.canMoveRow по строкам этой матрицы (одной фазы). */ +export function queuedStepMoveRule( + rows: IStepMoveRow[] +): (row: IStepMoveRow, direction: StepMoveDirection) => boolean { + return (row, direction) => row.id !== undefined && planQueuedStepMove(rows, row.id, direction) !== undefined; +} diff --git a/src/webparts/requestPortal/shared/approval/routeEditRights.ts b/src/webparts/requestPortal/shared/approval/routeEditRights.ts index ce16e29..5ffb5bf 100644 --- a/src/webparts/requestPortal/shared/approval/routeEditRights.ts +++ b/src/webparts/requestPortal/shared/approval/routeEditRights.ts @@ -13,6 +13,8 @@ import { effectiveUserEmail } from '../impersonation'; * Подключено только в 3 формах рекрутинга (Заявка на подбор, Заявка на прием, * Реферальная рекомендация) — каждая форма сама решает, передавать ли * canRemoveRow в ApprovalMatrix; здесь только сама проверка личности. + * В поданных подборе и приёме это же право даёт менять местами ждущие шаги + * (canMoveRow, правило — queuedStepMove.ts). */ const ROUTE_EDITOR_EMAILS: ReadonlySet = new Set([ 'a.bulturukova@tnsu.kz', // Алтынай Бултурукова diff --git a/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx b/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx index 6d59d08..7bc1510 100644 --- a/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx +++ b/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx @@ -8,6 +8,7 @@ import { StatusBadge } from './StatusBadge'; import { UserPicker } from './UserPicker'; import { ReplaceRowPanel } from './ReplaceRowPanel'; import { useFlipRows } from './useFlipRows'; +import { StepMoveDirection } from '../queuedStepMove'; export interface IApprovalMatrixProps { editor: IUseMatrixEditor; @@ -54,6 +55,14 @@ export interface IApprovalMatrixProps { canReplaceRow?: (row: IMatrixRow, index: number) => boolean; /** Выбран новый человек для строки (задаётся вместе с canReplaceRow). */ onReplaceRow?: (row: IMatrixRow, user: ISiteUser) => void; + /** + * Можно ли сдвинуть шаг ЭТОЙ строки выше/ниже в УЖЕ ПОДАННОЙ заявке — как и + * canRemoveRow, независимо от editable (см. queuedStepMoveRule). В черновике + * (editable) не нужен: там стрелки двигают шаги локально через editor. + */ + canMoveRow?: (row: IMatrixRow, direction: StepMoveDirection) => boolean; + /** Сдвинуть шаг на сервере (задаётся вместе с canMoveRow, см. useQueuedStepMove). */ + onMoveRow?: (row: IMatrixRow, direction: StepMoveDirection) => void; } /** Таблица маршрута (согласование или исполнение) + (если editable) правка состава. */ @@ -70,6 +79,8 @@ export const ApprovalMatrix: React.FC = ({ onRemoveRow, canReplaceRow, onReplaceRow, + canMoveRow, + onMoveRow, }) => { const { rows, loading, error, editable, blocks, retry } = editor; // Строка, для которой открыт выбор замены (ReplaceRowPanel); undefined — закрыт. @@ -103,7 +114,23 @@ export const ApprovalMatrix: React.FC = ({ } }); - const moveBlock = (rowIndex: number, direction: 'up' | 'down'): void => { + // Стрелки шагов: в черновике (editable) двигают строки локально, в поданной + // заявке — только там, где разрешил canMoveRow, и с записью на сервер (onMoveRow). + const hasServerMovable = + !editable && !!canMoveRow && rows.some((r) => canMoveRow(r, 'up') || canMoveRow(r, 'down')); + const showMoveCol = editable || hasServerMovable; + + const canMoveBlock = (rowIndex: number, direction: StepMoveDirection): boolean => { + const b = blockByRow[rowIndex]; + if (editable) return direction === 'up' ? b.index > 0 : b.index < blocks.length - 1; + return !!canMoveRow?.(rows[rowIndex], direction); + }; + + const moveBlock = (rowIndex: number, direction: StepMoveDirection): void => { + if (!editable) { + onMoveRow?.(rows[rowIndex], direction); + return; + } const b = blockByRow[rowIndex]; setActiveKeys(rowKeys.slice(b.start, b.start + b.size)); if (direction === 'up') editor.moveBlockUp(rowIndex); @@ -188,7 +215,7 @@ export const ApprovalMatrix: React.FC = ({ {hasStatuses && Статус} {hasDates && Дата} {hasSla && SLA} - {editable && } + {showMoveCol && } {(editable || hasRemovable) && } @@ -257,26 +284,33 @@ export const ApprovalMatrix: React.FC = ({ )} {/* Перемещение действует на ШАГ целиком (параллельные строки переезжают вместе): одна пара стрелок на весь шаг. */} - {editable && isBlockStart && ( + {showMoveCol && isBlockStart && ( - - + {/* В поданной заявке у шагов, которые двигать нельзя совсем + (активный, пройденный), стрелок нет — иначе вся колонка + из неактивных кнопок только шумит. */} + {(editable || canMoveBlock(i, 'up') || canMoveBlock(i, 'down')) && ( + <> + + + + )} )} {(editable || hasRemovable) && ( diff --git a/src/webparts/requestPortal/shared/approval/useQueuedStepMove.ts b/src/webparts/requestPortal/shared/approval/useQueuedStepMove.ts new file mode 100644 index 0000000..299d352 --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/useQueuedStepMove.ts @@ -0,0 +1,35 @@ +import { useRef } from 'react'; +import { friendlyErrorMessage } from '../diagnostics'; +import { ApproverService } from './approverDecision.service'; +import { IMatrixRow } from './models/IMatrixRow'; +import { StepMoveDirection } from './queuedStepMove'; + +/** + * Перестановка ждущих шагов поданной заявки из ApprovalMatrix (onMoveRow) — + * общая обвязка для форм просмотра (RecruitingHireView, RecruitingSelectionView). + * + * Пока одна перестановка пишется на сервер, следующие клики игнорируются: + * каждая перестановка заново читает маршрут, и быстрый двойной клик сдвинул бы + * шаг на две позиции вместо одной. + */ +export function useQueuedStepMove( + service: ApproverService, + requestId: number, + onMoved: () => void, + onError: (message: string | undefined) => void +): (row: IMatrixRow, direction: StepMoveDirection) => void { + const isMovingRef = useRef(false); + + return (row, direction) => { + if (!row.id || isMovingRef.current) return; + isMovingRef.current = true; + onError(undefined); + service + .moveQueuedStep(requestId, row.id, direction) + .then(onMoved) + .catch((e) => onError(friendlyErrorMessage(e))) + .finally(() => { + isMovingRef.current = false; + }); + }; +}