diff --git a/src/webparts/requestPortal/shared/approval/ui/ApprovalComment.module.scss b/src/webparts/requestPortal/shared/approval/ui/ApprovalComment.module.scss new file mode 100644 index 0000000..238d613 --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/ui/ApprovalComment.module.scss @@ -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; + } +} diff --git a/src/webparts/requestPortal/shared/approval/ui/ApprovalComment.tsx b/src/webparts/requestPortal/shared/approval/ui/ApprovalComment.tsx new file mode 100644 index 0000000..6f7bb5a --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/ui/ApprovalComment.tsx @@ -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 = { + [REQUEST_STATUS.approved]: 'positive', + [REQUEST_STATUS.executed]: 'positive', + [REQUEST_STATUS.rejected]: 'negative', + [REQUEST_STATUS.returned]: 'warning', +}; + +const TONE_CLASS: Record = { + 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 = ({ text, status }) => { + const [isExpanded, setIsExpanded] = React.useState(false); + const textRef = React.useRef(null); + const isClamped = useTextOverflow(textRef, text); + + const tone = (status && TONE_BY_STATUS[status]) || 'neutral'; + // В развёрнутом виде обрезки нет, и замер честно вернёт false, но кнопка + // «Свернуть» при этом всё равно нужна. + const showToggle = isExpanded || isClamped; + + return ( +
+ +
+
+ {text} +
+ {showToggle && ( + + )} +
+
+ ); +}; diff --git a/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx b/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx index 2ad38ca..b373c70 100644 --- a/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx +++ b/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx @@ -7,9 +7,16 @@ import { IMatrixRow } from '../models/IMatrixRow'; import { StatusBadge } from './StatusBadge'; import { UserPicker } from './UserPicker'; import { ReplaceRowPanel } from './ReplaceRowPanel'; +import { ApprovalComment } from './ApprovalComment'; import { useFlipRows } from './useFlipRows'; import { StepMoveDirection } from '../queuedStepMove'; +/** + * Ключ с комментарием участника. Своя строка — свой ключ: анимация + * перестановки (useFlipRows) должна везти комментарий вместе с его участником. + */ +const commentRowKey = (rowKey: string): string => `${rowKey}:comment`; + export interface IApprovalMatrixProps { editor: IUseMatrixEditor; search: (query: string) => Promise; @@ -114,10 +121,16 @@ export const ApprovalMatrix: React.FC = ({ // Строка → шаг, которому она принадлежит. Нужно, чтобы отрисовать ячейку // «Шаг» один раз на весь шаг (rowSpan) и понять, можно ли двигать его выше/ниже. - const blockByRow: Array<{ index: number; start: number; size: number }> = []; + // size — сколько участников в шаге; + // span — сколько он занимает в таблице: у участника с комментарием + // под его строкой идёт ещё одна (см. ApprovalComment), и объединённые + // ячейки «Шаг» и стрелок обязаны её накрыть, иначе таблица съедет. + const blockByRow: Array<{ index: number; start: number; size: number; span: number }> = []; 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) { - 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 = ({ return; } 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); else editor.moveBlockDown(rowIndex); }; @@ -190,8 +209,12 @@ export const ApprovalMatrix: React.FC = ({ 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 (
@@ -223,143 +246,150 @@ export const ApprovalMatrix: React.FC = ({ {hasDates && Дата} {hasSla && SLA} {showMoveCol && } - {(editable || hasRemovable) && } + {hasActionsCol && } {rows.map((r, i) => { const block = blockByRow[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 ( - 1 ? styles.parallelRow : undefined} - > - {isBlockStart && ( - - {r.order} - - )} - - {r.who} - {/* Причина, по которой approval-route не назначил на этот шаг - конкретного человека (роль не разрешилась и т.п., см. - IResolvedRouteStep.warning/IMatrixRow.warning) — та же строка, - что уходит в ошибку валидации при отправке (см. - unassignedStepMessage), но видна СРАЗУ в таблице, а не только - на попытке отправить: пустая строка «Согласующий» сама по себе - не объясняет, что делать — вручную выбрать человека здесь - нельзя, шаг заведомо требует правки маршрута в админке. */} - {!r.who && r.warning && ( - {r.warning} + + + {isBlockStart && ( + + {r.order} + )} - {/* Доп. участник шага, добавленный переназначением — помечаем, - кто его добавил. Пусто у обычных строк маршрута. Как и - комментарий — inline под именем, чтобы не ломать rowSpan - колонки «Шаг» у параллельных шагов. */} - {r.addedBy && ( - - {addedByLabel}{' '} - {r.addedBy} - - )} - {/* Комментарий решения — мелким текстом под именем (не отдельной - строкой: иначе ломается rowSpan колонки «Шаг» у параллельных). */} - {r.comment && ( - - Комментарий: {r.comment} - - )} - - {r.position} - {hasDepartment && {r.department || '—'}} - {hasOrganization && {r.organization || '—'}} - {hasStatuses && ( - - {r.status && r.status !== '—' ? : '—'} + + {r.who} + {/* Причина, по которой approval-route не назначил на этот шаг + конкретного человека (роль не разрешилась и т.п., см. + IResolvedRouteStep.warning/IMatrixRow.warning) — та же строка, + что уходит в ошибку валидации при отправке (см. + unassignedStepMessage), но видна СРАЗУ в таблице, а не только + на попытке отправить: пустая строка «Согласующий» сама по себе + не объясняет, что делать — вручную выбрать человека здесь + нельзя, шаг заведомо требует правки маршрута в админке. */} + {!r.who && r.warning && ( + {r.warning} + )} + {/* Доп. участник шага, добавленный переназначением — помечаем, + кто его добавил. Пусто у обычных строк маршрута. */} + {r.addedBy && ( + + {addedByLabel}{' '} + {r.addedBy} + + )} - )} - {hasDates && {r.date}} - {hasSla && ( - - {r.slaHours || r.slaHours === 0 ? `${r.slaHours} ч` : '—'} - {/* Настраивается только в админке — здесь чисто информативно, - чтобы участник видел: решение по шагу примут не только вручную. */} - {r.autoApprove && (авто)} - - )} - {/* Перемещение действует на ШАГ целиком (параллельные строки - переезжают вместе): одна пара стрелок на весь шаг. */} - {showMoveCol && isBlockStart && ( - - {/* В поданной заявке у шагов, которые двигать нельзя совсем - (активный, пройденный), стрелок нет — иначе вся колонка - из неактивных кнопок только шумит. */} - {(editable || canMoveBlock(i, 'up') || canMoveBlock(i, 'down')) && ( - <> + {r.position} + {hasDepartment && {r.department || '—'}} + {hasOrganization && {r.organization || '—'}} + {hasStatuses && ( + + {r.status && r.status !== '—' ? : '—'} + + )} + {hasDates && {r.date}} + {hasSla && ( + + {r.slaHours || r.slaHours === 0 ? `${r.slaHours} ч` : '—'} + {/* Настраивается только в админке — здесь чисто информативно, + чтобы участник видел: решение по шагу примут не только вручную. */} + {r.autoApprove && (авто)} + + )} + {/* Перемещение действует на ШАГ целиком (параллельные строки + переезжают вместе): одна пара стрелок на весь шаг. */} + {showMoveCol && isBlockStart && ( + + {/* В поданной заявке у шагов, которые двигать нельзя совсем + (активный, пройденный), стрелок нет — иначе вся колонка + из неактивных кнопок только шумит. */} + {(editable || canMoveBlock(i, 'up') || canMoveBlock(i, 'down')) && ( + <> + + + + )} + + )} + {hasActionsCol && ( + + {editable && ( + )} + {canReplaceRow?.(r, i) && ( - - )} - + )} + {canRemoveRow?.(r, i) && ( + + )} + + )} + + {/* Комментарий — отдельной строкой во всю ширину под участником: + в узкой колонке «Согласующий» текст ломался по слову на строку. + Ячейки «Шаг» и стрелок её не повторяют — их накрывает rowSpan + (см. block.span); колонка действий — пустая ячейка. */} + {r.comment && ( + + + + + {hasActionsCol && } + )} - {(editable || hasRemovable) && ( - - {editable && ( - - )} - {canReplaceRow?.(r, i) && ( - - )} - {canRemoveRow?.(r, i) && ( - - )} - - )} - + ); })} diff --git a/src/webparts/requestPortal/shared/approval/ui/approval.module.scss b/src/webparts/requestPortal/shared/approval/ui/approval.module.scss index 172bd4a..d75fb5d 100644 --- a/src/webparts/requestPortal/shared/approval/ui/approval.module.scss +++ b/src/webparts/requestPortal/shared/approval/ui/approval.module.scss @@ -157,27 +157,18 @@ } } -// Комментарий решения — плашкой под именем согласующего (в той же ячейке, -// а не отдельной строкой: иначе ломается rowSpan колонки «Шаг» у параллельных). -// Плашка, а не мелкий серый текст: пользователи его не замечали, а там бывает -// причина возврата/отклонения. -.commentInline { - display: block; - 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; // длинная ссылка без пробелов не распирает ячейку +// Строка участника, под которой идёт его комментарий (см. ApprovalComment): +// разделитель между ними убираем, иначе комментарий читается как отдельная +// запись маршрута. Ячейки «Шаг» и стрелок не трогаем — они объединены на весь +// шаг (rowSpan), и их нижняя граница — это граница всего шага. +.rowWithComment > td:not(.matrixStepCol):not(.matrixMoveCol) { + border-bottom-color: transparent; } -.commentLabel { - color: $navy-800; - font-weight: 600; +// Отступ сверху убран: комментарий прилегает к строке своего участника. +// Двойной класс — чтобы перебить padding из `.matrix td`. +.matrix .commentCell { + padding: 0 8px 10px; } // Пометка «доп. согласующий, добавил: …» — под именем согласующего. Золотой @@ -204,7 +195,7 @@ // Причина, по которой approval-route не назначил человека на шаг (см. // ApprovalMatrix.tsx/r.warning) — красным, как настоящая проблема маршрута -// (в отличие от нейтрального addedByInline/commentInline): пустая строка +// (в отличие от нейтрального addedByInline): пустая строка // «Согласующий» без этого текста выглядит как баг, а не как «маршрут просит // вмешательства администратора». .warningInline { @@ -378,12 +369,30 @@ font-weight: 500; } - // Комментарий под именем — на всю ширину ячейки (своя строка). - .commentInline, .addedByInline { 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, .matrixMoveCol { justify-content: flex-start; diff --git a/src/webparts/requestPortal/shared/approval/ui/useTextOverflow.ts b/src/webparts/requestPortal/shared/approval/ui/useTextOverflow.ts new file mode 100644 index 0000000..c39e0a9 --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/ui/useTextOverflow.ts @@ -0,0 +1,31 @@ +import * as React from 'react'; + +/** Расхождение меньше этого — округление субпикселей, а не обрезанный текст. */ +const OVERFLOW_TOLERANCE_PX = 1; + +/** + * Обрезан ли текст элемента по высоте (например, CSS line-clamp). + * + * Заранее по длине строки это не понять: сколько строк займёт текст, зависит от + * ширины колонки, а она меняется вместе с окном и набором колонок таблицы. Поэтому + * меряем реальную раскладку и перемеряем при каждом изменении размера элемента. + */ +export function useTextOverflow(ref: React.RefObject, 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; +}