feat: примечание для согласующих в заявке на приём

Инициатору нужно передать согласующим важную информацию так, чтобы её не
пропустили. Новый раздел «6. Примечание для согласующих» (текст + «Важное»);
у согласующих и исполнителей заполненное примечание показывается плашкой
над разделами заявки, важное выделено цветом и иконкой. Сделано общим
модулем shared/approverNote, чтобы подключать в другие формы. Колонки
HireApproverNote (Note) и HireApproverNoteImportant (Boolean).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-08 15:02:11 +05:00
parent 8a22767dd3
commit 545db1aee6
14 changed files with 307 additions and 3 deletions
@@ -0,0 +1,73 @@
@import '../../../../styles/design-tokens';
// Толщина цветной полосы слева у плашки: у важного примечания толще, чтобы
// отличалось не только цветом (дальтонизм, ч/б печать).
$banner-stripe: 3px;
$banner-stripe-important: 6px;
.banner {
display: flex;
align-items: flex-start;
gap: $sp-2;
width: 100%;
box-sizing: border-box;
margin-bottom: $sp-3;
padding: $sp-2 $sp-3;
border: 1px solid $gray-200;
border-left: $banner-stripe solid $navy-600;
border-radius: $r-sm;
background: $navy-50;
font-family: $font-sans;
color: $gray-900;
}
.bannerImportant {
border-color: $gold-300;
border-left-width: $banner-stripe-important;
border-left-color: $gold-600;
background: $gold-100;
.bannerIcon {
color: $gold-600;
}
}
.bannerIcon {
flex-shrink: 0;
margin-top: 2px;
color: $navy-600;
}
.bannerBody {
min-width: 0;
flex: 1;
}
.bannerTitle {
margin-bottom: 2px;
font-size: $fs-sm;
font-weight: 600;
}
.importantTag {
text-transform: uppercase;
color: $amber-700;
}
.noteText {
font-size: $fs-sm;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.link {
color: $navy-600;
text-decoration: underline;
}
.hint {
margin-top: $sp-1;
font-size: $fs-caption;
color: $gray-500;
}
@@ -0,0 +1,33 @@
import * as React from 'react';
import styles from './ApproverNote.module.scss';
import { Icon } from '../ui';
import { APPROVER_NOTE_TEXT } from './approverNoteTexts';
import { NoteText } from './NoteText';
interface Props {
text: string;
important: boolean;
}
/**
* Плашка «Примечание инициатора» над разделами заявки у согласующих и
* исполнителей — чтобы важное сообщение было видно без пролистывания формы.
* Пустое примечание — плашки нет. Важное выделено цветом и иконкой: если все
* примечания яркие, на них быстро перестают обращать внимание.
*/
export const ApproverNoteBanner: React.FC<Props> = ({ text, important }) => {
if (!text.trim()) return null;
return (
<div className={`${styles.banner} ${important ? styles.bannerImportant : ''}`} role="note">
<Icon name={important ? 'warning' : 'info'} size="sm" className={styles.bannerIcon} />
<div className={styles.bannerBody}>
<div className={styles.bannerTitle}>
{important && <span className={styles.importantTag}>{APPROVER_NOTE_TEXT.bannerImportantPrefix} · </span>}
{APPROVER_NOTE_TEXT.bannerTitle}
</div>
<NoteText text={text} />
</div>
</div>
);
};
@@ -0,0 +1,54 @@
import * as React from 'react';
import styles from './ApproverNote.module.scss';
import { CollapseSection, FormField, TextArea, Toggle, useFormReadOnly } from '../ui';
import { APPROVER_NOTE_TEXT } from './approverNoteTexts';
/** Примечание: текст + признак «Важное». */
export interface IApproverNote {
text: string;
important: boolean;
}
/** Ключи полей формы (= internalName колонок) — для подсветки правок и прокрутки к полю. */
export interface IApproverNoteFieldKeys {
text: string;
important: string;
}
interface Props {
/** Заголовок раздела с номером по порядку формы, напр. «6. Примечание для согласующих». */
title: string;
value: IApproverNote;
onChange: (value: IApproverNote) => void;
fieldKeys: IApproverNoteFieldKeys;
}
/**
* Раздел «Примечание для согласующих» — общий для форм. Сама форма решает, в
* какие колонки писать (fieldKeys) и где показывать плашку ApproverNoteBanner.
* В режиме чтения (FormModeProvider readOnly) поля только показываются.
*/
export const ApproverNoteSection: React.FC<Props> = ({ title, value, onChange, fieldKeys }) => {
const readOnly = useFormReadOnly();
return (
<CollapseSection title={title} icon="info">
<FormField label={APPROVER_NOTE_TEXT.fieldLabel} htmlFor={fieldKeys.text} full>
<TextArea
id={fieldKeys.text}
value={value.text}
onChange={(text) => onChange({ ...value, text })}
placeholder={APPROVER_NOTE_TEXT.placeholder}
/>
{!readOnly && <div className={styles.hint}>{APPROVER_NOTE_TEXT.fieldHint}</div>}
</FormField>
<FormField label={APPROVER_NOTE_TEXT.importantLabel} fieldKey={fieldKeys.important}>
<Toggle
checked={value.important}
onChange={(important) => onChange({ ...value, important })}
onLabel={APPROVER_NOTE_TEXT.importantOn}
/>
</FormField>
</CollapseSection>
);
};
@@ -0,0 +1,22 @@
import * as React from 'react';
import styles from './ApproverNote.module.scss';
import { linkify } from './linkify';
interface Props {
text: string;
}
/** Текст примечания: переносы строк сохраняются (CSS pre-wrap), ссылки кликабельны. */
export const NoteText: React.FC<Props> = ({ text }) => (
<div className={styles.noteText}>
{linkify(text).map((part, i) =>
part.href ? (
<a key={i} href={part.href} target="_blank" rel="noopener noreferrer" className={styles.link}>
{part.text}
</a>
) : (
<React.Fragment key={i}>{part.text}</React.Fragment>
)
)}
</div>
);
@@ -0,0 +1,10 @@
/** Пользовательские тексты «Примечания для согласующих» — в одном месте (задел под локализацию). */
export const APPROVER_NOTE_TEXT = {
fieldLabel: 'Примечание',
fieldHint: 'Увидят все согласующие и исполнители сразу при открытии заявки.',
placeholder: 'Важная информация для согласующих (необязательно)',
importantLabel: 'Важное',
importantOn: 'Да',
bannerTitle: 'Примечание инициатора',
bannerImportantPrefix: 'Важно',
} as const;
@@ -0,0 +1,5 @@
// «Примечание для согласующих» — общий для форм блок: раздел формы + плашка у согласующих.
export { ApproverNoteSection } from './ApproverNoteSection';
export type { IApproverNote, IApproverNoteFieldKeys } from './ApproverNoteSection';
export { ApproverNoteBanner } from './ApproverNoteBanner';
export { APPROVER_NOTE_TEXT } from './approverNoteTexts';
@@ -0,0 +1,32 @@
import { linkify } from './linkify';
describe('linkify', () => {
it('текст без ссылок — один кусок', () => {
expect(linkify('Оклад согласован устно.')).toEqual([{ text: 'Оклад согласован устно.' }]);
});
it('ссылка посреди текста, точка в конце предложения не входит в адрес', () => {
expect(linkify('Резюме: https://hh.kz/r/1. Спасибо')).toEqual([
{ text: 'Резюме: ' },
{ text: 'https://hh.kz/r/1', href: 'https://hh.kz/r/1' },
{ text: '. Спасибо' },
]);
});
it('несколько ссылок и переносы строк сохраняются как текст', () => {
expect(linkify('a http://x.kz\nb https://y.kz')).toEqual([
{ text: 'a ' },
{ text: 'http://x.kz', href: 'http://x.kz' },
{ text: '\nb ' },
{ text: 'https://y.kz', href: 'https://y.kz' },
]);
});
it('не-http схемы ссылкой не становятся', () => {
expect(linkify('javascript:alert(1)')).toEqual([{ text: 'javascript:alert(1)' }]);
});
it('пустая строка — пустой список', () => {
expect(linkify('')).toEqual([]);
});
});
@@ -0,0 +1,34 @@
/** Кусок текста примечания: обычный текст или ссылка. */
export interface ITextPart {
text: string;
/** Задан — кусок является ссылкой. */
href?: string;
}
// http(s)-ссылка до первого пробела. Другие схемы (javascript: и т.п.) не
// распознаются намеренно: текст пишет пользователь, в href он не должен
// протащить ничего, кроме обычной веб-ссылки.
const URL_PATTERN = /https?:\/\/[^\s]+/g;
// Знаки, которыми ссылку обычно заканчивает ПРЕДЛОЖЕНИЕ, а не сам адрес
// («см. https://x.kz/a.» — точка не часть адреса).
const TRAILING_PUNCTUATION = /[.,;:!?)»"']+$/;
/** Разбить текст на обычные куски и ссылки — для вывода с кликабельными ссылками. */
export function linkify(text: string): ITextPart[] {
const parts: ITextPart[] = [];
let last = 0;
let match: RegExpExecArray | null;
URL_PATTERN.lastIndex = 0;
while ((match = URL_PATTERN.exec(text)) !== null) {
const trailing = match[0].match(TRAILING_PUNCTUATION);
const url = trailing ? match[0].slice(0, -trailing[0].length) : match[0];
if (match.index > last) parts.push({ text: text.slice(last, match.index) });
parts.push({ text: url, href: url });
last = match.index + url.length;
}
if (last < text.length) parts.push({ text: text.slice(last) });
return parts;
}