5 Commits

Author SHA1 Message Date
Тимур Городецкий 4c851b4892 fix: объявление в Teams видно и во вкладке-сайте SharePoint
Tests / frontend-test (push) Successful in 1m19s
Если в Teams закреплена обычная страница SharePoint, SPFx не передаёт
веб-части Teams-контекст, и объявление «открывайте в браузере» не
показывалось. Теперь Teams определяется ещё по фрейму и User-Agent.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 09:43:20 +05:00
Тимур Городецкий 1e316a9558 feat: объявление в Teams — открывать портал в браузере
В приложении Teams часть функций портала работает ограниченно. На главной
в Teams теперь показывается объявление со ссылкой на страницу портала,
которая открывается во внешнем браузере. В браузере объявление не показывается.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 09:34:51 +05:00
Тимур Городецкий c91d2dbc04 fix: дата подачи — день отправки, а не сохранения черновика
В подборе и реферале черновик хранил дату первого сохранения, и через
несколько дней заявка уходила на согласование со старой датой подачи.
Теперь при отправке дата подачи всегда сегодняшняя, а открытый черновик
сразу показывает сегодня (как уже было в объяснительной по СКУД). В реферале
дату больше нельзя поправить руками. Общая логика — shared/submitDate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 09:34:51 +05:00
Тимур Городецкий 5702939b52 feat: подсветка незаполненных полей и переход к ним в приёме и подборе
Пользователи не понимали, почему кнопка «Отправить на согласование» серая.
Теперь в заявках на приём и подбор кнопки активны: нажатие при незаполненных
или неверных полях ничего не сохраняет, а подсвечивает эти поля с подсказкой
под ними и прокручивает к первому. Над кнопками — список проблем, пункты ведут
к полю. Механизм общий (FieldIssuesProvider + FormField), остальные формы
пока работают по-старому и переводятся по тому же паттерну.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 17:15:16 +05:00
Тимур Городецкий c95118a216 feat: тип приёма «Вместо переведенного сотрудника» 2026-09-30 12:03:20 +05:00
34 changed files with 675 additions and 149 deletions
@@ -28,6 +28,8 @@ import { getImpersonatedEmail, getImpersonatedDisplayName } from '../shared/impe
import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges'; import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges';
import { Drawer } from './Drawer/Drawer'; import { Drawer } from './Drawer/Drawer';
import { CategoryTabs } from './CategoryTabs/CategoryTabs'; import { CategoryTabs } from './CategoryTabs/CategoryTabs';
import { TeamsBrowserNotice } from './TeamsBrowserNotice/TeamsBrowserNotice';
import { isOpenedInTeams } from '../shared/hostEnvironment';
import { CATEGORIES, DEFAULT_CATEGORY_ID } from '../data/categories'; import { CATEGORIES, DEFAULT_CATEGORY_ID } from '../data/categories';
import { REQUEST_TYPES } from '../data/requests'; import { REQUEST_TYPES } from '../data/requests';
import SubmitFeature from '../features/submit/SubmitFeature'; import SubmitFeature from '../features/submit/SubmitFeature';
@@ -455,6 +457,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
onInput={markFormDirty} onInput={markFormDirty}
onChange={markFormDirty} onChange={markFormDirty}
> >
{isOpenedInTeams(hasTeamsContext) && <TeamsBrowserNotice />}
<div className={styles.tabsWrap}> <div className={styles.tabsWrap}>
<CategoryTabs <CategoryTabs
categories={CATEGORIES} categories={CATEGORIES}
@@ -0,0 +1,30 @@
@import '../../../../styles/design-tokens';
// Объявление в тоне портала (золото), а не красная ошибка: это рекомендация.
.notice {
margin-bottom: $sp-4;
padding: $sp-3 $sp-4;
border: 1px solid $gold-400;
border-left-width: 4px;
border-radius: $r-md;
background: $gold-50;
color: $navy-900;
font-size: $fs-sm;
line-height: 1.5;
}
.title {
font-weight: 600;
}
.text {
margin: $sp-1 0 $sp-2;
overflow-wrap: anywhere;
}
.link {
font-weight: 600;
color: $amber-700;
text-decoration: underline;
text-underline-offset: 2px;
}
@@ -0,0 +1,24 @@
import * as React from 'react';
import styles from './TeamsBrowserNotice.module.scss';
import { PORTAL_PAGE_URL } from '../../shared/config';
const NOTICE_TITLE = 'Уважаемые пользователи!';
const NOTICE_TEXT =
'Для корректной работы портала заявок рекомендуем открывать его в браузере (Microsoft Edge, Google Chrome). ' +
'В приложении Microsoft Teams отдельные функции портала могут работать ограниченно.';
const LINK_TEXT = 'Открыть портал в браузере →';
/**
* Объявление на главной портала, когда он открыт в Teams: в Teams хуже
* работают пикеры, печать и скачивание вложений. Ссылка с target="_blank" —
* Teams открывает такие ссылки во внешнем браузере.
*/
export const TeamsBrowserNotice: React.FC = () => (
<div className={styles.notice} role="note">
<div className={styles.title}>{NOTICE_TITLE}</div>
<p className={styles.text}>{NOTICE_TEXT}</p>
<a className={styles.link} href={PORTAL_PAGE_URL} target="_blank" rel="noopener noreferrer">
{LINK_TEXT}
</a>
</div>
);
@@ -22,7 +22,8 @@
`RecruitingAgency`; при включении показываются «Название компании» (`AgencyName`) `RecruitingAgency`; при включении показываются «Название компании» (`AgencyName`)
и «Условия» (`AgencyTerms`), при выключении оба поля очищаются. и «Условия» (`AgencyTerms`), при выключении оба поля очищаются.
- **Условное поле «Взамен кого» (ReplacedEmployee)** — показывается только когда - **Условное поле «Взамен кого» (ReplacedEmployee)** — показывается только когда
тип приёма = замена уволенного (см. `HIRE_TYPE_REPLACEMENT` в модели). тип приёма = вместо уволенного / вместо переведенного / декрет
(см. `REPLACED_EMPLOYEE_LABELS` в `sections/ApplicationSection.tsx`).
- Секция **СИЗ** (рост, размеры одежды/обуви). - Секция **СИЗ** (рост, размеры одежды/обуви).
- **Является источником для «Организация рабочего места»** — из просмотра этой - **Является источником для «Организация рабочего места»** — из просмотра этой
заявки кнопка «Организация рабочего места на основе этой» открывает форму заявки кнопка «Организация рабочего места на основе этой» открывает форму
@@ -8,6 +8,8 @@ import {
FormModeProvider, FormModeProvider,
FieldChangesProvider, FieldChangesProvider,
FieldChangesBanner, FieldChangesBanner,
FieldIssuesProvider,
issueLabels,
FileDropzone, FileDropzone,
AttachmentList, AttachmentList,
CollapseSection, CollapseSection,
@@ -52,7 +54,7 @@ import {
dataListTitleForListId dataListTitleForListId
} from '../../shared/listTitles'; } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { missingRequiredFields } from './validation'; import { draftIssues, submitIssues } from './validation';
import { saveHireRequest, hireApprovalService } from './submit'; import { saveHireRequest, hireApprovalService } from './submit';
import { loadHireRequest } from './load'; import { loadHireRequest } from './load';
import { import {
@@ -210,32 +212,18 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
resolveApproverOrgInfo(context) resolveApproverOrgInfo(context)
); );
// При совмещении (2+ компании в «Организация») нужно явно указать, какая из них основная. // Черновику нужны лишь несколько полей, отправке — все обязательные (см. validation.ts).
const mainCompanyMissing = data.Companies.length >= 2 && !data.MainCompanyId; const draftMissing = draftIssues(data);
// ProjectId обязателен (а не просто текст Project): маршрут резолвится ПО const submitMissing = submitIssues(data);
// ПРОЕКТУ (ByProject, см. projectId выше), и без выбранного из списка const isValid = draftMissing.length === 0;
// варианта (id) approval-route вернёт 404 — форма должна не пускать на const isSubmitValid = submitMissing.length === 0;
// отправку раньше, чем выяснится, что маршрут вообще некому строить (баг:
// раньше это не проверялось, и заявка молча уходила на согласование с
// пустой матрицей).
const isValid =
data.HireCandidateName.trim().length > 0 &&
data.Position.trim().length > 0 &&
!!data.ProjectId &&
!mainCompanyMissing;
const validate = (): string | undefined => { const validate = (): string | undefined => {
if (mainCompanyMissing) return 'Выбрано несколько организаций — укажите, какая из них основное место работы.'; if (!isValid) return `Заполните обязательные поля: ${issueLabels(draftMissing)}.`;
if (!isValid) return 'Заполните обязательные поля: Ф.И.О. кандидата, Должность, Проект (выберите вариант из списка).';
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.'; if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
return undefined; return undefined;
}; };
// Остальные поля формы обязательны только для ОТПРАВКИ на согласование —
// черновик по-прежнему можно сохранить не заполнив их (см. validation.ts).
const missingFields = missingRequiredFields(data);
const isSubmitValid = isValid && missingFields.length === 0;
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] => const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
// order обязателен: параллельные шаги делят один номер, по позиции в // order обязателен: параллельные шаги делят один номер, по позиции в
// массиве его не восстановить (см. useMatrixEditor/withOrders). // массиве его не восстановить (см. useMatrixEditor/withOrders).
@@ -327,8 +315,8 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
const validateSubmit = (): string | undefined => { const validateSubmit = (): string | undefined => {
const formError = validate(); const formError = validate();
if (formError) return formError; if (formError) return formError;
if (missingFields.length > 0) { if (!isSubmitValid) {
return `Заполните обязательные поля: ${missingFields.join(', ')}.`; return `Заполните обязательные поля: ${issueLabels(submitMissing)}.`;
} }
if (matrix.error) { if (matrix.error) {
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`; return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
@@ -381,7 +369,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
}); });
return ( return (
<div> <FieldIssuesProvider draftIssues={draftMissing} submitIssues={submitMissing}>
<button type="button" className={styles.back} onClick={onCancel}> <button type="button" className={styles.back} onClick={onCancel}>
<Icon name="arrow-left" size="sm" /> <Icon name="arrow-left" size="sm" />
<span>К списку заявок</span> <span>К списку заявок</span>
@@ -495,7 +483,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
onSubmit={handleSubmit} onSubmit={handleSubmit}
/> />
</FormCard> </FormCard>
</div> </FieldIssuesProvider>
); );
}; };
@@ -14,7 +14,8 @@ import {
Icon, Icon,
PrintButton, PrintButton,
FieldChangesProvider, FieldChangesProvider,
FieldChangesBanner FieldChangesBanner,
issueLabels
} from '../../shared/ui'; } from '../../shared/ui';
import { IFieldChange, mergeFieldChanges, toChangeMap } from '../../shared/fieldChanges/fieldChanges'; import { IFieldChange, mergeFieldChanges, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
import { HIRE_CHANGE_TRACKING } from './changeTracking'; import { HIRE_CHANGE_TRACKING } from './changeTracking';
@@ -51,7 +52,7 @@ import { loadHireRequest } from './load';
import { ATT_CATEGORY_ID, ATT_CATEGORY_RESUME, attachmentsOfCategory, uncategorizedAttachments } from './attachmentCategories'; import { ATT_CATEGORY_ID, ATT_CATEGORY_RESUME, attachmentsOfCategory, uncategorizedAttachments } from './attachmentCategories';
import { confirmDiscardFormChanges } from '../../shared/unsavedChanges'; import { confirmDiscardFormChanges } from '../../shared/unsavedChanges';
import { hireApprovalService, updateApproverFields, updateAllHireFields, IApproverEditableFields } from './submit'; import { hireApprovalService, updateApproverFields, updateAllHireFields, IApproverEditableFields } from './submit';
import { missingRequiredFields } from './validation'; import { submitIssues } from './validation';
import { canEditPayrollFields } from './approverRights'; import { canEditPayrollFields } from './approverRights';
import { ApplicationSection } from './sections/ApplicationSection'; import { ApplicationSection } from './sections/ApplicationSection';
import { CandidateSection } from './sections/CandidateSection'; import { CandidateSection } from './sections/CandidateSection';
@@ -230,8 +231,8 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
throw new Error(HIRE_DATE_REQUIRED_MESSAGE); throw new Error(HIRE_DATE_REQUIRED_MESSAGE);
} }
if (fullEditable && decision === 'approve') { if (fullEditable && decision === 'approve') {
const missing = missingRequiredFields(data); const missing = submitIssues(data);
if (missing.length > 0) throw new Error(`заполните обязательные поля: ${missing.join(', ')}.`); if (missing.length > 0) throw new Error(`заполните обязательные поля: ${issueLabels(missing)}.`);
} }
await saveApproverFields(); await saveApproverFields();
}; };
@@ -9,13 +9,20 @@ describe('hireTypeFromSelection', () => {
}); });
}); });
it('«Замена уволенного сотрудника» → «Вместо уволенного» + ФИО', () => { it('«Замена уволенного сотрудника» → «Вместо уволенного сотрудника» + ФИО', () => {
expect(hireTypeFromSelection('Замена уволенного сотрудника', 'Петров Пётр Петрович')).toEqual({ expect(hireTypeFromSelection('Замена уволенного сотрудника', 'Петров Пётр Петрович')).toEqual({
hireType: 'Вместо уволенного', hireType: 'Вместо уволенного сотрудника',
replacedEmployee: 'Петров Пётр Петрович' replacedEmployee: 'Петров Пётр Петрович'
}); });
}); });
it('«Перевод сотрудника» → «Вместо переведенного сотрудника» + ФИО', () => {
expect(hireTypeFromSelection('Перевод сотрудника', 'Сидоров Сидор Сидорович')).toEqual({
hireType: 'Вместо переведенного сотрудника',
replacedEmployee: 'Сидоров Сидор Сидорович'
});
});
it('«Новая штатная единица» → «Новый сотрудник» без ФИО', () => { it('«Новая штатная единица» → «Новый сотрудник» без ФИО', () => {
expect(hireTypeFromSelection('Новая штатная единица', 'что-то')).toEqual({ expect(hireTypeFromSelection('Новая штатная единица', 'что-то')).toEqual({
hireType: 'Новый сотрудник', hireType: 'Новый сотрудник',
@@ -23,15 +30,14 @@ describe('hireTypeFromSelection', () => {
}); });
}); });
it('перевод, «Другое» и пустая причина — без подстановки', () => { it('«Другое» и пустая причина — без подстановки', () => {
expect(hireTypeFromSelection('Перевод сотрудника', 'Сидоров')).toBeUndefined();
expect(hireTypeFromSelection('Другое', 'уточнение')).toBeUndefined(); expect(hireTypeFromSelection('Другое', 'уточнение')).toBeUndefined();
expect(hireTypeFromSelection('', '')).toBeUndefined(); expect(hireTypeFromSelection('', '')).toBeUndefined();
}); });
it('подставляемый тип всегда есть среди вариантов «Тип приёма»', () => { it('подставляемый тип всегда есть среди вариантов «Тип приёма»', () => {
const allowed = HIRE_TYPE_OPTIONS.map((o) => o.value); const allowed = HIRE_TYPE_OPTIONS.map((o) => o.value);
['Декрет', 'Замена уволенного сотрудника', 'Новая штатная единица'].forEach((reason) => { ['Декрет', 'Замена уволенного сотрудника', 'Перевод сотрудника', 'Новая штатная единица'].forEach((reason) => {
expect(allowed).toContain(hireTypeFromSelection(reason, '')?.hireType); expect(allowed).toContain(hireTypeFromSelection(reason, '')?.hireType);
}); });
}); });
@@ -1,23 +1,25 @@
import { import {
OPEN_REASON_REPLACE, OPEN_REASON_REPLACE,
OPEN_REASON_TRANSFER,
OPEN_REASON_NEW, OPEN_REASON_NEW,
OPEN_REASON_MATERNITY OPEN_REASON_MATERNITY
} from '../RecruitingSelection/model'; } from '../RecruitingSelection/model';
import { HIRE_TYPE_NEW, HIRE_TYPE_REPLACEMENT, HIRE_TYPE_MATERNITY } from './model'; import { HIRE_TYPE_NEW, HIRE_TYPE_REPLACEMENT, HIRE_TYPE_TRANSFER, HIRE_TYPE_MATERNITY } from './model';
/** /**
* Причина открытия вакансии (заявка на подбор) → тип приёма (заявка на приём). * Причина открытия вакансии (заявка на подбор) → тип приёма (заявка на приём).
* «Перевод сотрудника» и «Другое» однозначного соответствия не имеют — их тут * «Другое» однозначного соответствия не имеет — его тут нет, тип приёма в таком
* нет, тип приёма в таком случае выбирает инициатор сам. * случае выбирает инициатор сам.
*/ */
const REASON_TO_HIRE_TYPE: Record<string, string> = { const REASON_TO_HIRE_TYPE: Record<string, string> = {
[OPEN_REASON_REPLACE]: HIRE_TYPE_REPLACEMENT, [OPEN_REASON_REPLACE]: HIRE_TYPE_REPLACEMENT,
[OPEN_REASON_TRANSFER]: HIRE_TYPE_TRANSFER,
[OPEN_REASON_MATERNITY]: HIRE_TYPE_MATERNITY, [OPEN_REASON_MATERNITY]: HIRE_TYPE_MATERNITY,
[OPEN_REASON_NEW]: HIRE_TYPE_NEW [OPEN_REASON_NEW]: HIRE_TYPE_NEW
}; };
/** Типы приёма, для которых переносим ФИО замещаемого сотрудника. */ /** Типы приёма, для которых переносим ФИО замещаемого сотрудника. */
const HIRE_TYPES_WITH_REPLACED_EMPLOYEE = [HIRE_TYPE_REPLACEMENT, HIRE_TYPE_MATERNITY]; const HIRE_TYPES_WITH_REPLACED_EMPLOYEE = [HIRE_TYPE_REPLACEMENT, HIRE_TYPE_TRANSFER, HIRE_TYPE_MATERNITY];
export interface IHireTypePrefill { export interface IHireTypePrefill {
hireType: string; hireType: string;
@@ -2,7 +2,7 @@ import { getSP } from '../../shared/pnp/pnpjs';
import { ICompany } from '../../shared/dictionary'; import { ICompany } from '../../shared/dictionary';
import { dataListTitle } from '../../shared/listTitles'; import { dataListTitle } from '../../shared/listTitles';
import { IFieldChange, parseFieldChanges } from '../../shared/fieldChanges/fieldChanges'; import { IFieldChange, parseFieldChanges } from '../../shared/fieldChanges/fieldChanges';
import { FORM_ID, IFormData, EMPTY_DATA, ICompanyShare } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, ICompanyShare, LEGACY_HIRE_TYPES } from './model';
import { APPROVER_CHANGES_FIELD } from './changeTracking'; import { APPROVER_CHANGES_FIELD } from './changeTracking';
/** Безопасный разбор JSON-массива из Note-колонки (пустой при ошибке/пустом). */ /** Безопасный разбор JSON-массива из Note-колонки (пустой при ошибке/пустом). */
@@ -81,6 +81,7 @@ export async function loadHireRequest(itemId: number, listId?: string): Promise<
writable[key] = String(value); writable[key] = String(value);
} }
}); });
data.HireType = LEGACY_HIRE_TYPES[data.HireType] || data.HireType;
return { return {
data, data,
@@ -127,7 +127,10 @@ export const HIRE_ON_ARRIVAL_LABEL = 'По приезду на объект';
export const HIRE_TYPE_NEW = 'Новый сотрудник'; export const HIRE_TYPE_NEW = 'Новый сотрудник';
/** Значение «Тип приёма», при котором показывается поле «Взамен кого». */ /** Значение «Тип приёма», при котором показывается поле «Взамен кого». */
export const HIRE_TYPE_REPLACEMENT = 'Вместо уволенного'; export const HIRE_TYPE_REPLACEMENT = 'Вместо уволенного сотрудника';
/** Приём на место сотрудника, переведённого на другую должность, — тоже спрашиваем «Взамен кого». */
export const HIRE_TYPE_TRANSFER = 'Вместо переведенного сотрудника';
/** Приём на время декретного отпуска сотрудника — тоже спрашиваем «Взамен кого». */ /** Приём на время декретного отпуска сотрудника — тоже спрашиваем «Взамен кого». */
export const HIRE_TYPE_MATERNITY = 'Декрет'; export const HIRE_TYPE_MATERNITY = 'Декрет';
@@ -136,9 +139,18 @@ export const HIRE_TYPE_MATERNITY = 'Декрет';
export const HIRE_TYPE_OPTIONS: ISelectOption[] = [ export const HIRE_TYPE_OPTIONS: ISelectOption[] = [
{ value: HIRE_TYPE_NEW, label: HIRE_TYPE_NEW }, { value: HIRE_TYPE_NEW, label: HIRE_TYPE_NEW },
{ value: HIRE_TYPE_REPLACEMENT, label: HIRE_TYPE_REPLACEMENT }, { value: HIRE_TYPE_REPLACEMENT, label: HIRE_TYPE_REPLACEMENT },
{ value: HIRE_TYPE_TRANSFER, label: HIRE_TYPE_TRANSFER },
{ value: HIRE_TYPE_MATERNITY, label: HIRE_TYPE_MATERNITY } { value: HIRE_TYPE_MATERNITY, label: HIRE_TYPE_MATERNITY }
]; ];
/**
* Прежние значения «Тип приёма» → текущие. В уже поданных заявках SP хранит
* старый текст; без замены при загрузке поле 1.5 в них показывалось бы пустым.
*/
export const LEGACY_HIRE_TYPES: Record<string, string> = {
'Вместо уволенного': HIRE_TYPE_REPLACEMENT
};
/** Значение-триггер «Другое»: при нём показываем поле ручного ввода. */ /** Значение-триггер «Другое»: при нём показываем поле ручного ввода. */
export const OTHER_VALUE = 'Другое'; export const OTHER_VALUE = 'Другое';
@@ -9,7 +9,7 @@
{ "internalName": "CompanySharesJson", "title": "Распределение ЗП по компаниям (JSON)", "type": "Note" }, { "internalName": "CompanySharesJson", "title": "Распределение ЗП по компаниям (JSON)", "type": "Note" },
{ "internalName": "Phone", "title": "Телефон", "type": "Text" }, { "internalName": "Phone", "title": "Телефон", "type": "Text" },
{ "internalName": "HireType", "title": "Тип приёма", "type": "Choice", "choices": ["Новый сотрудник", "Вместо уволенного", "Декрет"] }, { "internalName": "HireType", "title": "Тип приёма", "type": "Choice", "choices": ["Новый сотрудник", "Вместо уволенного сотрудника", "Вместо переведенного сотрудника", "Декрет"] },
{ "internalName": "HireReplacedEmployee", "title": "Взамен кого (Ф.И.О. уволенного)", "type": "Text" }, { "internalName": "HireReplacedEmployee", "title": "Взамен кого (Ф.И.О. уволенного)", "type": "Text" },
{ "internalName": "Project", "title": "Проект", "type": "Text" }, { "internalName": "Project", "title": "Проект", "type": "Text" },
@@ -1,13 +1,14 @@
import * as React from 'react'; import * as React from 'react';
import { CollapseSection, FormField, TextInput, PhoneInput, Select } from '../../../shared/ui'; import { CollapseSection, FormField, TextInput, PhoneInput, Select } from '../../../shared/ui';
import { CompanyPickerMulti, ProjectPicker, ICompany } from '../../../shared/dictionary'; import { CompanyPickerMulti, ProjectPicker, ICompany } from '../../../shared/dictionary';
import { ISectionProps, HIRE_TYPE_OPTIONS, HIRE_TYPE_REPLACEMENT, HIRE_TYPE_MATERNITY } from '../model'; import { ISectionProps, HIRE_TYPE_OPTIONS, HIRE_TYPE_REPLACEMENT, HIRE_TYPE_TRANSFER, HIRE_TYPE_MATERNITY } from '../model';
import { SelectionRequestField } from './SelectionRequestField'; import { SelectionRequestField } from './SelectionRequestField';
import { CompanySharesField } from './CompanySharesField'; import { CompanySharesField } from './CompanySharesField';
/** Подпись поля «Взамен кого» — уточняем, чьё ФИО вписывать, по типу приёма. */ /** Подпись поля «Взамен кого» — уточняем, чьё ФИО вписывать, по типу приёма. */
const REPLACED_EMPLOYEE_LABELS: Record<string, string> = { const REPLACED_EMPLOYEE_LABELS: Record<string, string> = {
[HIRE_TYPE_REPLACEMENT]: 'Взамен кого (Ф.И.О. уволенного, полностью, пример: Иванов Иван Иванович)', [HIRE_TYPE_REPLACEMENT]: 'Взамен кого (Ф.И.О. уволенного, полностью, пример: Иванов Иван Иванович)',
[HIRE_TYPE_TRANSFER]: 'Взамен кого (Ф.И.О. переведенного сотрудника, полностью, пример: Иванов Иван Иванович)',
[HIRE_TYPE_MATERNITY]: 'Взамен кого (Ф.И.О. сотрудника в декрете, полностью, пример: Иванова Анна Ивановна)' [HIRE_TYPE_MATERNITY]: 'Взамен кого (Ф.И.О. сотрудника в декрете, полностью, пример: Иванова Анна Ивановна)'
}; };
@@ -0,0 +1,44 @@
import { EMPTY_DATA, IFormData } from './model';
import { draftIssues, submitIssues } from './validation';
const DRAFT_READY: IFormData = {
...EMPTY_DATA,
HireCandidateName: 'Иванов Иван Иванович',
Position: 'Инженер',
ProjectId: '42'
};
const labels = (issues: { label: string }[]): string[] => issues.map((i) => i.label);
describe('RecruitingHire validation', () => {
it('пустая форма: черновику не хватает проекта, ФИО и должности', () => {
expect(labels(draftIssues(EMPTY_DATA))).toEqual(['1.3 Проект', '2.1 Ф.И.О. кандидата', '2.3 Должность']);
});
it('черновик готов, но для отправки остаются поля — в порядке формы', () => {
expect(draftIssues(DRAFT_READY)).toEqual([]);
expect(labels(submitIssues(DRAFT_READY))).toEqual([
'1.2 Организация',
'1.4 Телефон',
'1.5 Тип приёма',
'2.2 Дата приёма',
'4.1 Испытательный срок',
'4.2 Оклад при испытательном сроке',
'4.3 Оклад по завершении ИС',
'4.4 График работы'
]);
});
it('ключ поля совпадает с htmlFor у FormField — по нему подсветка и прокрутка', () => {
expect(submitIssues(DRAFT_READY)[0]).toEqual({ field: 'Companies', label: '1.2 Организация', message: undefined });
expect(draftIssues(EMPTY_DATA)[0]).toEqual({
field: 'Project',
label: '1.3 Проект',
message: 'выберите вариант из списка'
});
});
it('«По приезду на объект» снимает обязательность даты приёма', () => {
expect(labels(submitIssues({ ...DRAFT_READY, HireOnArrival: true }))).not.toContain('2.2 Дата приёма');
});
});
@@ -1,29 +1,66 @@
import type { IFieldIssue } from '../../shared/ui';
import { IFormData } from './model'; import { IFormData } from './model';
interface IRequiredRule extends IFieldIssue {
ok: (d: IFormData) => boolean;
/** true — без этого поля не сохранить даже черновик. */
draft?: boolean;
}
/** /**
* Поля формы, обязательные для ОТПРАВКИ на согласование (черновик по-прежнему * Обязательные поля в порядке формы. field — ключ FormField (htmlFor/fieldKey):
* можно сохранить не заполнив их). Исключения по требованию заказчика: 2.4 * по нему поле подсвечивается и к нему прокручивается форма. Большинство полей
* «Отдел/Участок» (Department), 2.5 «Местоположение» (SiteLocation), 3.1–3.3 * нужны только для ОТПРАВКИ на согласование; draft — нужны уже черновику.
* СИЗ (PpeHeight/PpeClothingSize/PpeShoeSize). 1.1 «Заявка на подбор» *
* оставлена опциональной — приём иногда * Исключения по требованию заказчика: 2.4 «Отдел/Участок» (Department), 2.5
* идёт через рекрутинговое агентство (см. AgencySection), без внутреннего * «Местоположение» (SiteLocation), 3.1–3.3 СИЗ. 1.1 «Заявка на подбор»
* подбора. Toggle-поле 5.1 «Рекрутинговое агентство» в проверку не входит — у * опциональна — приём иногда идёт через рекрутинговое агентство (см.
* булевых нет пустого состояния. * AgencySection), без внутреннего подбора. Toggle 5.1 «Рекрутинговое
* агентство» не проверяется — у булевых нет пустого состояния.
* 2.2 «Дата приёма» не нужна, если отмечено «По приезду на объект» * 2.2 «Дата приёма» не нужна, если отмечено «По приезду на объект»
* (HireOnArrival) — тогда дату проставляет исполнитель на своём шаге. * (HireOnArrival) — тогда дату проставляет исполнитель на своём шаге.
*
* 1.3 «Проект» проверяется по ProjectId, а не по тексту: маршрут резолвится
* ПО ПРОЕКТУ (ByProject), и без выбранного из списка варианта approval-route
* вернёт 404 — заявка ушла бы на согласование с пустой матрицей.
*/ */
const REQUIRED_FIELDS: ReadonlyArray<{ label: string; ok: (d: IFormData) => boolean }> = [ const REQUIRED_RULES: ReadonlyArray<IRequiredRule> = [
{ label: '1.2 организация', ok: (d) => d.Companies.length > 0 }, { field: 'Companies', label: '1.2 Организация', ok: (d) => d.Companies.length > 0 },
{ label: '1.4 телефон', ok: (d) => !!d.Phone.trim() }, {
{ label: '1.5 тип приёма', ok: (d) => !!d.HireType }, field: 'MainCompanyId',
{ label: '2.2 дата приёма', ok: (d) => !!d.HireHireDate || d.HireOnArrival }, label: '1.2 Основное место работы',
{ label: '4.1 испытательный срок', ok: (d) => !!d.ProbationPeriod }, message: 'выбрано несколько организаций — укажите основную',
{ label: '4.2 оклад при испытательном сроке', ok: (d) => !!d.SalaryProbation.trim() }, ok: (d) => d.Companies.length < 2 || !!d.MainCompanyId,
{ label: '4.3 оклад по завершении ИС', ok: (d) => !!d.SalaryAfterProbation.trim() }, draft: true
{ label: '4.4 график работы', ok: (d) => !!d.WorkSchedule } },
{
field: 'Project',
label: '1.3 Проект',
message: 'выберите вариант из списка',
ok: (d) => !!d.ProjectId,
draft: true
},
{ field: 'Phone', label: '1.4 Телефон', ok: (d) => !!d.Phone.trim() },
{ field: 'HireType', label: '1.5 Тип приёма', ok: (d) => !!d.HireType },
{ field: 'HireCandidateName', label: '2.1 Ф.И.О. кандидата', ok: (d) => !!d.HireCandidateName.trim(), draft: true },
{ field: 'HireHireDate', label: '2.2 Дата приёма', ok: (d) => !!d.HireHireDate || d.HireOnArrival },
{ field: 'Position', label: '2.3 Должность', ok: (d) => !!d.Position.trim(), draft: true },
{ field: 'ProbationPeriod', label: '4.1 Испытательный срок', ok: (d) => !!d.ProbationPeriod },
{ field: 'SalaryProbation', label: '4.2 Оклад при испытательном сроке', ok: (d) => !!d.SalaryProbation.trim() },
{ field: 'SalaryAfterProbation', label: '4.3 Оклад по завершении ИС', ok: (d) => !!d.SalaryAfterProbation.trim() },
{ field: 'WorkSchedule', label: '4.4 График работы', ok: (d) => !!d.WorkSchedule }
]; ];
/** Подписи незаполненных обязательных-для-отправки полей (пусто — всё заполнено). */ function toIssue({ field, label, message }: IRequiredRule): IFieldIssue {
export function missingRequiredFields(data: IFormData): string[] { return { field, label, message };
return REQUIRED_FIELDS.filter((f) => !f.ok(data)).map((f) => f.label); }
/** Что мешает сохранить черновик (пусто — сохранить можно). */
export function draftIssues(data: IFormData): IFieldIssue[] {
return REQUIRED_RULES.filter((r) => r.draft && !r.ok(data)).map(toIssue);
}
/** Всё, что мешает отправке на согласование, в порядке формы (пусто — отправлять можно). */
export function submitIssues(data: IFormData): IFieldIssue[] {
return REQUIRED_RULES.filter((r) => !r.ok(data)).map(toIssue);
} }
@@ -8,6 +8,8 @@ import {
FormModeProvider, FormModeProvider,
FieldChangesProvider, FieldChangesProvider,
FieldChangesBanner, FieldChangesBanner,
FieldIssuesProvider,
issueLabels,
FileDropzone, FileDropzone,
AttachmentList, AttachmentList,
CollapseSection, CollapseSection,
@@ -53,7 +55,7 @@ import {
dataListTitleForListId dataListTitleForListId
} from '../../shared/listTitles'; } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField, todayIso } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField, todayIso } from './model';
import { submitBlocker } from './validation'; import { draftIssues, submitBlocker, submitIssues } from './validation';
import { saveSelectionRequest, selectionApprovalService } from './submit'; import { saveSelectionRequest, selectionApprovalService } from './submit';
import { loadSelectionRequest } from './load'; import { loadSelectionRequest } from './load';
import { ApplicationSection } from './sections/ApplicationSection'; import { ApplicationSection } from './sections/ApplicationSection';
@@ -62,6 +64,7 @@ import { VacancySection } from './sections/VacancySection';
import { HealthTripsSection } from './sections/HealthTripsSection'; import { HealthTripsSection } from './sections/HealthTripsSection';
import { ConditionsSection } from './sections/ConditionsSection'; import { ConditionsSection } from './sections/ConditionsSection';
import { useStableCallback } from '../../shared/useStableCallback'; import { useStableCallback } from '../../shared/useStableCallback';
import { submitDateOnOpen } from '../../shared/submitDate';
const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
requestType, requestType,
@@ -103,7 +106,7 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
Promise.all([loadSelectionRequest(editId, editListId), getAttachments(editListId || dataListTitle(FORM_ID), editId)]) Promise.all([loadSelectionRequest(editId, editListId), getAttachments(editListId || dataListTitle(FORM_ID), editId)])
.then(([loaded, atts]) => { .then(([loaded, atts]) => {
if (alive) { if (alive) {
setData(loaded.data); setData({ ...loaded.data, SubmitDate: submitDateOnOpen(loaded.status, loaded.data.SubmitDate) });
setApproverChanges(loaded.approverChanges); setApproverChanges(loaded.approverChanges);
setRequestStatus(loaded.status); setRequestStatus(loaded.status);
setRequestCode(loaded.code || undefined); setRequestCode(loaded.code || undefined);
@@ -239,33 +242,18 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
resolveApproverOrgInfo(context) resolveApproverOrgInfo(context)
); );
// ObjectProjectId обязателен (а не просто текст ObjectProject): маршрут // Черновику нужны лишь несколько полей, отправке — все обязательные (см. validation.ts).
// согласования резолвится ПО ПРОЕКТУ (ByProject, см. projectId выше), и без const draftMissing = draftIssues(data);
// выбранного из списка варианта (id) approval-route вернёт 404 — форма const submitProblems = submitIssues(data);
// должна не пускать на отправку раньше, чем выяснится, что маршрут вообще const isValid = draftMissing.length === 0;
// некому строить (баг: раньше это не проверялось, и заявка молча уходила const isSubmitValid = submitProblems.length === 0;
// на согласование с пустой матрицей).
const isValid = !!(
data.Responsible &&
data.DepartmentName.trim() &&
data.Position.trim() &&
data.Residence &&
data.ObjectProjectId
);
const validate = (): string | undefined => { const validate = (): string | undefined => {
if (!isValid) { if (!isValid) return `Заполните обязательные поля: ${issueLabels(draftMissing)}.`;
return 'Заполните обязательные поля: ответственный за подачу, название отдела, должность, проживание, объект/проект (выберите вариант из списка).';
}
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.'; if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
return undefined; return undefined;
}; };
// Остальные поля формы обязательны только для ОТПРАВКИ на согласование —
// черновик по-прежнему можно сохранить не заполнив их (см. validation.ts).
const blocker = submitBlocker(data);
const isSubmitValid = isValid && !blocker;
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] => const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
rows.map((r) => ({ rows.map((r) => ({
email: r.email, email: r.email,
@@ -344,6 +332,7 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
const validateSubmit = (): string | undefined => { const validateSubmit = (): string | undefined => {
const formError = validate(); const formError = validate();
if (formError) return formError; if (formError) return formError;
const blocker = submitBlocker(data);
if (blocker) return blocker; if (blocker) return blocker;
if (matrix.error) { if (matrix.error) {
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`; return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
@@ -387,7 +376,7 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
}); });
return ( return (
<div> <FieldIssuesProvider draftIssues={draftMissing} submitIssues={submitProblems}>
<button type="button" className={styles.back} onClick={onCancel}> <button type="button" className={styles.back} onClick={onCancel}>
<Icon name="arrow-left" size="sm" /> <Icon name="arrow-left" size="sm" />
<span>К списку заявок</span> <span>К списку заявок</span>
@@ -486,7 +475,7 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
submitLabel="Отправить на согласование" submitLabel="Отправить на согласование"
/> />
</FormCard> </FormCard>
</div> </FieldIssuesProvider>
); );
}; };
@@ -3,6 +3,7 @@ import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute }
import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList'; import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList';
import { IFieldChange, serializeFieldChanges } from '../../shared/fieldChanges/fieldChanges'; import { IFieldChange, serializeFieldChanges } from '../../shared/fieldChanges/fieldChanges';
import { buildRequestTitle } from '../../shared/requestTitle'; import { buildRequestTitle } from '../../shared/requestTitle';
import { submitDateOnSave } from '../../shared/submitDate';
import { FORM_ID, IFormData } from './model'; import { FORM_ID, IFormData } from './model';
import { APPROVER_CHANGES_FIELD } from './changeTracking'; import { APPROVER_CHANGES_FIELD } from './changeTracking';
import { cleanVacancyReasons, legacyDetailsText } from './vacancyReasons'; import { cleanVacancyReasons, legacyDetailsText } from './vacancyReasons';
@@ -146,6 +147,7 @@ export async function saveSelectionRequest(
): Promise<ISaveResult> { ): Promise<ISaveResult> {
const item = buildScalarBody(data); const item = buildScalarBody(data);
item.RequestStatus = status; item.RequestStatus = status;
item.SubmitDate = submitDateOnSave(status, data.SubmitDate) || null;
await applyPeople(item, data); await applyPeople(item, data);
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item); const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
@@ -1,54 +1,107 @@
import type { IFieldIssue } from '../../shared/ui';
import { IFormData } from './model'; import { IFormData } from './model';
import { parseVacanciesCount, vacancyReasonsError } from './vacancyReasons'; import { parseVacanciesCount, vacancyReasonsError } from './vacancyReasons';
interface IRequiredRule extends IFieldIssue {
ok: (d: IFormData) => boolean;
/** true — без этого поля не сохранить даже черновик. */
draft?: boolean;
}
/** /**
* Поля формы, обязательные для ОТПРАВКИ на согласование (черновик по-прежнему * Обязательные поля в порядке формы. field — ключ FormField (htmlFor/fieldKey):
* можно сохранить не заполнив их). Исключения по требованию заказчика: 3.8 * по нему поле подсвечивается и к нему прокручивается форма. Большинство полей
* «Необходимые знания в программах» (Programs), 3.9 «Иностранные языки» * нужны только для ОТПРАВКИ на согласование; draft — нужны уже черновику.
* (Languages), 3.11 «Водительские права, стаж вождения» (DrivingLicense). *
* Toggle-поля (BusinessTrips, PieceworkPay, MaterialResponsibility, * Исключения по требованию заказчика: 3.8 «Необходимые знания в программах»
* ReferralProgram) и условные «другое»-подполя в проверку не входят — у * (Programs), 3.9 «Иностранные языки» (Languages), 3.11 «Водительские права,
* булевых нет пустого состояния, а «другое» раскрывается только при выборе * стаж вождения» (DrivingLicense). Toggle-поля (BusinessTrips, PieceworkPay,
* соответствующего варианта в уже обязательном родительском поле. * MaterialResponsibility, ReferralProgram) и условные «другое»-подполя в
* проверку не входят — у булевых нет пустого состояния, а «другое»
* раскрывается только при выборе соответствующего варианта в уже обязательном
* родительском поле.
* 5.10 «Предполагаемый наставник» (ProposedMentor) сделан необязательным * 5.10 «Предполагаемый наставник» (ProposedMentor) сделан необязательным
* по просьбе Тимура (2026-09-24) — не всегда есть кого назначить заранее. * по просьбе Тимура (2026-09-24) — не всегда есть кого назначить заранее.
* 5.1 «Испытательный срок» добавлен 2026-09-24 — нумерация раздела 5 сдвинута на 1. * 5.1 «Испытательный срок» добавлен 2026-09-24 — нумерация раздела 5 сдвинута на 1.
*
* 1.6 «Объект/Проект» проверяется по ObjectProjectId, а не по тексту: маршрут
* резолвится ПО ПРОЕКТУ (ByProject), и без выбранного из списка варианта
* approval-route вернёт 404 — заявка ушла бы на согласование с пустой матрицей.
*/ */
const REQUIRED_FIELDS: ReadonlyArray<{ label: string; ok: (d: IFormData) => boolean }> = [ const REQUIRED_RULES: ReadonlyArray<IRequiredRule> = [
{ label: '1.3 дата подачи заявки', ok: (d) => !!d.SubmitDate }, { field: 'Responsible', label: '1.1 Ответственный за подачу заявки', ok: (d) => !!d.Responsible, draft: true },
{ label: '1.4 крайний срок закрытия', ok: (d) => !!d.ClosingDeadline }, {
{ label: '1.5 организация', ok: (d) => d.Companies.length > 0 }, field: 'DepartmentName',
{ label: '2.1 подразделение', ok: (d) => !!d.Department.trim() }, label: '1.2 Подразделение ответственного за подачу',
{ label: '2.2 количество открытых вакансий', ok: (d) => !!d.OpenVacanciesCount.trim() }, ok: (d) => !!d.DepartmentName.trim(),
{ label: '2.3 причина открытия вакансии', ok: (d) => d.VacancyReasons.length > 0 }, draft: true
{ label: '3.2 характер занятости', ok: (d) => !!d.EmploymentType }, },
{ label: '3.3 функционал', ok: (d) => !!d.JobFunction.trim() }, { field: 'SubmitDate', label: '1.3 Дата подачи заявки', ok: (d) => !!d.SubmitDate },
{ label: '3.4 компетенции', ok: (d) => !!d.Competencies.trim() }, { field: 'ClosingDeadline', label: '1.4 Крайний срок закрытия', ok: (d) => !!d.ClosingDeadline },
{ label: '3.5 личностные характеристики', ok: (d) => !!d.PersonalTraits.trim() }, { field: 'Companies', label: '1.5 Организация', ok: (d) => d.Companies.length > 0 },
{ label: '3.6 образование', ok: (d) => !!d.Education }, {
{ label: '3.7 опыт работы', ok: (d) => !!d.WorkExperience }, field: 'ObjectProject',
{ label: '5.1 испытательный срок', ok: (d) => !!d.SelectionProbationPeriod }, label: '1.6 Объект/Проект',
{ label: '5.2 ставка на ИС', ok: (d) => !!d.RateProbation.trim() }, message: 'выберите вариант из списка',
{ label: '5.3 ставка после ИС', ok: (d) => !!d.RateAfterProbation.trim() }, ok: (d) => !!d.ObjectProjectId,
{ label: '5.5 график работы', ok: (d) => !!d.WorkSchedule }, draft: true
{ label: '5.6 время работы', ok: (d) => !!d.WorkHours }, },
{ label: '5.8 обеспечение рабочего места', ok: (d) => d.WorkplaceProvision.length > 0 }, { field: 'Department', label: '2.1 Подразделение', ok: (d) => !!d.Department.trim() },
{ label: '5.11 непосредственный руководитель', ok: (d) => !!d.DirectManager }, { field: 'OpenVacanciesCount', label: '2.2 Количество открытых вакансий', ok: (d) => !!d.OpenVacanciesCount.trim() },
{ label: '5.12 кто принимает решение о приёме', ok: (d) => d.HiringDecisionMaker.length > 0 } { field: 'VacancyReasons', label: '2.3 Причина открытия вакансии', ok: (d) => d.VacancyReasons.length > 0 },
{ field: 'Position', label: '3.1 Должность', ok: (d) => !!d.Position.trim(), draft: true },
{ field: 'EmploymentType', label: '3.2 Характер занятости', ok: (d) => !!d.EmploymentType },
{ field: 'JobFunction', label: '3.3 Функционал', ok: (d) => !!d.JobFunction.trim() },
{ field: 'Competencies', label: '3.4 Компетенции', ok: (d) => !!d.Competencies.trim() },
{ field: 'PersonalTraits', label: '3.5 Личностные характеристики', ok: (d) => !!d.PersonalTraits.trim() },
{ field: 'Education', label: '3.6 Образование', ok: (d) => !!d.Education },
{ field: 'WorkExperience', label: '3.7 Опыт работы', ok: (d) => !!d.WorkExperience },
{ field: 'Residence', label: '3.10 Проживание', ok: (d) => !!d.Residence, draft: true },
{ field: 'SelectionProbationPeriod', label: '5.1 Испытательный срок', ok: (d) => !!d.SelectionProbationPeriod },
{ field: 'RateProbation', label: '5.2 Ставка на ИС', ok: (d) => !!d.RateProbation.trim() },
{ field: 'RateAfterProbation', label: '5.3 Ставка после ИС', ok: (d) => !!d.RateAfterProbation.trim() },
{ field: 'WorkSchedule', label: '5.5 График работы', ok: (d) => !!d.WorkSchedule },
{ field: 'WorkHours', label: '5.6 Время работы', ok: (d) => !!d.WorkHours },
{ field: 'WorkplaceProvision', label: '5.8 Обеспечение рабочего места', ok: (d) => d.WorkplaceProvision.length > 0 },
{ field: 'DirectManager', label: '5.11 Непосредственный руководитель', ok: (d) => !!d.DirectManager },
{ field: 'HiringDecisionMaker', label: '5.12 Кто принимает решение о приёме', ok: (d) => d.HiringDecisionMaker.length > 0 }
]; ];
/** Подписи незаполненных обязательных-для-отправки полей (пусто — всё заполнено). */ function missingFields(data: IFormData, draftOnly: boolean): IFieldIssue[] {
export function missingRequiredFields(data: IFormData): string[] { return REQUIRED_RULES.filter((r) => (!draftOnly || r.draft) && !r.ok(data)).map(({ field, label, message }) => ({
return REQUIRED_FIELDS.filter((f) => !f.ok(data)).map((f) => f.label); field,
label,
message
}));
}
/** Несогласованность причин (2.3) с количеством вакансий (2.2) — заполнено, но неверно. */
function vacancyReasonsIssue(data: IFormData): IFieldIssue | undefined {
const error = vacancyReasonsError(data.VacancyReasons, parseVacanciesCount(data.OpenVacanciesCount));
if (!error) return undefined;
// Текст ошибки — готовое предложение с заглавной; в списке после тире нужна строчная.
const message = error.charAt(0).toLowerCase() + error.slice(1);
return { field: 'VacancyReasons', label: '2.3 Причина открытия вакансии', message };
}
/** Что мешает сохранить черновик (пусто — сохранить можно). */
export function draftIssues(data: IFormData): IFieldIssue[] {
return missingFields(data, true);
} }
/** /**
* Всё, что мешает отправке/согласованию, одной строкой: сначала незаполненные * Всё, что мешает отправке на согласование: незаполненные поля в порядке формы,
* обязательные поля, затем согласованность причин с количеством вакансий. * затем несогласованность причин с количеством вакансий (пусто — отправлять можно).
* undefined — отправлять можно.
*/ */
export function submitIssues(data: IFormData): IFieldIssue[] {
const reasonsIssue = vacancyReasonsIssue(data);
const missing = missingFields(data, false);
return reasonsIssue ? [...missing, reasonsIssue] : missing;
}
/** Причина, по которой нельзя отправить/согласовать, одной строкой (undefined — можно). */
export function submitBlocker(data: IFormData): string | undefined { export function submitBlocker(data: IFormData): string | undefined {
const missing = missingRequiredFields(data); const missing = missingFields(data, false);
if (missing.length > 0) return `Заполните обязательные поля: ${missing.join(', ')}.`; if (missing.length > 0) return `Заполните обязательные поля: ${missing.map((m) => m.label).join(', ')}.`;
return vacancyReasonsError(data.VacancyReasons, parseVacanciesCount(data.OpenVacanciesCount)); return vacancyReasonsError(data.VacancyReasons, parseVacanciesCount(data.OpenVacanciesCount));
} }
@@ -59,6 +59,7 @@ import { VacancySection } from './sections/VacancySection';
import { CandidateInfoSection } from './sections/CandidateInfoSection'; import { CandidateInfoSection } from './sections/CandidateInfoSection';
import { AssessmentSection } from './sections/AssessmentSection'; import { AssessmentSection } from './sections/AssessmentSection';
import { useStableCallback } from '../../shared/useStableCallback'; import { useStableCallback } from '../../shared/useStableCallback';
import { submitDateOnOpen } from '../../shared/submitDate';
const ReferralForm: React.FC<IRequestFormProps> = ({ const ReferralForm: React.FC<IRequestFormProps> = ({
requestType, requestType,
@@ -111,7 +112,7 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
Promise.all([loadReferralRequest(editId, editListId), getAttachments(editListId || dataListTitle(FORM_ID), editId)]) Promise.all([loadReferralRequest(editId, editListId), getAttachments(editListId || dataListTitle(FORM_ID), editId)])
.then(([loaded, atts]) => { .then(([loaded, atts]) => {
if (alive) { if (alive) {
setData(loaded.data); setData({ ...loaded.data, SubmitDate: submitDateOnOpen(loaded.status, loaded.data.SubmitDate) });
setRequestStatus(loaded.status); setRequestStatus(loaded.status);
setRequestCode(loaded.code || undefined); setRequestCode(loaded.code || undefined);
if (loaded.code) notifyCode(loaded.code); if (loaded.code) notifyCode(loaded.code);
@@ -141,7 +142,7 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
setData((prev) => ({ setData((prev) => ({
...prev, ...prev,
Referrer: prev.Referrer || user.email, Referrer: prev.Referrer || user.email,
SubmitDate: prev.SubmitDate || todayIso() SubmitDate: todayIso()
})); }));
approval approval
.getProfileFields(user.loginName) .getProfileFields(user.loginName)
@@ -5,8 +5,9 @@ import { ISectionProps, REFERRER_PLACEHOLDER } from '../model';
/** Секция 1: кто рекомендует — дата подачи, сам рекомендатель и его контакты. */ /** Секция 1: кто рекомендует — дата подачи, сам рекомендатель и его контакты. */
export const ReferrerSection: React.FC<ISectionProps> = ({ data, set, context, onReferrerChange, printOrgIndex }) => ( export const ReferrerSection: React.FC<ISectionProps> = ({ data, set, context, onReferrerChange, printOrgIndex }) => (
<CollapseSection title="1. Рекомендатель" icon="people"> <CollapseSection title="1. Рекомендатель" icon="people">
{/* Дата подачи — всегда сегодняшняя (ставит форма, при отправке — заново), руками не меняется. */}
<FormField label="1.1 Дата подачи" htmlFor="SubmitDate" required> <FormField label="1.1 Дата подачи" htmlFor="SubmitDate" required>
<TextInput id="SubmitDate" type="date" value={data.SubmitDate} onChange={(v) => set('SubmitDate', v)} /> <TextInput id="SubmitDate" type="date" value={data.SubmitDate} onChange={(v) => set('SubmitDate', v)} readOnly />
</FormField> </FormField>
<FormField label="1.2 Ф.И.О. рекомендателя" htmlFor="Referrer" required full> <FormField label="1.2 Ф.И.О. рекомендателя" htmlFor="Referrer" required full>
<PeopleField <PeopleField
@@ -1,6 +1,7 @@
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval'; import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList'; import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
import { ensureEmployee } from '../../shared/employee'; import { ensureEmployee } from '../../shared/employee';
import { submitDateOnSave } from '../../shared/submitDate';
import { FORM_ID, IFormData, OTHER_VALUE } from './model'; import { FORM_ID, IFormData, OTHER_VALUE } from './model';
/** /**
@@ -28,7 +29,7 @@ function buildItem(data: IFormData, status: string, referrerId: number): Record<
return { return {
Title: buildTitle(data.CandidateName, data.VacancyTitle), Title: buildTitle(data.CandidateName, data.VacancyTitle),
RequestStatus: status, RequestStatus: status,
SubmitDate: data.SubmitDate || null, SubmitDate: submitDateOnSave(status, data.SubmitDate) || null,
ReferrerId: referrerId, ReferrerId: referrerId,
ReferrerPosition: data.ReferrerPosition || null, ReferrerPosition: data.ReferrerPosition || null,
ReferrerDepartment: data.ReferrerDepartment || null, ReferrerDepartment: data.ReferrerDepartment || null,
@@ -35,6 +35,7 @@ import {
dataListTitleForListId dataListTitleForListId
} from '../../shared/listTitles'; } from '../../shared/listTitles';
import { useStableCallback } from '../../shared/useStableCallback'; import { useStableCallback } from '../../shared/useStableCallback';
import { submitDateOnOpen } from '../../shared/submitDate';
import { FORM_ID, FORM_TITLE, IFormData, EMPTY_DATA, SetField } from './model'; import { FORM_ID, FORM_TITLE, IFormData, EMPTY_DATA, SetField } from './model';
import { validateSkudExplanation } from './validation'; import { validateSkudExplanation } from './validation';
import { saveSkudExplanationRequest, skudExplanationApprovalService } from './submit'; import { saveSkudExplanationRequest, skudExplanationApprovalService } from './submit';
@@ -83,10 +84,7 @@ const SkudExplanationForm: React.FC<IRequestFormProps> = ({
loadSkudExplanationRequest(editId, editListId) loadSkudExplanationRequest(editId, editListId)
.then((loaded) => { .then((loaded) => {
if (!alive) return; if (!alive) return;
// Черновик/доработку подают заново — дата подачи становится сегодняшней; setData({ ...loaded.data, SubmitDate: submitDateOnOpen(loaded.status, loaded.data.SubmitDate) });
// у отправленной заявки остаётся та, с которой её подали.
const submitDate = isEditableStatus(loaded.status) ? todayDateKey() : loaded.data.SubmitDate;
setData({ ...loaded.data, SubmitDate: submitDate });
setRequestStatus(loaded.status); setRequestStatus(loaded.status);
setRequestCode(loaded.code || undefined); setRequestCode(loaded.code || undefined);
if (loaded.code) notifyCode(loaded.code); if (loaded.code) notifyCode(loaded.code);
@@ -1,6 +1,7 @@
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval'; import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList'; import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
import { ensureEmployee } from '../../shared/employee'; import { ensureEmployee } from '../../shared/employee';
import { submitDateOnSave } from '../../shared/submitDate';
import { FORM_ID, IFormData, OTHER_REASON } from './model'; import { FORM_ID, IFormData, OTHER_REASON } from './model';
import { formatMissedDaysSummary, formatShortDate } from './noteText'; import { formatMissedDaysSummary, formatShortDate } from './noteText';
import { todayDateKey } from './skud/skudDays'; import { todayDateKey } from './skud/skudDays';
@@ -19,7 +20,7 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee
return { return {
Title: buildTitle(employeeName), Title: buildTitle(employeeName),
RequestStatus: status, RequestStatus: status,
SubmitDate: data.SubmitDate || null, SubmitDate: submitDateOnSave(status, data.SubmitDate) || null,
EmployeeId: employeeId, EmployeeId: employeeId,
SkudEmployeeFio: data.EmployeeFio || null, SkudEmployeeFio: data.EmployeeFio || null,
Position: data.Position || null, Position: data.Position || null,
@@ -15,6 +15,13 @@ export const DATA_SITE_URL = 'https://tnsukz.sharepoint.com/sites/requests';
export const USER_GUIDE_URL = export const USER_GUIDE_URL =
'https://tnsukz.sharepoint.com/:w:/r/sites/knowledge/DocLib8/%D0%98%D0%BD%D1%81%D1%82%D1%80%D1%83%D0%BA%D1%86%D0%B8%D0%B8%20%D0%BA%20IT%20%D1%81%D0%B8%D1%81%D1%82%D0%B5%D0%BC%D0%B0%D0%BC/%D0%98%D0%BD%D1%81%D1%82%D1%80%D1%83%D0%BA%D1%86%D0%B8%D1%8F%20-%20%D0%9F%D0%BE%D1%80%D1%82%D0%B0%D0%BB%20%D0%B7%D0%B0%D1%8F%D0%B2%D0%BE%D0%BA.docx?d=wce77e8a0d19d49b5b2fcdf13828e8bb3&csf=1&web=1&e=p9V3hU'; 'https://tnsukz.sharepoint.com/:w:/r/sites/knowledge/DocLib8/%D0%98%D0%BD%D1%81%D1%82%D1%80%D1%83%D0%BA%D1%86%D0%B8%D0%B8%20%D0%BA%20IT%20%D1%81%D0%B8%D1%81%D1%82%D0%B5%D0%BC%D0%B0%D0%BC/%D0%98%D0%BD%D1%81%D1%82%D1%80%D1%83%D0%BA%D1%86%D0%B8%D1%8F%20-%20%D0%9F%D0%BE%D1%80%D1%82%D0%B0%D0%BB%20%D0%B7%D0%B0%D1%8F%D0%B2%D0%BE%D0%BA.docx?d=wce77e8a0d19d49b5b2fcdf13828e8bb3&csf=1&web=1&e=p9V3hU';
/**
* Страница портала в SharePoint — ссылка «Открыть портал в браузере» в
* объявлении для Teams (см. components/TeamsBrowserNotice). Внутри Teams
* портал свой адрес не знает (вкладка Teams — служебная страница), поэтому задан явно.
*/
export const PORTAL_PAGE_URL = `${DATA_SITE_URL}/SitePages/${encodeURIComponent('Портал-Заявок')}.aspx`;
/** /**
* Внешний approval-route API (маршруты согласования по ролям). * Внешний approval-route API (маршруты согласования по ролям).
* Авторизация — AadHttpClient (SSO текущего пользователя) по clientId * Авторизация — AadHttpClient (SSO текущего пользователя) по clientId
@@ -0,0 +1,23 @@
/** Признак настольного/мобильного Teams в User-Agent (классический и новый клиент). */
const TEAMS_USER_AGENT = /\bTeams\//i;
/** Страница открыта не на верхнем уровне окна, а внутри фрейма (вкладка Teams). */
function isInsideFrame(): boolean {
try {
return window.self !== window.top;
} catch {
// Доступ к window.top из чужого домена бросает исключение — это тоже фрейм.
return true;
}
}
/**
* Открыт ли портал внутри Teams. Teams-контекста SPFx (sdks.microsoftTeams)
* недостаточно: он есть только у приложения/вкладки SPFx. Если в Teams
* закреплена обычная страница SharePoint (вкладка «Сайт»/«SharePoint»), веб-часть
* думает, что она в браузере, — поэтому смотрим ещё на фрейм и User-Agent.
* В браузере страница портала открыта на верхнем уровне и без «Teams/» в UA.
*/
export function isOpenedInTeams(hasTeamsContext: boolean): boolean {
return hasTeamsContext || isInsideFrame() || TEAMS_USER_AGENT.test(navigator.userAgent);
}
@@ -0,0 +1,21 @@
import { REQUEST_STATUS } from './approval/models/statuses';
import { submitDateOnOpen, submitDateOnSave, todaySubmitDate } from './submitDate';
const OLD_DATE = '2026-09-01';
describe('submitDate', () => {
it('отправка на согласование — сегодня, даже если черновик сохранён раньше', () => {
expect(submitDateOnSave(REQUEST_STATUS.onApproval, OLD_DATE)).toBe(todaySubmitDate());
expect(submitDateOnSave(REQUEST_STATUS.queued, OLD_DATE)).toBe(todaySubmitDate());
});
it('черновик хранит дату с экрана', () => {
expect(submitDateOnSave(REQUEST_STATUS.draft, OLD_DATE)).toBe(OLD_DATE);
});
it('открыли черновик или доработку — сегодня, отправленную — как подали', () => {
expect(submitDateOnOpen(REQUEST_STATUS.draft, OLD_DATE)).toBe(todaySubmitDate());
expect(submitDateOnOpen(REQUEST_STATUS.returned, OLD_DATE)).toBe(todaySubmitDate());
expect(submitDateOnOpen(REQUEST_STATUS.onApproval, OLD_DATE)).toBe(OLD_DATE);
});
});
@@ -0,0 +1,30 @@
import { REQUEST_STATUS, isEditableStatus } from './approval/models/statuses';
function pad2(n: number): string {
return n < 10 ? `0${n}` : `${n}`;
}
/** Сегодняшняя дата по местному времени, YYYY-MM-DD (формат input type=date). */
export function todaySubmitDate(): string {
const now = new Date();
return `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`;
}
/**
* «Дата подачи» для формы, открытой на редактирование. Черновик и доработку
* подают заново — на экране сегодняшняя дата, а не дата первого сохранения;
* у уже отправленной заявки остаётся та, с которой её подали.
*/
export function submitDateOnOpen(status: string, savedDate: string): string {
return isEditableStatus(status) ? todaySubmitDate() : savedDate;
}
/**
* «Дата подачи» для записи в SharePoint из формы подачи. Любое сохранение кроме
* черновика — это отправка («На согласовании» или «Ждёт очереди», см.
* initialSubmitStatus): дата всегда сегодняшняя — форму могли открыть вчера, а
* черновик сохранить неделю назад. Черновик хранит то, что на экране.
*/
export function submitDateOnSave(status: string, currentDate: string): string {
return status === REQUEST_STATUS.draft ? currentDate : todaySubmitDate();
}
@@ -0,0 +1,70 @@
import * as React from 'react';
import { useContext, useState } from 'react';
import { IFieldIssue, issueFieldText, scrollToField } from './fieldIssues';
/** Какую кнопку нажимали: от этого зависит, какие поля подсвечивать. */
export type RevealMode = 'draft' | 'submit';
interface IFieldIssuesContext {
draftIssues: IFieldIssue[];
submitIssues: IFieldIssue[];
/** Подсвечиваемые сейчас поля: ключ поля → текст под ним. */
visible: Map<string, string>;
/** Показать проблемы под полями и прокрутить к полю (по умолчанию — к первому). */
reveal: (mode: RevealMode, field?: string) => void;
}
const FieldIssuesContext = React.createContext<IFieldIssuesContext | undefined>(undefined);
interface IFieldIssuesProviderProps {
/** Что мешает сохранить черновик (обычно подмножество submitIssues). */
draftIssues: IFieldIssue[];
/** Что мешает отправке на согласование. */
submitIssues: IFieldIssue[];
children: React.ReactNode;
}
function toVisibleMap(issues: IFieldIssue[]): Map<string, string> {
const map = new Map<string, string>();
issues.forEach((issue) => {
// Первое сообщение по полю важнее: правила идут в порядке формы.
if (!map.has(issue.field)) map.set(issue.field, issueFieldText(issue));
});
return map;
}
/**
* Подсветка незаполненных/неверных полей формы. До первого нажатия кнопки
* поля не краснеют (пустая форма не должна пугать); после — подсвечиваются
* и гаснут сами, как только поле исправлено: списки пересчитываются из данных.
*/
export const FieldIssuesProvider: React.FC<IFieldIssuesProviderProps> = ({ draftIssues, submitIssues, children }) => {
const [mode, setMode] = useState<RevealMode | undefined>(undefined);
const reveal = (nextMode: RevealMode, field?: string): void => {
// Раз показали проблемы отправки — не сужаем обратно до черновика.
setMode((prev) => (prev === 'submit' ? prev : nextMode));
const issues = nextMode === 'draft' ? draftIssues : submitIssues;
const target = field || (issues.length > 0 ? issues[0].field : undefined);
if (target) scrollToField(target);
};
let shown: IFieldIssue[] = [];
if (mode === 'submit') shown = submitIssues;
if (mode === 'draft') shown = draftIssues;
const value: IFieldIssuesContext = { draftIssues, submitIssues, visible: toVisibleMap(shown), reveal };
return <FieldIssuesContext.Provider value={value}>{children}</FieldIssuesContext.Provider>;
};
/** Управление подсветкой для кнопок формы; undefined — форма без FieldIssuesProvider. */
export function useFieldIssuesControl(): IFieldIssuesContext | undefined {
return useContext(FieldIssuesContext);
}
/** Текст проблемы под полем, если его сейчас надо подсветить. */
export function useFieldIssue(field: string | undefined): string | undefined {
const context = useContext(FieldIssuesContext);
if (!context || !field) return undefined;
return context.visible.get(field);
}
@@ -0,0 +1,53 @@
/** Проблема с полем формы, из-за которой заявку нельзя сохранить/отправить. */
export interface IFieldIssue {
/** Ключ поля — тот же, что fieldKey/htmlFor у FormField: по нему подсветка и прокрутка. */
field: string;
/** Подпись в списке над кнопками — как у поля на форме, с номером. */
label: string;
/** Уточнение со строчной буквы («выберите вариант из списка»). Не задано — «заполните поле». */
message?: string;
}
const DEFAULT_ISSUE_MESSAGE = 'заполните поле';
/** Атрибут-метка обёртки поля в FormField — по нему ищем поле для прокрутки. */
export const FIELD_ANCHOR_ATTR = 'data-rp-field';
/** Атрибут-метка списка проблем над кнопками — запасная цель прокрутки. */
export const ISSUES_ANCHOR_ATTR = 'data-rp-issues';
const FOCUSABLE = 'input:not([type="hidden"]):not([disabled]), select, textarea, button:not([disabled])';
/** Строка для списка над кнопками: «1.3 Проект — выберите вариант из списка». */
export function issueListText(issue: IFieldIssue): string {
return issue.message ? `${issue.label} — ${issue.message}` : issue.label;
}
/** Текст под полем: «Выберите вариант из списка» / «Заполните поле». */
export function issueFieldText(issue: IFieldIssue): string {
const text = issue.message || DEFAULT_ISSUE_MESSAGE;
return text.charAt(0).toUpperCase() + text.slice(1);
}
/** Подписи проблем одной строкой — для текста ошибки: «1.4 Телефон, 1.5 Тип приёма». */
export function issueLabels(issues: IFieldIssue[]): string {
return issues.map((i) => i.label).join(', ');
}
/**
* Прокрутить к полю и поставить в него курсор. Поле может отсутствовать в DOM —
* CollapseSection из ui-kit убирает тело свёрнутой секции целиком; тогда
* прокручиваем к списку проблем над кнопками, там поле названо.
*/
export function scrollToField(field: string): void {
const anchor =
document.querySelector<HTMLElement>(`[${FIELD_ANCHOR_ATTR}="${field}"]`) ||
document.querySelector<HTMLElement>(`[${ISSUES_ANCHOR_ATTR}]`);
if (!anchor) return;
anchor.scrollIntoView({ behavior: 'smooth', block: 'center' });
const focusable = anchor.querySelector<HTMLElement>(FOCUSABLE);
if (focusable) {
// preventScroll: иначе фокус мгновенно дёрнет страницу и собьёт плавную прокрутку.
focusable.focus({ preventScroll: true });
}
}
@@ -20,6 +20,22 @@
font-weight: 600; font-weight: 600;
} }
// Незаполненное/неверное поле после нажатия «Отправить» (см. FieldIssuesProvider).
// Полоса слева, а не рамка контрола: у пикеров, мультивыборов и переключателей
// своей рамки нет или она во Fluent — полоса одинаково видна у любого поля.
.invalid {
padding-left: $sp-2;
border-left: 3px solid $red-500;
border-radius: $r-sm;
}
.issue {
margin-top: $sp-1;
font-size: $fs-caption;
color: $red-500;
overflow-wrap: anywhere;
}
// На печати подсветка не нужна — бумажная форма показывает итоговые значения. // На печати подсветка не нужна — бумажная форма показывает итоговые значения.
@media print { @media print {
.changed { .changed {
@@ -3,6 +3,8 @@ import { FormField as KitFormField } from '@tnsu/ui-kit';
import styles from './FormField.module.scss'; import styles from './FormField.module.scss';
import { useFieldChange } from '../FieldChanges/FieldChangesContext'; import { useFieldChange } from '../FieldChanges/FieldChangesContext';
import { formatDateTime } from '../FieldChanges/formatDateTime'; import { formatDateTime } from '../FieldChanges/formatDateTime';
import { useFieldIssue } from '../FieldIssues/FieldIssuesContext';
import { FIELD_ANCHOR_ATTR } from '../FieldIssues/fieldIssues';
interface IFormFieldProps { interface IFormFieldProps {
label: string; label: string;
@@ -11,30 +13,43 @@ interface IFormFieldProps {
/** Занять обе колонки сетки. */ /** Занять обе колонки сетки. */
full?: boolean; full?: boolean;
/** /**
* Ключ поля в журнале правок (internalName колонки). По умолчанию — htmlFor; * Ключ поля в журнале правок (internalName колонки) и в списке проблем формы
* задаётся явно у полей без htmlFor (люди, мультивыбор, переключатели). * (FieldIssuesProvider). По умолчанию — htmlFor; задаётся явно у полей без
* htmlFor (люди, мультивыбор, переключатели).
*/ */
fieldKey?: string; fieldKey?: string;
children: React.ReactNode; children: React.ReactNode;
} }
/** /**
* FormField из ui-kit + подсветка правки согласующего: если поле есть в * FormField из ui-kit + две подсветки:
* журнале (см. FieldChangesProvider), контрол подсвечивается, а под ним — * - правка согласующего (см. FieldChangesProvider): контрол выделен, под ним — прежнее значение;
* прежнее значение. Без журнала ведёт себя ровно как FormField из ui-kit. * - проблема поля (см. FieldIssuesProvider): красная полоса и текст под контролом.
*
* Обёртка с меткой поля есть всегда, а не только при ошибке: иначе при появлении
* и исчезновении ошибки React пересоздавал бы контрол и сбрасывал фокус посреди ввода.
*/ */
export const FormField: React.FC<IFormFieldProps> = ({ fieldKey, children, ...fieldProps }) => { export const FormField: React.FC<IFormFieldProps> = ({ fieldKey, children, ...fieldProps }) => {
const change = useFieldChange(fieldKey ?? fieldProps.htmlFor); const key = fieldKey ?? fieldProps.htmlFor;
if (!change) { const change = useFieldChange(key);
return <KitFormField {...fieldProps}>{children}</KitFormField>; const issue = useFieldIssue(key);
} const anchorProps = key ? { [FIELD_ANCHOR_ATTR]: key } : {};
return ( return (
<KitFormField {...fieldProps}> <KitFormField {...fieldProps}>
<div className={styles.changed}>{children}</div> <div {...anchorProps} className={issue ? styles.invalid : undefined}>
{change ? <div className={styles.changed}>{children}</div> : children}
</div>
{change && (
<div className={`${styles.before} rp-no-print`} title={`Изменено: ${change.byName}, ${formatDateTime(change.at)}`}> <div className={`${styles.before} rp-no-print`} title={`Изменено: ${change.byName}, ${formatDateTime(change.at)}`}>
<span className={styles.beforeLabel}>Было:</span> {change.before} <span className={styles.beforeLabel}>Было:</span> {change.before}
</div> </div>
)}
{issue && (
<div className={`${styles.issue} rp-no-print`} role="alert">
{issue}
</div>
)}
</KitFormField> </KitFormField>
); );
}; };
@@ -2,6 +2,8 @@ import * as React from 'react';
import { Button, Icon, FormActions, Alert } from '@tnsu/ui-kit'; import { Button, Icon, FormActions, Alert } from '@tnsu/ui-kit';
import { IRequestSubmit } from '../useRequestSubmit'; import { IRequestSubmit } from '../useRequestSubmit';
import { isCurrentUserTester } from './testerGroupAccess'; import { isCurrentUserTester } from './testerGroupAccess';
import { SubmitIssues } from './SubmitIssues';
import { useFieldIssuesControl } from '../FieldIssues/FieldIssuesContext';
/** /**
* ВРЕМЕННАЯ ЗАМОРОЗКА (включена 2026-09-21 по просьбе Тимура Городецкого): * ВРЕМЕННАЯ ЗАМОРОЗКА (включена 2026-09-21 по просьбе Тимура Городецкого):
@@ -65,6 +67,11 @@ export interface ISubmitActionsProps {
// Логика доступности — здесь, в одном месте, чтобы все формы вели себя одинаково: // Логика доступности — здесь, в одном месте, чтобы все формы вели себя одинаково:
// • сохранять/отправлять нельзя, пока идёт сохранение или заявка уже read-only; // • сохранять/отправлять нельзя, пока идёт сохранение или заявка уже read-only;
// • «Отмена» доступна всегда (возврат к списку), кроме момента сохранения. // • «Отмена» доступна всегда (возврат к списку), кроме момента сохранения.
//
// Форма внутри FieldIssuesProvider (см. shared/ui/FieldIssues) ведёт себя иначе:
// кнопки сохранения не гаснут из-за незаполненных полей — нажатие при проблемах
// ничего не сохраняет, а подсвечивает поля и прокручивает к первому. Над кнопками
// список проблем со ссылками на поля. Формы без провайдера — как раньше, по isValid.
export const SubmitActions: React.FC<ISubmitActionsProps> = ({ export const SubmitActions: React.FC<ISubmitActionsProps> = ({
submit, submit,
isValid, isValid,
@@ -98,11 +105,33 @@ export const SubmitActions: React.FC<ISubmitActionsProps> = ({
const frozen = SUBMISSION_FROZEN && !unfrozenForm && !isTester; const frozen = SUBMISSION_FROZEN && !unfrozenForm && !isTester;
const locked = saving || readOnly || frozen; const locked = saving || readOnly || frozen;
const canSubmit = submitValid ?? isValid; const issuesControl = useFieldIssuesControl();
// С провайдером кнопки не гаснут по полям: проблемы показываются по нажатию.
const canSaveDraft = issuesControl ? true : isValid;
const canSubmit = issuesControl ? true : submitValid ?? isValid;
const handleSaveDraft = (): void => {
if (issuesControl && issuesControl.draftIssues.length > 0) {
issuesControl.reveal('draft');
return;
}
onSaveDraft();
};
const handleSubmit = (): void => {
if (issuesControl && issuesControl.submitIssues.length > 0) {
issuesControl.reveal('submit');
return;
}
onSubmit();
};
return ( return (
<> <>
{frozen && !readOnly && <Alert variant="warn">Подача заявок временно недоступна.</Alert>} {frozen && !readOnly && <Alert variant="warn">Подача заявок временно недоступна.</Alert>}
{/* При заморозке кнопки неактивны не из-за полей — список только сбил бы с толку. */}
{issuesControl && !frozen && !readOnly && (
<SubmitIssues issues={issuesControl.submitIssues} onSelect={(field) => issuesControl.reveal('submit', field)} />
)}
<FormActions> <FormActions>
{onDelete && ( {onDelete && (
<Button variant="danger" onClick={onDelete} disabled={saving}> <Button variant="danger" onClick={onDelete} disabled={saving}>
@@ -113,11 +142,11 @@ export const SubmitActions: React.FC<ISubmitActionsProps> = ({
<Button variant="secondary" onClick={onCancel} disabled={saving}> <Button variant="secondary" onClick={onCancel} disabled={saving}>
Отмена Отмена
</Button> </Button>
<Button variant="secondary" onClick={onSaveDraft} disabled={!isValid || locked}> <Button variant="secondary" onClick={handleSaveDraft} disabled={!canSaveDraft || locked}>
<Icon name="document" size="sm" /> <Icon name="document" size="sm" />
Сохранить черновик Сохранить черновик
</Button> </Button>
<Button variant="accent" onClick={onSubmit} disabled={!canSubmit || locked}> <Button variant="accent" onClick={handleSubmit} disabled={!canSubmit || locked}>
<Icon name="send" size="sm" /> <Icon name="send" size="sm" />
{saving && kind === 'submit' ? 'Отправка…' : submitLabel} {saving && kind === 'submit' ? 'Отправка…' : submitLabel}
</Button> </Button>
@@ -0,0 +1,24 @@
@import '../../../../../styles/design-tokens';
.title {
font-weight: 600;
}
.list {
margin: $sp-1 0 0;
padding-left: $sp-5;
}
// Пункт списка — ссылка на поле: выглядит как текст с подчёркиванием, без рамки кнопки.
.link {
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
text-align: left;
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
overflow-wrap: anywhere;
}
@@ -0,0 +1,39 @@
import * as React from 'react';
import { Alert } from '@tnsu/ui-kit';
import styles from './SubmitIssues.module.scss';
import { IFieldIssue, ISSUES_ANCHOR_ATTR, issueListText } from '../FieldIssues/fieldIssues';
interface ISubmitIssuesProps {
/** Что мешает отправке — в порядке формы. */
issues: IFieldIssue[];
/** Клик по пункту — прокрутка к полю. */
onSelect: (field: string) => void;
}
const TITLE = 'Чтобы отправить на согласование, заполните или исправьте:';
/**
* Список причин, по которым заявку пока нельзя отправить. Пункт кликабелен —
* ведёт к полю: на длинной форме иначе пришлось бы искать его самому.
*/
export const SubmitIssues: React.FC<ISubmitIssuesProps> = ({ issues, onSelect }) => {
if (issues.length === 0) return null;
const anchorProps = { [ISSUES_ANCHOR_ATTR]: '' };
return (
<div {...anchorProps}>
<Alert variant="warn">
<div className={styles.title}>{TITLE}</div>
<ul className={styles.list}>
{issues.map((issue) => (
<li key={`${issue.field}-${issue.label}`}>
<button type="button" className={styles.link} onClick={() => onSelect(issue.field)}>
{issueListText(issue)}
</button>
</li>
))}
</ul>
</Alert>
</div>
);
};
@@ -52,6 +52,9 @@ export { OrgLetterhead } from './OrgLetterhead/OrgLetterhead';
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';
export { FieldIssuesProvider } from './FieldIssues/FieldIssuesContext';
export type { IFieldIssue } from './FieldIssues/fieldIssues';
export { issueLabels } from './FieldIssues/fieldIssues';
export { getDraftDeleteHandler } from './SubmitActions/getDraftDeleteHandler'; export { getDraftDeleteHandler } from './SubmitActions/getDraftDeleteHandler';
export { InitiatorLine } from './InitiatorLine/InitiatorLine'; export { InitiatorLine } from './InitiatorLine/InitiatorLine';
export type { IInitiatorLineProps } from './InitiatorLine/InitiatorLine'; export type { IInitiatorLineProps } from './InitiatorLine/InitiatorLine';