feat: итог отправки в заголовке истории согласования
Tests / frontend-test (push) Successful in 1m21s
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:
@@ -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) => (
|
||||
<section key={round.number} className={styles.round}>
|
||||
<div className={styles.roundTitle}>{HISTORY_TEXT.round(round.number)}</div>
|
||||
<ul className={styles.events}>
|
||||
{/* «Сейчас» — только у последней (верхней) отправки. */}
|
||||
{i === 0 && 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>
|
||||
))}
|
||||
{rounds.map((round, i) => {
|
||||
// «Сейчас» — только у последней (верхней) отправки: прошлые уже закончились.
|
||||
const isLatest = i === 0;
|
||||
const isActive = isLatest && current.length > 0;
|
||||
return (
|
||||
<section key={round.number} className={styles.round}>
|
||||
<RoundTitle number={round.number} outcome={roundOutcome(round, isActive)} />
|
||||
<ul className={styles.events}>
|
||||
{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];
|
||||
}
|
||||
|
||||
/**
|
||||
* Над кем действие — дописывается к имени автора: «кому: Асель Самаева» для
|
||||
* передачи, «Иванов → Петров» для замены. У прочих событий пусто.
|
||||
|
||||
Reference in New Issue
Block a user