feat: перестановка ждущих шагов маршрута в поданных заявках на подбор и приём
Алтынай Бултурукова (право canEditRoute) просила менять порядок участников уже поданной заявки. Стрелки у шагов согласования и исполнения: меняются местами только соседние шаги одной фазы, оба «Ждут очереди» — активный и пройденные шаги не трогаются, поэтому никто не теряет заявку и оповещать некого. Строки перечитываются перед записью, StepOrder пишется одним батчем. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<IRecruitingHireViewProps> = ({ 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<IRecruitingHireViewProps> = ({ 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<IRecruitingHireViewProps> = ({ context, itemI
|
||||
title="Исполнение"
|
||||
roleLabel="Исполнитель"
|
||||
emptyText="Исполнители не назначены."
|
||||
canMoveRow={canRemoveApprover ? queuedStepMoveRule(executors.rows) : undefined}
|
||||
onMoveRow={handleMoveStep}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<IRecruitingSelectionViewProps> = ({ 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<IRecruitingSelectionViewProps> = ({ 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<IRecruitingSelectionViewProps> = ({ cont
|
||||
canRemoveApprover ? (row) => isPendingExecutor(row) && pendingExecutorsCount > 1 : undefined
|
||||
}
|
||||
onRemoveRow={handleRemoveExecutor}
|
||||
canMoveRow={canRemoveApprover ? queuedStepMoveRule(executors.rows) : undefined}
|
||||
onMoveRow={handleMoveStep}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<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 (для делегата).
|
||||
// delegatedById — Id пользователя, который выполнил переназначение (не строки
|
||||
// из маршрута — у них делегата нет). Он сохраняет право переназначить эту
|
||||
|
||||
@@ -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<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 { 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';
|
||||
|
||||
@@ -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 формах рекрутинга (Заявка на подбор, Заявка на прием,
|
||||
* Реферальная рекомендация) — каждая форма сама решает, передавать ли
|
||||
* canRemoveRow в ApprovalMatrix; здесь только сама проверка личности.
|
||||
* В поданных подборе и приёме это же право даёт менять местами ждущие шаги
|
||||
* (canMoveRow, правило — queuedStepMove.ts).
|
||||
*/
|
||||
const ROUTE_EDITOR_EMAILS: ReadonlySet<string> = new Set([
|
||||
'a.bulturukova@tnsu.kz', // Алтынай Бултурукова
|
||||
|
||||
@@ -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<IApprovalMatrixProps> = ({
|
||||
onRemoveRow,
|
||||
canReplaceRow,
|
||||
onReplaceRow,
|
||||
canMoveRow,
|
||||
onMoveRow,
|
||||
}) => {
|
||||
const { rows, loading, error, editable, blocks, retry } = editor;
|
||||
// Строка, для которой открыт выбор замены (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];
|
||||
setActiveKeys(rowKeys.slice(b.start, b.start + b.size));
|
||||
if (direction === 'up') editor.moveBlockUp(rowIndex);
|
||||
@@ -188,7 +215,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
||||
{hasStatuses && <th>Статус</th>}
|
||||
{hasDates && <th>Дата</th>}
|
||||
{hasSla && <th>SLA</th>}
|
||||
{editable && <th />}
|
||||
{showMoveCol && <th />}
|
||||
{(editable || hasRemovable) && <th />}
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -257,26 +284,33 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
||||
)}
|
||||
{/* Перемещение действует на ШАГ целиком (параллельные строки
|
||||
переезжают вместе): одна пара стрелок на весь шаг. */}
|
||||
{editable && isBlockStart && (
|
||||
{showMoveCol && isBlockStart && (
|
||||
<td className={styles.matrixMoveCol} rowSpan={block.size}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
disabled={block.index === 0}
|
||||
onClick={() => moveBlock(i, 'up')}
|
||||
title="Шаг выше"
|
||||
>
|
||||
<Icon name="chevron-down" size="sm" className={styles.flipY} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
disabled={block.index === blocks.length - 1}
|
||||
onClick={() => moveBlock(i, 'down')}
|
||||
title="Шаг ниже"
|
||||
>
|
||||
<Icon name="chevron-down" size="sm" />
|
||||
</button>
|
||||
{/* В поданной заявке у шагов, которые двигать нельзя совсем
|
||||
(активный, пройденный), стрелок нет — иначе вся колонка
|
||||
из неактивных кнопок только шумит. */}
|
||||
{(editable || canMoveBlock(i, 'up') || canMoveBlock(i, 'down')) && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
disabled={!canMoveBlock(i, 'up')}
|
||||
onClick={() => moveBlock(i, 'up')}
|
||||
title="Шаг выше"
|
||||
>
|
||||
<Icon name="chevron-down" size="sm" className={styles.flipY} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
disabled={!canMoveBlock(i, 'down')}
|
||||
onClick={() => moveBlock(i, 'down')}
|
||||
title="Шаг ниже"
|
||||
>
|
||||
<Icon name="chevron-down" size="sm" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{(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;
|
||||
});
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user