feat: added status next in que and changed forms state machine

This commit is contained in:
Тимур Городецкий
2026-08-05 13:53:26 +05:00
parent 5bc80df11c
commit 571614c031
18 changed files with 373 additions and 68 deletions
@@ -8,7 +8,7 @@ import '@pnp/sp/profiles';
import { IApprovalRow } from './models/IApprovalRow';
import { IMatrixRow } from './models/IMatrixRow';
import { ISiteUser } from './models/ISiteUser';
import { APPROVAL_PHASE } from './models/statuses';
import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses';
// Внутренние имена колонок списка согласования (создаются провижинингом).
const FIELDS = {
@@ -187,6 +187,19 @@ export class ApprovalListService {
});
}
// Активирует следующий шаг маршрута: первую по очереди строку со статусом
// «Ждёт очереди» переводит в активный статус её фазы (Согласование → «На
// согласовании», Исполнение → «На исполнении»). Если ждущих нет — ничего.
// Вызывается при отправке (активировать первый шаг) и после каждого решения.
public async activateNextQueued(requestId: number): Promise<void> {
const rows = await this.getForRequest(requestId); // отсортированы по StepOrder
const next = rows.filter((r) => r.status === REQUEST_STATUS.queued)[0];
if (!next) return;
const active =
next.phase === APPROVAL_PHASE.execution ? REQUEST_STATUS.onExecution : REQUEST_STATUS.onApproval;
await this.list().items.getById(next.id).update({ [FIELDS.status]: active });
}
// Удаляет все строки согласования по заявке (при возврате/повторной отправке).
public async deleteForRequest(requestId: number): Promise<void> {
const rows = await this.getForRequest(requestId);
@@ -3,7 +3,7 @@ import { resolveRoute } from './approvalRoute.service';
import { ApprovalListService, IApproverToWrite } from './approvalList.service';
import { IMatrixRow } from './models/IMatrixRow';
import { ApprovalStatus } from './models/IApprovalRouteApi';
import { APPROVAL_PHASE } from './models/statuses';
import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses';
// Планируемый маршрут для НОВОЙ заявки: resolve по email инициатора → строки матрицы.
// status выбирает фазу: 'Approval' (согласующие, по умолчанию) или 'Execution'
@@ -58,12 +58,23 @@ export async function writeRoute(
status: string
): Promise<void> {
await approval.deleteForRequest(requestId);
await approval.createSteps(requestId, approvers, status, APPROVAL_PHASE.approval, 1);
// При ОТПРАВКЕ все шаги пишем «Ждёт очереди», затем активируем первый —
// так активен ровно текущий шаг, остальные ждут. В черновике — статус заявки
// (Черновик), без активации.
const submitted = status === REQUEST_STATUS.onApproval;
const rowStatus = submitted ? REQUEST_STATUS.queued : status;
await approval.createSteps(requestId, approvers, rowStatus, APPROVAL_PHASE.approval, 1);
await approval.createSteps(
requestId,
executors,
status,
rowStatus,
APPROVAL_PHASE.execution,
approvers.length + 1
);
if (submitted) {
await approval.activateNextQueued(requestId);
}
}
@@ -8,13 +8,15 @@ import { IApprovalRow } from './models/IApprovalRow';
import { ISiteUser } from './models/ISiteUser';
import { emitRequestsChanged } from '../requestEvents';
export type ApproverDecision = 'approve' | 'return' | 'reject';
// approve/return/reject — согласующий; execute — исполнитель отметил «Выполнено».
export type ApproverDecision = 'approve' | 'return' | 'reject' | 'execute';
// Решение согласующего → статус его строки в списке согласования.
// Решение → статус его строки в списке согласования.
const ROW_STATUS: Record<ApproverDecision, string> = {
approve: REQUEST_STATUS.approved,
return: REQUEST_STATUS.returned,
reject: REQUEST_STATUS.rejected,
execute: REQUEST_STATUS.executed,
};
// Действия согласующего: определение его строки, применение решения,
@@ -34,7 +36,11 @@ export class ApproverService {
public async getPendingRowForCurrentUser(requestId: number): Promise<IApprovalRow | undefined> {
const rows = await this.approval.getForRequest(requestId);
const userId = await this.approval.getCurrentUserId();
const firstPending = rows.filter((r) => r.status === REQUEST_STATUS.onApproval)[0];
// Активна первая незакрытая строка по очереди: согласующий («На согласовании»)
// или, когда согласование пройдено, исполнитель («На исполнении»).
const firstPending = rows.filter(
(r) => r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution
)[0];
if (firstPending && firstPending.approverId === userId) return firstPending;
return undefined;
}
@@ -53,12 +59,17 @@ export class ApproverService {
const prevOrder = before.length > 0 ? before[before.length - 1].order : current.order - 1;
const newOrder = (prevOrder + current.order) / 2;
const position = await this.approval.getJobTitle(user.loginName);
// Делегат наследует фазу той строки, вместо которой согласует (согласование/исполнение).
// Делегат наследует фазу той строки, вместо которой действует, и её статус
// ожидания (согласование → «На согласовании», исполнение → «На исполнении»).
const pendingStatus =
current.phase === APPROVAL_PHASE.execution
? REQUEST_STATUS.onExecution
: REQUEST_STATUS.onApproval;
await this.approval.addApproverRow(
requestId,
{ email: user.email, position, allowReassign: false },
newOrder,
REQUEST_STATUS.onApproval,
pendingStatus,
current.phase
);
}
@@ -72,6 +83,12 @@ export class ApproverService {
comment: string
): Promise<string> {
await this.approval.setDecision(rowId, ROW_STATUS[decision], comment);
// Положительное решение двигает очередь — активируем следующий ждущий шаг
// (в т.ч. переход из согласования в исполнение). При возврате/отклонении
// очередь не двигаем: заявка уходит на доработку.
if (decision === 'approve' || decision === 'execute') {
await this.approval.activateNextQueued(requestId);
}
const rows = await this.approval.getForRequest(requestId);
const requestStatus = this.computeRequestStatus(rows);
await this.updateRequestStatus(requestId, requestStatus);
@@ -99,7 +116,7 @@ export class ApproverService {
if (!approvalDone) return REQUEST_STATUS.onApproval;
if (execRows.length === 0) return REQUEST_STATUS.approved;
if (execRows.every((r) => r.status === REQUEST_STATUS.approved)) return REQUEST_STATUS.closed;
if (execRows.every((r) => r.status === REQUEST_STATUS.executed)) return REQUEST_STATUS.closed;
return REQUEST_STATUS.execution;
}
@@ -9,6 +9,12 @@ export const REQUEST_STATUS = {
approved: 'Согласовано',
rejected: 'Отклонено',
returned: 'Возвращена на доработку',
// Статус СТРОКИ маршрута, до которой ещё не дошла очередь (активен только текущий шаг).
queued: 'Ждёт очереди',
// Статусы СТРОК фазы исполнения: активная очередь исполнителя и выполнено.
onExecution: 'На исполнении',
executed: 'Выполнено',
// Статусы ЗАЯВКИ на этапе исполнения.
execution: 'Выполнение',
closed: 'Закрыта',
} as const;
@@ -27,13 +33,16 @@ export function isEditableStatus(status: string | undefined): boolean {
return !status || EDITABLE_STATUSES.indexOf(status) !== -1;
}
// Полный набор значений для Choice-колонки при провижининге.
// Полный набор значений для Choice-колонки Status при провижининге.
export const REQUEST_STATUS_CHOICES: string[] = [
REQUEST_STATUS.draft,
REQUEST_STATUS.onApproval,
REQUEST_STATUS.approved,
REQUEST_STATUS.rejected,
REQUEST_STATUS.returned,
REQUEST_STATUS.queued,
REQUEST_STATUS.onExecution,
REQUEST_STATUS.executed,
REQUEST_STATUS.execution,
REQUEST_STATUS.closed,
];
@@ -3,6 +3,7 @@ import styles from './approval.module.scss';
import { Icon } from '../../ui';
import { IUseMatrixEditor } from '../useMatrixEditor';
import { ISiteUser } from '../models/ISiteUser';
import { StatusBadge } from './StatusBadge';
import { UserPicker } from './UserPicker';
export interface IApprovalMatrixProps {
@@ -53,7 +54,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
<tr key={i}>
<td>{r.who}</td>
<td>{r.position}</td>
<td>{r.status}</td>
<td>{r.status && r.status !== '—' ? <StatusBadge status={r.status} /> : '—'}</td>
<td>{r.date}</td>
{editable && (
<td className={styles.matrixActions}>
@@ -74,8 +74,10 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({ service, requestI
const isExecution = phase === APPROVAL_PHASE.execution;
if (checking) {
return <div className={styles.state}>Проверяем вашу очередь…</div>;
// Пока проверяем очередь или если действий нет (не твоя очередь / заявка
// обработана / тебя нет в маршруте) — панель не показываем вовсе.
if (checking || rowId === undefined) {
return null;
}
if (resultStatus) {
@@ -89,14 +91,6 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({ service, requestI
);
}
if (rowId === undefined) {
return (
<div className={styles.panel}>
<Alert variant="warn">Сейчас не ваша очередь, либо заявка уже обработана.</Alert>
</div>
);
}
// Фаза исполнения: одно действие «Выполнено» (без обязательного комментария),
// строка помечается как «Согласовано» → computeRequestStatus переведёт заявку в
// «Выполнение»/«Закрыта».
@@ -114,7 +108,7 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({ service, requestI
/>
</FormField>
<div className={styles.actions}>
<Button variant="accent" disabled={submitting} onClick={() => decide('approve')}>
<Button variant="accent" disabled={submitting} onClick={() => decide('execute')}>
<Icon name="check" size="sm" />
Выполнено
</Button>
@@ -17,6 +17,11 @@
color: $gray-700;
}
.queued {
background: $gray-100;
color: $gray-500;
}
.onApproval {
background: $blue-50;
color: $blue-600;
@@ -5,8 +5,11 @@ import { REQUEST_STATUS } from '../models/statuses';
// Цвет бейджа по статусу заявки. Неизвестный статус → нейтральный (как черновик).
const VARIANT: Record<string, string> = {
[REQUEST_STATUS.draft]: styles.draft,
[REQUEST_STATUS.queued]: styles.queued,
[REQUEST_STATUS.onApproval]: styles.onApproval,
[REQUEST_STATUS.onExecution]: styles.onApproval, // активный шаг — как «на согласовании»
[REQUEST_STATUS.approved]: styles.approved,
[REQUEST_STATUS.executed]: styles.approved, // выполнено — как «согласовано» (зелёный)
[REQUEST_STATUS.rejected]: styles.rejected,
[REQUEST_STATUS.returned]: styles.returned,
[REQUEST_STATUS.execution]: styles.execution,