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;
|
gap: $sp-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Номер отправки + плашка итога (см. RoundTitle). Капслок — только у номера:
|
||||||
|
// повесь его на весь заголовок, и плашка унаследовала бы его вместе с разрядкой.
|
||||||
.roundTitle {
|
.roundTitle {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-1 $sp-2;
|
||||||
padding-bottom: $sp-1;
|
padding-bottom: $sp-1;
|
||||||
border-bottom: 1px solid $gray-200;
|
border-bottom: 1px solid $gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roundNumber {
|
||||||
font-size: $fs-overline;
|
font-size: $fs-overline;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 1.4px;
|
letter-spacing: 1.4px;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { ApprovalListService } from '../approvalList.service';
|
import { ApprovalListService } from '../approvalList.service';
|
||||||
import { IApprovalHistory } from './historyModel';
|
import { IApprovalHistory, roundOutcome } from './historyModel';
|
||||||
import { loadApprovalHistory } from './approvalHistory.service';
|
import { loadApprovalHistory } from './approvalHistory.service';
|
||||||
import { CurrentHolderRow } from './CurrentHolderRow';
|
import { CurrentHolderRow } from './CurrentHolderRow';
|
||||||
import { HistoryEventRow } from './HistoryEventRow';
|
import { HistoryEventRow } from './HistoryEventRow';
|
||||||
|
import { RoundTitle } from './RoundTitle';
|
||||||
import { HISTORY_TEXT } from './historyTexts';
|
import { HISTORY_TEXT } from './historyTexts';
|
||||||
import styles from './ApprovalHistory.module.scss';
|
import styles from './ApprovalHistory.module.scss';
|
||||||
|
|
||||||
@@ -49,18 +50,22 @@ export const ApprovalHistoryFeed: React.FC<Props> = ({ approval, dataListRef, re
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.feed}>
|
<div className={styles.feed}>
|
||||||
{rounds.map((round, i) => (
|
{rounds.map((round, i) => {
|
||||||
<section key={round.number} className={styles.round}>
|
// «Сейчас» — только у последней (верхней) отправки: прошлые уже закончились.
|
||||||
<div className={styles.roundTitle}>{HISTORY_TEXT.round(round.number)}</div>
|
const isLatest = i === 0;
|
||||||
<ul className={styles.events}>
|
const isActive = isLatest && current.length > 0;
|
||||||
{/* «Сейчас» — только у последней (верхней) отправки. */}
|
return (
|
||||||
{i === 0 && current.map((holder, j) => <CurrentHolderRow key={`current-${j}`} holder={holder} />)}
|
<section key={round.number} className={styles.round}>
|
||||||
{round.events.map((event, j) => (
|
<RoundTitle number={round.number} outcome={roundOutcome(round, isActive)} />
|
||||||
<HistoryEventRow key={`${event.time}-${event.kind}-${j}`} event={event} />
|
<ul className={styles.events}>
|
||||||
))}
|
{isLatest && current.map((holder, j) => <CurrentHolderRow key={`current-${j}`} holder={holder} />)}
|
||||||
</ul>
|
{round.events.map((event, j) => (
|
||||||
</section>
|
<HistoryEventRow key={`${event.time}-${event.kind}-${j}`} event={event} />
|
||||||
))}
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
<div className={styles.note}>{HISTORY_TEXT.partialNote}</div>
|
<div className={styles.note}>{HISTORY_TEXT.partialNote}</div>
|
||||||
</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 { BadgeVariant } from '@tnsu/ui-kit';
|
||||||
import { CommentTone } from '../ui/ApprovalComment';
|
import { CommentTone } from '../ui/ApprovalComment';
|
||||||
import { HistoryEventKind } from './historyModel';
|
import { HistoryEventKind, RoundOutcome } from './historyModel';
|
||||||
|
|
||||||
/** Как событие выглядит в ленте: значок в кружке и цвет (кружок, плашка, комментарий). */
|
/** Как событие выглядит в ленте: значок в кружке и цвет (кружок, плашка, комментарий). */
|
||||||
export interface IKindView {
|
export interface IKindView {
|
||||||
@@ -27,3 +27,11 @@ export const BADGE_BY_TONE: Record<CommentTone, BadgeVariant> = {
|
|||||||
warning: 'warning',
|
warning: 'warning',
|
||||||
neutral: 'neutral',
|
neutral: 'neutral',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Цвет плашки итога отправки: у решения — тот же, что у его события в ленте;
|
||||||
|
* «в работе» — синий, как плашка «Ожидает решения» у текущего шага.
|
||||||
|
*/
|
||||||
|
export function outcomeBadge(outcome: RoundOutcome): BadgeVariant {
|
||||||
|
return outcome === 'inProgress' ? 'info' : BADGE_BY_TONE[KIND_VIEW[outcome].tone];
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
buildApprovalHistory,
|
buildApprovalHistory,
|
||||||
formatDuration,
|
formatDuration,
|
||||||
|
HistoryEventKind,
|
||||||
|
IHistoryRound,
|
||||||
IRequestVersion,
|
IRequestVersion,
|
||||||
IRouteRowVersion,
|
IRouteRowVersion,
|
||||||
|
roundOutcome,
|
||||||
rowEvents,
|
rowEvents,
|
||||||
submitEvents,
|
submitEvents,
|
||||||
} from './historyModel';
|
} 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', () => {
|
describe('formatDuration', () => {
|
||||||
it('минуты, часы, дни', () => {
|
it('минуты, часы, дни', () => {
|
||||||
expect(formatDuration(30 * 1000)).toBe('1 мин');
|
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 MINUTE_MS = 60 * 1000;
|
||||||
const HOUR_MS = 60 * MINUTE_MS;
|
const HOUR_MS = 60 * MINUTE_MS;
|
||||||
const DAY_MS = 24 * HOUR_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: 'Пока нет событий: заявка ещё не отправлялась.',
|
empty: 'Пока нет событий: заявка ещё не отправлялась.',
|
||||||
errorPrefix: 'Не удалось загрузить историю:',
|
errorPrefix: 'Не удалось загрузить историю:',
|
||||||
round: (n: number): string => `Отправка ${n}`,
|
round: (n: number): string => `Отправка ${n}`,
|
||||||
|
inProgress: 'В работе',
|
||||||
awaiting: 'Ожидает решения',
|
awaiting: 'Ожидает решения',
|
||||||
waiting: (duration: string): string => `уже ${duration}`,
|
waiting: (duration: string): string => `уже ${duration}`,
|
||||||
took: (duration: string): string => `за ${duration}`,
|
took: (duration: string): string => `за ${duration}`,
|
||||||
@@ -34,6 +35,11 @@ export function eventLabel(kind: HistoryEventKind): string {
|
|||||||
return ACTION_LABEL[kind];
|
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