From f0951cdda26b97612773592d27056f9e7b80769e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Thu, 29 Oct 2026 09:20:15 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B8=D1=81=D1=82=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D1=8F=20=D1=81=D0=BE=D0=B3=D0=BB=D0=B0=D1=81=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=E2=80=94=20=D1=82=D0=B0=D0=B9=D0=BC=D0=BB?= =?UTF-8?q?=D0=B0=D0=B9=D0=BD=20=D1=81=20=D1=86=D0=B2=D0=B5=D1=82=D0=BD?= =?UTF-8?q?=D1=8B=D0=BC=D0=B8=20=D0=BF=D0=BB=D0=B0=D1=88=D0=BA=D0=B0=D0=BC?= =?UTF-8?q?=D0=B8=20=D0=B4=D0=B5=D0=B9=D1=81=D1=82=D0=B2=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Действие в ленте не выделялось среди остального текста, а комментарий шёл серым курсивом. Теперь события на вертикальной линии с цветными кружками, что произошло — плашкой по смыслу решения, комментарий — тем же блоком, что в маршруте (ApprovalComment получил проп tone), у кого заявка сейчас — пульсирующий кружок. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../history/ApprovalHistory.module.scss | 128 +++++++++++++----- .../approval/history/CurrentHolderRow.tsx | 15 +- .../approval/history/HistoryEventRow.tsx | 49 ++++--- .../shared/approval/history/historyKinds.ts | 29 ++++ .../shared/approval/history/historyTexts.ts | 43 +++--- .../approval/ui/ApprovalComment.module.scss | 3 +- .../shared/approval/ui/ApprovalComment.tsx | 11 +- 7 files changed, 196 insertions(+), 82 deletions(-) create mode 100644 src/webparts/requestPortal/shared/approval/history/historyKinds.ts diff --git a/src/webparts/requestPortal/shared/approval/history/ApprovalHistory.module.scss b/src/webparts/requestPortal/shared/approval/history/ApprovalHistory.module.scss index 2592719..99cf848 100644 --- a/src/webparts/requestPortal/shared/approval/history/ApprovalHistory.module.scss +++ b/src/webparts/requestPortal/shared/approval/history/ApprovalHistory.module.scss @@ -1,20 +1,22 @@ @import '../../../../../styles/design-tokens'; -// Ширина колонки со временем — под «29.09.2026 11:02»; уже — текст переносится. -$time-col-width: 124px; -// Ниже этой ширины время встаёт над текстом события, а не слева от него. -$feed-stack-breakpoint: 640px; +// Кружок события на линии ленты. +$marker-size: 24px; +// Толщина линии, соединяющей события. +$line-width: 2px; +// Зазор между событиями — линия обязана его перекрыть, иначе рвётся. +$event-gap: $sp-4; .feed { display: flex; flex-direction: column; - gap: $sp-4; + gap: $sp-5; } .round { display: flex; flex-direction: column; - gap: $sp-2; + gap: $sp-3; } .roundTitle { @@ -30,62 +32,122 @@ $feed-stack-breakpoint: 640px; .events { display: flex; flex-direction: column; - gap: $sp-3; + gap: $event-gap; margin: 0; padding: 0; list-style: none; } .event { + position: relative; display: flex; align-items: flex-start; gap: $sp-3; font-size: $fs-sm; color: $gray-900; -} -.current { - padding: $sp-2 $sp-3; - border-radius: $r-sm; - background: $gold-50; + // Линия от кружка к следующему событию — лента читается как хронология, а не + // как стопка одинаковых строк. У последнего события отправки линии нет: + // отправки разделены заголовками, а не одной сплошной нитью. + &:not(:last-child)::before { + content: ''; + position: absolute; + top: $marker-size; + bottom: -$event-gap; + left: calc((#{$marker-size} - #{$line-width}) / 2); + width: $line-width; + background: $gray-200; + } } .marker { - flex: 0 0 auto; - width: 20px; - text-align: center; + display: flex; + flex: 0 0 $marker-size; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: $marker-size; + height: $marker-size; + border: 1.5px solid; + border-radius: 50%; + font-size: 12px; font-weight: 700; - line-height: 1.4; + line-height: 1; } -.markerNeutral { color: $navy-700; } -.markerPositive { color: $green-600; } -.markerWarning { color: $amber-700; } -.markerNegative { color: $red-500; } -.markerCurrent { color: $gold-600; } +// Цвета — те же смысловые, что у плашки события и полосы комментария +// (см. historyKinds.ts): кружок, плашка и комментарий одного события — одного цвета. +.markerNeutral { + border-color: $navy-600; + background: $navy-50; + color: $navy-700; +} + +.markerPositive { + border-color: $green-600; + background: $green-50; + color: $green-600; +} + +.markerWarning { + border-color: $gold-500; + background: $gold-100; + color: $amber-700; +} + +.markerNegative { + border-color: $red-500; + background: $red-50; + color: $red-500; +} + +.markerCurrent { + border-color: $gold-500; + background: $gold-50; + color: $gold-600; + animation: current-pulse 2s ease-out infinite; +} + +// Расходящееся кольцо вокруг кружка «сейчас». +@keyframes current-pulse { + 0% { box-shadow: 0 0 0 0 rgba($gold-500, .45); } + 70% { box-shadow: 0 0 0 8px rgba($gold-500, 0); } + 100% { box-shadow: 0 0 0 0 rgba($gold-500, 0); } +} + +@media (prefers-reduced-motion: reduce) { + .markerCurrent { + animation: none; + } +} .eventBody { display: flex; + flex: 1 1 auto; flex-direction: column; - gap: 2px; - min-width: 0; + gap: $sp-1; + min-width: 0; // иначе длинный комментарий распирает ленту вбок } -.eventLine { +// Плашка + время + длительность. На узкой ширине переносятся сами (flex-wrap), +// отдельный медиазапрос не нужен. min-height — чтобы плашка стояла вровень +// с центром кружка. +.eventHead { display: flex; flex-wrap: wrap; - align-items: baseline; - gap: $sp-1 $sp-3; + align-items: center; + gap: $sp-1 $sp-2; + min-height: $marker-size; } .time { - flex: 0 0 $time-col-width; + font-size: $fs-label; color: $gray-500; font-variant-numeric: tabular-nums; +} - @media (max-width: $feed-stack-breakpoint) { - flex-basis: 100%; - } +.actor { + overflow-wrap: anywhere; } .meta { @@ -93,12 +155,6 @@ $feed-stack-breakpoint: 640px; color: $gray-500; } -.comment { - color: $gray-700; - font-style: italic; - overflow-wrap: anywhere; -} - .note { font-size: $fs-label; color: $gray-500; diff --git a/src/webparts/requestPortal/shared/approval/history/CurrentHolderRow.tsx b/src/webparts/requestPortal/shared/approval/history/CurrentHolderRow.tsx index 2c46e1c..5b5b8b9 100644 --- a/src/webparts/requestPortal/shared/approval/history/CurrentHolderRow.tsx +++ b/src/webparts/requestPortal/shared/approval/history/CurrentHolderRow.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { Badge } from '@tnsu/ui-kit'; import { formatDuration, ICurrentHolder } from './historyModel'; import { HISTORY_TEXT } from './historyTexts'; import styles from './ApprovalHistory.module.scss'; @@ -11,16 +12,22 @@ interface Props { export const CurrentHolderRow: React.FC = ({ holder }) => { const waitedMs = holder.since ? Date.now() - new Date(holder.since).getTime() : NaN; return ( -
  • +
  • + {/* Пульсирующий кружок — единственное «живое» место ленты: всё остальное уже + случилось, а это ещё ждёт. */}
    -
    - {HISTORY_TEXT.now} - {holder.person || '—'} +
    + + {HISTORY_TEXT.awaiting} + {waitedMs > 0 && {HISTORY_TEXT.waiting(formatDuration(waitedMs))}}
    +
    + {holder.person || '—'} +
    {holder.stepTitle &&
    {holder.stepTitle}
    }
  • diff --git a/src/webparts/requestPortal/shared/approval/history/HistoryEventRow.tsx b/src/webparts/requestPortal/shared/approval/history/HistoryEventRow.tsx index 089157f..e1b2e62 100644 --- a/src/webparts/requestPortal/shared/approval/history/HistoryEventRow.tsx +++ b/src/webparts/requestPortal/shared/approval/history/HistoryEventRow.tsx @@ -1,22 +1,20 @@ import * as React from 'react'; -import { formatDuration, HistoryEventKind, IHistoryEvent } from './historyModel'; -import { eventAction, HISTORY_TEXT } from './historyTexts'; +import { Badge } from '@tnsu/ui-kit'; +import { ApprovalComment, CommentTone } from '../ui/ApprovalComment'; +import { formatDuration, IHistoryEvent } from './historyModel'; +import { BADGE_BY_TONE, KIND_VIEW } from './historyKinds'; +import { eventDetails, eventLabel, HISTORY_TEXT } from './historyTexts'; import styles from './ApprovalHistory.module.scss'; interface Props { event: IHistoryEvent; } -// Значок и цвет события — по смыслу решения, как у статусов в StatusBadge. -const MARKER: Record = { - submitted: { symbol: '↗', className: styles.markerNeutral }, - resubmitted: { symbol: '↗', className: styles.markerNeutral }, - approved: { symbol: '✓', className: styles.markerPositive }, - executed: { symbol: '✓', className: styles.markerPositive }, - returned: { symbol: '↩', className: styles.markerWarning }, - rejected: { symbol: '✕', className: styles.markerNegative }, - delegated: { symbol: '→', className: styles.markerNeutral }, - replaced: { symbol: '⇄', className: styles.markerNeutral }, +const MARKER_CLASS: Record = { + positive: styles.markerPositive, + negative: styles.markerNegative, + warning: styles.markerWarning, + neutral: styles.markerNeutral, }; export function formatDateTime(iso: string): string { @@ -26,26 +24,35 @@ export function formatDateTime(iso: string): string { return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`; } -/** Одно событие ленты: время · кто — что сделал · за сколько + комментарий. */ +/** + * Одно событие ленты. Сверху — ЧТО произошло (цветная плашка) и когда: это то, + * за чем пользователь открывает историю. Ниже — кто, на каком шаге и почему. + */ export const HistoryEventRow: React.FC = ({ event }) => { - const marker = MARKER[event.kind]; + const view = KIND_VIEW[event.kind]; + const details = eventDetails(event); + return (
  • -
  • ); diff --git a/src/webparts/requestPortal/shared/approval/history/historyKinds.ts b/src/webparts/requestPortal/shared/approval/history/historyKinds.ts new file mode 100644 index 0000000..971a296 --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/history/historyKinds.ts @@ -0,0 +1,29 @@ +import { BadgeVariant } from '@tnsu/ui-kit'; +import { CommentTone } from '../ui/ApprovalComment'; +import { HistoryEventKind } from './historyModel'; + +/** Как событие выглядит в ленте: значок в кружке и цвет (кружок, плашка, комментарий). */ +export interface IKindView { + symbol: string; + tone: CommentTone; +} + +// Цвет — по смыслу решения, как у статусов в StatusBadge: зелёное — пройдено, +// золотое — вернули, красное — отказ, синее — служебное движение заявки. +export const KIND_VIEW: Record = { + submitted: { symbol: '↗', tone: 'neutral' }, + resubmitted: { symbol: '↗', tone: 'neutral' }, + approved: { symbol: '✓', tone: 'positive' }, + executed: { symbol: '✓', tone: 'positive' }, + returned: { symbol: '↩', tone: 'warning' }, + rejected: { symbol: '✕', tone: 'negative' }, + delegated: { symbol: '→', tone: 'neutral' }, + replaced: { symbol: '⇄', tone: 'neutral' }, +}; + +export const BADGE_BY_TONE: Record = { + positive: 'success', + negative: 'danger', + warning: 'warning', + neutral: 'neutral', +}; diff --git a/src/webparts/requestPortal/shared/approval/history/historyTexts.ts b/src/webparts/requestPortal/shared/approval/history/historyTexts.ts index 26c58d6..7a9679d 100644 --- a/src/webparts/requestPortal/shared/approval/history/historyTexts.ts +++ b/src/webparts/requestPortal/shared/approval/history/historyTexts.ts @@ -9,28 +9,37 @@ export const HISTORY_TEXT = { empty: 'Пока нет событий: заявка ещё не отправлялась.', errorPrefix: 'Не удалось загрузить историю:', round: (n: number): string => `Отправка ${n}`, - now: 'сейчас', - waiting: (duration: string): string => `ждёт ${duration}`, + awaiting: 'Ожидает решения', + waiting: (duration: string): string => `уже ${duration}`, took: (duration: string): string => `за ${duration}`, + delegatedTo: (name: string): string => `кому: ${name}`, partialNote: 'Показаны события, сохранённые в SharePoint. Решения из отправок до включения истории могли не сохраниться.', }; -const ACTION: Record = { - submitted: 'отправлено на согласование', - resubmitted: 'отправлено повторно', - approved: 'согласовано', - executed: 'выполнено', - returned: 'возвращено на доработку', - rejected: 'отклонено', - delegated: 'передано', - replaced: 'заменён исполнитель', +// С заглавной: текст стоит первым словом в плашке события. +const ACTION_LABEL: Record = { + submitted: 'Отправлено на согласование', + resubmitted: 'Отправлено повторно', + approved: 'Согласовано', + executed: 'Выполнено', + returned: 'Возвращено на доработку', + rejected: 'Отклонено', + delegated: 'Передано', + replaced: 'Заменён исполнитель', }; -/** Что произошло — «согласовано», «передано: Асель Самаева», «заменён исполнитель: А → Б». */ -export function eventAction(event: IHistoryEvent): string { - const action = ACTION[event.kind]; - if (event.kind === 'delegated' && event.target) return `${action}: ${event.target}`; - if (event.kind === 'replaced') return `${action}: ${event.previous || '—'} → ${event.target || '—'}`; - return action; +/** Что произошло — текст плашки: «Согласовано», «Возвращено на доработку». */ +export function eventLabel(kind: HistoryEventKind): string { + return ACTION_LABEL[kind]; +} + +/** + * Над кем действие — дописывается к имени автора: «кому: Асель Самаева» для + * передачи, «Иванов → Петров» для замены. У прочих событий пусто. + */ +export function eventDetails(event: IHistoryEvent): string { + if (event.kind === 'delegated' && event.target) return HISTORY_TEXT.delegatedTo(event.target); + if (event.kind === 'replaced') return `${event.previous || '—'} → ${event.target || '—'}`; + return ''; } diff --git a/src/webparts/requestPortal/shared/approval/ui/ApprovalComment.module.scss b/src/webparts/requestPortal/shared/approval/ui/ApprovalComment.module.scss index 238d613..8300613 100644 --- a/src/webparts/requestPortal/shared/approval/ui/ApprovalComment.module.scss +++ b/src/webparts/requestPortal/shared/approval/ui/ApprovalComment.module.scss @@ -22,7 +22,8 @@ $quote-line-height: 19px; } // Цвет — у полосы и кавычки, фон лишь подтон: текст должен читаться так же -// спокойно, как остальная таблица, а не кричать. +// спокойно, как остальная таблица, а не кричать. Белую карточку пробовали — +// внутри формы она похожа на поле ввода. .neutral { border-left-color: $navy-600; background: $navy-50; diff --git a/src/webparts/requestPortal/shared/approval/ui/ApprovalComment.tsx b/src/webparts/requestPortal/shared/approval/ui/ApprovalComment.tsx index 6f7bb5a..e5bdc58 100644 --- a/src/webparts/requestPortal/shared/approval/ui/ApprovalComment.tsx +++ b/src/webparts/requestPortal/shared/approval/ui/ApprovalComment.tsx @@ -3,7 +3,7 @@ import styles from './ApprovalComment.module.scss'; import { REQUEST_STATUS } from '../models/statuses'; import { useTextOverflow } from './useTextOverflow'; -type CommentTone = 'positive' | 'negative' | 'warning' | 'neutral'; +export type CommentTone = 'positive' | 'negative' | 'warning' | 'neutral'; // Цвет полосы — по решению на шаге: причину отказа или возврата видно с первого // взгляда, ещё до того, как глаз дойдёт до колонки «Статус». @@ -31,18 +31,23 @@ export interface IApprovalCommentProps { text: string; /** Статус строки маршрута (см. REQUEST_STATUS) — от него цвет полосы. */ status?: string; + /** + * Цвет полосы напрямую — для мест, где статуса строки нет (напр. лента + * «Истории согласования» знает вид события, а не статус). Важнее status. + */ + tone?: CommentTone; } /** * Комментарий участника маршрута к своему решению. Длинный текст свёрнут до * нескольких строк (см. $comment-visible-lines), остальное — по кнопке. */ -export const ApprovalComment: React.FC = ({ text, status }) => { +export const ApprovalComment: React.FC = ({ text, status, tone: toneProp }) => { const [isExpanded, setIsExpanded] = React.useState(false); const textRef = React.useRef(null); const isClamped = useTextOverflow(textRef, text); - const tone = (status && TONE_BY_STATUS[status]) || 'neutral'; + const tone = toneProp || (status && TONE_BY_STATUS[status]) || 'neutral'; // В развёрнутом виде обрезки нет, и замер честно вернёт false, но кнопка // «Свернуть» при этом всё равно нужна. const showToggle = isExpanded || isClamped;