5 Commits

Author SHA1 Message Date
Тимур Городецкий 9fd94e0adf feat: таблица «Исполнение» не печатается в заявлениях на отпуск
Tests / frontend-test (push) Successful in 1m19s
Исполнение — внутренняя работа кадров после согласования и к заявлению
сотрудника не относится. На печати «Заявки на отпуск» и «Отпуска без
содержания» остаётся только маршрут согласования; на экране таблица видна.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 10:13:59 +05:00
Тимур Городецкий d3940ce584 feat: место для подписи сотрудника на печати заявлений на отпуск
Печатное заявление подписывают от руки, а на листе было негде. В правом
верхнем углу шапки, под статусом, теперь ФИО сотрудника, место под роспись
и линия «(подпись)» — только при печати. ФИО именно сотрудника, а не
инициатора: заявку могут оформить за него. Общий компонент
shared/ui/PrintSignature, подключён в «Заявку на отпуск» и «Отпуск без
содержания».

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 10:13:40 +05:00
Тимур Городецкий 49568e00e8 feat: итог отправки в заголовке истории согласования
Tests / frontend-test (push) Successful in 1m21s
Чтобы понять, чем закончилась отправка, приходилось читать все её события.
Теперь рядом с номером отправки — плашка итога: возврат/отказ, «В работе»,
если заявка сейчас у кого-то на шаге, иначе последнее решение. Правила —
в чистой функции roundOutcome, покрыты тестами.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 09:29:13 +05:00
Тимур Городецкий f0951cdda2 feat: история согласования — таймлайн с цветными плашками действий
Действие в ленте не выделялось среди остального текста, а комментарий шёл
серым курсивом. Теперь события на вертикальной линии с цветными кружками,
что произошло — плашкой по смыслу решения, комментарий — тем же блоком, что
в маршруте (ApprovalComment получил проп tone), у кого заявка сейчас —
пульсирующий кружок.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 09:20:15 +05:00
Тимур Городецкий 26ddfcb5ba fix: кнопки действий маршрута вровень со стрелками у строки с комментарием
Стрелки шага объединены на строку участника и его комментария и стояли по
центру пары, а кнопки действий — по центру одной строки, из-за чего
расходились по высоте. Ячейка действий теперь тоже накрывает комментарий.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 09:20:15 +05:00
25 changed files with 484 additions and 119 deletions
+1 -1
View File
@@ -1 +1 @@
@tnsu:registry=https://git.tnsu.kz/api/packages/tansu/npm/ @tnsu:registry=http://git.tnsu.kz/api/packages/Tansu/npm/
+2 -2
View File
@@ -269,7 +269,7 @@ Info "Подключаем @tnsu/ui-kit..."
# .npmrc: адрес registry для скоупа @tnsu (НЕ секрет, можно коммитить). # .npmrc: адрес registry для скоупа @tnsu (НЕ секрет, можно коммитить).
# Токен на чтение — у каждого разработчика свой, в ~/.npmrc (в проект не кладём). # Токен на чтение — у каждого разработчика свой, в ~/.npmrc (в проект не кладём).
$npmrc = @' $npmrc = @'
@tnsu:registry=https://git.tnsu.kz/api/packages/tansu/npm/ @tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/
'@ '@
$npmrc | Set-Content -Encoding UTF8 ".npmrc" $npmrc | Set-Content -Encoding UTF8 ".npmrc"
Success ".npmrc создан (адрес registry @tnsu)." Success ".npmrc создан (адрес registry @tnsu)."
@@ -282,7 +282,7 @@ try {
} catch { } catch {
Warn "Не удалось поставить @tnsu/ui-kit. Скорее всего нет токена @tnsu в ~/.npmrc." Warn "Не удалось поставить @tnsu/ui-kit. Скорее всего нет токена @tnsu в ~/.npmrc."
Warn "Создай токен (Gitea → Настройки → Приложения → package: Read) и пропиши в ~/.npmrc:" Warn "Создай токен (Gitea → Настройки → Приложения → package: Read) и пропиши в ~/.npmrc:"
Warn " //git.tnsu.kz/api/packages/tansu/npm/:_authToken=ТВОЙ_ТОКЕН" Warn " //git.tnsu.kz/api/packages/timur.gorodetskiy/npm/:_authToken=ТВОЙ_ТОКЕН"
Warn "Потом выполни: npm install @tnsu/ui-kit" Warn "Потом выполни: npm install @tnsu/ui-kit"
} }
+1 -1
View File
@@ -9887,7 +9887,7 @@
}, },
"node_modules/@tnsu/ui-kit": { "node_modules/@tnsu/ui-kit": {
"version": "0.14.0", "version": "0.14.0",
"resolved": "https://git.tnsu.kz/api/packages/tansu/npm/%40tnsu%2Fui-kit/-/0.14.0/ui-kit-0.14.0.tgz", "resolved": "http://git.tnsu.kz/api/packages/Tansu/npm/%40tnsu%2Fui-kit/-/0.14.0/ui-kit-0.14.0.tgz",
"integrity": "sha512-Vun9AUM9jL8rttbsbKo5Yh6TUz1srkNqxiO6mmzebrSo1JOASNJqNjJPMTS+8Jt3MTrJPklvh0TbeqGQzw7qxQ==", "integrity": "sha512-Vun9AUM9jL8rttbsbKo5Yh6TUz1srkNqxiO6mmzebrSo1JOASNJqNjJPMTS+8Jt3MTrJPklvh0TbeqGQzw7qxQ==",
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
@@ -33,6 +33,11 @@
(черновик проверяется так же строго, как отправка). (черновик проверяется так же строго, как отправка).
- **Замещающий** (`Substitute`, People) — необязателен. - **Замещающий** (`Substitute`, People) — необязателен.
- **Заголовок элемента** (`Title`): `Отпуск без содержания — {ФИО} — дд.мм.гггг`. - **Заголовок элемента** (`Title`): `Отпуск без содержания — {ФИО} — дд.мм.гггг`.
- **Место для подписи на печати** (с 10-29, `PrintSignature`) — как в «Заявке на отпуск»:
столбиком «ФИО / линия / (подпись)» в правом верхнем углу шапки под статусом; ФИО сотрудника,
а не инициатора.
- **Таблица «Исполнение» на печать не идёт** (с 10-29, `rp-no-print`) — как в «Заявке на отпуск»;
на печати только маршрут согласования.
## Отличия от «Заявка на отпуск» (hr-vacation) ## Отличия от «Заявка на отпуск» (hr-vacation)
- Те же «дата начала + количество дней»: даты окончания нет, праздники и выходные не считаются. - Те же «дата начала + количество дней»: даты окончания нет, праздники и выходные не считаются.
@@ -15,3 +15,12 @@
gap: $sp-3; gap: $sp-3;
margin-bottom: $sp-4; margin-bottom: $sp-4;
} }
// Правый угол шапки: статус, под ним (только в печати) подпись сотрудника.
// headerRight у FormCard раскладывает содержимое в строку — здесь нужен столбик.
.headerAside {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: $sp-3;
}
@@ -9,7 +9,8 @@ import {
FormModeProvider, FormModeProvider,
PrintSplitButton, PrintSplitButton,
OrgLetterhead, OrgLetterhead,
LeaveNotice LeaveNotice,
PrintSignature
} from '../../shared/ui'; } from '../../shared/ui';
import { import {
ApproverService, ApproverService,
@@ -64,6 +65,8 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
// Ф.И.О. сотрудника (Employee/Title) — для строки подписи на печатном заявлении.
const [employeeName, setEmployeeName] = useState<string>('');
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Какая из организаций сотрудника (см. OrganizationField) идёт в печать — // Какая из организаций сотрудника (см. OrganizationField) идёт в печать —
@@ -97,6 +100,7 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
setData(loaded.data); setData(loaded.data);
setStatus(loaded.status); setStatus(loaded.status);
setCode(loaded.code); setCode(loaded.code);
setEmployeeName(loaded.employeeName);
notifyCode(loaded.code); notifyCode(loaded.code);
setError(null); setError(null);
setLoading(false); setLoading(false);
@@ -158,7 +162,13 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
itemId={itemId} itemId={itemId}
/> />
} }
headerRight={status ? <StatusBadge status={status} /> : undefined} headerRight={
<div className={styles.headerAside}>
{status && <StatusBadge status={status} />}
{/* Подписывает сотрудник, а не инициатор: заявку могут оформить за него. */}
<PrintSignature name={employeeName} />
</div>
}
> >
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} /> <LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
<FormModeProvider readOnly> <FormModeProvider readOnly>
@@ -168,7 +178,11 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} /> <ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} /> {/* Исполнение — внутренняя работа кадров после согласования, к заявлению
сотрудника не относится: на печати только маршрут согласования. */}
<div className="rp-no-print">
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
</div>
</div> </div>
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} {/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
@@ -31,6 +31,11 @@
- **Комментарий** (с 10-28, `VacationComment`, Note) — необязательный, заполняет инициатор, - **Комментарий** (с 10-28, `VacationComment`, Note) — необязательный, заполняет инициатор,
последним полем секции; в просмотре пустой не показывается. В DOCX для ЭДО (`vacationDocument.ts`) не идёт; в печать последним полем секции; в просмотре пустой не показывается. В DOCX для ЭДО (`vacationDocument.ts`) не идёт; в печать
тоже не попадает (обёртка `rp-no-print` с `display: contents`, чтобы на экране поле оставалось ячейкой сетки). тоже не попадает (обёртка `rp-no-print` с `display: contents`, чтобы на экране поле оставалось ячейкой сетки).
- **Место для подписи на печати** (с 10-29, `PrintSignature` из `shared/ui`) — столбиком «ФИО /
линия / (подпись)» в правом верхнем углу шапки под статусом (`headerAside`), только при печати. ФИО — **сотрудника** (`Employee/Title`), а не
инициатора: заявку могут оформить за сотрудника, а заявление подписывает он сам.
- **Таблица «Исполнение» на печать не идёт** (с 10-29, обёртка `rp-no-print`) — это внутренняя
работа кадров после согласования; на печати только маршрут согласования. На экране видна.
## Подписание в ЭДО ## Подписание в ЭДО
> **Сейчас выключено** (`VACATION_EDO_SIGNING_ENABLED = false` в `shared/config.ts`): решение последнего > **Сейчас выключено** (`VACATION_EDO_SIGNING_ENABLED = false` в `shared/config.ts`): решение последнего
@@ -15,3 +15,12 @@
gap: $sp-3; gap: $sp-3;
margin-bottom: $sp-4; margin-bottom: $sp-4;
} }
// Правый угол шапки: статус, под ним (только в печати) подпись сотрудника.
// headerRight у FormCard раскладывает содержимое в строку — здесь нужен столбик.
.headerAside {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: $sp-3;
}
@@ -10,7 +10,8 @@ import {
FormModeProvider, FormModeProvider,
PrintSplitButton, PrintSplitButton,
OrgLetterhead, OrgLetterhead,
LeaveNotice LeaveNotice,
PrintSignature
} from '../../shared/ui'; } from '../../shared/ui';
import { import {
ApproverService, ApproverService,
@@ -220,7 +221,13 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
itemId={itemId} itemId={itemId}
/> />
} }
headerRight={status ? <StatusBadge status={status} /> : undefined} headerRight={
<div className={styles.headerAside}>
{status && <StatusBadge status={status} />}
{/* Подписывает сотрудник, а не инициатор: заявку могут оформить за него. */}
<PrintSignature name={employeeName} />
</div>
}
> >
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} /> <LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
<FormModeProvider readOnly> <FormModeProvider readOnly>
@@ -229,7 +236,11 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
</FormCard> </FormCard>
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} /> <ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} /> {/* Исполнение — внутренняя работа кадров после согласования, к заявлению
сотрудника не относится: на печати только маршрут согласования. */}
<div className="rp-no-print">
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
</div>
</div> </div>
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} {/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
@@ -1,25 +1,36 @@
@import '../../../../../styles/design-tokens'; @import '../../../../../styles/design-tokens';
// Ширина колонки со временем — под «29.09.2026 11:02»; уже — текст переносится. // Кружок события на линии ленты.
$time-col-width: 124px; $marker-size: 24px;
// Ниже этой ширины время встаёт над текстом события, а не слева от него. // Толщина линии, соединяющей события.
$feed-stack-breakpoint: 640px; $line-width: 2px;
// Зазор между событиями — линия обязана его перекрыть, иначе рвётся.
$event-gap: $sp-4;
.feed { .feed {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $sp-4; gap: $sp-5;
} }
.round { .round {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $sp-2; 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;
@@ -30,62 +41,122 @@ $feed-stack-breakpoint: 640px;
.events { .events {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $sp-3; gap: $event-gap;
margin: 0; margin: 0;
padding: 0; padding: 0;
list-style: none; list-style: none;
} }
.event { .event {
position: relative;
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: $sp-3; gap: $sp-3;
font-size: $fs-sm; font-size: $fs-sm;
color: $gray-900; 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 { .marker {
flex: 0 0 auto; display: flex;
width: 20px; flex: 0 0 $marker-size;
text-align: center; 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; font-weight: 700;
line-height: 1.4; line-height: 1;
} }
.markerNeutral { color: $navy-700; } // Цвета — те же смысловые, что у плашки события и полосы комментария
.markerPositive { color: $green-600; } // (см. historyKinds.ts): кружок, плашка и комментарий одного события — одного цвета.
.markerWarning { color: $amber-700; } .markerNeutral {
.markerNegative { color: $red-500; } border-color: $navy-600;
.markerCurrent { color: $gold-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 { .eventBody {
display: flex; display: flex;
flex: 1 1 auto;
flex-direction: column; flex-direction: column;
gap: 2px; gap: $sp-1;
min-width: 0; min-width: 0; // иначе длинный комментарий распирает ленту вбок
} }
.eventLine { // Плашка + время + длительность. На узкой ширине переносятся сами (flex-wrap),
// отдельный медиазапрос не нужен. min-height — чтобы плашка стояла вровень
// с центром кружка.
.eventHead {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: baseline; align-items: center;
gap: $sp-1 $sp-3; gap: $sp-1 $sp-2;
min-height: $marker-size;
} }
.time { .time {
flex: 0 0 $time-col-width; font-size: $fs-label;
color: $gray-500; color: $gray-500;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
}
@media (max-width: $feed-stack-breakpoint) { .actor {
flex-basis: 100%; overflow-wrap: anywhere;
}
} }
.meta { .meta {
@@ -93,12 +164,6 @@ $feed-stack-breakpoint: 640px;
color: $gray-500; color: $gray-500;
} }
.comment {
color: $gray-700;
font-style: italic;
overflow-wrap: anywhere;
}
.note { .note {
font-size: $fs-label; font-size: $fs-label;
color: $gray-500; color: $gray-500;
@@ -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>
); );
@@ -1,4 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { Badge } from '@tnsu/ui-kit';
import { formatDuration, ICurrentHolder } from './historyModel'; import { formatDuration, ICurrentHolder } from './historyModel';
import { HISTORY_TEXT } from './historyTexts'; import { HISTORY_TEXT } from './historyTexts';
import styles from './ApprovalHistory.module.scss'; import styles from './ApprovalHistory.module.scss';
@@ -11,16 +12,22 @@ interface Props {
export const CurrentHolderRow: React.FC<Props> = ({ holder }) => { export const CurrentHolderRow: React.FC<Props> = ({ holder }) => {
const waitedMs = holder.since ? Date.now() - new Date(holder.since).getTime() : NaN; const waitedMs = holder.since ? Date.now() - new Date(holder.since).getTime() : NaN;
return ( return (
<li className={`${styles.event} ${styles.current}`}> <li className={styles.event}>
{/* Пульсирующий кружок — единственное «живое» место ленты: всё остальное уже
случилось, а это ещё ждёт. */}
<span className={`${styles.marker} ${styles.markerCurrent}`} aria-hidden="true"> <span className={`${styles.marker} ${styles.markerCurrent}`} aria-hidden="true">
● ●
</span> </span>
<div className={styles.eventBody}> <div className={styles.eventBody}>
<div className={styles.eventLine}> <div className={styles.eventHead}>
<span className={styles.time}>{HISTORY_TEXT.now}</span> <Badge variant="info" size="sm">
<strong>{holder.person || '—'}</strong> {HISTORY_TEXT.awaiting}
</Badge>
{waitedMs > 0 && <span className={styles.meta}>{HISTORY_TEXT.waiting(formatDuration(waitedMs))}</span>} {waitedMs > 0 && <span className={styles.meta}>{HISTORY_TEXT.waiting(formatDuration(waitedMs))}</span>}
</div> </div>
<div className={styles.actor}>
<strong>{holder.person || '—'}</strong>
</div>
{holder.stepTitle && <div className={styles.meta}>{holder.stepTitle}</div>} {holder.stepTitle && <div className={styles.meta}>{holder.stepTitle}</div>}
</div> </div>
</li> </li>
@@ -1,22 +1,20 @@
import * as React from 'react'; import * as React from 'react';
import { formatDuration, HistoryEventKind, IHistoryEvent } from './historyModel'; import { Badge } from '@tnsu/ui-kit';
import { eventAction, HISTORY_TEXT } from './historyTexts'; 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'; import styles from './ApprovalHistory.module.scss';
interface Props { interface Props {
event: IHistoryEvent; event: IHistoryEvent;
} }
// Значок и цвет события — по смыслу решения, как у статусов в StatusBadge. const MARKER_CLASS: Record<CommentTone, string> = {
const MARKER: Record<HistoryEventKind, { symbol: string; className: string }> = { positive: styles.markerPositive,
submitted: { symbol: '↗', className: styles.markerNeutral }, negative: styles.markerNegative,
resubmitted: { symbol: '↗', className: styles.markerNeutral }, warning: styles.markerWarning,
approved: { symbol: '✓', className: styles.markerPositive }, neutral: styles.markerNeutral,
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 },
}; };
export function formatDateTime(iso: string): string { 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())}`; return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
} }
/** Одно событие ленты: время · кто — что сделал · за сколько + комментарий. */ /**
* Одно событие ленты. Сверху — ЧТО произошло (цветная плашка) и когда: это то,
* за чем пользователь открывает историю. Ниже — кто, на каком шаге и почему.
*/
export const HistoryEventRow: React.FC<Props> = ({ event }) => { export const HistoryEventRow: React.FC<Props> = ({ event }) => {
const marker = MARKER[event.kind]; const view = KIND_VIEW[event.kind];
const details = eventDetails(event);
return ( return (
<li className={styles.event}> <li className={styles.event}>
<span className={`${styles.marker} ${marker.className}`} aria-hidden="true"> <span className={`${styles.marker} ${MARKER_CLASS[view.tone]}`} aria-hidden="true">
{marker.symbol} {view.symbol}
</span> </span>
<div className={styles.eventBody}> <div className={styles.eventBody}>
<div className={styles.eventLine}> <div className={styles.eventHead}>
<Badge variant={BADGE_BY_TONE[view.tone]} size="sm">
{eventLabel(event.kind)}
</Badge>
<span className={styles.time}>{formatDateTime(event.time)}</span> <span className={styles.time}>{formatDateTime(event.time)}</span>
<span>
<strong>{event.actor || '—'}</strong> — {eventAction(event)}
</span>
{event.durationMs !== undefined && ( {event.durationMs !== undefined && (
<span className={styles.meta}>{HISTORY_TEXT.took(formatDuration(event.durationMs))}</span> <span className={styles.meta}>{HISTORY_TEXT.took(formatDuration(event.durationMs))}</span>
)} )}
</div> </div>
<div className={styles.actor}>
<strong>{event.actor || '—'}</strong>
{details && <span className={styles.meta}> · {details}</span>}
</div>
{event.stepTitle && <div className={styles.meta}>{event.stepTitle}</div>} {event.stepTitle && <div className={styles.meta}>{event.stepTitle}</div>}
{event.comment && <div className={styles.comment}>«{event.comment}»</div>} {event.comment && <ApprovalComment text={event.comment} tone={view.tone} />}
</div> </div>
</li> </li>
); );
@@ -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>
);
@@ -0,0 +1,37 @@
import { BadgeVariant } from '@tnsu/ui-kit';
import { CommentTone } from '../ui/ApprovalComment';
import { HistoryEventKind, RoundOutcome } from './historyModel';
/** Как событие выглядит в ленте: значок в кружке и цвет (кружок, плашка, комментарий). */
export interface IKindView {
symbol: string;
tone: CommentTone;
}
// Цвет — по смыслу решения, как у статусов в StatusBadge: зелёное — пройдено,
// золотое — вернули, красное — отказ, синее — служебное движение заявки.
export const KIND_VIEW: Record<HistoryEventKind, IKindView> = {
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<CommentTone, BadgeVariant> = {
positive: 'success',
negative: 'danger',
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 { 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,28 +9,43 @@ export const HISTORY_TEXT = {
empty: 'Пока нет событий: заявка ещё не отправлялась.', empty: 'Пока нет событий: заявка ещё не отправлялась.',
errorPrefix: 'Не удалось загрузить историю:', errorPrefix: 'Не удалось загрузить историю:',
round: (n: number): string => `Отправка ${n}`, round: (n: number): string => `Отправка ${n}`,
now: 'сейчас', inProgress: 'В работе',
waiting: (duration: string): string => `ждёт ${duration}`, awaiting: 'Ожидает решения',
waiting: (duration: string): string => `уже ${duration}`,
took: (duration: string): string => `за ${duration}`, took: (duration: string): string => `за ${duration}`,
delegatedTo: (name: string): string => `кому: ${name}`,
partialNote: partialNote:
'Показаны события, сохранённые в SharePoint. Решения из отправок до включения истории могли не сохраниться.', 'Показаны события, сохранённые в SharePoint. Решения из отправок до включения истории могли не сохраниться.',
}; };
const ACTION: Record<HistoryEventKind, string> = { // С заглавной: текст стоит первым словом в плашке события.
submitted: 'отправлено на согласование', const ACTION_LABEL: Record<HistoryEventKind, string> = {
resubmitted: 'отправлено повторно', submitted: 'Отправлено на согласование',
approved: 'согласовано', resubmitted: 'Отправлено повторно',
executed: 'выполнено', approved: 'Согласовано',
returned: 'возвращено на доработку', executed: 'Выполнено',
rejected: 'отклонено', returned: 'Возвращено на доработку',
delegated: 'передано', rejected: 'Отклонено',
replaced: 'заменён исполнитель', delegated: 'Передано',
replaced: 'Заменён исполнитель',
}; };
/** Что произошло — «согласовано», «передано: Асель Самаева», «заменён исполнитель: А → Б». */ /** Что произошло — текст плашки: «Согласовано», «Возвращено на доработку». */
export function eventAction(event: IHistoryEvent): string { export function eventLabel(kind: HistoryEventKind): string {
const action = ACTION[event.kind]; return ACTION_LABEL[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 outcomeLabel(outcome: RoundOutcome): string {
return outcome === 'inProgress' ? HISTORY_TEXT.inProgress : ACTION_LABEL[outcome];
}
/**
* Над кем действие — дописывается к имени автора: «кому: Асель Самаева» для
* передачи, «Иванов → Петров» для замены. У прочих событий пусто.
*/
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 '';
} }
@@ -22,7 +22,8 @@ $quote-line-height: 19px;
} }
// Цвет — у полосы и кавычки, фон лишь подтон: текст должен читаться так же // Цвет — у полосы и кавычки, фон лишь подтон: текст должен читаться так же
// спокойно, как остальная таблица, а не кричать. // спокойно, как остальная таблица, а не кричать. Белую карточку пробовали —
// внутри формы она похожа на поле ввода.
.neutral { .neutral {
border-left-color: $navy-600; border-left-color: $navy-600;
background: $navy-50; background: $navy-50;
@@ -3,7 +3,7 @@ import styles from './ApprovalComment.module.scss';
import { REQUEST_STATUS } from '../models/statuses'; import { REQUEST_STATUS } from '../models/statuses';
import { useTextOverflow } from './useTextOverflow'; 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; text: string;
/** Статус строки маршрута (см. REQUEST_STATUS) — от него цвет полосы. */ /** Статус строки маршрута (см. REQUEST_STATUS) — от него цвет полосы. */
status?: string; status?: string;
/**
* Цвет полосы напрямую — для мест, где статуса строки нет (напр. лента
* «Истории согласования» знает вид события, а не статус). Важнее status.
*/
tone?: CommentTone;
} }
/** /**
* Комментарий участника маршрута к своему решению. Длинный текст свёрнут до * Комментарий участника маршрута к своему решению. Длинный текст свёрнут до
* нескольких строк (см. $comment-visible-lines), остальное — по кнопке. * нескольких строк (см. $comment-visible-lines), остальное — по кнопке.
*/ */
export const ApprovalComment: React.FC<IApprovalCommentProps> = ({ text, status }) => { export const ApprovalComment: React.FC<IApprovalCommentProps> = ({ text, status, tone: toneProp }) => {
const [isExpanded, setIsExpanded] = React.useState(false); const [isExpanded, setIsExpanded] = React.useState(false);
const textRef = React.useRef<HTMLDivElement>(null); const textRef = React.useRef<HTMLDivElement>(null);
const isClamped = useTextOverflow(textRef, text); const isClamped = useTextOverflow(textRef, text);
const tone = (status && TONE_BY_STATUS[status]) || 'neutral'; const tone = toneProp || (status && TONE_BY_STATUS[status]) || 'neutral';
// В развёрнутом виде обрезки нет, и замер честно вернёт false, но кнопка // В развёрнутом виде обрезки нет, и замер честно вернёт false, но кнопка
// «Свернуть» при этом всё равно нужна. // «Свернуть» при этом всё равно нужна.
const showToggle = isExpanded || isClamped; const showToggle = isExpanded || isClamped;
@@ -211,7 +211,8 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
// Комментарий занимает всю ширину между объединёнными колонками «Шаг» и // Комментарий занимает всю ширину между объединёнными колонками «Шаг» и
// стрелок: «Согласующий» + «Должность» + те необязательные колонки, что // стрелок: «Согласующий» + «Должность» + те необязательные колонки, что
// сейчас показаны. Колонку действий не захватывает — в ней своя пустая ячейка. // сейчас показаны. Колонку действий не захватывает — её ячейка из строки
// участника объединена и на строку комментария (rowSpan у .matrixActions).
const optionalColumns = [hasDepartment, hasOrganization, hasStatuses, hasDates, hasSla]; const optionalColumns = [hasDepartment, hasOrganization, hasStatuses, hasDates, hasSla];
const commentColSpan = 2 + optionalColumns.filter(Boolean).length; const commentColSpan = 2 + optionalColumns.filter(Boolean).length;
const hasActionsCol = editable || hasRemovable; const hasActionsCol = editable || hasRemovable;
@@ -337,8 +338,11 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
)} )}
</td> </td>
)} )}
{/* Кнопки — на участника вместе с его комментарием, как номер
шага и стрелки: иначе стрелки стоят по центру пары строк,
а кнопки — по центру одной, и они расходятся по высоте. */}
{hasActionsCol && ( {hasActionsCol && (
<td className={styles.matrixActions}> <td className={styles.matrixActions} rowSpan={r.comment ? 2 : 1}>
{editable && ( {editable && (
<button <button
type="button" type="button"
@@ -379,14 +383,13 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
</tr> </tr>
{/* Комментарий — отдельной строкой во всю ширину под участником: {/* Комментарий — отдельной строкой во всю ширину под участником:
в узкой колонке «Согласующий» текст ломался по слову на строку. в узкой колонке «Согласующий» текст ломался по слову на строку.
Ячейки «Шаг» и стрелок её не повторяют — их накрывает rowSpan Ячейки «Шаг», стрелок и действий её не повторяют — их накрывает
(см. block.span); колонка действий — пустая ячейка. */} rowSpan из строки участника. */}
{r.comment && ( {r.comment && (
<tr ref={registerRow(commentRowKey(rowKeys[i]))} className={commentClassNames.join(' ')}> <tr ref={registerRow(commentRowKey(rowKeys[i]))} className={commentClassNames.join(' ')}>
<td colSpan={commentColSpan} className={styles.commentCell}> <td colSpan={commentColSpan} className={styles.commentCell}>
<ApprovalComment text={r.comment} status={r.status} /> <ApprovalComment text={r.comment} status={r.status} />
</td> </td>
{hasActionsCol && <td />}
</tr> </tr>
)} )}
</React.Fragment> </React.Fragment>
@@ -159,9 +159,10 @@
// Строка участника, под которой идёт его комментарий (см. ApprovalComment): // Строка участника, под которой идёт его комментарий (см. ApprovalComment):
// разделитель между ними убираем, иначе комментарий читается как отдельная // разделитель между ними убираем, иначе комментарий читается как отдельная
// запись маршрута. Ячейки «Шаг» и стрелок не трогаем — они объединены на весь // запись маршрута. Объединённые ячейки (rowSpan) не трогаем: «Шаг» и стрелки
// шаг (rowSpan), и их нижняя граница — это граница всего шага. // накрывают весь шаг, действия — участника с комментарием, и их нижняя
.rowWithComment > td:not(.matrixStepCol):not(.matrixMoveCol) { // граница — это граница всей записи.
.rowWithComment > td:not(.matrixStepCol):not(.matrixMoveCol):not(.matrixActions) {
border-bottom-color: transparent; border-bottom-color: transparent;
} }
@@ -388,11 +389,6 @@
flex: 1 1 100%; flex: 1 1 100%;
} }
// Пустая ячейка под колонкой действий на телефоне дала бы лишний отступ.
.commentRow > td:empty {
display: none;
}
.matrixActions, .matrixActions,
.matrixMoveCol { .matrixMoveCol {
justify-content: flex-start; justify-content: flex-start;
@@ -0,0 +1,43 @@
@import '../../../../../styles/design-tokens';
// Длина линии ≈ 5 см на листе A4 — хватает на роспись от руки.
$signature-line-width: 200px;
// Место под роспись между ФИО и линией.
$signature-height: $sp-8;
// На экране блока нет: расписываться негде.
.signature {
display: none;
}
@media print {
// Столбик, а не строка «ФИО ____»: в шапке рядом с заголовком места мало,
// строка в ширину сжала бы заголовок до трёх строк.
.signature {
display: flex;
flex-direction: column;
align-items: center;
// min-width, а не width: длинное ФИО растягивает столбик (и линию), а не
// вылезает за край листа.
min-width: $signature-line-width;
font-size: $fs-sm;
color: #000;
break-inside: avoid;
page-break-inside: avoid;
}
.name {
white-space: nowrap; // перенос фамилии на бланке выглядит как опечатка
}
.line {
align-self: stretch;
height: $signature-height;
border-bottom: 1px solid #000;
}
.caption {
margin-top: 2px;
font-size: $fs-caption;
}
}
@@ -0,0 +1,23 @@
import * as React from 'react';
import styles from './PrintSignature.module.scss';
const CAPTION = '(подпись)';
interface IPrintSignatureProps {
/** ФИО того, кто подписывает. Пусто — печатается только линия. */
name?: string;
}
/**
* Место для подписи на печатном заявлении — столбиком: ФИО, под ним место под
* роспись и линия, под линией «(подпись)». Узкий столбик рассчитан на правый
* верхний угол шапки (FormCard headerRight), как угол бумажного заявления.
* Видно только при печати — на экране расписываться негде.
*/
export const PrintSignature: React.FC<IPrintSignatureProps> = ({ name }) => (
<div className={styles.signature}>
{name && <span className={styles.name}>{name}</span>}
<span className={styles.line} />
<span className={styles.caption}>{CAPTION}</span>
</div>
);
@@ -50,6 +50,7 @@ export { OrganizationField } from './OrganizationField/OrganizationField';
export { OrganizationTable } from './OrganizationField/OrganizationTable'; export { OrganizationTable } from './OrganizationField/OrganizationTable';
export { PrintSplitButton } from './PrintSplitButton/PrintSplitButton'; export { PrintSplitButton } from './PrintSplitButton/PrintSplitButton';
export { OrgLetterhead } from './OrgLetterhead/OrgLetterhead'; export { OrgLetterhead } from './OrgLetterhead/OrgLetterhead';
export { PrintSignature } from './PrintSignature/PrintSignature';
export { LinkField } from './LinkField/LinkField'; export { LinkField } from './LinkField/LinkField';
export { ErrorOverlay } from './ErrorOverlay/ErrorOverlay'; export { ErrorOverlay } from './ErrorOverlay/ErrorOverlay';
export { SubmitActions } from './SubmitActions/SubmitActions'; export { SubmitActions } from './SubmitActions/SubmitActions';