основное готово

This commit is contained in:
gorbenko.pavel
2026-08-06 16:01:33 +05:00
parent 6acb22dd95
commit 13833217d0
15 changed files with 426 additions and 83 deletions
@@ -63,8 +63,13 @@ export class ApprovalListService {
phase: string = APPROVAL_PHASE.approval,
orderStart: number = 1
): Promise<void> {
// orderStart — СМЕЩЕНИЕ фазы: у согласующих оно 1, у исполнителей начинается
// после последнего согласующего (обе фазы живут в одном списке и делят
// сквозную нумерацию). a.order — номер шага ВНУТРИ фазы (1..N), и у
// параллельных шагов он совпадает, поэтому нумеровать по позиции в массиве
// нельзя. Без a.order падаем на позицию — для вызовов, где параллельности нет.
const withOrder = approvers
.map((a, i) => ({ a, order: a.order ?? orderStart + i }))
.map((a, i) => ({ a, order: orderStart - 1 + (a.order ?? i + 1) }))
.filter((x) => !!x.a.email);
for (const { a, order } of withOrder) {
@@ -76,13 +76,24 @@ export async function writeRoute(
const submitted = status === REQUEST_STATUS.onApproval;
const rowStatus = submitted ? REQUEST_STATUS.queued : status;
// Смещение фазы исполнения — по МАКСИМАЛЬНОМУ номеру шага согласующих, а не по
// их количеству: при параллельных шагах несколько согласующих делят один номер,
// поэтому approvers.length больше последнего реального шага (три согласующих,
// из которых двое параллельны, — это шаги 1 и 2, а не 1..3). На очередь это не
// влияет (activateNextQueued берёт минимальный order среди ждущих, разрывы ему
// безразличны) — но нумерация шагов в таблице «Исполнение» шла бы с пропуском.
const lastApprovalStep = approvers.reduce(
(max, a, i) => Math.max(max, a.order ?? i + 1),
0
);
await approval.createSteps(requestId, approvers, rowStatus, APPROVAL_PHASE.approval, 1);
await approval.createSteps(
requestId,
executors,
rowStatus,
APPROVAL_PHASE.execution,
approvers.length + 1
lastApprovalStep + 1
);
if (submitted) {
@@ -9,4 +9,10 @@ export interface IMatrixRow {
comment: string;
allowReassign: boolean; // может ли этот согласующий добавить делеганта
phase?: string; // фаза строки: «Согласование» | «Исполнение» (см. APPROVAL_PHASE)
// Шаг идёт ОДНОВРЕМЕННО с предыдущим (у обоих одинаковый order — так
// activateNextQueued активирует их вместе). У первой строки всегда false.
// При загрузке выводится из совпадения order с предыдущей строкой, при
// редактировании — переключается кнопкой и заново раздаёт order (см.
// useMatrixEditor/withOrders).
parallelWithPrev?: boolean;
}
@@ -28,7 +28,22 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
emptyText = 'Согласующие не заданы.',
pickerPlaceholder = 'Добавить согласующего: начните вводить ФИО…',
}) => {
const { rows, loading, error, editable } = editor;
const { rows, loading, error, editable, blocks } = editor;
// Строка → шаг, которому она принадлежит. Нужно, чтобы отрисовать ячейку
// «Шаг» один раз на весь шаг (rowSpan) и понять, можно ли двигать его выше/ниже.
const blockByRow: Array<{ index: number; start: number; size: number }> = [];
blocks.forEach((b, bi) => {
for (let i = b.start; i <= b.end; i += 1) {
blockByRow[i] = { index: bi, start: b.start, size: b.end - b.start + 1 };
}
});
// Пока по маршруту не принято ни одного решения (заявка не отправлена на
// согласование — черновик, доработка), колонки «Статус» и «Дата» пустые у всех
// строк: показывать столбец из одних прочерков смысла нет. Как только
// появляется первое решение — колонки возвращаются.
const hasDecisions = rows.some((r) => (r.status && r.status !== '—') || r.date);
return (
<div className={styles.matrixWrap}>
@@ -42,52 +57,87 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
<table className={styles.matrix}>
<thead>
<tr>
<th className={styles.matrixStepCol}>Шаг</th>
<th>{roleLabel}</th>
<th>Должность</th>
<th>Статус</th>
<th>Дата</th>
{hasDecisions && <th>Статус</th>}
{hasDecisions && <th>Дата</th>}
{editable && <th />}
{editable && <th />}
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i}>
<td>{r.who}</td>
<td>{r.position}</td>
<td>{r.status && r.status !== '—' ? <StatusBadge status={r.status} /> : '—'}</td>
<td>{r.date}</td>
{editable && (
<td className={styles.matrixActions}>
<button
type="button"
className={styles.iconBtn}
disabled={i === 0}
onClick={() => editor.moveUp(i)}
title="Выше"
>
<Icon name="chevron-down" size="sm" className={styles.flipY} />
</button>
<button
type="button"
className={styles.iconBtn}
disabled={i === rows.length - 1}
onClick={() => editor.moveDown(i)}
title="Ниже"
>
<Icon name="chevron-down" size="sm" />
</button>
<button
type="button"
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
onClick={() => editor.remove(i)}
title="Удалить"
>
<Icon name="trash" size="sm" />
</button>
</td>
)}
</tr>
))}
{rows.map((r, i) => {
const block = blockByRow[i];
// Ячейка «Шаг» рисуется один раз на весь шаг и растягивается на его
// строки. Кнопки перемещения живут в ней же: двигать параллельные
// строки поодиночке бессмысленно — шаг переезжает целиком.
const isBlockStart = block.start === i;
return (
<tr key={i} className={block.size > 1 ? styles.parallelRow : undefined}>
{isBlockStart && (
<td className={styles.matrixStepCol} rowSpan={block.size}>
<span className={styles.stepNo}>{r.order}</span>
</td>
)}
<td>{r.who}</td>
<td>{r.position}</td>
{hasDecisions && (
<td>{r.status && r.status !== '—' ? <StatusBadge status={r.status} /> : '—'}</td>
)}
{hasDecisions && <td>{r.date}</td>}
{editable && (
<td className={styles.matrixActions}>
<button
type="button"
className={`${styles.iconBtn} ${r.parallelWithPrev ? styles.iconBtnActive : ''}`}
disabled={i === 0}
onClick={() => editor.toggleParallel(i)}
title={
r.parallelWithPrev
? 'Отцепить: выполнять после предыдущего шага'
: 'Объединить: выполнять одновременно с предыдущим шагом'
}
>
<Icon name="user-check" size="sm" />
</button>
<button
type="button"
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
onClick={() => editor.remove(i)}
title="Удалить"
>
<Icon name="trash" size="sm" />
</button>
</td>
)}
{/* Перемещение — одно на весь шаг: параллельные строки
переезжают вместе, поэтому ячейка объединена по rowSpan. */}
{editable && isBlockStart && (
<td className={styles.matrixBlockMove} rowSpan={block.size}>
<button
type="button"
className={styles.iconBtn}
disabled={block.index === 0}
onClick={() => editor.moveBlockUp(i)}
title="Шаг выше"
>
<Icon name="chevron-down" size="sm" className={styles.flipY} />
</button>
<button
type="button"
className={styles.iconBtn}
disabled={block.index === blocks.length - 1}
onClick={() => editor.moveBlockDown(i)}
title="Шаг ниже"
>
<Icon name="chevron-down" size="sm" />
</button>
</td>
)}
</tr>
);
})}
</tbody>
</table>
)}
@@ -151,6 +151,54 @@
}
}
// Включённый режим-переключатель (шаг помечен как параллельный) — в отличие от
// остальных кнопок это состояние, а не разовое действие, поэтому оно залито.
.iconBtnActive {
border-color: $gold-500;
background: $gold-500;
color: $navy-950;
&:hover:not(:disabled) {
border-color: $gold-600;
background: $gold-600;
color: $navy-950;
}
}
// Колонка с номером шага. Объединяется по вертикали (rowSpan) на все строки
// одного шага, поэтому содержимое центрируем — иначе номер и кнопки прижимались
// бы к верхней строке блока.
.matrixStepCol {
width: 48px;
white-space: nowrap;
vertical-align: middle;
}
.stepNo {
font-weight: 600;
color: $navy-700;
}
// Перемещение шага целиком — тоже объединённая по вертикали ячейка, поэтому
// кнопки центрируем относительно всего блока.
.matrixBlockMove {
width: 64px;
white-space: nowrap;
vertical-align: middle;
text-align: right;
.iconBtn + .iconBtn {
margin-left: $sp-1;
}
}
// Строки, входящие в шаг из нескольких параллельных согласующих — лёгкая
// заливка, чтобы блок читался как единое целое (границы ячейки «Шаг» его уже
// объединяют, но без фона строки визуально распадаются).
.parallelRow > td {
background: rgba($gold-500, .06);
}
.flipY {
transform: scaleY(-1);
}
@@ -8,15 +8,74 @@ export interface IUseMatrixEditor {
error: string | undefined;
editable: boolean;
addApprover: (user: ISiteUser) => void;
moveUp: (index: number) => void;
moveDown: (index: number) => void;
/** Переместить ШАГ целиком (вместе со всеми параллельными строками) выше/ниже. */
moveBlockUp: (index: number) => void;
moveBlockDown: (index: number) => void;
remove: (index: number) => void;
/** Переключить «идёт параллельно с предыдущим шагом». Для первой строки — no-op. */
toggleParallel: (index: number) => void;
/** Границы шагов: индексы строк, объединённых общим order. */
blocks: IMatrixBlock[];
}
function swap(rows: IMatrixRow[], a: number, b: number): IMatrixRow[] {
const next = [...rows];
[next[a], next[b]] = [next[b], next[a]];
return next;
/** Один шаг маршрута: одна строка либо несколько параллельных (общий order). */
export interface IMatrixBlock {
start: number;
end: number;
}
// Разбивает строки на шаги: новый шаг начинается там, где нет флага
// parallelWithPrev. Используется и для перемещения (двигаем шаг целиком), и для
// отрисовки (одна ячейка «Шаг» на все строки шага, см. ApprovalMatrix/rowSpan).
export function blockRanges(rows: IMatrixRow[]): IMatrixBlock[] {
const ranges: IMatrixBlock[] = [];
rows.forEach((r, i) => {
if (i === 0 || !r.parallelWithPrev) ranges.push({ start: i, end: i });
else ranges[ranges.length - 1].end = i;
});
return ranges;
}
// Индекс шага, которому принадлежит строка (-1 — не найдено).
function blockIndexOf(ranges: IMatrixBlock[], row: number): number {
for (let i = 0; i < ranges.length; i += 1) {
if (row >= ranges[i].start && row <= ranges[i].end) return i;
}
return -1;
}
// Меняет местами два СОСЕДНИХ шага целиком. Флаги parallelWithPrev внутри шага
// остаются верными сами собой: у первой строки шага флаг снят, у остальных —
// стоит, а порядок строк внутри шага не меняется.
function swapBlocks(rows: IMatrixRow[], a: IMatrixBlock, b: IMatrixBlock): IMatrixRow[] {
return [
...rows.slice(0, a.start),
...rows.slice(b.start, b.end + 1),
...rows.slice(a.start, a.end + 1),
...rows.slice(b.end + 1),
];
}
// Раздаёт order по флагу parallelWithPrev: у параллельных шагов order общий с
// предыдущим, у последовательных — на единицу больше. Вызывается после КАЖДОГО
// изменения состава/порядка, иначе order разъедется с тем, что видно на экране.
// Первая строка параллельной быть не может — ей всегда достаётся order 1.
function withOrders(rows: IMatrixRow[]): IMatrixRow[] {
let order = 0;
return rows.map((r, i) => {
const parallel = i > 0 && !!r.parallelWithPrev;
if (!parallel) order += 1;
return { ...r, order, parallelWithPrev: parallel };
});
}
// Обратное преобразование для загруженных строк: одинаковый order у соседей
// означает параллельный шаг.
function markParallel(rows: IMatrixRow[]): IMatrixRow[] {
return rows.map((r, i) => ({
...r,
parallelWithPrev: i > 0 && r.order === rows[i - 1].order,
}));
}
/**
@@ -45,7 +104,7 @@ export function useMatrixEditor(
load()
.then((r) => {
if (!active) return;
setRows(r);
setRows(markParallel(r));
setError(undefined);
setLoading(false);
})
@@ -65,7 +124,7 @@ export function useMatrixEditor(
setRows((prev) => {
if (user.email && prev.some((r) => r.email === user.email)) return prev;
const row: IMatrixRow = {
order: prev.length + 1,
order: 0, // проставит withOrders
who: user.name,
email: user.email,
position,
@@ -73,8 +132,9 @@ export function useMatrixEditor(
date: '',
comment: '',
allowReassign: false,
parallelWithPrev: false,
};
return [...prev, row];
return withOrders([...prev, row]);
});
};
@@ -85,10 +145,42 @@ export function useMatrixEditor(
.catch(() => appendUser(user, ''));
};
const moveUp = (i: number): void => setRows((prev) => (i <= 0 ? prev : swap(prev, i, i - 1)));
const moveDown = (i: number): void =>
setRows((prev) => (i >= prev.length - 1 ? prev : swap(prev, i, i + 1)));
const remove = (i: number): void => setRows((prev) => prev.filter((_, idx) => idx !== i));
const moveBlockUp = (i: number): void =>
setRows((prev) => {
const ranges = blockRanges(prev);
const bi = blockIndexOf(ranges, i);
if (bi <= 0) return prev;
return withOrders(swapBlocks(prev, ranges[bi - 1], ranges[bi]));
});
return { rows, loading, error, editable, addApprover, moveUp, moveDown, remove };
const moveBlockDown = (i: number): void =>
setRows((prev) => {
const ranges = blockRanges(prev);
const bi = blockIndexOf(ranges, i);
if (bi < 0 || bi >= ranges.length - 1) return prev;
return withOrders(swapBlocks(prev, ranges[bi], ranges[bi + 1]));
});
const remove = (i: number): void =>
setRows((prev) => withOrders(prev.filter((_, idx) => idx !== i)));
const toggleParallel = (i: number): void =>
setRows((prev) =>
i <= 0
? prev
: withOrders(prev.map((r, idx) => (idx === i ? { ...r, parallelWithPrev: !r.parallelWithPrev } : r)))
);
return {
rows,
loading,
error,
editable,
addApprover,
moveBlockUp,
moveBlockDown,
remove,
toggleParallel,
blocks: blockRanges(rows),
};
}
@@ -0,0 +1,33 @@
// Есть ли в открытой форме «Новая заявка» правки, которые ещё не сохранены.
//
// Нужно, потому что раздел «Новая заявка» держится смонтированным при
// переключении вкладок (см. RequestPortal.tsx), и единственное, что реально
// пересоздаёт форму, — переход «создать на основе». Перед ним и спрашиваем,
// но только если терять действительно есть что.
//
// Флаг ставится по факту пользовательского ввода (событие input/change из
// раздела формы — см. RequestPortal), а снимается после сохранения: черновик
// тоже считается сохранением, форма зовёт onSaved и в обоих случаях
// («Сохранить черновик» и «Отправить»), см. forms/*/Form.tsx.
let dirty = false;
export function markFormDirty(): void {
dirty = true;
}
export function markFormClean(): void {
dirty = false;
}
export function hasUnsavedFormChanges(): boolean {
return dirty;
}
// Спросить подтверждение, если несохранённые правки есть. true — можно
// продолжать (правок нет либо пользователь согласился их потерять).
export function confirmDiscardFormChanges(): boolean {
if (!dirty) return true;
return window.confirm(
'В открытой форме «Новая заявка» есть несохранённые изменения — они будут потеряны. Продолжить?'
);
}