feat: перестановка ждущих шагов маршрута в поданных заявках на подбор и приём
Алтынай Бултурукова (право canEditRoute) просила менять порядок участников уже поданной заявки. Стрелки у шагов согласования и исполнения: меняются местами только соседние шаги одной фазы, оба «Ждут очереди» — активный и пройденные шаги не трогаются, поэтому никто не теряет заявку и оповещать некого. Строки перечитываются перед записью, StepOrder пишется одним батчем. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user