diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx index 637ed16..d343a09 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import tableStyles from '../ui/DataTable.module.scss'; import styles from './ManageRequestsScreen.module.scss'; -import { Employee, ManagedApprovalRow, isEditableRowStatus } from '../../models'; +import { Employee, ManagedApprovalRow, isEditableRowStatus, isMovableRowStatus } from '../../models'; import { texts } from '../../texts'; import { toEmployeeOption } from '../../utils/employee'; import { blockIndexOf, blockRanges, markParallel, withOrdersFrom } from '../../utils/orderBlocks'; @@ -13,6 +13,17 @@ export interface IManageRequestsPhaseTableProps { rows: ManagedApprovalRow[]; /** Заявка целиком только для просмотра (закрыта) — контролов нет вообще. */ readOnly: boolean; + /** + * Заявка отклонена целиком (REQUEST_STATUS.rejected) — маршрут дальше не + * продолжится ни в одной фазе, поэтому добавлять исполнителя нельзя НИГДЕ, + * даже в фазу, где сейчас вообще нет строк (иначе rows.length===0 не даёт + * phaseClosed сработать по строкам — см. phaseClosed ниже). readOnly здесь + * не подходит: он делает всю заявку read-only целиком (для isFullyClosed — + * REQUEST_STATUS.closed), а отклонённую заявку можно продолжать + * редактировать (переназначать/удалять уже существующие шаги), просто + * новые добавлять больше некуда. + */ + requestRejected: boolean; statusVariant: (status: string) => BadgeVariant; onReassign: (row: ManagedApprovalRow) => void; onRemove: (row: ManagedApprovalRow) => void; @@ -40,13 +51,20 @@ function diffOrders(original: ManagedApprovalRow[], renumbered: Row[]): Array<{ * повторяет request-portal/shared/approval/ui/ApprovalMatrix.tsx (та же идея: * колонка «Шаг» объединена по rowSpan на блок, кнопки ↑/↓ двигают блок целиком), * но с одним отличием: часть строк тут может быть УЖЕ РЕШЕНА (approved/executed/ - * rejected/returned) — такие блоки «заблокированы» и никогда не двигаются и не - * участвуют в переключении параллельности. Блокировка распространяется на все - * блоки НЕ ПОЗЖЕ последнего заблокированного — по построению маршрута (шаги + * rejected) ИЛИ возвращена на доработку (returned) — такие блоки «заблокированы» + * и никогда не двигаются и не участвуют в переключении параллельности (см. + * isMovableRowStatus в models/requestManage.ts). Блокировка распространяется на + * все блоки НЕ ПОЗЖЕ последнего заблокированного — по построению маршрута (шаги * решаются по возрастанию order) заблокированные блоки всегда образуют * непрерывный префикс, поэтому «после последнего заблокированного» и есть та * самая граница, ниже которой перестановка запрещена (см. utils/orderBlocks.ts). * + * Важно: «заблокирован для перестановки» ≠ «нельзя редактировать». Возвращённый + * на доработку шаг двигать нельзя (решение по нему уже принято), но человека на + * нём переназначить всё ещё можно — это две разные проверки (isMovableRowStatus + * для перестановки/параллельности, isEditableRowStatus для переназначения и + * удаления, см. rowEditable ниже). + * * Переназначение/удаление СТРОКИ (не блока) разрешено независимо от соседей — * если у одного из двух параллельных согласующих решение уже принято, а у * другого нет, второго всё ещё можно переназначить. @@ -55,6 +73,7 @@ export const ManageRequestsPhaseTable: React.FC title, rows, readOnly, + requestRejected, statusVariant, onReassign, onRemove, @@ -74,8 +93,11 @@ export const ManageRequestsPhaseTable: React.FC const withParallel = React.useMemo(() => markParallel(rows) as Row[], [rows]); const blocks = React.useMemo(() => blockRanges(withParallel), [withParallel]); + // isMovableRowStatus, а НЕ isEditableRowStatus: «Возвращена на доработку» — + // редактируемый статус (переназначить можно), но НЕ движимый — своё слово по + // шагу уже сказано, менять его место в маршруте нельзя (см. models/requestManage.ts). const lockedBlock = React.useMemo( - () => blocks.map(b => withParallel.slice(b.start, b.end + 1).some(r => !isEditableRowStatus(r.status))), + () => blocks.map(b => withParallel.slice(b.start, b.end + 1).some(r => !isMovableRowStatus(r.status))), [blocks, withParallel] ); // Индекс последнего заблокированного блока — все блоки ПОСЛЕ него движимы, @@ -98,8 +120,12 @@ export const ManageRequestsPhaseTable: React.FC // в статус «Выполнение») — добавлять в неё ещё один шаг бессмысленно, очередь // сюда больше не вернётся. Пустую фазу (rows.length===0) это не блокирует — // так можно завести самый первый шаг фазы, у которой согласующих ещё не было - // вовсе. - const phaseClosed = rows.length > 0 && rows.every(r => !isEditableRowStatus(r.status)); + // вовсе. requestRejected — отдельная причина закрытости, которая, В ОТЛИЧИЕ + // от строк выше, обязана блокировать добавление даже в ПУСТУЮ фазу: если + // заявку отклонили на согласовании, до фазы исполнения дело уже не дойдёт, + // хотя строк там могло вообще не быть (см. скрин — «Исполнение» пустая, но + // добавлять туда исполнителя всё равно бессмысленно). + const phaseClosed = requestRejected || (rows.length > 0 && rows.every(r => !isEditableRowStatus(r.status))); const applyReorder = (mutate: (rows: Row[]) => Row[]): void => { const renumbered = withOrdersFrom(mutate(withParallel), 0); @@ -160,7 +186,13 @@ export const ManageRequestsPhaseTable: React.FC const isBlockStart = block.start === i; const movable = blockIndex > lastLockedBlockIndex; const rowEditable = isEditableRowStatus(r.status); - const canToggle = rowEditable && i !== firstMovableRowIndex; + // Переключение параллельности — тоже перестановка (меняет order + // относительно соседней строки), поэтому смотрим на isMovableRowStatus, + // а не на rowEditable: у «Возвращена на доработку» кнопка + // объединения должна быть недоступна, хотя переназначить/удалить + // такую строку всё ещё можно (см. ветку ниже, где rowEditable решает, + // показывать ActionsCell целиком или lockedHint). + const canToggle = isMovableRowStatus(r.status) && i !== firstMovableRowIndex; // Отрицательный Id — призрачная строка (добавлена на клиенте, // ответ сервера с настоящим Id ещё не пришёл, см. // ManageRequestsScreen/handleAddApprover). Трогать её нельзя: @@ -264,7 +296,7 @@ export const ManageRequestsPhaseTable: React.FC (см. onAddApprover в ManageRequestsScreen). */} {!readOnly && phaseClosed && (
- {texts.manageRequests.phaseClosedHint} + {requestRejected ? texts.manageRequests.requestRejectedHint : texts.manageRequests.phaseClosedHint}
)} diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx index 784bba0..f8486d8 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx @@ -321,6 +321,13 @@ export const ManageRequestsScreen: React.FC = ({ api // от статуса отдельных строк (см. isFullyClosedRequestStatus). const readOnly = matrix ? isFullyClosedRequestStatus(matrix.request.status) : true; + // Заявка отклонена — дальше по маршруту дело не пойдёт ни в одной фазе, значит + // добавлять исполнителя нельзя нигде, даже в фазу, где ещё вообще нет строк + // (readOnly для этого не подходит: он рассчитан только на REQUEST_STATUS.closed + // и делает ВСЮ заявку неизменяемой, а отклонённую всё ещё можно редактировать — + // переназначать/удалять уже существующие шаги, просто новые уже некуда). + const requestRejected = matrix?.request.status === REQUEST_STATUS.rejected; + // Сортировка по order — ОБЯЗАТЕЛЬНА, а не косметика: ManageRequestsPhaseTable // считает блоки и параллельность по ФИЗИЧЕСКОМУ порядку строк в массиве // (markParallel сравнивает order с предыдущей строкой), а оптимистичные @@ -511,6 +518,7 @@ export const ManageRequestsScreen: React.FC = ({ api title={texts.manageRequests.approvalPhaseTitle} rows={approvalRows} readOnly={readOnly} + requestRejected={requestRejected} statusVariant={statusVariant} onReassign={openReassign} onRemove={askDelete} @@ -524,6 +532,7 @@ export const ManageRequestsScreen: React.FC = ({ api title={texts.manageRequests.executionPhaseTitle} rows={executionRows} readOnly={readOnly} + requestRejected={requestRejected} statusVariant={statusVariant} onReassign={openReassign} onRemove={askDelete} diff --git a/frontend/src/webparts/approvalAdmin/models/requestManage.test.ts b/frontend/src/webparts/approvalAdmin/models/requestManage.test.ts index 3d45b00..ff50ab6 100644 --- a/frontend/src/webparts/approvalAdmin/models/requestManage.test.ts +++ b/frontend/src/webparts/approvalAdmin/models/requestManage.test.ts @@ -1,4 +1,4 @@ -import { REQUEST_STATUS, isEditableRowStatus, isFullyClosedRequestStatus } from './requestManage'; +import { REQUEST_STATUS, isEditableRowStatus, isFullyClosedRequestStatus, isMovableRowStatus } from './requestManage'; describe('isEditableRowStatus', () => { it('черновик/в очереди/на согласовании/на исполнении — редактируемые', () => { @@ -23,6 +23,26 @@ describe('isEditableRowStatus', () => { }); }); +describe('isMovableRowStatus', () => { + it('черновик/в очереди/на согласовании/на исполнении — движимые', () => { + expect(isMovableRowStatus(REQUEST_STATUS.draft)).toBe(true); + expect(isMovableRowStatus(REQUEST_STATUS.queued)).toBe(true); + expect(isMovableRowStatus(REQUEST_STATUS.onApproval)).toBe(true); + expect(isMovableRowStatus(REQUEST_STATUS.onExecution)).toBe(true); + }); + + it('"Возвращена на доработку" — редактируемый, но НЕ движимый статус', () => { + expect(isEditableRowStatus(REQUEST_STATUS.returned)).toBe(true); + expect(isMovableRowStatus(REQUEST_STATUS.returned)).toBe(false); + }); + + it('согласовано/выполнено/отклонено — тоже не движимые', () => { + expect(isMovableRowStatus(REQUEST_STATUS.approved)).toBe(false); + expect(isMovableRowStatus(REQUEST_STATUS.executed)).toBe(false); + expect(isMovableRowStatus(REQUEST_STATUS.rejected)).toBe(false); + }); +}); + describe('isFullyClosedRequestStatus', () => { it('true только для статуса "Закрыта"', () => { expect(isFullyClosedRequestStatus(REQUEST_STATUS.closed)).toBe(true); diff --git a/frontend/src/webparts/approvalAdmin/models/requestManage.ts b/frontend/src/webparts/approvalAdmin/models/requestManage.ts index ee9dda8..7b73348 100644 --- a/frontend/src/webparts/approvalAdmin/models/requestManage.ts +++ b/frontend/src/webparts/approvalAdmin/models/requestManage.ts @@ -27,11 +27,16 @@ export const APPROVAL_PHASE = { } as const; // Строка маршрута в одном из этих статусов ЕЩЁ НЕ решена согласующим/исполнителем -// ОКОНЧАТЕЛЬНО — её можно менять (переназначить человека, удалить, переставить, -// добавить новую рядом). «Возвращена на доработку» сюда включена нарочно: это не -// финальное решение (заявка вернётся на новый круг после доработки), поэтому -// согласующего на этом шаге всё ещё можно переназначить. approved/executed/ -// rejected — финальные решения, трогать нельзя ни при каких условиях. +// ОКОНЧАТЕЛЬНО — её можно менять (переназначить человека, удалить, добавить новую +// рядом). «Возвращена на доработку» сюда включена нарочно: это не финальное +// решение (заявка вернётся на новый круг после доработки), поэтому согласующего +// на этом шаге всё ещё можно переназначить. approved/executed/rejected — +// финальные решения, трогать нельзя ни при каких условиях. +// +// ВАЖНО: это НЕ то же самое, что «можно переставить» (см. isMovableRowStatus +// ниже) — «Возвращена на доработку» уже означает, что своё слово по этому шагу +// согласующий сказал (просто мы разрешаем потом переназначить человека), поэтому +// двигать такой шаг/менять его параллельность нельзя, а переназначить — можно. const EDITABLE_ROW_STATUSES: string[] = [ REQUEST_STATUS.draft, REQUEST_STATUS.queued, @@ -44,6 +49,22 @@ export function isEditableRowStatus(status: string): boolean { return EDITABLE_ROW_STATUSES.indexOf(status) !== -1; } +// Строка в одном из этих статусов можно двигать (стрелки вверх/вниз) и менять её +// параллельность с соседней — строго подмножество EDITABLE_ROW_STATUSES, без +// «Возвращена на доработку»: решение по шагу уже принято, менять его МЕСТО в +// маршруте (а значит и то, когда он активируется повторно) не должно быть можно, +// хотя переназначить исполнителя на нём — можно (см. isEditableRowStatus). +const MOVABLE_ROW_STATUSES: string[] = [ + REQUEST_STATUS.draft, + REQUEST_STATUS.queued, + REQUEST_STATUS.onApproval, + REQUEST_STATUS.onExecution +]; + +export function isMovableRowStatus(status: string): boolean { + return MOVABLE_ROW_STATUSES.indexOf(status) !== -1; +} + // Заявка в этом статусе прошла полностью (все фазы завершены) — маршрут можно // только посмотреть, редактирование выключается целиком независимо от статусов // отдельных строк. diff --git a/frontend/src/webparts/approvalAdmin/texts.ts b/frontend/src/webparts/approvalAdmin/texts.ts index 80bf214..8419b67 100644 --- a/frontend/src/webparts/approvalAdmin/texts.ts +++ b/frontend/src/webparts/approvalAdmin/texts.ts @@ -307,6 +307,7 @@ export const texts = { lockedHint: 'Решение уже принято — недоступно для правки.', ghostHint: 'Сохраняется…', phaseClosedHint: 'Эта фаза уже завершена — добавить в неё новый шаг нельзя.', + requestRejectedHint: 'Заявка отклонена — добавить исполнителя нельзя.', moveUp: 'Шаг выше', moveDown: 'Шаг ниже', mergeWithPrev: 'Объединить с предыдущим шагом (выполнять параллельно)',