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
@@ -71,6 +71,7 @@ import { CandidateSection } from './sections/CandidateSection';
import { PpeSection } from './sections/PpeSection';
import { ConditionsSection } from './sections/ConditionsSection';
import { AgencySection } from './sections/AgencySection';
import { NoteSection } from './sections/NoteSection';
import { useStableCallback } from '../../shared/useStableCallback';
const RecruitingHireForm: React.FC<IRequestFormProps> = ({
@@ -411,6 +412,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
<PpeSection {...sectionProps} />
<ConditionsSection {...sectionProps} />
<AgencySection {...sectionProps} />
<NoteSection {...sectionProps} />
</FormModeProvider>
</FieldChangesProvider>
@@ -59,6 +59,8 @@ import { CandidateSection } from './sections/CandidateSection';
import { PpeSection } from './sections/PpeSection';
import { ConditionsSection } from './sections/ConditionsSection';
import { AgencySection } from './sections/AgencySection';
import { NoteSection } from './sections/NoteSection';
import { ApproverNoteBanner } from '../../shared/approverNote';
import { useStableCallback } from '../../shared/useStableCallback';
interface IRecruitingHireViewProps {
@@ -374,6 +376,9 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
}
headerRight={status ? <StatusBadge status={status} /> : undefined}
>
{/* Сохранённое значение, а не редактируемое: плашка — то, что написал
инициатор, и не должна меняться по ходу набора правок согласующим. */}
<ApproverNoteBanner text={savedData.HireApproverNote} important={savedData.HireApproverNoteImportant} />
<FieldChangesBanner changes={approverChanges} />
<FieldChangesProvider changes={toChangeMap(approverChanges)}>
<FormModeProvider readOnly={!fullEditable}>
@@ -382,6 +387,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
<PpeSection {...sectionProps} />
<ConditionsSection {...sectionProps} />
<AgencySection {...sectionProps} />
<NoteSection {...sectionProps} />
</FormModeProvider>
</FieldChangesProvider>
@@ -25,7 +25,7 @@ const SCALAR_FIELDS: (keyof IFormData)[] = [
'HireCandidateName', 'HireHireDate', 'Position',
'Department', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod', 'ProbationPeriodOther',
'SalaryProbation', 'SalaryAfterProbation', 'WorkSchedule', 'WorkScheduleOther',
'AgencyName', 'AgencyTerms',
'AgencyName', 'AgencyTerms', 'HireApproverNote',
'SelectionRequestId', 'SelectionRequestListId', 'SelectionRequestCode'
];
@@ -62,6 +62,7 @@ export async function loadHireRequest(itemId: number, listId?: string): Promise<
'ProjectCode',
'RecruitingAgency',
'HireOnArrival',
'HireApproverNoteImportant',
APPROVER_CHANGES_FIELD,
'RequestStatus',
'RequestUniqueCode'
@@ -78,6 +79,7 @@ export async function loadHireRequest(itemId: number, listId?: string): Promise<
// Флаг агентства — булево поле (Yes/No), не строка.
(data as unknown as Record<string, unknown>).RecruitingAgency = !!item.RecruitingAgency;
(data as unknown as Record<string, unknown>).HireOnArrival = !!item.HireOnArrival;
data.HireApproverNoteImportant = !!item.HireApproverNoteImportant;
SCALAR_FIELDS.forEach((key) => {
const value = item[key];
@@ -79,6 +79,10 @@ export interface IFormData {
RecruitingAgency: boolean;
AgencyName: string;
AgencyTerms: string;
// Примечание для согласующих (shared/approverNote): текст + «Важное».
// Префикс Hire — чтобы internalName не совпал с одноимёнными колонками других форм.
HireApproverNote: string;
HireApproverNoteImportant: boolean;
}
/** Обновление одного поля формы с сохранением типа значения. */
@@ -119,7 +123,9 @@ export const EMPTY_DATA: IFormData = {
WorkScheduleOther: '',
RecruitingAgency: false,
AgencyName: '',
AgencyTerms: ''
AgencyTerms: '',
HireApproverNote: '',
HireApproverNoteImportant: false
};
/** Подпись флажка HireOnArrival рядом с датой приёма. */
@@ -44,6 +44,9 @@
{ "internalName": "RecruitingAgency", "title": "Рекрутинговое агентство", "type": "Boolean" },
{ "internalName": "AgencyName", "title": "Название компании (агентство)", "type": "Text" },
{ "internalName": "AgencyTerms", "title": "Условия (агентство)", "type": "Note" }
{ "internalName": "AgencyTerms", "title": "Условия (агентство)", "type": "Note" },
{ "internalName": "HireApproverNote", "title": "Примечание для согласующих", "type": "Note" },
{ "internalName": "HireApproverNoteImportant", "title": "Примечание — важное", "type": "Boolean" }
]
}
@@ -0,0 +1,22 @@
import * as React from 'react';
import { ApproverNoteSection, IApproverNoteFieldKeys } from '../../../shared/approverNote';
import { ISectionProps } from '../model';
/** Колонки примечания в списке данных приёма (см. provisioning.json). */
const NOTE_FIELD_KEYS: IApproverNoteFieldKeys = {
text: 'HireApproverNote',
important: 'HireApproverNoteImportant'
};
/** 6. Примечание для согласующих — общий раздел shared/approverNote на полях приёма. */
export const NoteSection: React.FC<ISectionProps> = ({ data, set }) => (
<ApproverNoteSection
title="6. Примечание для согласующих"
value={{ text: data.HireApproverNote, important: data.HireApproverNoteImportant }}
onChange={(note) => {
set('HireApproverNote', note.text);
set('HireApproverNoteImportant', note.important);
}}
fieldKeys={NOTE_FIELD_KEYS}
/>
);
@@ -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;
}