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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user