feat: итог отправки в заголовке истории согласования
Tests / frontend-test (push) Successful in 1m21s

Чтобы понять, чем закончилась отправка, приходилось читать все её события.
Теперь рядом с номером отправки — плашка итога: возврат/отказ, «В работе»,
если заявка сейчас у кого-то на шаге, иначе последнее решение. Правила —
в чистой функции roundOutcome, покрыты тестами.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-29 09:29:13 +05:00
parent f0951cdda2
commit 49568e00e8
7 changed files with 147 additions and 15 deletions
@@ -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;
@@ -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<Props> = ({ approval, dataListRef, re
return (
<div className={styles.feed}>
{rounds.map((round, i) => (
{rounds.map((round, i) => {
// «Сейчас» — только у последней (верхней) отправки: прошлые уже закончились.
const isLatest = i === 0;
const isActive = isLatest && current.length > 0;
return (
<section key={round.number} className={styles.round}>
<div className={styles.roundTitle}>{HISTORY_TEXT.round(round.number)}</div>
<RoundTitle number={round.number} outcome={roundOutcome(round, isActive)} />
<ul className={styles.events}>
{/* «Сейчас» — только у последней (верхней) отправки. */}
{i === 0 && current.map((holder, j) => <CurrentHolderRow key={`current-${j}`} holder={holder} />)}
{isLatest && current.map((holder, j) => <CurrentHolderRow key={`current-${j}`} holder={holder} />)}
{round.events.map((event, j) => (
<HistoryEventRow key={`${event.time}-${event.kind}-${j}`} event={event} />
))}
</ul>
</section>
))}
);
})}
<div className={styles.note}>{HISTORY_TEXT.partialNote}</div>
</div>
);
@@ -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<Props> = ({ number, outcome }) => (
<div className={styles.roundTitle}>
<span className={styles.roundNumber}>{HISTORY_TEXT.round(number)}</span>
{outcome && (
<Badge variant={outcomeBadge(outcome)} size="sm">
{outcomeLabel(outcome)}
</Badge>
)}
</div>
);
@@ -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<CommentTone, BadgeVariant> = {
warning: 'warning',
neutral: 'neutral',
};
/**
* Цвет плашки итога отправки: у решения — тот же, что у его события в ленте;
* «в работе» — синий, как плашка «Ожидает решения» у текущего шага.
*/
export function outcomeBadge(outcome: RoundOutcome): BadgeVariant {
return outcome === 'inProgress' ? 'info' : BADGE_BY_TONE[KIND_VIEW[outcome].tone];
}
@@ -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 мин');
@@ -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;
@@ -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];
}
/**
* Над кем действие — дописывается к имени автора: «кому: Асель Самаева» для
* передачи, «Иванов → Петров» для замены. У прочих событий пусто.