feat: комментарий согласующего отдельной строкой во всю ширину маршрута

В узкой колонке «Согласующий» комментарий ломался по слову на строку, а
длинный текст растягивал таблицу. Теперь он идёт строкой под участником во
всю ширину (rowSpan «Шаг»/стрелок учитывает такие строки), полоса окрашена
по решению на шаге, длинный текст свёрнут до 3 строк с «Показать полностью»;
в печати выводится целиком.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-29 08:52:27 +05:00
parent 9cacb6b271
commit 7f641b36c6
5 changed files with 395 additions and 146 deletions
@@ -0,0 +1,107 @@
@import '../../../../../styles/design-tokens';
// Сколько строк комментария видно до «Показать полностью». Три — короткие
// пометки («Вместо Иванова») видны целиком, а длинная причина отказа не
// растягивает таблицу маршрута на пол-экрана.
$comment-visible-lines: 3;
// Высота первой строки текста ($fs-sm × line-height 1.45 ≈ 19px): кавычка
// стоит вровень с ней, а не сползает вниз из-за своего крупного кегля.
$quote-line-height: 19px;
.comment {
display: flex;
align-items: flex-start;
gap: $sp-2;
padding: $sp-2 $sp-3;
border-left: 3px solid;
border-radius: 0 $r-sm $r-sm 0;
font-size: $fs-sm;
line-height: 1.45;
color: $gray-900;
}
// Цвет — у полосы и кавычки, фон лишь подтон: текст должен читаться так же
// спокойно, как остальная таблица, а не кричать.
.neutral {
border-left-color: $navy-600;
background: $navy-50;
.quote { color: $navy-600; }
}
.positive {
border-left-color: $green-600;
background: $green-50;
.quote { color: $green-600; }
}
.negative {
border-left-color: $red-500;
background: $red-50;
.quote { color: $red-500; }
}
.warning {
border-left-color: $gold-500;
background: $gold-50;
.quote { color: $gold-600; }
}
.quote {
flex: 0 0 auto;
font-family: $font-serif;
font-size: 22px;
font-weight: 600;
line-height: $quote-line-height;
}
.body {
flex: 1 1 auto;
min-width: 0; // иначе flex-элемент не сужается и длинный текст распирает таблицу
}
.text {
white-space: pre-wrap; // сохранить переносы строк из комментария
overflow-wrap: anywhere; // длинная ссылка без пробелов не распирает ячейку
}
.clamped {
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: $comment-visible-lines;
-webkit-box-orient: vertical;
}
.toggle {
margin-top: 2px;
padding: 0;
border: 0;
background: none;
font-family: inherit;
font-size: $fs-label;
font-weight: 500;
color: $navy-700;
cursor: pointer;
&:hover {
color: $gold-600;
text-decoration: underline;
}
}
// На бумаге кнопку не нажать — печатаем комментарий целиком.
@media print {
.clamped {
display: block;
overflow: visible;
-webkit-line-clamp: unset;
}
.toggle {
display: none;
}
}
@@ -0,0 +1,72 @@
import * as React from 'react';
import styles from './ApprovalComment.module.scss';
import { REQUEST_STATUS } from '../models/statuses';
import { useTextOverflow } from './useTextOverflow';
type CommentTone = 'positive' | 'negative' | 'warning' | 'neutral';
// Цвет полосы — по решению на шаге: причину отказа или возврата видно с первого
// взгляда, ещё до того, как глаз дойдёт до колонки «Статус».
const TONE_BY_STATUS: Record<string, CommentTone> = {
[REQUEST_STATUS.approved]: 'positive',
[REQUEST_STATUS.executed]: 'positive',
[REQUEST_STATUS.rejected]: 'negative',
[REQUEST_STATUS.returned]: 'warning',
};
const TONE_CLASS: Record<CommentTone, string> = {
positive: styles.positive,
negative: styles.negative,
warning: styles.warning,
neutral: styles.neutral,
};
const TEXT = {
expand: 'Показать полностью',
collapse: 'Свернуть',
};
export interface IApprovalCommentProps {
/** Текст комментария к решению на шаге. */
text: string;
/** Статус строки маршрута (см. REQUEST_STATUS) — от него цвет полосы. */
status?: string;
}
/**
* Комментарий участника маршрута к своему решению. Длинный текст свёрнут до
* нескольких строк (см. $comment-visible-lines), остальное — по кнопке.
*/
export const ApprovalComment: React.FC<IApprovalCommentProps> = ({ text, status }) => {
const [isExpanded, setIsExpanded] = React.useState(false);
const textRef = React.useRef<HTMLDivElement>(null);
const isClamped = useTextOverflow(textRef, text);
const tone = (status && TONE_BY_STATUS[status]) || 'neutral';
// В развёрнутом виде обрезки нет, и замер честно вернёт false, но кнопка
// «Свернуть» при этом всё равно нужна.
const showToggle = isExpanded || isClamped;
return (
<div className={`${styles.comment} ${TONE_CLASS[tone]}`}>
<span className={styles.quote} aria-hidden="true">
“
</span>
<div className={styles.body}>
<div ref={textRef} className={isExpanded ? styles.text : `${styles.text} ${styles.clamped}`}>
{text}
</div>
{showToggle && (
<button
type="button"
className={styles.toggle}
aria-expanded={isExpanded}
onClick={() => setIsExpanded((prev) => !prev)}
>
{isExpanded ? TEXT.collapse : TEXT.expand}
</button>
)}
</div>
</div>
);
};
@@ -7,9 +7,16 @@ import { IMatrixRow } from '../models/IMatrixRow';
import { StatusBadge } from './StatusBadge'; import { StatusBadge } from './StatusBadge';
import { UserPicker } from './UserPicker'; import { UserPicker } from './UserPicker';
import { ReplaceRowPanel } from './ReplaceRowPanel'; import { ReplaceRowPanel } from './ReplaceRowPanel';
import { ApprovalComment } from './ApprovalComment';
import { useFlipRows } from './useFlipRows'; import { useFlipRows } from './useFlipRows';
import { StepMoveDirection } from '../queuedStepMove'; import { StepMoveDirection } from '../queuedStepMove';
/**
* Ключ <tr> с комментарием участника. Своя строка — свой ключ: анимация
* перестановки (useFlipRows) должна везти комментарий вместе с его участником.
*/
const commentRowKey = (rowKey: string): string => `${rowKey}:comment`;
export interface IApprovalMatrixProps { export interface IApprovalMatrixProps {
editor: IUseMatrixEditor; editor: IUseMatrixEditor;
search: (query: string) => Promise<ISiteUser[]>; search: (query: string) => Promise<ISiteUser[]>;
@@ -114,10 +121,16 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
// Строка → шаг, которому она принадлежит. Нужно, чтобы отрисовать ячейку // Строка → шаг, которому она принадлежит. Нужно, чтобы отрисовать ячейку
// «Шаг» один раз на весь шаг (rowSpan) и понять, можно ли двигать его выше/ниже. // «Шаг» один раз на весь шаг (rowSpan) и понять, можно ли двигать его выше/ниже.
const blockByRow: Array<{ index: number; start: number; size: number }> = []; // size — сколько участников в шаге;
// span — сколько <tr> он занимает в таблице: у участника с комментарием
// под его строкой идёт ещё одна (см. ApprovalComment), и объединённые
// ячейки «Шаг» и стрелок обязаны её накрыть, иначе таблица съедет.
const blockByRow: Array<{ index: number; start: number; size: number; span: number }> = [];
blocks.forEach((b, bi) => { blocks.forEach((b, bi) => {
const size = b.end - b.start + 1;
const commentCount = rows.slice(b.start, b.end + 1).filter((r) => !!r.comment).length;
for (let i = b.start; i <= b.end; i += 1) { for (let i = b.start; i <= b.end; i += 1) {
blockByRow[i] = { index: bi, start: b.start, size: b.end - b.start + 1 }; blockByRow[i] = { index: bi, start: b.start, size, span: size + commentCount };
} }
}); });
@@ -139,7 +152,13 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
return; return;
} }
const b = blockByRow[rowIndex]; const b = blockByRow[rowIndex];
setActiveKeys(rowKeys.slice(b.start, b.start + b.size)); // Подсвечиваем и поднимаем шаг вместе со строками комментариев его участников.
const blockKeys: string[] = [];
for (let i = b.start; i < b.start + b.size; i += 1) {
blockKeys.push(rowKeys[i]);
if (rows[i].comment) blockKeys.push(commentRowKey(rowKeys[i]));
}
setActiveKeys(blockKeys);
if (direction === 'up') editor.moveBlockUp(rowIndex); if (direction === 'up') editor.moveBlockUp(rowIndex);
else editor.moveBlockDown(rowIndex); else editor.moveBlockDown(rowIndex);
}; };
@@ -190,8 +209,12 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
if (replacingRow && onReplaceRow) onReplaceRow(replacingRow, user); if (replacingRow && onReplaceRow) onReplaceRow(replacingRow, user);
}; };
// Число колонок (для colSpan) — на случай будущих нужд не используется: комментарий // Комментарий занимает всю ширину между объединёнными колонками «Шаг» и
// выводим внутри ячейки согласующего, чтобы не ломать rowSpan колонки «Шаг». // стрелок: «Согласующий» + «Должность» + те необязательные колонки, что
// сейчас показаны. Колонку действий не захватывает — в ней своя пустая ячейка.
const optionalColumns = [hasDepartment, hasOrganization, hasStatuses, hasDates, hasSla];
const commentColSpan = 2 + optionalColumns.filter(Boolean).length;
const hasActionsCol = editable || hasRemovable;
return ( return (
<div className={styles.matrixWrap}> <div className={styles.matrixWrap}>
@@ -223,143 +246,150 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
{hasDates && <th>Дата</th>} {hasDates && <th>Дата</th>}
{hasSla && <th>SLA</th>} {hasSla && <th>SLA</th>}
{showMoveCol && <th />} {showMoveCol && <th />}
{(editable || hasRemovable) && <th />} {hasActionsCol && <th />}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((r, i) => { {rows.map((r, i) => {
const block = blockByRow[i]; const block = blockByRow[i];
const isBlockStart = block.start === i; const isBlockStart = block.start === i;
const isParallel = block.size > 1;
const rowClassNames: string[] = [];
if (isParallel) rowClassNames.push(styles.parallelRow);
if (r.comment) rowClassNames.push(styles.rowWithComment);
const commentClassNames: string[] = [styles.commentRow];
if (isParallel) commentClassNames.push(styles.parallelRow);
return ( return (
<tr <React.Fragment key={rowKeys[i]}>
key={rowKeys[i]} <tr ref={registerRow(rowKeys[i])} className={rowClassNames.join(' ') || undefined}>
ref={registerRow(rowKeys[i])} {isBlockStart && (
className={block.size > 1 ? styles.parallelRow : undefined} <td className={styles.matrixStepCol} rowSpan={block.span}>
> <span className={styles.stepNo}>{r.order}</span>
{isBlockStart && ( </td>
<td className={styles.matrixStepCol} rowSpan={block.size}>
<span className={styles.stepNo}>{r.order}</span>
</td>
)}
<td data-label={roleLabel}>
{r.who}
{/* Причина, по которой approval-route не назначил на этот шаг
конкретного человека (роль не разрешилась и т.п., см.
IResolvedRouteStep.warning/IMatrixRow.warning) — та же строка,
что уходит в ошибку валидации при отправке (см.
unassignedStepMessage), но видна СРАЗУ в таблице, а не только
на попытке отправить: пустая строка «Согласующий» сама по себе
не объясняет, что делать — вручную выбрать человека здесь
нельзя, шаг заведомо требует правки маршрута в админке. */}
{!r.who && r.warning && (
<span className={styles.warningInline}>{r.warning}</span>
)} )}
{/* Доп. участник шага, добавленный переназначением — помечаем, <td data-label={roleLabel}>
кто его добавил. Пусто у обычных строк маршрута. Как и {r.who}
комментарий — inline под именем, чтобы не ломать rowSpan {/* Причина, по которой approval-route не назначил на этот шаг
колонки «Шаг» у параллельных шагов. */} конкретного человека (роль не разрешилась и т.п., см.
{r.addedBy && ( IResolvedRouteStep.warning/IMatrixRow.warning) — та же строка,
<span className={styles.addedByInline}> что уходит в ошибку валидации при отправке (см.
<span className={styles.addedByLabel}>{addedByLabel}</span>{' '} unassignedStepMessage), но видна СРАЗУ в таблице, а не только
{r.addedBy} на попытке отправить: пустая строка «Согласующий» сама по себе
</span> не объясняет, что делать — вручную выбрать человека здесь
)} нельзя, шаг заведомо требует правки маршрута в админке. */}
{/* Комментарий решения — мелким текстом под именем (не отдельной {!r.who && r.warning && (
строкой: иначе ломается rowSpan колонки «Шаг» у параллельных). */} <span className={styles.warningInline}>{r.warning}</span>
{r.comment && ( )}
<span className={styles.commentInline}> {/* Доп. участник шага, добавленный переназначением — помечаем,
<span className={styles.commentLabel}>Комментарий:</span> {r.comment} кто его добавил. Пусто у обычных строк маршрута. */}
</span> {r.addedBy && (
)} <span className={styles.addedByInline}>
</td> <span className={styles.addedByLabel}>{addedByLabel}</span>{' '}
<td data-label="Должность">{r.position}</td> {r.addedBy}
{hasDepartment && <td data-label="Отдел">{r.department || '—'}</td>} </span>
{hasOrganization && <td data-label="Организация">{r.organization || '—'}</td>} )}
{hasStatuses && (
<td data-label="Статус">
{r.status && r.status !== '—' ? <StatusBadge status={r.status} /> : '—'}
</td> </td>
)} <td data-label="Должность">{r.position}</td>
{hasDates && <td data-label="Дата">{r.date}</td>} {hasDepartment && <td data-label="Отдел">{r.department || '—'}</td>}
{hasSla && ( {hasOrganization && <td data-label="Организация">{r.organization || '—'}</td>}
<td data-label="SLA"> {hasStatuses && (
{r.slaHours || r.slaHours === 0 ? `${r.slaHours} ч` : '—'} <td data-label="Статус">
{/* Настраивается только в админке — здесь чисто информативно, {r.status && r.status !== '—' ? <StatusBadge status={r.status} /> : '—'}
чтобы участник видел: решение по шагу примут не только вручную. */} </td>
{r.autoApprove && <span className={styles.autoApproveBadge}> (авто)</span>} )}
</td> {hasDates && <td data-label="Дата">{r.date}</td>}
)} {hasSla && (
{/* Перемещение действует на ШАГ целиком (параллельные строки <td data-label="SLA">
переезжают вместе): одна пара стрелок на весь шаг. */} {r.slaHours || r.slaHours === 0 ? `${r.slaHours} ч` : '—'}
{showMoveCol && isBlockStart && ( {/* Настраивается только в админке — здесь чисто информативно,
<td className={styles.matrixMoveCol} rowSpan={block.size}> чтобы участник видел: решение по шагу примут не только вручную. */}
{/* В поданной заявке у шагов, которые двигать нельзя совсем {r.autoApprove && <span className={styles.autoApproveBadge}> (авто)</span>}
(активный, пройденный), стрелок нет — иначе вся колонка </td>
из неактивных кнопок только шумит. */} )}
{(editable || canMoveBlock(i, 'up') || canMoveBlock(i, 'down')) && ( {/* Перемещение действует на ШАГ целиком (параллельные строки
<> переезжают вместе): одна пара стрелок на весь шаг. */}
{showMoveCol && isBlockStart && (
<td className={styles.matrixMoveCol} rowSpan={block.span}>
{/* В поданной заявке у шагов, которые двигать нельзя совсем
(активный, пройденный), стрелок нет — иначе вся колонка
из неактивных кнопок только шумит. */}
{(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>
)}
{hasActionsCol && (
<td className={styles.matrixActions}>
{editable && (
<button <button
type="button" type="button"
className={styles.iconBtn} className={`${styles.iconBtn} ${r.parallelWithPrev ? styles.iconBtnActive : ''}`}
disabled={!canMoveBlock(i, 'up')} disabled={i === 0}
onClick={() => moveBlock(i, 'up')} onClick={() => toggleParallel(i)}
title="Шаг выше" title={
r.parallelWithPrev
? 'Отцепить: выполнять после предыдущего шага'
: 'Объединить: выполнять одновременно с предыдущим шагом'
}
> >
<Icon name="chevron-down" size="sm" className={styles.flipY} /> <Icon name="user-check" size="sm" />
</button> </button>
)}
{canReplaceRow?.(r, i) && (
<button <button
type="button" type="button"
className={styles.iconBtn} className={`${styles.iconBtn} ${replacingKey === rowKeys[i] ? styles.iconBtnActive : ''}`}
disabled={!canMoveBlock(i, 'down')} onClick={() => setReplacingKey(replacingKey === rowKeys[i] ? undefined : rowKeys[i])}
onClick={() => moveBlock(i, 'down')} title="Заменить"
title="Шаг ниже"
> >
<Icon name="chevron-down" size="sm" /> <Icon name="swap" size="sm" />
</button> </button>
</> )}
)} {canRemoveRow?.(r, i) && (
</td> <button
type="button"
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
onClick={() => removeRow(r, i)}
title="Удалить"
>
<Icon name="trash" size="sm" />
</button>
)}
</td>
)}
</tr>
{/* Комментарий — отдельной строкой во всю ширину под участником:
в узкой колонке «Согласующий» текст ломался по слову на строку.
Ячейки «Шаг» и стрелок её не повторяют — их накрывает rowSpan
(см. block.span); колонка действий — пустая ячейка. */}
{r.comment && (
<tr ref={registerRow(commentRowKey(rowKeys[i]))} className={commentClassNames.join(' ')}>
<td colSpan={commentColSpan} className={styles.commentCell}>
<ApprovalComment text={r.comment} status={r.status} />
</td>
{hasActionsCol && <td />}
</tr>
)} )}
{(editable || hasRemovable) && ( </React.Fragment>
<td className={styles.matrixActions}>
{editable && (
<button
type="button"
className={`${styles.iconBtn} ${r.parallelWithPrev ? styles.iconBtnActive : ''}`}
disabled={i === 0}
onClick={() => toggleParallel(i)}
title={
r.parallelWithPrev
? 'Отцепить: выполнять после предыдущего шага'
: 'Объединить: выполнять одновременно с предыдущим шагом'
}
>
<Icon name="user-check" size="sm" />
</button>
)}
{canReplaceRow?.(r, i) && (
<button
type="button"
className={`${styles.iconBtn} ${replacingKey === rowKeys[i] ? styles.iconBtnActive : ''}`}
onClick={() => setReplacingKey(replacingKey === rowKeys[i] ? undefined : rowKeys[i])}
title="Заменить"
>
<Icon name="swap" size="sm" />
</button>
)}
{canRemoveRow?.(r, i) && (
<button
type="button"
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
onClick={() => removeRow(r, i)}
title="Удалить"
>
<Icon name="trash" size="sm" />
</button>
)}
</td>
)}
</tr>
); );
})} })}
</tbody> </tbody>
@@ -157,27 +157,18 @@
} }
} }
// Комментарий решения — плашкой под именем согласующего (в той же ячейке, // Строка участника, под которой идёт его комментарий (см. ApprovalComment):
// а не отдельной строкой: иначе ломается rowSpan колонки «Шаг» у параллельных). // разделитель между ними убираем, иначе комментарий читается как отдельная
// Плашка, а не мелкий серый текст: пользователи его не замечали, а там бывает // запись маршрута. Ячейки «Шаг» и стрелок не трогаем — они объединены на весь
// причина возврата/отклонения. // шаг (rowSpan), и их нижняя граница — это граница всего шага.
.commentInline { .rowWithComment > td:not(.matrixStepCol):not(.matrixMoveCol) {
display: block; border-bottom-color: transparent;
margin-top: $sp-1;
padding: 6px 10px;
border-left: 3px solid $navy-600;
border-radius: 0 $r-sm $r-sm 0;
background: $navy-100;
font-size: $fs-sm;
color: $gray-900;
line-height: 1.45;
white-space: pre-wrap; // сохранить переносы строк из комментария
overflow-wrap: anywhere; // длинная ссылка без пробелов не распирает ячейку
} }
.commentLabel { // Отступ сверху убран: комментарий прилегает к строке своего участника.
color: $navy-800; // Двойной класс — чтобы перебить padding из `.matrix td`.
font-weight: 600; .matrix .commentCell {
padding: 0 8px 10px;
} }
// Пометка «доп. согласующий, добавил: …» — под именем согласующего. Золотой // Пометка «доп. согласующий, добавил: …» — под именем согласующего. Золотой
@@ -204,7 +195,7 @@
// Причина, по которой approval-route не назначил человека на шаг (см. // Причина, по которой approval-route не назначил человека на шаг (см.
// ApprovalMatrix.tsx/r.warning) — красным, как настоящая проблема маршрута // ApprovalMatrix.tsx/r.warning) — красным, как настоящая проблема маршрута
// (в отличие от нейтрального addedByInline/commentInline): пустая строка // (в отличие от нейтрального addedByInline): пустая строка
// «Согласующий» без этого текста выглядит как баг, а не как «маршрут просит // «Согласующий» без этого текста выглядит как баг, а не как «маршрут просит
// вмешательства администратора». // вмешательства администратора».
.warningInline { .warningInline {
@@ -378,12 +369,30 @@
font-weight: 500; font-weight: 500;
} }
// Комментарий под именем — на всю ширину ячейки (своя строка).
.commentInline,
.addedByInline { .addedByInline {
flex-basis: 100%; flex-basis: 100%;
} }
// Карточка участника и его комментарий — одна карточка: без линии и
// отступа между ними.
.matrix tr.rowWithComment {
border-bottom: 0;
padding-bottom: 0;
}
.matrix tr.commentRow {
padding-top: $sp-1;
}
.commentCell > * {
flex: 1 1 100%;
}
// Пустая ячейка под колонкой действий на телефоне дала бы лишний отступ.
.commentRow > td:empty {
display: none;
}
.matrixActions, .matrixActions,
.matrixMoveCol { .matrixMoveCol {
justify-content: flex-start; justify-content: flex-start;
@@ -0,0 +1,31 @@
import * as React from 'react';
/** Расхождение меньше этого — округление субпикселей, а не обрезанный текст. */
const OVERFLOW_TOLERANCE_PX = 1;
/**
* Обрезан ли текст элемента по высоте (например, CSS line-clamp).
*
* Заранее по длине строки это не понять: сколько строк займёт текст, зависит от
* ширины колонки, а она меняется вместе с окном и набором колонок таблицы. Поэтому
* меряем реальную раскладку и перемеряем при каждом изменении размера элемента.
*/
export function useTextOverflow<T extends HTMLElement>(ref: React.RefObject<T>, text: string): boolean {
const [isOverflowing, setIsOverflowing] = React.useState(false);
// useLayoutEffect, а не useEffect: кнопка «Показать полностью» должна появиться
// в том же кадре, что и текст, иначе строка таблицы дёрнется по высоте.
React.useLayoutEffect(() => {
const el = ref.current;
if (!el) return undefined;
const check = (): void => setIsOverflowing(el.scrollHeight - el.clientHeight > OVERFLOW_TOLERANCE_PX);
check();
const observer = new ResizeObserver(check);
observer.observe(el);
return () => observer.disconnect();
}, [ref, text]);
return isOverflowing;
}