feat: перестановка ждущих шагов маршрута в поданных заявках на подбор и приём

Алтынай Бултурукова (право canEditRoute) просила менять порядок участников
уже поданной заявки. Стрелки у шагов согласования и исполнения: меняются
местами только соседние шаги одной фазы, оба «Ждут очереди» — активный и
пройденные шаги не трогаются, поэтому никто не теряет заявку и оповещать
некого. Строки перечитываются перед записью, StepOrder пишется одним батчем.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-08 12:39:10 +05:00
parent 962d8f9c5c
commit cfe97dc67e
11 changed files with 357 additions and 23 deletions
+1
View File
@@ -19,6 +19,7 @@
| Удалять согласующих, которые ещё ждут очереди | Алтынай Бултурукова, Павел Горбенко | email в коде | Подбор, Приём, Реферальная рекомендация: и при составлении, и в поданной заявке | `shared/approval/routeEditRights.ts` → `canEditRoute` | | Удалять согласующих, которые ещё ждут очереди | Алтынай Бултурукова, Павел Горбенко | email в коде | Подбор, Приём, Реферальная рекомендация: и при составлении, и в поданной заявке | `shared/approval/routeEditRights.ts` → `canEditRoute` |
| Удалять любых согласующих **при составлении** заявки | те же (`canEditRoute`) | email в коде | Все остальные формы с маршрутом согласования (отпуск, увольнение, 1С и т. д.); в поданной заявке — нет | `shared/approval/routeEditRights.ts` → `composeRemoveRowRule` | | Удалять любых согласующих **при составлении** заявки | те же (`canEditRoute`) | email в коде | Все остальные формы с маршрутом согласования (отпуск, увольнение, 1С и т. д.); в поданной заявке — нет | `shared/approval/routeEditRights.ts` → `composeRemoveRowRule` |
| Добавлять и удалять исполнителей **при составлении** заявки | те же (`canEditRoute`) | email в коде | Все формы, где в маршруте из админки есть фаза исполнения; в поданной заявке — нет | `shared/approval/routeEditRights.ts` → `canEditExecutors` | | Добавлять и удалять исполнителей **при составлении** заявки | те же (`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 (`canEditRoute`) | **Только** Заявка на подбор, матрица «Исполнение» | `canEditRoute` + `forms/RecruitingSelection/RecruitingSelectionView.tsx` |
| То же в поданной заявке: заменять и удалять невыполненных исполнителей, **в том числе активного и себя** | Исполнители заявки, у которых свой шаг исполнения ещё не выполнен | роль в маршруте (фаза «Исполнение»), без списка email | **Только** Заявка в отдел 1С | `forms/Ticket1C/useExecutorEditing.ts` | | То же в поданной заявке: заменять и удалять невыполненных исполнителей, **в том числе активного и себя** | Исполнители заявки, у которых свой шаг исполнения ещё не выполнен | роль в маршруте (фаза «Исполнение»), без списка email | **Только** Заявка в отдел 1С | `forms/Ticket1C/useExecutorEditing.ts` |
| Править **все поля и вложения** заявки (добавлять и удалять файлы) на своём активном шаге согласования | Алтынай Бултурукова | email в коде (список по каждой форме) | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` | | Править **все поля и вложения** заявки (добавлять и удалять файлы) на своём активном шаге согласования | Алтынай Бултурукова | email в коде (список по каждой форме) | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` |
@@ -32,7 +32,9 @@ import {
canEditAllFields, canEditAllFields,
REQUEST_STATUS, REQUEST_STATUS,
IMatrixRow, IMatrixRow,
ApproverDecision ApproverDecision,
queuedStepMoveRule,
useQueuedStepMove
} from '../../shared/approval'; } from '../../shared/approval';
import { navigate } from '../../shared/routing'; import { navigate } from '../../shared/routing';
import { effectiveUserEmail } from '../../shared/impersonation'; import { effectiveUserEmail } from '../../shared/impersonation';
@@ -267,6 +269,15 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
.catch((e) => setRouteError(friendlyErrorMessage(e))); .catch((e) => setRouteError(friendlyErrorMessage(e)));
}; };
// Поменять местами ждущие шаги (согласующих или исполнителей) — то же право
// routeEditRights.ts; правило «только среди ждущих» — см. queuedStepMove.ts.
const handleMoveStep = useQueuedStepMove(
approverService,
itemId,
() => setDecisionSeq((n) => n + 1),
setRouteError
);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
setLoading(true); setLoading(true);
@@ -385,6 +396,8 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
search={(q) => approval.searchUsers(q)} search={(q) => approval.searchUsers(q)}
canRemoveRow={canRemoveApprover ? (row) => row.status === REQUEST_STATUS.queued : undefined} canRemoveRow={canRemoveApprover ? (row) => row.status === REQUEST_STATUS.queued : undefined}
onRemoveRow={handleRemoveApprover} onRemoveRow={handleRemoveApprover}
canMoveRow={canRemoveApprover ? queuedStepMoveRule(approvers.rows) : undefined}
onMoveRow={handleMoveStep}
/> />
{!executors.loading && executors.rows.length > 0 && ( {!executors.loading && executors.rows.length > 0 && (
@@ -394,6 +407,8 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
title="Исполнение" title="Исполнение"
roleLabel="Исполнитель" roleLabel="Исполнитель"
emptyText="Исполнители не назначены." emptyText="Исполнители не назначены."
canMoveRow={canRemoveApprover ? queuedStepMoveRule(executors.rows) : undefined}
onMoveRow={handleMoveStep}
/> />
)} )}
</div> </div>
@@ -31,7 +31,9 @@ import {
IMatrixRow, IMatrixRow,
ISiteUser, ISiteUser,
APPROVAL_PHASE, APPROVAL_PHASE,
ApproverDecision ApproverDecision,
queuedStepMoveRule,
useQueuedStepMove
} from '../../shared/approval'; } from '../../shared/approval';
import { getAttachments } from '../../shared/attachments/attachments.service'; import { getAttachments } from '../../shared/attachments/attachments.service';
import { useAttachmentEditor } from '../../shared/attachments/useAttachmentEditor'; import { useAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
@@ -256,6 +258,15 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
.catch((e) => setRouteError(friendlyErrorMessage(e))); .catch((e) => setRouteError(friendlyErrorMessage(e)));
}; };
// Поменять местами ждущие шаги (согласующих или исполнителей) — то же право
// routeEditRights.ts; правило «только среди ждущих» — см. queuedStepMove.ts.
const handleMoveStep = useQueuedStepMove(
approverService,
itemId,
() => setDecisionSeq((n) => n + 1),
setRouteError
);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
setLoading(true); setLoading(true);
@@ -358,6 +369,8 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
search={(q) => approval.searchUsers(q)} search={(q) => approval.searchUsers(q)}
canRemoveRow={canRemoveApprover ? (row) => row.status === REQUEST_STATUS.queued : undefined} canRemoveRow={canRemoveApprover ? (row) => row.status === REQUEST_STATUS.queued : undefined}
onRemoveRow={handleRemoveApprover} onRemoveRow={handleRemoveApprover}
canMoveRow={canRemoveApprover ? queuedStepMoveRule(approvers.rows) : undefined}
onMoveRow={handleMoveStep}
/> />
{!executors.loading && executors.rows.length > 0 && ( {!executors.loading && executors.rows.length > 0 && (
@@ -373,6 +386,8 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
canRemoveApprover ? (row) => isPendingExecutor(row) && pendingExecutorsCount > 1 : undefined canRemoveApprover ? (row) => isPendingExecutor(row) && pendingExecutorsCount > 1 : undefined
} }
onRemoveRow={handleRemoveExecutor} onRemoveRow={handleRemoveExecutor}
canMoveRow={canRemoveApprover ? queuedStepMoveRule(executors.rows) : undefined}
onMoveRow={handleMoveStep}
/> />
)} )}
</div> </div>
@@ -13,6 +13,7 @@ import { ISiteUser } from './models/ISiteUser';
import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses'; import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses';
import { userSearchVariants } from './userSearchVariants'; import { userSearchVariants } from './userSearchVariants';
import { planRoutePatch } from './routePatchPlan'; import { planRoutePatch } from './routePatchPlan';
import { IStepOrderChange } from './queuedStepMove';
// Внутренние имена колонок списка согласования (создаются провижинингом). // Внутренние имена колонок списка согласования (создаются провижинингом).
const FIELDS = { const FIELDS = {
@@ -304,6 +305,21 @@ export class ApprovalListService {
}); });
} }
// Новые StepOrder для нескольких строк — ОДНИМ батчем (см. createSteps): при
// обмене двух шагов местами половинчатая запись дала бы два шага с одним
// order, то есть внезапный параллельный шаг. См. ApproverService.moveQueuedStep.
public async setStepOrders(changes: IStepOrderChange[]): Promise<void> {
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 (для делегата). // Добавляет одну строку согласования с заданным Order (для делегата).
// delegatedById — Id пользователя, который выполнил переназначение (не строки // delegatedById — Id пользователя, который выполнил переназначение (не строки
// из маршрута — у них делегата нет). Он сохраняет право переназначить эту // из маршрута — у них делегата нет). Он сохраняет право переназначить эту
@@ -8,6 +8,7 @@ import { REQUEST_STATUS, APPROVAL_PHASE } from './models/statuses';
import { IApprovalRow } from './models/IApprovalRow'; import { IApprovalRow } from './models/IApprovalRow';
import { ISiteUser } from './models/ISiteUser'; import { ISiteUser } from './models/ISiteUser';
import { emitRequestsChanged } from '../requestEvents'; import { emitRequestsChanged } from '../requestEvents';
import { planQueuedStepMove, StepMoveDirection } from './queuedStepMove';
// approve/return/reject — согласующий; execute — исполнитель отметил «Выполнено». // approve/return/reject — согласующий; execute — исполнитель отметил «Выполнено».
export type ApproverDecision = 'approve' | 'return' | 'reject' | 'execute'; export type ApproverDecision = 'approve' | 'return' | 'reject' | 'execute';
@@ -141,6 +142,29 @@ export class ApproverService {
emitRequestsChanged(); emitRequestsChanged();
} }
/**
* Поменять местами ещё не начатый шаг маршрута с соседним ждущим шагом той же
* фазы (согласование или исполнение) — право см. routeEditRights.ts, правило
* см. queuedStepMove.ts.
*
* Строки перечитываются с сервера прямо перед записью: пока на экране открыт
* маршрут, кто-то мог уже согласовать, и сосед стал активным, — тогда
* перестановка отменяется, а не ломает очередь.
*
* Участники не меняются, поэтому заявку не «трогаем» (см. touchRequest): права
* на элемент данных пересчитывать незачем, оповещать тоже некого — активный
* шаг остаётся прежним.
*/
public async moveQueuedStep(requestId: number, rowId: number, direction: StepMoveDirection): Promise<void> {
const rows = await this.approval.getForRequest(requestId);
const changes = planQueuedStepMove(rows, rowId, direction);
if (!changes) {
throw new Error('Маршрут уже изменился (шаг взят в работу или решён) — обновите заявку.');
}
await this.approval.setStepOrders(changes);
emitRequestsChanged();
}
/** /**
* Шаг фазы «Исполнение», который ещё можно заменить или удалить: ждёт очереди * Шаг фазы «Исполнение», который ещё можно заменить или удалить: ждёт очереди
* или уже активен («На исполнении»). Выполненный шаг — решение человека, его * или уже активен («На исполнении»). Выполненный шаг — решение человека, его
@@ -76,6 +76,9 @@ export { resolveApproverOrgInfo } from './approverOrgInfo';
export type { IApproverOrgInfo } from './approverOrgInfo'; export type { IApproverOrgInfo } from './approverOrgInfo';
export { canEditRoute, canEditExecutors, composeRemoveRowRule } from './routeEditRights'; export { canEditRoute, canEditExecutors, composeRemoveRowRule } from './routeEditRights';
export { canEditAllFields } from './fullEditRights'; 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 { ApprovalMatrix } from './ui/ApprovalMatrix';
export { ExecutorsMatrix } from './ui/ExecutorsMatrix'; export { ExecutorsMatrix } from './ui/ExecutorsMatrix';
export { ApprovalHistory } from './history/ApprovalHistory'; export { ApprovalHistory } from './history/ApprovalHistory';
@@ -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);
});
});
@@ -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;
}
@@ -13,6 +13,8 @@ import { effectiveUserEmail } from '../impersonation';
* Подключено только в 3 формах рекрутинга (Заявка на подбор, Заявка на прием, * Подключено только в 3 формах рекрутинга (Заявка на подбор, Заявка на прием,
* Реферальная рекомендация) — каждая форма сама решает, передавать ли * Реферальная рекомендация) — каждая форма сама решает, передавать ли
* canRemoveRow в ApprovalMatrix; здесь только сама проверка личности. * canRemoveRow в ApprovalMatrix; здесь только сама проверка личности.
* В поданных подборе и приёме это же право даёт менять местами ждущие шаги
* (canMoveRow, правило — queuedStepMove.ts).
*/ */
const ROUTE_EDITOR_EMAILS: ReadonlySet<string> = new Set([ const ROUTE_EDITOR_EMAILS: ReadonlySet<string> = new Set([
'a.bulturukova@tnsu.kz', // Алтынай Бултурукова 'a.bulturukova@tnsu.kz', // Алтынай Бултурукова
@@ -8,6 +8,7 @@ import { StatusBadge } from './StatusBadge';
import { UserPicker } from './UserPicker'; import { UserPicker } from './UserPicker';
import { ReplaceRowPanel } from './ReplaceRowPanel'; import { ReplaceRowPanel } from './ReplaceRowPanel';
import { useFlipRows } from './useFlipRows'; import { useFlipRows } from './useFlipRows';
import { StepMoveDirection } from '../queuedStepMove';
export interface IApprovalMatrixProps { export interface IApprovalMatrixProps {
editor: IUseMatrixEditor; editor: IUseMatrixEditor;
@@ -54,6 +55,14 @@ export interface IApprovalMatrixProps {
canReplaceRow?: (row: IMatrixRow, index: number) => boolean; canReplaceRow?: (row: IMatrixRow, index: number) => boolean;
/** Выбран новый человек для строки (задаётся вместе с canReplaceRow). */ /** Выбран новый человек для строки (задаётся вместе с canReplaceRow). */
onReplaceRow?: (row: IMatrixRow, user: ISiteUser) => void; 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) правка состава. */ /** Таблица маршрута (согласование или исполнение) + (если editable) правка состава. */
@@ -70,6 +79,8 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
onRemoveRow, onRemoveRow,
canReplaceRow, canReplaceRow,
onReplaceRow, onReplaceRow,
canMoveRow,
onMoveRow,
}) => { }) => {
const { rows, loading, error, editable, blocks, retry } = editor; const { rows, loading, error, editable, blocks, retry } = editor;
// Строка, для которой открыт выбор замены (ReplaceRowPanel); undefined — закрыт. // Строка, для которой открыт выбор замены (ReplaceRowPanel); undefined — закрыт.
@@ -103,7 +114,23 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
} }
}); });
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]; const b = blockByRow[rowIndex];
setActiveKeys(rowKeys.slice(b.start, b.start + b.size)); setActiveKeys(rowKeys.slice(b.start, b.start + b.size));
if (direction === 'up') editor.moveBlockUp(rowIndex); if (direction === 'up') editor.moveBlockUp(rowIndex);
@@ -188,7 +215,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
{hasStatuses && <th>Статус</th>} {hasStatuses && <th>Статус</th>}
{hasDates && <th>Дата</th>} {hasDates && <th>Дата</th>}
{hasSla && <th>SLA</th>} {hasSla && <th>SLA</th>}
{editable && <th />} {showMoveCol && <th />}
{(editable || hasRemovable) && <th />} {(editable || hasRemovable) && <th />}
</tr> </tr>
</thead> </thead>
@@ -257,26 +284,33 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
)} )}
{/* Перемещение действует на ШАГ целиком (параллельные строки {/* Перемещение действует на ШАГ целиком (параллельные строки
переезжают вместе): одна пара стрелок на весь шаг. */} переезжают вместе): одна пара стрелок на весь шаг. */}
{editable && isBlockStart && ( {showMoveCol && isBlockStart && (
<td className={styles.matrixMoveCol} rowSpan={block.size}> <td className={styles.matrixMoveCol} rowSpan={block.size}>
<button {/* В поданной заявке у шагов, которые двигать нельзя совсем
type="button" (активный, пройденный), стрелок нет — иначе вся колонка
className={styles.iconBtn} из неактивных кнопок только шумит. */}
disabled={block.index === 0} {(editable || canMoveBlock(i, 'up') || canMoveBlock(i, 'down')) && (
onClick={() => moveBlock(i, 'up')} <>
title="Шаг выше" <button
> type="button"
<Icon name="chevron-down" size="sm" className={styles.flipY} /> className={styles.iconBtn}
</button> disabled={!canMoveBlock(i, 'up')}
<button onClick={() => moveBlock(i, 'up')}
type="button" title="Шаг выше"
className={styles.iconBtn} >
disabled={block.index === blocks.length - 1} <Icon name="chevron-down" size="sm" className={styles.flipY} />
onClick={() => moveBlock(i, 'down')} </button>
title="Шаг ниже" <button
> type="button"
<Icon name="chevron-down" size="sm" /> className={styles.iconBtn}
</button> disabled={!canMoveBlock(i, 'down')}
onClick={() => moveBlock(i, 'down')}
title="Шаг ниже"
>
<Icon name="chevron-down" size="sm" />
</button>
</>
)}
</td> </td>
)} )}
{(editable || hasRemovable) && ( {(editable || hasRemovable) && (
@@ -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<boolean>(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;
});
};
}