feat: комментарий согласующего отдельной строкой во всю ширину маршрута
В узкой колонке «Согласующий» комментарий ломался по слову на строку, а длинный текст растягивал таблицу. Теперь он идёт строкой под участником во всю ширину (rowSpan «Шаг»/стрелок учитывает такие строки), полоса окрашена по решению на шаге, длинный текст свёрнут до 3 строк с «Показать полностью»; в печати выводится целиком. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user