правки по логике переключения
This commit is contained in:
+41
-9
@@ -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: 'Объединить с предыдущим шагом (выполнять параллельно)',
|
||||||
|
|||||||
Reference in New Issue
Block a user