Мердж origin/main в feature/vacation-edo-signing: Ticket1C берём версию main (plannerHandoff, submitStatus)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
+30
@@ -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,
|
||||||
@@ -53,7 +55,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 {
|
||||||
@@ -211,32 +213,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).
|
||||||
@@ -328,8 +316,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}. Попробуйте ещё раз или обратитесь к администратору.`;
|
||||||
@@ -382,7 +370,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>
|
||||||
@@ -498,7 +486,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,
|
||||||
@@ -54,7 +56,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';
|
||||||
@@ -63,6 +65,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,
|
||||||
@@ -104,7 +107,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);
|
||||||
@@ -240,33 +243,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,
|
||||||
@@ -345,6 +333,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}. Попробуйте ещё раз или обратитесь к администратору.`;
|
||||||
@@ -388,7 +377,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>
|
||||||
@@ -489,7 +478,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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,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,
|
||||||
@@ -112,7 +113,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);
|
||||||
@@ -142,7 +143,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,
|
||||||
|
|||||||
@@ -38,6 +38,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';
|
||||||
@@ -86,10 +87,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,
|
||||||
|
|||||||
@@ -6,9 +6,11 @@
|
|||||||
**RequestTypeId (внешний API маршрутов):** 10
|
**RequestTypeId (внешний API маршрутов):** 10
|
||||||
|
|
||||||
## Маршрут
|
## Маршрут
|
||||||
- Фазы: только согласование. Отдельной фазы исполнения нет — после того как
|
- Фазы: согласование + исполнение. Шаги согласования условные (вид работ/отдел),
|
||||||
согласование пройдено (или если для вида работ оно не требуется), заявка сразу
|
шаг исполнения — сотрудник отдела 1С (в маршруте №8 без условия). После
|
||||||
уходит не исполнителю из матрицы, а в Microsoft Planner (см. «Особое поведение»).
|
согласования заявка «Выполнение» у исполнителя; он отмечает «Выполнено» → «Закрыта».
|
||||||
|
Если для вида работ согласование не требуется — заявка сразу «Выполнение».
|
||||||
|
Работу отдел 1С ведёт в карточке Microsoft Planner (см. «Особое поведение»).
|
||||||
- Особенности: маршрут условный — набор шагов зависит от полей **«Виды работ»**
|
- Особенности: маршрут условный — набор шагов зависит от полей **«Виды работ»**
|
||||||
(`WorkType`) и **«Отдел»** (`Department`, свободный текст из профиля AAD, см.
|
(`WorkType`) и **«Отдел»** (`Department`, свободный текст из профиля AAD, см.
|
||||||
«Особое поведение»), которые передаются в `resolveRoute`/`getPlannedMatrix` через
|
«Особое поведение»), которые передаются в `resolveRoute`/`getPlannedMatrix` через
|
||||||
@@ -40,13 +42,16 @@
|
|||||||
телефон, отдел, должность — единственная форма портала, которая это делает
|
телефон, отдел, должность — единственная форма портала, которая это делает
|
||||||
(`services/profile.service.ts`, только для новой заявки, при ошибке Graph — просто
|
(`services/profile.service.ts`, только для новой заявки, при ошибке Graph — просто
|
||||||
остаётся пусто, без ошибки формы).
|
остаётся пусто, без ошибки формы).
|
||||||
- **Planner вместо фазы исполнения.** Когда маршрут согласования полностью пройден
|
- **Planner вместе с фазой исполнения** (`plannerHandoff.ts`). Когда заявка доходит
|
||||||
(или изначально пуст) — `Ticket1CForm`/`Ticket1CView` создают карточку в Microsoft
|
до исполнения («Выполнение» — после согласования в `Ticket1CView` или сразу при
|
||||||
Planner через `services/plannerTicket.service.ts` (`api.tnsu.kz/Planner/CreateTask`):
|
отправке в `Ticket1CForm`, если согласование не требуется), создаётся карточка в
|
||||||
рабочий бакет — при согласовании, отдельный бакет «Отклонённые» — при отклонении
|
Microsoft Planner через `services/plannerTicket.service.ts`
|
||||||
(с указанием, кто отклонил и по какой причине). Id созданной карточки сохраняется в
|
(`api.tnsu.kz/Planner/CreateTask`) — рабочий бакет; при отклонении — бакет
|
||||||
колонку `PlannerTaskId` — это защита от повторного создания карточки, если решение
|
«Отклонённые» (кто отклонил и по какой причине). Маршрут без шага исполнения
|
||||||
применяется из нескольких вкладок/сессий одновременно.
|
(«Согласовано») тоже уходит в рабочий бакет. Отметку «Выполнено» исполнитель
|
||||||
|
ставит в портале — Planner с порталом не синхронизируется. Id созданной карточки
|
||||||
|
сохраняется в колонку `PlannerTaskId` — это защита от повторного создания карточки,
|
||||||
|
если решение применяется из нескольких вкладок/сессий одновременно.
|
||||||
- Ошибку резолва маршрута (например, если в админке маршрутов ещё не настроен маршрут
|
- Ошибку резолва маршрута (например, если в админке маршрутов ещё не настроен маршрут
|
||||||
для этого вида работ) форма не путает с «согласование не требуется» — отправка
|
для этого вида работ) форма не путает с «согласование не требуется» — отправка
|
||||||
блокируется с понятным сообщением, а не молча уходит в Planner в обход согласования.
|
блокируется с понятным сообщением, а не молча уходит в Planner в обход согласования.
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
useRequestSubmit
|
useRequestSubmit
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
composeRemoveRowRule,
|
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
@@ -30,11 +29,7 @@ import {
|
|||||||
isEditableStatus,
|
isEditableStatus,
|
||||||
IApproverToWrite,
|
IApproverToWrite,
|
||||||
unassignedStepMessage,
|
unassignedStepMessage,
|
||||||
approvalPhaseRows,
|
APPROVAL_PHASE
|
||||||
executorsToWrite,
|
|
||||||
useFormExecutors,
|
|
||||||
ExecutorsMatrix,
|
|
||||||
initialSubmitStatus
|
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import {
|
import {
|
||||||
uploadAttachments,
|
uploadAttachments,
|
||||||
@@ -60,6 +55,8 @@ import { RequestSection } from './sections/RequestSection';
|
|||||||
import { ContactSection } from './sections/ContactSection';
|
import { ContactSection } from './sections/ContactSection';
|
||||||
import { useStableCallback } from '../../shared/useStableCallback';
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
import { applyManagerOverride, managerEmail } from './managerOverride';
|
import { applyManagerOverride, managerEmail } from './managerOverride';
|
||||||
|
import { handoffToPlanner } from './plannerHandoff';
|
||||||
|
import { submitStatus } from './submitStatus';
|
||||||
|
|
||||||
const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
||||||
requestType,
|
requestType,
|
||||||
@@ -174,38 +171,39 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
// а не выбирается заявителем. Поэтому матрица здесь ВСЕГДА нередактируема
|
// а не выбирается заявителем. Поэтому матрица здесь ВСЕГДА нередактируема
|
||||||
// (editable=false) — заявитель только видит, кто согласует, без возможности
|
// (editable=false) — заявитель только видит, кто согласует, без возможности
|
||||||
// добавить/убрать/переставить согласующего.
|
// добавить/убрать/переставить согласующего.
|
||||||
|
// Условия шагов (и согласования, и исполнения) — по виду работ и отделу.
|
||||||
|
const fieldValues = useMemo(
|
||||||
|
() => [
|
||||||
|
{ fieldName: 'WorkType', value: data.WorkType },
|
||||||
|
{ fieldName: 'Department', value: data.Department }
|
||||||
|
],
|
||||||
|
[data.WorkType, data.Department]
|
||||||
|
);
|
||||||
|
const matrixKey = `${userEmail}-${editId ?? 'new'}-${data.WorkType}-${data.Department}`;
|
||||||
|
const matrixReady = !!editId || (!!data.WorkType && !!requestTypeId);
|
||||||
const loadMatrix = useCallback(
|
const loadMatrix = useCallback(
|
||||||
() =>
|
() =>
|
||||||
editId
|
editId
|
||||||
? approval.getMatrixForRequest(editId).then(approvalPhaseRows)
|
? approval
|
||||||
: getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, [
|
.getMatrixForRequest(editId)
|
||||||
{ fieldName: 'WorkType', value: data.WorkType },
|
.then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution))
|
||||||
{ fieldName: 'Department', value: data.Department }
|
: getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, fieldValues),
|
||||||
]),
|
[context, userEmail, approval, editId, fieldValues, requestTypeId]
|
||||||
[context, userEmail, approval, editId, data.WorkType, data.Department, requestTypeId]
|
|
||||||
);
|
);
|
||||||
const matrix = useMatrixEditor(
|
const matrix = useMatrixEditor(loadMatrix, matrixKey, false, matrixReady, (login) => approval.getJobTitle(login));
|
||||||
loadMatrix,
|
|
||||||
`${userEmail}-${editId ?? 'new'}-${data.WorkType}-${data.Department}`,
|
// Исполнение — сотрудник отдела 1С (шаг «Исполнение» маршрута): работу ведёт
|
||||||
false,
|
// в карточке Planner, в портале отмечает «Выполнено» (см. plannerHandoff.ts).
|
||||||
!!editId || (!!data.WorkType && !!requestTypeId),
|
const loadExecutors = useCallback(
|
||||||
(login) => approval.getJobTitle(login)
|
() =>
|
||||||
|
editId
|
||||||
|
? approval
|
||||||
|
.getMatrixForRequest(editId)
|
||||||
|
.then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution))
|
||||||
|
: getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Execution', null, fieldValues),
|
||||||
|
[context, userEmail, approval, editId, fieldValues, requestTypeId]
|
||||||
);
|
);
|
||||||
// Исполнители (фаза «Исполнение») резолвятся с теми же fieldValues, что и
|
const executors = useMatrixEditor(loadExecutors, matrixKey, false, matrixReady, (login) => approval.getJobTitle(login));
|
||||||
// согласующие: условные шаги маршрута зависят от вида работ и отдела.
|
|
||||||
const executors = useFormExecutors({
|
|
||||||
readOnly,
|
|
||||||
context,
|
|
||||||
approval,
|
|
||||||
requestTypeId,
|
|
||||||
userEmail,
|
|
||||||
editId,
|
|
||||||
departmentId: null,
|
|
||||||
fieldValues: [
|
|
||||||
{ fieldName: 'WorkType', value: data.WorkType },
|
|
||||||
{ fieldName: 'Department', value: data.Department }
|
|
||||||
]
|
|
||||||
});
|
|
||||||
// Должность нового «Руководителя» — approval-route её не знает (резолвил шаг
|
// Должность нового «Руководителя» — approval-route её не знает (резолвил шаг
|
||||||
// на ДРУГОГО человека и отдал его должность), поэтому подтягиваем сами по
|
// на ДРУГОГО человека и отдал его должность), поэтому подтягиваем сами по
|
||||||
// e-mail из поля (SPS-JobTitle, тот же источник, что и у обычных согласующих,
|
// e-mail из поля (SPS-JobTitle, тот же источник, что и у обычных согласующих,
|
||||||
@@ -293,18 +291,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
if (!rtId) {
|
if (!rtId) {
|
||||||
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
||||||
}
|
}
|
||||||
// Маршрут запрашивается для ЛЮБОГО вида работ и используется как есть —
|
const finalStatus = submitStatus(intendedStatus, matrixRows.length, executors.rows.length);
|
||||||
// без авто-approve, если админ ещё не настроил шаги для конкретного вида
|
|
||||||
// работ (раньше в этом случае заявка сразу становилась «Согласовано» в
|
|
||||||
// обход согласования; теперь это просто «На согласовании» с пустой
|
|
||||||
// матрицей — сигнал администратору маршрутов донастроить условие).
|
|
||||||
// Есть исполнители — статус отправки по initialSubmitStatus (без согласующих
|
|
||||||
// сразу «Выполнение»); иначе, как раньше, «На согласовании» (в т.ч. при пустом маршруте).
|
|
||||||
const submitStatus =
|
|
||||||
executors.rows.length > 0
|
|
||||||
? initialSubmitStatus(matrixRows.length, executors.rows.length)
|
|
||||||
: REQUEST_STATUS.onApproval;
|
|
||||||
const finalStatus = intendedStatus === REQUEST_STATUS.draft ? REQUEST_STATUS.draft : submitStatus;
|
|
||||||
|
|
||||||
// requestId уже задан → редактируем существующую заявку, пишем в ту же
|
// requestId уже задан → редактируем существующую заявку, пишем в ту же
|
||||||
// партицию (editListId). Иначе — создаём новую, и партицию для записи
|
// партицию (editListId). Иначе — создаём новую, и партицию для записи
|
||||||
@@ -319,7 +306,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
requestId,
|
requestId,
|
||||||
data,
|
data,
|
||||||
toApprovers(matrixRows),
|
toApprovers(matrixRows),
|
||||||
executorsToWrite(executors.rows),
|
toApprovers(executors.rows),
|
||||||
finalStatus,
|
finalStatus,
|
||||||
writeListId
|
writeListId
|
||||||
);
|
);
|
||||||
@@ -339,10 +326,18 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
onSaved();
|
onSaved();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Передача в Planner теперь происходит только после реального решения
|
// Согласование для этого вида работ не нужно — заявка сразу у исполнителя,
|
||||||
// согласующего (см. Ticket1CView.handoffToPlanner) — здесь заявка всегда
|
// и карточка Planner нужна уже сейчас (иначе её создаёт решение согласующего
|
||||||
// отправляется на согласование, минуя Planner напрямую.
|
// в Ticket1CView). Сбой Planner не отменяет уже отправленную заявку.
|
||||||
if (finalStatus === REQUEST_STATUS.onApproval || finalStatus === REQUEST_STATUS.execution) {
|
if (finalStatus === REQUEST_STATUS.execution) {
|
||||||
|
try {
|
||||||
|
await handoffToPlanner({ context, approval, itemId: id, listId: writeListId, status: finalStatus, data, code: code || requestCode || '' });
|
||||||
|
} catch (e) {
|
||||||
|
setSubmitError(`Заявка отправлена, но не удалось создать карточку в Planner: ${friendlyErrorMessage(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalStatus !== REQUEST_STATUS.draft) {
|
||||||
// Имена списков — той же партиции, куда фактически записали (writeListId),
|
// Имена списков — той же партиции, куда фактически записали (writeListId),
|
||||||
// не обязательно текущей на момент рендера формы: поток ищет строку
|
// не обязательно текущей на момент рендера формы: поток ищет строку
|
||||||
// маршрута именно в НЕЙ.
|
// маршрута именно в НЕЙ.
|
||||||
@@ -372,12 +367,13 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») не назначен на конкретного человека. Обратитесь к администратору маршрутов согласования.`
|
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») не назначен на конкретного человека. Обратитесь к администратору маршрутов согласования.`
|
||||||
);
|
);
|
||||||
if (unassignedError) return unassignedError;
|
if (unassignedError) return unassignedError;
|
||||||
const unassignedExecutorError = unassignedStepMessage(
|
if (executors.error) {
|
||||||
|
return `Не удалось определить исполнителя: ${executors.error}. Попробуйте ещё раз или обратитесь к администратору.`;
|
||||||
|
}
|
||||||
|
return unassignedStepMessage(
|
||||||
executors.rows,
|
executors.rows,
|
||||||
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
|
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») не назначен на конкретного человека. Обратитесь к администратору маршрутов согласования.`
|
||||||
);
|
);
|
||||||
if (unassignedExecutorError) return unassignedExecutorError;
|
|
||||||
return undefined;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
||||||
@@ -448,7 +444,6 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
|
|
||||||
<ApprovalMatrix
|
<ApprovalMatrix
|
||||||
editor={matrixEditor}
|
editor={matrixEditor}
|
||||||
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
|
||||||
search={(q) => approval.searchUsers(q)}
|
search={(q) => approval.searchUsers(q)}
|
||||||
// Пустой маршрут — штатная ситуация: для части видов работ согласование
|
// Пустой маршрут — штатная ситуация: для части видов работ согласование
|
||||||
// не нужно, заявка сразу уходит в Planner. Сбой загрузки маршрута
|
// не нужно, заявка сразу уходит в Planner. Сбой загрузки маршрута
|
||||||
@@ -456,7 +451,15 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
emptyText="Для этого вида работ согласование не требуется — заявка сразу поступит в отдел 1С."
|
emptyText="Для этого вида работ согласование не требуется — заявка сразу поступит в отдел 1С."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
{!executors.loading && executors.rows.length > 0 && (
|
||||||
|
<ApprovalMatrix
|
||||||
|
editor={executors}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
title="Исполнение"
|
||||||
|
roleLabel="Исполнитель"
|
||||||
|
emptyText="Исполнители не назначены."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Автору на доработке важнее всего видеть, почему вернули или
|
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||||
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
||||||
|
|||||||
@@ -25,10 +25,7 @@ import {
|
|||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
isEditableStatus,
|
isEditableStatus,
|
||||||
REQUEST_STATUS,
|
APPROVAL_PHASE
|
||||||
approvalPhaseRows,
|
|
||||||
useViewExecutors,
|
|
||||||
ExecutorsMatrix
|
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
@@ -38,11 +35,10 @@ import {
|
|||||||
dataListTitle,
|
dataListTitle,
|
||||||
dataListTitleForListId
|
dataListTitleForListId
|
||||||
} from '../../shared/listTitles';
|
} from '../../shared/listTitles';
|
||||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField, hasOtherDatabase } from './model';
|
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||||
import { loadTicketRequest } from './load';
|
import { loadTicketRequest } from './load';
|
||||||
import { ticket1cApprovalService, getPlannerTaskId, setPlannerTaskId } from './submit';
|
import { ticket1cApprovalService } from './submit';
|
||||||
import { PlannerTicketService } from './services/plannerTicket.service';
|
import { handoffToPlanner, isPlannerHandoffStatus } from './plannerHandoff';
|
||||||
import { PLANNER_PLAN_ID, PLANNER_BUCKET_NAME, PLANNER_REJECTED_BUCKET_NAME } from './config';
|
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
import { ContactSection } from './sections/ContactSection';
|
import { ContactSection } from './sections/ContactSection';
|
||||||
import { useStableCallback } from '../../shared/useStableCallback';
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
@@ -58,35 +54,6 @@ interface ITicket1CViewProps {
|
|||||||
onCodeLoaded?: (code: string) => void;
|
onCodeLoaded?: (code: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Текст карточки Planner из данных заявки; rejection — автор и причина отклонения, если есть. */
|
|
||||||
function buildPlannerNotes(
|
|
||||||
data: IFormData,
|
|
||||||
code: string,
|
|
||||||
rejection?: { who: string; comment: string }
|
|
||||||
): string {
|
|
||||||
const lines: string[] = [];
|
|
||||||
if (code) lines.push(`Код заявки: ${code}`);
|
|
||||||
lines.push(`Организация: ${data.Organization}`);
|
|
||||||
lines.push(`ФИО: ${data.FIO}`);
|
|
||||||
lines.push(`Email: ${data.Email}`);
|
|
||||||
lines.push(`Телефон: ${data.Phone}`);
|
|
||||||
lines.push(`Отдел: ${data.Department}`);
|
|
||||||
lines.push(`Должность: ${data.Position}`);
|
|
||||||
lines.push(`База 1С: ${data.DB1C.join(', ')}`);
|
|
||||||
if (hasOtherDatabase(data)) lines.push(`Другая база 1С: ${data.DB1COther}`);
|
|
||||||
lines.push(`Виды работ: ${data.WorkType}`);
|
|
||||||
lines.push('');
|
|
||||||
lines.push('Вопрос:');
|
|
||||||
lines.push(data.Question);
|
|
||||||
if (rejection) {
|
|
||||||
lines.push('');
|
|
||||||
lines.push('ОТКЛОНЕНО');
|
|
||||||
lines.push(`Кто отклонил: ${rejection.who}`);
|
|
||||||
lines.push(`Причина: ${rejection.comment}`);
|
|
||||||
}
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Заявка в отдел 1С только для просмотра (режим согласующего).
|
// Заявка в отдел 1С только для просмотра (режим согласующего).
|
||||||
const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||||
@@ -115,11 +82,24 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
|
|||||||
// decisionSeq входит в reloadKey: после решения строки маршрута меняются на
|
// decisionSeq входит в reloadKey: после решения строки маршрута меняются на
|
||||||
// сервере, поэтому счётчик инкрементируется и useMatrixEditor перечитывает матрицу.
|
// сервере, поэтому счётчик инкрементируется и useMatrixEditor перечитывает матрицу.
|
||||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||||
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]);
|
const loadApprovers = useCallback(
|
||||||
|
() =>
|
||||||
|
approval.getMatrixForRequest(itemId).then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution)),
|
||||||
|
[approval, itemId]
|
||||||
|
);
|
||||||
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||||
approval.getJobTitle(l)
|
approval.getJobTitle(l)
|
||||||
);
|
);
|
||||||
const executors = useViewExecutors(context, approval, itemId, decisionSeq);
|
// Исполнение — отдел 1С (шаг «Исполнение» маршрута): отмечает «Выполнено» здесь,
|
||||||
|
// а саму работу ведёт в карточке Planner (см. plannerHandoff.ts).
|
||||||
|
const loadExecutors = useCallback(
|
||||||
|
() =>
|
||||||
|
approval.getMatrixForRequest(itemId).then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution)),
|
||||||
|
[approval, itemId]
|
||||||
|
);
|
||||||
|
const executors = useMatrixEditor(loadExecutors, `e-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||||
|
approval.getJobTitle(l)
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -147,41 +127,6 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
|
|||||||
};
|
};
|
||||||
}, [itemId, listId, notifyCode]);
|
}, [itemId, listId, notifyCode]);
|
||||||
|
|
||||||
// Передать согласованную/отклонённую заявку в Planner. PlannerTaskId — защита
|
|
||||||
// от повторного создания карточки, если решение применяется из нескольких
|
|
||||||
// вкладок/сессий одновременно (см. submit.ts).
|
|
||||||
const handoffToPlanner = useCallback(
|
|
||||||
async (finalStatus: string, currentData: IFormData, currentCode: string): Promise<void> => {
|
|
||||||
const existingTaskId = await getPlannerTaskId(itemId, listId);
|
|
||||||
if (existingTaskId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let rejection: { who: string; comment: string } | undefined;
|
|
||||||
if (finalStatus === REQUEST_STATUS.rejected) {
|
|
||||||
const rows = await approval.getMatrixForRequest(itemId);
|
|
||||||
const rejectedRow = rows.filter((r) => r.status === REQUEST_STATUS.rejected)[0];
|
|
||||||
if (rejectedRow) {
|
|
||||||
rejection = { who: rejectedRow.who, comment: rejectedRow.comment };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const bucketName =
|
|
||||||
finalStatus === REQUEST_STATUS.rejected ? PLANNER_REJECTED_BUCKET_NAME : PLANNER_BUCKET_NAME;
|
|
||||||
const atts = await getAttachments(listId || dataListTitle(FORM_ID), itemId);
|
|
||||||
const taskId = await new PlannerTicketService(context).createTask({
|
|
||||||
planId: PLANNER_PLAN_ID,
|
|
||||||
bucketName,
|
|
||||||
title: currentData.TaskName,
|
|
||||||
notes: buildPlannerNotes(currentData, currentCode, rejection),
|
|
||||||
attachments: atts,
|
|
||||||
webAbsoluteUrl: context.pageContext.web.absoluteUrl
|
|
||||||
});
|
|
||||||
await setPlannerTaskId(itemId, taskId, listId);
|
|
||||||
},
|
|
||||||
[approval, context, itemId, listId]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className={styles.state}>Загрузка заявки…</div>;
|
return <div className={styles.state}>Загрузка заявки…</div>;
|
||||||
}
|
}
|
||||||
@@ -251,7 +196,15 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
|
|||||||
emptyText="Согласование не требовалось для этого вида работ."
|
emptyText="Согласование не требовалось для этого вида работ."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
{!executors.loading && executors.rows.length > 0 && (
|
||||||
|
<ApprovalMatrix
|
||||||
|
editor={executors}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
title="Исполнение"
|
||||||
|
roleLabel="Исполнитель"
|
||||||
|
emptyText="Исполнители не назначены."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||||
@@ -271,8 +224,10 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
|
|||||||
// Очередь сдвинулась — уведомляем следующий активный шаг.
|
// Очередь сдвинулась — уведомляем следующий активный шаг.
|
||||||
notify();
|
notify();
|
||||||
|
|
||||||
if (newStatus === REQUEST_STATUS.approved || newStatus === REQUEST_STATUS.rejected) {
|
// Дошла до исполнения / завершилась / отклонена — карточка в Planner
|
||||||
handoffToPlanner(newStatus, data, code).catch((e) =>
|
// (повторно не создаётся, см. plannerHandoff.ts).
|
||||||
|
if (isPlannerHandoffStatus(newStatus)) {
|
||||||
|
handoffToPlanner({ context, approval, itemId, listId, status: newStatus, data, code }).catch((e) =>
|
||||||
setError(`Не удалось передать заявку в Planner: ${(e as Error).message}`)
|
setError(`Не удалось передать заявку в Planner: ${(e as Error).message}`)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { ApprovalListService, REQUEST_STATUS } from '../../shared/approval';
|
||||||
|
import { getAttachments } from '../../shared/attachments/attachments.service';
|
||||||
|
import { dataListTitle } from '../../shared/listTitles';
|
||||||
|
import { FORM_ID, IFormData, hasOtherDatabase } from './model';
|
||||||
|
import { getPlannerTaskId, setPlannerTaskId } from './submit';
|
||||||
|
import { PlannerTicketService } from './services/plannerTicket.service';
|
||||||
|
import { PLANNER_PLAN_ID, PLANNER_BUCKET_NAME, PLANNER_REJECTED_BUCKET_NAME } from './config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Статусы, при которых заявка уходит в Planner: согласование пройдено и
|
||||||
|
* началось исполнение («Выполнение»), маршрут без исполнения завершён
|
||||||
|
* («Согласовано») или заявку отклонили (бакет «Отклонённые»).
|
||||||
|
*/
|
||||||
|
const HANDOFF_STATUSES: ReadonlyArray<string> = [
|
||||||
|
REQUEST_STATUS.execution,
|
||||||
|
REQUEST_STATUS.approved,
|
||||||
|
REQUEST_STATUS.rejected
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isPlannerHandoffStatus(status: string): boolean {
|
||||||
|
return HANDOFF_STATUSES.indexOf(status) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Текст карточки Planner из данных заявки; rejection — автор и причина отклонения, если есть. */
|
||||||
|
function buildPlannerNotes(data: IFormData, code: string, rejection?: { who: string; comment: string }): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (code) lines.push(`Код заявки: ${code}`);
|
||||||
|
lines.push(`Организация: ${data.Organization}`);
|
||||||
|
lines.push(`ФИО: ${data.FIO}`);
|
||||||
|
lines.push(`Email: ${data.Email}`);
|
||||||
|
lines.push(`Телефон: ${data.Phone}`);
|
||||||
|
lines.push(`Отдел: ${data.Department}`);
|
||||||
|
lines.push(`Должность: ${data.Position}`);
|
||||||
|
lines.push(`База 1С: ${data.DB1C.join(', ')}`);
|
||||||
|
if (hasOtherDatabase(data)) lines.push(`Другая база 1С: ${data.DB1COther}`);
|
||||||
|
lines.push(`Виды работ: ${data.WorkType}`);
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Вопрос:');
|
||||||
|
lines.push(data.Question);
|
||||||
|
if (rejection) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push('ОТКЛОНЕНО');
|
||||||
|
lines.push(`Кто отклонил: ${rejection.who}`);
|
||||||
|
lines.push(`Причина: ${rejection.comment}`);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPlannerHandoffParams {
|
||||||
|
context: WebPartContext;
|
||||||
|
approval: ApprovalListService;
|
||||||
|
itemId: number;
|
||||||
|
listId?: string;
|
||||||
|
status: string;
|
||||||
|
data: IFormData;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создать карточку Planner для заявки. Отдел 1С ведёт работу в Planner, а в
|
||||||
|
* портале исполнитель отмечает «Выполнено» — поэтому карточка создаётся, когда
|
||||||
|
* заявка доходит до исполнения, а не вместо него. PlannerTaskId — защита от
|
||||||
|
* повторного создания, если решение применяется из нескольких вкладок/сессий.
|
||||||
|
*/
|
||||||
|
export async function handoffToPlanner({ context, approval, itemId, listId, status, data, code }: IPlannerHandoffParams): Promise<void> {
|
||||||
|
if (!isPlannerHandoffStatus(status)) return;
|
||||||
|
const existingTaskId = await getPlannerTaskId(itemId, listId);
|
||||||
|
if (existingTaskId) return;
|
||||||
|
|
||||||
|
let rejection: { who: string; comment: string } | undefined;
|
||||||
|
if (status === REQUEST_STATUS.rejected) {
|
||||||
|
const rows = await approval.getMatrixForRequest(itemId);
|
||||||
|
const rejectedRow = rows.filter((r) => r.status === REQUEST_STATUS.rejected)[0];
|
||||||
|
if (rejectedRow) {
|
||||||
|
rejection = { who: rejectedRow.who, comment: rejectedRow.comment };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucketName = status === REQUEST_STATUS.rejected ? PLANNER_REJECTED_BUCKET_NAME : PLANNER_BUCKET_NAME;
|
||||||
|
const attachments = await getAttachments(listId || dataListTitle(FORM_ID), itemId);
|
||||||
|
const taskId = await new PlannerTicketService(context).createTask({
|
||||||
|
planId: PLANNER_PLAN_ID,
|
||||||
|
bucketName,
|
||||||
|
title: data.TaskName,
|
||||||
|
notes: buildPlannerNotes(data, code, rejection),
|
||||||
|
attachments,
|
||||||
|
webAbsoluteUrl: context.pageContext.web.absoluteUrl
|
||||||
|
});
|
||||||
|
await setPlannerTaskId(itemId, taskId, listId);
|
||||||
|
}
|
||||||
@@ -32,10 +32,12 @@ function buildItem(data: IFormData, status: string): Record<string, unknown> {
|
|||||||
export type ISaveResult = ISaveItemResult;
|
export type ISaveResult = ISaveItemResult;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сохранить заявку в отдел 1С: содержимое + маршрут (согласующие и исполнители).
|
* Сохранить заявку в отдел 1С: содержимое + маршрут (согласование и исполнение).
|
||||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||||
* executors — исполнители из маршрута админки (фаза «Исполнение»), могут быть пустыми:
|
* Исполнение — сотрудник отдела 1С из маршрута: работу ведёт в карточке Planner
|
||||||
* тогда после согласования заявка уходит в Planner. Возвращает Id заявки (+ код при создании).
|
* (создаётся при переходе в «Выполнение», см. plannerHandoff.ts), а в портале
|
||||||
|
* отмечает «Выполнено». executors может быть пустым — тогда после согласования
|
||||||
|
* заявка станет «Согласовано». Возвращает Id заявки (+ код при создании).
|
||||||
*
|
*
|
||||||
* listId — партиция, в которую фактически пишем: при редактировании — партиция
|
* listId — партиция, в которую фактически пишем: при редактировании — партиция
|
||||||
* ОБНОВЛЯЕМОЙ заявки (см. loadTicketRequest), при создании — партиция,
|
* ОБНОВЛЯЕМОЙ заявки (см. loadTicketRequest), при создании — партиция,
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { REQUEST_STATUS } from '../../shared/approval/models/statuses';
|
||||||
|
import { submitStatus } from './submitStatus';
|
||||||
|
|
||||||
|
describe('Ticket1C submitStatus', () => {
|
||||||
|
it('черновик остаётся черновиком', () => {
|
||||||
|
expect(submitStatus(REQUEST_STATUS.draft, 2, 1)).toBe(REQUEST_STATUS.draft);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('есть согласующие — на согласование', () => {
|
||||||
|
expect(submitStatus(REQUEST_STATUS.onApproval, 2, 1)).toBe(REQUEST_STATUS.onApproval);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('согласование не нужно, исполнитель есть — сразу выполнение', () => {
|
||||||
|
expect(submitStatus(REQUEST_STATUS.onApproval, 0, 1)).toBe(REQUEST_STATUS.execution);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('маршрут пуст совсем — на согласование, не «Согласовано» в обход', () => {
|
||||||
|
expect(submitStatus(REQUEST_STATUS.onApproval, 0, 0)).toBe(REQUEST_STATUS.onApproval);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { REQUEST_STATUS } from '../../shared/approval/models/statuses';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Статус заявки 1С при сохранении.
|
||||||
|
* - Черновик — черновик.
|
||||||
|
* - Есть согласующие — «На согласовании».
|
||||||
|
* - Согласующих нет (условия маршрута для этого вида работ не сработали), но есть
|
||||||
|
* исполнитель — сразу «Выполнение»: заявка у отдела 1С.
|
||||||
|
* - Пусто совсем — «На согласовании» с пустым маршрутом: не «Согласовано» в обход
|
||||||
|
* согласования, а сигнал администратору маршрутов донастроить условия.
|
||||||
|
*/
|
||||||
|
export function submitStatus(intendedStatus: string, approversCount: number, executorsCount: number): string {
|
||||||
|
if (intendedStatus === REQUEST_STATUS.draft) return REQUEST_STATUS.draft;
|
||||||
|
if (approversCount === 0 && executorsCount > 0) return REQUEST_STATUS.execution;
|
||||||
|
return REQUEST_STATUS.onApproval;
|
||||||
|
}
|
||||||
@@ -241,8 +241,8 @@ export class ApproverService {
|
|||||||
// 2) согласование завершено: нет исполнителей → «Согласовано» (финал);
|
// 2) согласование завершено: нет исполнителей → «Согласовано» (финал);
|
||||||
// исполнители есть и не все выполнили → «Выполнение»;
|
// исполнители есть и не все выполнили → «Выполнение»;
|
||||||
// все выполнили → «Закрыта».
|
// все выполнили → «Закрыта».
|
||||||
// Строка «выполнена» исполнителем помечается тем же статусом «Согласовано»
|
// Строка исполнителя после действия «Выполнено» (DecisionPanel) получает
|
||||||
// (действие «Выполнено» в DecisionPanel), фазы различаются колонкой Phase.
|
// статус «Выполнено»; фазы различаются колонкой Phase.
|
||||||
private computeRequestStatus(rows: IApprovalRow[]): string {
|
private computeRequestStatus(rows: IApprovalRow[]): string {
|
||||||
const execRows = rows.filter((r) => r.phase === APPROVAL_PHASE.execution);
|
const execRows = rows.filter((r) => r.phase === APPROVAL_PHASE.execution);
|
||||||
const approvalRows = rows.filter((r) => r.phase !== APPROVAL_PHASE.execution);
|
const approvalRows = rows.filter((r) => r.phase !== APPROVAL_PHASE.execution);
|
||||||
|
|||||||
@@ -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}>
|
||||||
<div className={`${styles.before} rp-no-print`} title={`Изменено: ${change.byName}, ${formatDateTime(change.at)}`}>
|
{change ? <div className={styles.changed}>{children}</div> : children}
|
||||||
<span className={styles.beforeLabel}>Было:</span> {change.before}
|
|
||||||
</div>
|
</div>
|
||||||
|
{change && (
|
||||||
|
<div className={`${styles.before} rp-no-print`} title={`Изменено: ${change.byName}, ${formatDateTime(change.at)}`}>
|
||||||
|
<span className={styles.beforeLabel}>Было:</span> {change.before}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -53,6 +53,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';
|
||||||
|
|||||||
Reference in New Issue
Block a user