Параллельные шаги (#3)

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/3
This commit was merged in pull request #3.
This commit is contained in:
2026-08-06 12:36:22 +00:00
parent 40f9ac6c03
commit df9d12622d
24 changed files with 967 additions and 175 deletions
@@ -28,7 +28,23 @@ 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 hasStatuses = rows.some((r) => r.status && r.status !== '—');
const hasDates = rows.some((r) => !!r.date);
return (
<div className={styles.matrixWrap}>
@@ -42,52 +58,89 @@ 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>
{hasStatuses && <th>Статус</th>}
{hasDates && <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>
{hasStatuses && (
<td>{r.status && r.status !== '—' ? <StatusBadge status={r.status} /> : '—'}</td>
)}
{hasDates && <td>{r.date}</td>}
{/* Перемещение действует на ШАГ целиком (параллельные строки
переезжают вместе), поэтому стрелки живут в отдельной
объединённой по вертикали ячейке: одна пара на весь шаг,
по центру его строк. */}
{editable && isBlockStart && (
<td className={styles.matrixMoveCol} 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>
)}
{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>
)}
</tr>
);
})}
</tbody>
</table>
)}
@@ -113,16 +113,43 @@
}
}
// ВАЖНО: не display:flex — это сняло бы с ячейки display:table-cell, и она
// перестала бы вставать в строку рядом с соседней (кнопки уезжали на вторую
// строку). Ячейка остаётся табличной, кнопки держатся в строке через nowrap.
.matrixActions {
display: flex;
gap: $sp-1;
justify-content: flex-end;
white-space: nowrap;
text-align: right;
vertical-align: middle;
// Отступ между любыми соседними элементами: кроме кнопок тут бывают распорки
// (см. .iconBtnSpacer), на которые селектор .iconBtn + .iconBtn не сработал бы.
> * + * {
margin-left: $sp-1;
}
}
// Стрелки перемещения шага. Ячейка объединена по вертикали (rowSpan) на все
// строки шага, поэтому пара кнопок стоит по центру блока, а не у его первой
// строки. Как и .matrixActions — остаётся табличной ячейкой (см. комментарий
// там), кнопки держатся в строке через nowrap.
.matrixMoveCol {
width: 1%; // ужимает колонку по содержимому, ширину забирают текстовые колонки
white-space: nowrap;
text-align: right;
vertical-align: middle;
> * + * {
margin-left: $sp-1;
}
}
.iconBtn {
display: inline-flex;
align-items: center;
justify-content: center;
// Инлайновые кнопки иначе выравниваются по baseline и «прыгают» относительно
// кнопок соседней ячейки.
vertical-align: middle;
width: 26px;
height: 26px;
padding: 0;
@@ -151,6 +178,41 @@
}
}
// Включённый режим-переключатель (шаг помечен как параллельный) — в отличие от
// остальных кнопок это состояние, а не разовое действие, поэтому оно залито.
.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;
}
// Строки, входящие в шаг из нескольких параллельных согласующих — лёгкая
// заливка, чтобы блок читался как единое целое (границы ячейки «Шаг» его уже
// объединяют, но без фона строки визуально распадаются).
.parallelRow > td {
background: rgba($gold-500, .06);
}
.flipY {
transform: scaleY(-1);
}