правки по логике переключения
Tests / frontend-test (push) Successful in 45s
Tests / backend-test (push) Successful in 2m11s

This commit is contained in:
gorbenko.pavel
2026-08-07 16:04:22 +05:00
parent 1d45780294
commit 11738b6e4a
5 changed files with 98 additions and 15 deletions
@@ -1,7 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import tableStyles from '../ui/DataTable.module.scss'; import tableStyles from '../ui/DataTable.module.scss';
import styles from './ManageRequestsScreen.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 { texts } from '../../texts';
import { toEmployeeOption } from '../../utils/employee'; import { toEmployeeOption } from '../../utils/employee';
import { blockIndexOf, blockRanges, markParallel, withOrdersFrom } from '../../utils/orderBlocks'; import { blockIndexOf, blockRanges, markParallel, withOrdersFrom } from '../../utils/orderBlocks';
@@ -13,6 +13,17 @@ export interface IManageRequestsPhaseTableProps {
rows: ManagedApprovalRow[]; rows: ManagedApprovalRow[];
/** Заявка целиком только для просмотра (закрыта) — контролов нет вообще. */ /** Заявка целиком только для просмотра (закрыта) — контролов нет вообще. */
readOnly: boolean; readOnly: boolean;
/**
* Заявка отклонена целиком (REQUEST_STATUS.rejected) — маршрут дальше не
* продолжится ни в одной фазе, поэтому добавлять исполнителя нельзя НИГДЕ,
* даже в фазу, где сейчас вообще нет строк (иначе rows.length===0 не даёт
* phaseClosed сработать по строкам — см. phaseClosed ниже). readOnly здесь
* не подходит: он делает всю заявку read-only целиком (для isFullyClosed —
* REQUEST_STATUS.closed), а отклонённую заявку можно продолжать
* редактировать (переназначать/удалять уже существующие шаги), просто
* новые добавлять больше некуда.
*/
requestRejected: boolean;
statusVariant: (status: string) => BadgeVariant; statusVariant: (status: string) => BadgeVariant;
onReassign: (row: ManagedApprovalRow) => void; onReassign: (row: ManagedApprovalRow) => void;
onRemove: (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 (та же идея: * повторяет request-portal/shared/approval/ui/ApprovalMatrix.tsx (та же идея:
* колонка «Шаг» объединена по rowSpan на блок, кнопки ↑/↓ двигают блок целиком), * колонка «Шаг» объединена по rowSpan на блок, кнопки ↑/↓ двигают блок целиком),
* но с одним отличием: часть строк тут может быть УЖЕ РЕШЕНА (approved/executed/ * но с одним отличием: часть строк тут может быть УЖЕ РЕШЕНА (approved/executed/
* rejected/returned) — такие блоки «заблокированы» и никогда не двигаются и не * rejected) ИЛИ возвращена на доработку (returned) — такие блоки «заблокированы»
* участвуют в переключении параллельности. Блокировка распространяется на все * и никогда не двигаются и не участвуют в переключении параллельности (см.
* блоки НЕ ПОЗЖЕ последнего заблокированного — по построению маршрута (шаги * isMovableRowStatus в models/requestManage.ts). Блокировка распространяется на
* все блоки НЕ ПОЗЖЕ последнего заблокированного — по построению маршрута (шаги
* решаются по возрастанию order) заблокированные блоки всегда образуют * решаются по возрастанию order) заблокированные блоки всегда образуют
* непрерывный префикс, поэтому «после последнего заблокированного» и есть та * непрерывный префикс, поэтому «после последнего заблокированного» и есть та
* самая граница, ниже которой перестановка запрещена (см. utils/orderBlocks.ts). * самая граница, ниже которой перестановка запрещена (см. utils/orderBlocks.ts).
* *
* Важно: «заблокирован для перестановки» ≠ «нельзя редактировать». Возвращённый
* на доработку шаг двигать нельзя (решение по нему уже принято), но человека на
* нём переназначить всё ещё можно — это две разные проверки (isMovableRowStatus
* для перестановки/параллельности, isEditableRowStatus для переназначения и
* удаления, см. rowEditable ниже).
*
* Переназначение/удаление СТРОКИ (не блока) разрешено независимо от соседей — * Переназначение/удаление СТРОКИ (не блока) разрешено независимо от соседей —
* если у одного из двух параллельных согласующих решение уже принято, а у * если у одного из двух параллельных согласующих решение уже принято, а у
* другого нет, второго всё ещё можно переназначить. * другого нет, второго всё ещё можно переназначить.
@@ -55,6 +73,7 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
title, title,
rows, rows,
readOnly, readOnly,
requestRejected,
statusVariant, statusVariant,
onReassign, onReassign,
onRemove, onRemove,
@@ -74,8 +93,11 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
const withParallel = React.useMemo(() => markParallel(rows) as Row[], [rows]); const withParallel = React.useMemo(() => markParallel(rows) as Row[], [rows]);
const blocks = React.useMemo(() => blockRanges(withParallel), [withParallel]); const blocks = React.useMemo(() => blockRanges(withParallel), [withParallel]);
// isMovableRowStatus, а НЕ isEditableRowStatus: «Возвращена на доработку» —
// редактируемый статус (переназначить можно), но НЕ движимый — своё слово по
// шагу уже сказано, менять его место в маршруте нельзя (см. models/requestManage.ts).
const lockedBlock = React.useMemo( 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] [blocks, withParallel]
); );
// Индекс последнего заблокированного блока — все блоки ПОСЛЕ него движимы, // Индекс последнего заблокированного блока — все блоки ПОСЛЕ него движимы,
@@ -98,8 +120,12 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
// в статус «Выполнение») — добавлять в неё ещё один шаг бессмысленно, очередь // в статус «Выполнение») — добавлять в неё ещё один шаг бессмысленно, очередь
// сюда больше не вернётся. Пустую фазу (rows.length===0) это не блокирует — // сюда больше не вернётся. Пустую фазу (rows.length===0) это не блокирует —
// так можно завести самый первый шаг фазы, у которой согласующих ещё не было // так можно завести самый первый шаг фазы, у которой согласующих ещё не было
// вовсе. // вовсе. requestRejected — отдельная причина закрытости, которая, В ОТЛИЧИЕ
const phaseClosed = rows.length > 0 && rows.every(r => !isEditableRowStatus(r.status)); // от строк выше, обязана блокировать добавление даже в ПУСТУЮ фазу: если
// заявку отклонили на согласовании, до фазы исполнения дело уже не дойдёт,
// хотя строк там могло вообще не быть (см. скрин — «Исполнение» пустая, но
// добавлять туда исполнителя всё равно бессмысленно).
const phaseClosed = requestRejected || (rows.length > 0 && rows.every(r => !isEditableRowStatus(r.status)));
const applyReorder = (mutate: (rows: Row[]) => Row[]): void => { const applyReorder = (mutate: (rows: Row[]) => Row[]): void => {
const renumbered = withOrdersFrom(mutate(withParallel), 0); const renumbered = withOrdersFrom(mutate(withParallel), 0);
@@ -160,7 +186,13 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
const isBlockStart = block.start === i; const isBlockStart = block.start === i;
const movable = blockIndex > lastLockedBlockIndex; const movable = blockIndex > lastLockedBlockIndex;
const rowEditable = isEditableRowStatus(r.status); 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 — призрачная строка (добавлена на клиенте,
// ответ сервера с настоящим Id ещё не пришёл, см. // ответ сервера с настоящим Id ещё не пришёл, см.
// ManageRequestsScreen/handleAddApprover). Трогать её нельзя: // ManageRequestsScreen/handleAddApprover). Трогать её нельзя:
@@ -264,7 +296,7 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
(см. onAddApprover в ManageRequestsScreen). */} (см. onAddApprover в ManageRequestsScreen). */}
{!readOnly && phaseClosed && ( {!readOnly && phaseClosed && (
<div className={styles.addRow}> <div className={styles.addRow}>
<CellNote>{texts.manageRequests.phaseClosedHint}</CellNote> <CellNote>{requestRejected ? texts.manageRequests.requestRejectedHint : texts.manageRequests.phaseClosedHint}</CellNote>
</div> </div>
)} )}
@@ -321,6 +321,13 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
// от статуса отдельных строк (см. isFullyClosedRequestStatus). // от статуса отдельных строк (см. isFullyClosedRequestStatus).
const readOnly = matrix ? isFullyClosedRequestStatus(matrix.request.status) : true; const readOnly = matrix ? isFullyClosedRequestStatus(matrix.request.status) : true;
// Заявка отклонена — дальше по маршруту дело не пойдёт ни в одной фазе, значит
// добавлять исполнителя нельзя нигде, даже в фазу, где ещё вообще нет строк
// (readOnly для этого не подходит: он рассчитан только на REQUEST_STATUS.closed
// и делает ВСЮ заявку неизменяемой, а отклонённую всё ещё можно редактировать —
// переназначать/удалять уже существующие шаги, просто новые уже некуда).
const requestRejected = matrix?.request.status === REQUEST_STATUS.rejected;
// Сортировка по order — ОБЯЗАТЕЛЬНА, а не косметика: ManageRequestsPhaseTable // Сортировка по order — ОБЯЗАТЕЛЬНА, а не косметика: ManageRequestsPhaseTable
// считает блоки и параллельность по ФИЗИЧЕСКОМУ порядку строк в массиве // считает блоки и параллельность по ФИЗИЧЕСКОМУ порядку строк в массиве
// (markParallel сравнивает order с предыдущей строкой), а оптимистичные // (markParallel сравнивает order с предыдущей строкой), а оптимистичные
@@ -511,6 +518,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
title={texts.manageRequests.approvalPhaseTitle} title={texts.manageRequests.approvalPhaseTitle}
rows={approvalRows} rows={approvalRows}
readOnly={readOnly} readOnly={readOnly}
requestRejected={requestRejected}
statusVariant={statusVariant} statusVariant={statusVariant}
onReassign={openReassign} onReassign={openReassign}
onRemove={askDelete} onRemove={askDelete}
@@ -524,6 +532,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
title={texts.manageRequests.executionPhaseTitle} title={texts.manageRequests.executionPhaseTitle}
rows={executionRows} rows={executionRows}
readOnly={readOnly} readOnly={readOnly}
requestRejected={requestRejected}
statusVariant={statusVariant} statusVariant={statusVariant}
onReassign={openReassign} onReassign={openReassign}
onRemove={askDelete} onRemove={askDelete}
@@ -1,4 +1,4 @@
import { REQUEST_STATUS, isEditableRowStatus, isFullyClosedRequestStatus } from './requestManage'; import { REQUEST_STATUS, isEditableRowStatus, isFullyClosedRequestStatus, isMovableRowStatus } from './requestManage';
describe('isEditableRowStatus', () => { describe('isEditableRowStatus', () => {
it('черновик/в очереди/на согласовании/на исполнении — редактируемые', () => { 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', () => { describe('isFullyClosedRequestStatus', () => {
it('true только для статуса "Закрыта"', () => { it('true только для статуса "Закрыта"', () => {
expect(isFullyClosedRequestStatus(REQUEST_STATUS.closed)).toBe(true); expect(isFullyClosedRequestStatus(REQUEST_STATUS.closed)).toBe(true);
@@ -27,11 +27,16 @@ export const APPROVAL_PHASE = {
} as const; } as const;
// Строка маршрута в одном из этих статусов ЕЩЁ НЕ решена согласующим/исполнителем // Строка маршрута в одном из этих статусов ЕЩЁ НЕ решена согласующим/исполнителем
// ОКОНЧАТЕЛЬНО — её можно менять (переназначить человека, удалить, переставить, // ОКОНЧАТЕЛЬНО — её можно менять (переназначить человека, удалить, добавить новую
// добавить новую рядом). «Возвращена на доработку» сюда включена нарочно: это не // рядом). «Возвращена на доработку» сюда включена нарочно: это не финальное
// финальное решение (заявка вернётся на новый круг после доработки), поэтому // решение (заявка вернётся на новый круг после доработки), поэтому согласующего
// согласующего на этом шаге всё ещё можно переназначить. approved/executed/ // на этом шаге всё ещё можно переназначить. approved/executed/rejected —
// rejected — финальные решения, трогать нельзя ни при каких условиях. // финальные решения, трогать нельзя ни при каких условиях.
//
// ВАЖНО: это НЕ то же самое, что «можно переставить» (см. isMovableRowStatus
// ниже) — «Возвращена на доработку» уже означает, что своё слово по этому шагу
// согласующий сказал (просто мы разрешаем потом переназначить человека), поэтому
// двигать такой шаг/менять его параллельность нельзя, а переназначить — можно.
const EDITABLE_ROW_STATUSES: string[] = [ const EDITABLE_ROW_STATUSES: string[] = [
REQUEST_STATUS.draft, REQUEST_STATUS.draft,
REQUEST_STATUS.queued, REQUEST_STATUS.queued,
@@ -44,6 +49,22 @@ export function isEditableRowStatus(status: string): boolean {
return EDITABLE_ROW_STATUSES.indexOf(status) !== -1; 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;
}
// Заявка в этом статусе прошла полностью (все фазы завершены) — маршрут можно // Заявка в этом статусе прошла полностью (все фазы завершены) — маршрут можно
// только посмотреть, редактирование выключается целиком независимо от статусов // только посмотреть, редактирование выключается целиком независимо от статусов
// отдельных строк. // отдельных строк.
@@ -307,6 +307,7 @@ export const texts = {
lockedHint: 'Решение уже принято — недоступно для правки.', lockedHint: 'Решение уже принято — недоступно для правки.',
ghostHint: 'Сохраняется…', ghostHint: 'Сохраняется…',
phaseClosedHint: 'Эта фаза уже завершена — добавить в неё новый шаг нельзя.', phaseClosedHint: 'Эта фаза уже завершена — добавить в неё новый шаг нельзя.',
requestRejectedHint: 'Заявка отклонена — добавить исполнителя нельзя.',
moveUp: 'Шаг выше', moveUp: 'Шаг выше',
moveDown: 'Шаг ниже', moveDown: 'Шаг ниже',
mergeWithPrev: 'Объединить с предыдущим шагом (выполнять параллельно)', mergeWithPrev: 'Объединить с предыдущим шагом (выполнять параллельно)',