From 49568e00e8ea1503a54f8b5337bfaa061210aeb0 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:29:13 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B8=D1=82=D0=BE=D0=B3=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D0=B2=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B3=D0=BE=D0=BB=D0=BE=D0=B2=D0=BA=D0=B5=20=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=B8=D0=B8=20=D1=81=D0=BE=D0=B3=D0=BB=D0=B0=D1=81?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Чтобы понять, чем закончилась отправка, приходилось читать все её события. Теперь рядом с номером отправки — плашка итога: возврат/отказ, «В работе», если заявка сейчас у кого-то на шаге, иначе последнее решение. Правила — в чистой функции roundOutcome, покрыты тестами. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../history/ApprovalHistory.module.scss | 9 ++++ .../approval/history/ApprovalHistoryFeed.tsx | 31 ++++++++------ .../shared/approval/history/RoundTitle.tsx | 27 ++++++++++++ .../shared/approval/history/historyKinds.ts | 10 ++++- .../approval/history/historyModel.test.ts | 41 +++++++++++++++++++ .../shared/approval/history/historyModel.ts | 36 ++++++++++++++++ .../shared/approval/history/historyTexts.ts | 8 +++- 7 files changed, 147 insertions(+), 15 deletions(-) create mode 100644 src/webparts/requestPortal/shared/approval/history/RoundTitle.tsx diff --git a/src/webparts/requestPortal/shared/approval/history/ApprovalHistory.module.scss b/src/webparts/requestPortal/shared/approval/history/ApprovalHistory.module.scss index 99cf848..1a30e89 100644 --- a/src/webparts/requestPortal/shared/approval/history/ApprovalHistory.module.scss +++ b/src/webparts/requestPortal/shared/approval/history/ApprovalHistory.module.scss @@ -19,9 +19,18 @@ $event-gap: $sp-4; gap: $sp-3; } +// Номер отправки + плашка итога (см. RoundTitle). Капслок — только у номера: +// повесь его на весь заголовок, и плашка унаследовала бы его вместе с разрядкой. .roundTitle { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: $sp-1 $sp-2; padding-bottom: $sp-1; border-bottom: 1px solid $gray-200; +} + +.roundNumber { font-size: $fs-overline; font-weight: 600; letter-spacing: 1.4px; diff --git a/src/webparts/requestPortal/shared/approval/history/ApprovalHistoryFeed.tsx b/src/webparts/requestPortal/shared/approval/history/ApprovalHistoryFeed.tsx index 7fc85dd..7398bf7 100644 --- a/src/webparts/requestPortal/shared/approval/history/ApprovalHistoryFeed.tsx +++ b/src/webparts/requestPortal/shared/approval/history/ApprovalHistoryFeed.tsx @@ -1,9 +1,10 @@ import * as React from 'react'; import { ApprovalListService } from '../approvalList.service'; -import { IApprovalHistory } from './historyModel'; +import { IApprovalHistory, roundOutcome } from './historyModel'; import { loadApprovalHistory } from './approvalHistory.service'; import { CurrentHolderRow } from './CurrentHolderRow'; import { HistoryEventRow } from './HistoryEventRow'; +import { RoundTitle } from './RoundTitle'; import { HISTORY_TEXT } from './historyTexts'; import styles from './ApprovalHistory.module.scss'; @@ -49,18 +50,22 @@ export const ApprovalHistoryFeed: React.FC = ({ approval, dataListRef, re return (
- {rounds.map((round, i) => ( -
-
{HISTORY_TEXT.round(round.number)}
-
    - {/* «Сейчас» — только у последней (верхней) отправки. */} - {i === 0 && current.map((holder, j) => )} - {round.events.map((event, j) => ( - - ))} -
-
- ))} + {rounds.map((round, i) => { + // «Сейчас» — только у последней (верхней) отправки: прошлые уже закончились. + const isLatest = i === 0; + const isActive = isLatest && current.length > 0; + return ( +
+ +
    + {isLatest && current.map((holder, j) => )} + {round.events.map((event, j) => ( + + ))} +
+
+ ); + })}
{HISTORY_TEXT.partialNote}
); diff --git a/src/webparts/requestPortal/shared/approval/history/RoundTitle.tsx b/src/webparts/requestPortal/shared/approval/history/RoundTitle.tsx new file mode 100644 index 0000000..a63373e --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/history/RoundTitle.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import { Badge } from '@tnsu/ui-kit'; +import { RoundOutcome } from './historyModel'; +import { outcomeBadge } from './historyKinds'; +import { HISTORY_TEXT, outcomeLabel } from './historyTexts'; +import styles from './ApprovalHistory.module.scss'; + +interface Props { + number: number; + /** Чем закончилась отправка (см. roundOutcome); нет — только номер. */ + outcome?: RoundOutcome; +} + +/** + * Заголовок отправки: номер + плашка итога. По одним заголовкам видна вся + * судьба заявки («вернули, вернули, в работе») без чтения событий. + */ +export const RoundTitle: React.FC = ({ number, outcome }) => ( +
+ {HISTORY_TEXT.round(number)} + {outcome && ( + + {outcomeLabel(outcome)} + + )} +
+); diff --git a/src/webparts/requestPortal/shared/approval/history/historyKinds.ts b/src/webparts/requestPortal/shared/approval/history/historyKinds.ts index 971a296..95051ab 100644 --- a/src/webparts/requestPortal/shared/approval/history/historyKinds.ts +++ b/src/webparts/requestPortal/shared/approval/history/historyKinds.ts @@ -1,6 +1,6 @@ import { BadgeVariant } from '@tnsu/ui-kit'; import { CommentTone } from '../ui/ApprovalComment'; -import { HistoryEventKind } from './historyModel'; +import { HistoryEventKind, RoundOutcome } from './historyModel'; /** Как событие выглядит в ленте: значок в кружке и цвет (кружок, плашка, комментарий). */ export interface IKindView { @@ -27,3 +27,11 @@ export const BADGE_BY_TONE: Record = { warning: 'warning', neutral: 'neutral', }; + +/** + * Цвет плашки итога отправки: у решения — тот же, что у его события в ленте; + * «в работе» — синий, как плашка «Ожидает решения» у текущего шага. + */ +export function outcomeBadge(outcome: RoundOutcome): BadgeVariant { + return outcome === 'inProgress' ? 'info' : BADGE_BY_TONE[KIND_VIEW[outcome].tone]; +} diff --git a/src/webparts/requestPortal/shared/approval/history/historyModel.test.ts b/src/webparts/requestPortal/shared/approval/history/historyModel.test.ts index a00a714..f638403 100644 --- a/src/webparts/requestPortal/shared/approval/history/historyModel.test.ts +++ b/src/webparts/requestPortal/shared/approval/history/historyModel.test.ts @@ -1,8 +1,11 @@ import { buildApprovalHistory, formatDuration, + HistoryEventKind, + IHistoryRound, IRequestVersion, IRouteRowVersion, + roundOutcome, rowEvents, submitEvents, } from './historyModel'; @@ -131,6 +134,44 @@ describe('buildApprovalHistory', () => { }); }); +/** Отправка из событий заданных видов — в том же порядке, что в ленте: НОВЫЕ СВЕРХУ. */ +function round(...kinds: HistoryEventKind[]): IHistoryRound { + return { number: 1, events: kinds.map((kind) => ({ kind, time: '2026-09-24T10:00:00Z', actor: 'Анна Ким' })) }; +} + +describe('roundOutcome', () => { + it('возврат — итог отправки', () => { + expect(roundOutcome(round('returned', 'approved', 'submitted'), false)).toBe('returned'); + }); + + it('отказ — итог отправки', () => { + expect(roundOutcome(round('rejected', 'submitted'), false)).toBe('rejected'); + }); + + it('возврат важнее «в работе»: вернули, а заново ещё не отправили', () => { + expect(roundOutcome(round('returned', 'submitted'), true)).toBe('returned'); + }); + + it('заявка сейчас у кого-то на шаге — «в работе», даже если часть шагов уже согласована', () => { + expect(roundOutcome(round('resubmitted'), true)).toBe('inProgress'); + expect(roundOutcome(round('approved', 'resubmitted'), true)).toBe('inProgress'); + }); + + it('без возврата и не в работе — самое свежее решение', () => { + expect(roundOutcome(round('approved', 'approved', 'resubmitted'), false)).toBe('approved'); + expect(roundOutcome(round('executed', 'approved', 'submitted'), false)).toBe('executed'); + }); + + it('делегирование и замена решением не считаются', () => { + expect(roundOutcome(round('delegated', 'approved', 'submitted'), false)).toBe('approved'); + }); + + it('решений нет — итога нет', () => { + expect(roundOutcome(round('delegated', 'submitted'), false)).toBeUndefined(); + expect(roundOutcome(round(), false)).toBeUndefined(); + }); +}); + describe('formatDuration', () => { it('минуты, часы, дни', () => { expect(formatDuration(30 * 1000)).toBe('1 мин'); diff --git a/src/webparts/requestPortal/shared/approval/history/historyModel.ts b/src/webparts/requestPortal/shared/approval/history/historyModel.ts index 281229b..741cc8f 100644 --- a/src/webparts/requestPortal/shared/approval/history/historyModel.ts +++ b/src/webparts/requestPortal/shared/approval/history/historyModel.ts @@ -181,6 +181,42 @@ export function buildApprovalHistory(rows: IRouteRowVersion[][], requestVersions }; } +/** Виды событий-решений — только ими может закончиться отправка. */ +export type DecisionKind = 'approved' | 'executed' | 'returned' | 'rejected'; + +/** Итог отправки для плашки в её заголовке. 'inProgress' — заявка сейчас у кого-то на шаге. */ +export type RoundOutcome = DecisionKind | 'inProgress'; + +const DECISION_EVENT_KINDS: HistoryEventKind[] = ['approved', 'executed', 'returned', 'rejected']; +// Решения, после которых отправка заканчивается: дальше автор либо правит +// и отправляет заново (новая отправка), либо заявка закрыта отказом. +const CLOSING_KINDS: DecisionKind[] = ['returned', 'rejected']; + +function isDecisionKind(kind: HistoryEventKind): kind is DecisionKind { + return DECISION_EVENT_KINDS.indexOf(kind) >= 0; +} + +/** + * Чем закончилась отправка. Правила по порядку: + * 1. был возврат или отказ — это итог (отправка на нём оборвалась); + * 2. заявка сейчас у кого-то на шаге — «в работе»; + * 3. иначе — последнее решение («согласовано» / «выполнено»); + * 4. решений нет (история сохранилась не полностью) — итога нет. + * @param round отправка; события в ней НОВЫЕ СВЕРХУ (см. buildApprovalHistory) + * @param isActive у кого-то на шаге сейчас именно эта отправка — имеет смысл + * только для последней (верхней) + */ +export function roundOutcome(round: IHistoryRound, isActive: boolean): RoundOutcome | undefined { + for (const event of round.events) { + if (isDecisionKind(event.kind) && CLOSING_KINDS.indexOf(event.kind) >= 0) return event.kind; + } + if (isActive) return 'inProgress'; + for (const event of round.events) { + if (isDecisionKind(event.kind)) return event.kind; + } + return undefined; +} + const MINUTE_MS = 60 * 1000; const HOUR_MS = 60 * MINUTE_MS; const DAY_MS = 24 * HOUR_MS; diff --git a/src/webparts/requestPortal/shared/approval/history/historyTexts.ts b/src/webparts/requestPortal/shared/approval/history/historyTexts.ts index 7a9679d..c0fef54 100644 --- a/src/webparts/requestPortal/shared/approval/history/historyTexts.ts +++ b/src/webparts/requestPortal/shared/approval/history/historyTexts.ts @@ -1,4 +1,4 @@ -import { HistoryEventKind, IHistoryEvent } from './historyModel'; +import { HistoryEventKind, IHistoryEvent, RoundOutcome } from './historyModel'; // Тексты «Истории согласования» — в одном месте (задел под локализацию). // Формулировки безличные («согласовано», а не «согласовала»): пол по имени не @@ -9,6 +9,7 @@ export const HISTORY_TEXT = { empty: 'Пока нет событий: заявка ещё не отправлялась.', errorPrefix: 'Не удалось загрузить историю:', round: (n: number): string => `Отправка ${n}`, + inProgress: 'В работе', awaiting: 'Ожидает решения', waiting: (duration: string): string => `уже ${duration}`, took: (duration: string): string => `за ${duration}`, @@ -34,6 +35,11 @@ export function eventLabel(kind: HistoryEventKind): string { return ACTION_LABEL[kind]; } +/** Итог отправки — текст плашки в её заголовке. Решения — теми же словами, что в ленте. */ +export function outcomeLabel(outcome: RoundOutcome): string { + return outcome === 'inProgress' ? HISTORY_TEXT.inProgress : ACTION_LABEL[outcome]; +} + /** * Над кем действие — дописывается к имени автора: «кому: Асель Самаева» для * передачи, «Иванов → Петров» для замены. У прочих событий пусто.