feat: оформление приёма прямо из согласованной заявки на подбор

Просили создавать заявку на приём из самого подбора, а не искать его в
поле 1.1. Под вложениями согласованного подбора — «+ Оформить приём по этой
заявке» со счётчиком «занято X из Y» (и предупреждением, если вакансии
заняты). Новая заявка на приём сразу связана с подбором и заполнена из
него: логика вынесена в selectionPrefill и общая с полем 1.1. Вместе с
rpsource в адресе передаётся партиция подбора (rplist): Id в партициях
повторяются. Приёмы по подбору грузятся один раз на оба блока (useLinkedHires).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-09 14:12:33 +05:00
parent 79963e4035
commit af93ae57f4
14 changed files with 330 additions and 84 deletions
@@ -468,7 +468,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
<SubmitFeature <SubmitFeature
key={ key={
route.mode === 'create' && route.formKey route.mode === 'create' && route.formKey
? `create-${route.formKey}-${route.sourceId ?? route.positionId ?? 'blank'}` ? `create-${route.formKey}-${route.sourceId ?? route.positionId ?? 'blank'}-${route.listId ?? ''}`
: 'blank' : 'blank'
} }
context={context} context={context}
@@ -478,6 +478,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
? { ? {
formKey: route.formKey, formKey: route.formKey,
sourceId: route.sourceId, sourceId: route.sourceId,
sourceListId: route.listId,
positionId: route.positionId, positionId: route.positionId,
nomenclatureId: route.nomenclatureId, nomenclatureId: route.nomenclatureId,
storeName: route.storeName, storeName: route.storeName,
@@ -23,6 +23,8 @@ export interface ISubmitFeatureProps {
initialForm?: { initialForm?: {
formKey: string; formKey: string;
sourceId?: number; sourceId?: number;
/** Партиция исходной заявки (rplist) — см. IRequestFormProps.sourceListId. */
sourceListId?: string;
positionId?: string; positionId?: string;
nomenclatureId?: string; nomenclatureId?: string;
storeName?: string; storeName?: string;
@@ -148,6 +150,7 @@ const SubmitFeature: React.FC<ISubmitFeatureProps> = ({
onCancel={handleBack} onCancel={handleBack}
onSaved={emitRequestsChanged} onSaved={emitRequestsChanged}
sourceId={sourceId} sourceId={sourceId}
sourceListId={sourceId ? external?.sourceListId : undefined}
editId={editId} editId={editId}
positionId={external?.positionId} positionId={external?.positionId}
nomenclatureId={external?.nomenclatureId} nomenclatureId={external?.nomenclatureId}
@@ -59,6 +59,9 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { draftIssues, submitIssues } 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 { isApprovedStatus } from '../../shared/approval/models/statuses';
import { loadSelectionRequest } from '../RecruitingSelection/load';
import { selectionPrefill } from './selectionPrefill';
import { import {
ATT_CATEGORY_ID, ATT_CATEGORY_ID,
ATT_CATEGORY_RESUME, ATT_CATEGORY_RESUME,
@@ -83,6 +86,8 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
onSaved, onSaved,
editId, editId,
editListId, editListId,
sourceId,
sourceListId,
onCodeLoaded onCodeLoaded
}) => { }) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
@@ -141,6 +146,31 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
}; };
}, [editId, editListId, notifyCode, setSubmitError]); }, [editId, editListId, notifyCode, setSubmitError]);
// Новая заявка «на основе» заявки на подбор (кнопка «Оформить приём по этой
// заявке» в её просмотре): связь 1.1 + те же поля, что при выборе подбора
// вручную (selectionPrefill). Приём оформляют только по утверждённому подбору —
// как и в списке выбора поля 1.1.
useEffect(() => {
if (editId || !sourceId) return;
let alive = true;
loadSelectionRequest(sourceId, sourceListId)
.then((loaded) => {
if (!alive) return;
if (!isApprovedStatus(loaded.status)) {
setSubmitError(`Заявка на подбор №${loaded.code || sourceId} ещё не согласована — приём по ней оформить нельзя.`);
return;
}
const link = { id: sourceId, listId: sourceListId || '', code: loaded.code };
setData((prev) => ({ ...prev, ...selectionPrefill(link, loaded.data) }));
})
.catch((e) => {
if (alive) setSubmitError(`Не удалось предзаполнить из заявки на подбор №${sourceId}: ${friendlyErrorMessage(e)}`);
});
return () => {
alive = false;
};
}, [editId, sourceId, sourceListId, setSubmitError]);
// Маршрут согласования (планируемый) резолвится по email инициатора — текущего // Маршрут согласования (планируемый) резолвится по email инициатора — текущего
// пользователя (кандидата ещё нет в системе). У заявки, открытой на // пользователя (кандидата ещё нет в системе). У заявки, открытой на
// редактирование, маршрут читается из уже записанных строк — планирование // редактирование, маршрут читается из уже записанных строк — планирование
@@ -5,7 +5,14 @@ import { routeUrl } from '../../../shared/routing';
import { getApprovedSelectionRequests, ISelectionRequestOption } from '../selectionLookup'; import { getApprovedSelectionRequests, ISelectionRequestOption } from '../selectionLookup';
import { loadSelectionRequest } from '../../RecruitingSelection/load'; import { loadSelectionRequest } from '../../RecruitingSelection/load';
import { IFormData, SetField } from '../model'; import { IFormData, SetField } from '../model';
import { hireTypeFromSelection } from '../hireTypeFromSelection'; import { selectionPrefill } from '../selectionPrefill';
/** Записать набор полей через SetField формы — по одному ключу. */
function applyPatch(set: SetField, patch: Partial<IFormData>): void {
(Object.keys(patch) as (keyof IFormData)[]).forEach((key) => {
set(key, patch[key] as IFormData[typeof key]);
});
}
/** Слаг формы подбора в реестре форм — цель ссылки (rptype). */ /** Слаг формы подбора в реестре форм — цель ссылки (rptype). */
const SELECTION_FORM_KEY = 'recruiting-selection'; const SELECTION_FORM_KEY = 'recruiting-selection';
@@ -91,40 +98,11 @@ export const SelectionRequestField: React.FC<ISelectionRequestFieldProps> = ({ d
setOpen(false); setOpen(false);
setQuery(''); setQuery('');
// Максимум полей, общих с заявкой на подбор, подтягиваем из неё — перезаписываем // Максимум полей, общих с заявкой на подбор, подтягиваем из неё (см.
// даже уже заполненные вручную (пользователь осознанно выбрал/сменил связь, см. // selectionPrefill — та же логика у «Оформить приём» из просмотра подбора).
// обсуждение с Тимуром: перезапись — ожидаемое поведение при смене связанной заявки).
loadSelectionRequest(o.id, o.listId || undefined) loadSelectionRequest(o.id, o.listId || undefined)
.then(({ data: selection }) => { .then(({ data: selection }) => {
set('Companies', selection.Companies); applyPatch(set, selectionPrefill({ id: o.id, listId: o.listId, code: o.code }, selection));
set('MainCompanyId', ''); // компании заменены целиком — зависимые поля пересчитывать не от чего
set('CompanyShares', []);
// В подборе проект один — он становится единственным выбранным проектом приёма.
set(
'Projects',
selection.ObjectProjectId
? [{ Id: selection.ObjectProjectId, Name: selection.ObjectProject, Code: selection.ObjectProjectCode }]
: []
);
set('Position', selection.Position);
set('Department', selection.Department);
set('WorkSchedule', selection.WorkSchedule);
set('WorkScheduleOther', selection.WorkScheduleOther);
set('ProbationPeriod', selection.SelectionProbationPeriod);
set('ProbationPeriodOther', selection.SelectionProbationPeriodOther);
set('SalaryProbation', selection.RateProbation);
set('SalaryAfterProbation', selection.RateAfterProbation);
// Тип приёма однозначен, только если в подборе ОДНА причина; ФИО «взамен
// кого» — только если под ней ОДИН сотрудник. Иначе инициатор выбирает сам.
const reasons = selection.VacancyReasons;
const single = reasons.length === 1 ? reasons[0] : undefined;
const hireTypePrefill = single
? hireTypeFromSelection(single.reason, single.employees.length === 1 ? single.employees[0] : '')
: undefined;
if (hireTypePrefill) {
set('HireType', hireTypePrefill.hireType);
set('HireReplacedEmployee', hireTypePrefill.replacedEmployee);
}
}) })
.catch(() => undefined); // связь уже сохранена по id/коду — сбой обогащения не критичен .catch(() => undefined); // связь уже сохранена по id/коду — сбой обогащения не критичен
}; };
@@ -0,0 +1,55 @@
import { IFormData as ISelectionData } from '../RecruitingSelection/model';
import { hireTypeFromSelection } from './hireTypeFromSelection';
import { IFormData } from './model';
/** Какая заявка на подбор связывается с приёмом (см. SelectionRequestField). */
export interface ISelectionLink {
id: number;
/** Url партиции подбора; '' — неизвестна. */
listId: string;
code: string;
}
/**
* Поля приёма, которые берутся из связанной заявки на подбор, + сама связь.
* Одна функция на оба пути: выбор подбора в поле 1.1 (SelectionRequestField) и
* «+ Оформить приём по этой заявке» из просмотра подбора — чтобы они не
* разошлись. Уже заполненное вручную перезаписывается: пользователь осознанно
* выбрал/сменил связь.
*/
export function selectionPrefill(link: ISelectionLink, selection: ISelectionData): Partial<IFormData> {
const patch: Partial<IFormData> = {
SelectionRequestId: String(link.id),
SelectionRequestListId: link.listId,
SelectionRequestCode: link.code,
Companies: selection.Companies,
// Компании заменены целиком — зависимые поля пересчитывать не от чего.
MainCompanyId: '',
CompanyShares: [],
// В подборе проект один — он становится единственным выбранным проектом приёма.
Projects: selection.ObjectProjectId
? [{ Id: selection.ObjectProjectId, Name: selection.ObjectProject, Code: selection.ObjectProjectCode }]
: [],
Position: selection.Position,
Department: selection.Department,
WorkSchedule: selection.WorkSchedule,
WorkScheduleOther: selection.WorkScheduleOther,
ProbationPeriod: selection.SelectionProbationPeriod,
ProbationPeriodOther: selection.SelectionProbationPeriodOther,
SalaryProbation: selection.RateProbation,
SalaryAfterProbation: selection.RateAfterProbation,
};
// Тип приёма однозначен, только если в подборе ОДНА причина; ФИО «взамен
// кого» — только если под ней ОДИН сотрудник. Иначе инициатор выбирает сам.
const reasons = selection.VacancyReasons;
const single = reasons.length === 1 ? reasons[0] : undefined;
const hireType = single
? hireTypeFromSelection(single.reason, single.employees.length === 1 ? single.employees[0] : '')
: undefined;
if (hireType) {
patch.HireType = hireType.hireType;
patch.HireReplacedEmployee = hireType.replacedEmployee;
}
return patch;
}
@@ -27,4 +27,7 @@ ApplicationSection · DepartmentSection · VacancySection · HealthTripsSection
## Точки входа ## Точки входа
- rptype: `recruiting-selection` - rptype: `recruiting-selection`
- Режим согласующего (просмотр): есть (`RecruitingSelectionView`) - Режим согласующего (просмотр): есть (`RecruitingSelectionView`)
- «Создать на основе»: — - «Создать на основе»: из согласованного подбора — «+ Оформить приём по этой заявке»
(`sections/StartHireBar`, под вложениями) → `recruiting-hire` с `rpsource`=Id подбора и
`rplist`=его партиция; форма приёма заполняет поля через `RecruitingHire/selectionPrefill.ts`
(та же логика, что при выборе подбора в поле 1.1)
@@ -57,6 +57,11 @@ 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 { HiresProgressSection } from './sections/HiresProgressSection'; import { HiresProgressSection } from './sections/HiresProgressSection';
import { StartHireBar } from './sections/StartHireBar';
import { useLinkedHires } from './useLinkedHires';
import { FORM_ID as HIRE_FORM_ID } from '../RecruitingHire/model';
import { navigate } from '../../shared/routing';
import { confirmDiscardFormChanges } from '../../shared/unsavedChanges';
import { parseVacanciesCount } from './vacancyReasons'; import { parseVacanciesCount } from './vacancyReasons';
import { useStableCallback } from '../../shared/useStableCallback'; import { useStableCallback } from '../../shared/useStableCallback';
@@ -289,6 +294,22 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
handleStepAdded, handleStepAdded,
setRouteError setRouteError
); );
// Приёмы по этому подбору — один запрос на оба блока: «Приём по заявке» и
// «Оформить приём» под вложениями. Только для согласованного подбора.
const linkedHires = useLinkedHires(itemId, listId || '', isApprovedStatus(status));
// Новая заявка на приём, связанная с этим подбором и заполненная из него (см.
// RecruitingHireForm/selectionPrefill). Несохранённые правки в другой новой
// заявке потерялись бы — спрашиваем, как при «рабочем месте на основе приёма».
const handleStartHire = (): void => {
confirmDiscardFormChanges()
.then((ok) => {
if (!ok) return;
navigate({ mode: 'create', nav: 'new', formKey: HIRE_FORM_ID, sourceId: itemId, listId });
})
.catch(() => undefined);
};
// По статусу заявки: согласующих — пока идёт согласование, исполнителей — // По статусу заявки: согласующих — пока идёт согласование, исполнителей —
// пока не завершено исполнение. Сервер перепроверит по строкам (routeStepAdd.ts). // пока не завершено исполнение. Сервер перепроверит по строкам (routeStepAdd.ts).
const canAddApprover = canRemoveApprover && status === REQUEST_STATUS.onApproval; const canAddApprover = canRemoveApprover && status === REQUEST_STATUS.onApproval;
@@ -374,11 +395,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
<DepartmentSection {...sectionProps} /> <DepartmentSection {...sectionProps} />
{/* Приём оформляют только по утверждённому подбору — до этого блоку показывать нечего. */} {/* Приём оформляют только по утверждённому подбору — до этого блоку показывать нечего. */}
{isApprovedStatus(status) && ( {isApprovedStatus(status) && (
<HiresProgressSection <HiresProgressSection linked={linkedHires} vacancies={parseVacanciesCount(data.OpenVacanciesCount)} />
selectionId={itemId}
selectionListId={listId || ''}
vacancies={parseVacanciesCount(data.OpenVacanciesCount)}
/>
)} )}
<VacancySection {...sectionProps} /> <VacancySection {...sectionProps} />
<HealthTripsSection {...sectionProps} /> <HealthTripsSection {...sectionProps} />
@@ -389,6 +406,15 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
<div className="rp-no-print"> <div className="rp-no-print">
<EditableAttachments editor={attachments} editable={fullEditable} /> <EditableAttachments editor={attachments} editable={fullEditable} />
</div> </div>
{/* После вложений, перед маршрутом: приём оформляют, уже просмотрев подбор целиком. */}
{isApprovedStatus(status) && (
<StartHireBar
linked={linkedHires}
vacancies={parseVacanciesCount(data.OpenVacanciesCount)}
onStart={handleStartHire}
/>
)}
</FormCard> </FormCard>
{routeError && <Alert variant="err">{routeError}</Alert>} {routeError && <Alert variant="err">{routeError}</Alert>}
@@ -3,14 +3,13 @@ import styles from './HiresProgressSection.module.scss';
import { Alert, CollapseSection, FormField } from '../../../shared/ui'; import { Alert, CollapseSection, FormField } from '../../../shared/ui';
import { StatusBadge } from '../../../shared/approval'; import { StatusBadge } from '../../../shared/approval';
import { routeUrl } from '../../../shared/routing'; import { routeUrl } from '../../../shared/routing';
import { friendlyErrorMessage } from '../../../shared/diagnostics';
import { FORM_ID as HIRE_FORM_ID } from '../../RecruitingHire/model'; import { FORM_ID as HIRE_FORM_ID } from '../../RecruitingHire/model';
import { getLinkedHires, ILinkedHire, isOccupyingHireStatus } from '../../RecruitingHire/linkedHires'; import { ILinkedHire } from '../../RecruitingHire/linkedHires';
import { ILinkedHiresState, occupiedLabel } from '../useLinkedHires';
interface IHiresProgressSectionProps { interface IHiresProgressSectionProps {
selectionId: number; /** Заявки на приём по этому подбору (см. useLinkedHires — грузятся один раз на просмотр). */
/** Партиция, где лежит заявка на подбор ('' — неизвестна). */ linked: ILinkedHiresState;
selectionListId: string;
/** Количество открытых вакансий из 2.2. 0 — не указано. */ /** Количество открытых вакансий из 2.2. 0 — не указано. */
vacancies: number; vacancies: number;
} }
@@ -20,14 +19,9 @@ const TEXT = {
occupied: 'Занято вакансий', occupied: 'Занято вакансий',
hires: 'Заявки на приём', hires: 'Заявки на приём',
loading: 'Загрузка…', loading: 'Загрузка…',
empty: 'Заявок на приём по этому подбору пока нет.', empty: 'Заявок на приём по этому подбору пока нет.'
loadError: 'Не удалось загрузить заявки на приём'
}; };
function occupiedLabel(occupied: number, vacancies: number): string {
return vacancies > 0 ? `${occupied} из ${vacancies}` : String(occupied);
}
function hireHref(hire: ILinkedHire): string { function hireHref(hire: ILinkedHire): string {
return routeUrl({ return routeUrl({
mode: 'approve', mode: 'approve',
@@ -44,36 +38,8 @@ function hireHref(hire: ILinkedHire): string {
* Правило «приём занимает вакансию» — то же, что и в выборе подбора в форме * Правило «приём занимает вакансию» — то же, что и в выборе подбора в форме
* приёма (см. RecruitingHire/linkedHires.ts). * приёма (см. RecruitingHire/linkedHires.ts).
*/ */
export const HiresProgressSection: React.FC<IHiresProgressSectionProps> = ({ export const HiresProgressSection: React.FC<IHiresProgressSectionProps> = ({ linked, vacancies }) => {
selectionId, const { hires, occupied, isLoading, error } = linked;
selectionListId,
vacancies
}) => {
const [hires, setHires] = React.useState<ILinkedHire[]>([]);
const [isLoading, setIsLoading] = React.useState<boolean>(true);
const [error, setError] = React.useState<string>('');
React.useEffect(() => {
let alive = true;
setIsLoading(true);
getLinkedHires(selectionId, selectionListId)
.then((rows) => {
if (!alive) return;
setHires(rows);
setError('');
})
.catch((e) => {
if (alive) setError(`${TEXT.loadError}: ${friendlyErrorMessage(e)}`);
})
.finally(() => {
if (alive) setIsLoading(false);
});
return () => {
alive = false;
};
}, [selectionId, selectionListId]);
const occupied = hires.filter((h) => isOccupyingHireStatus(h.status)).length;
const renderHires = (): React.ReactNode => { const renderHires = (): React.ReactNode => {
if (isLoading) return <span className={styles.note}>{TEXT.loading}</span>; if (isLoading) return <span className={styles.note}>{TEXT.loading}</span>;
@@ -0,0 +1,41 @@
@import '../../../../../styles/design-tokens';
.bar {
display: flex;
flex-direction: column;
gap: $sp-2;
width: 100%;
box-sizing: border-box;
margin-top: $sp-3;
padding: $sp-3 $sp-4;
border: 1px solid $gray-200;
border-radius: $r-sm;
background: $navy-50;
font-family: $font-sans;
}
.head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: $sp-2;
}
.title {
font-size: $fs-sm;
font-weight: 600;
color: $gray-900;
}
.count {
font-size: $fs-sm;
color: $gray-500;
}
.warning {
display: flex;
align-items: flex-start;
gap: $sp-2;
font-size: $fs-sm;
color: $amber-700;
}
@@ -0,0 +1,54 @@
import * as React from 'react';
import styles from './StartHireBar.module.scss';
import { Button, Icon } from '../../../shared/ui';
import { ILinkedHiresState, allVacanciesOccupied, occupiedLabel } from '../useLinkedHires';
interface Props {
linked: ILinkedHiresState;
/** Количество открытых вакансий из 2.2. 0 — не указано. */
vacancies: number;
onStart: () => void;
}
const TEXT = {
title: 'Приём по этой заявке',
occupied: 'занято',
button: 'Оформить приём по этой заявке',
allOccupied: 'Все вакансии по этой заявке уже заняты — оформляйте, только если это замена или дополнительный приём.'
};
/**
* «+ Оформить приём по этой заявке» под вложениями согласованного подбора:
* открывает новую заявку на приём, уже связанную с этим подбором и заполненную
* из него. Когда вакансии заняты, кнопка не прячется (бывают замены), но рядом
* предупреждение. На печать не выводится.
*/
export const StartHireBar: React.FC<Props> = ({ linked, vacancies, onStart }) => {
const { occupied, isLoading, error } = linked;
const known = !isLoading && !error;
return (
<div className={`${styles.bar} rp-no-print`}>
<div className={styles.head}>
<span className={styles.title}>{TEXT.title}</span>
{known && (
<span className={styles.count}>
{TEXT.occupied} {occupiedLabel(occupied, vacancies)}
</span>
)}
</div>
<div>
<Button variant="secondary" onClick={onStart}>
<Icon name="plus" size="sm" />
{TEXT.button}
</Button>
</div>
{known && allVacanciesOccupied(occupied, vacancies) && (
<div className={styles.warning} role="note">
<Icon name="warning" size="sm" />
<span>{TEXT.allOccupied}</span>
</div>
)}
</div>
);
};
@@ -0,0 +1,58 @@
import * as React from 'react';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import { getLinkedHires, ILinkedHire, isOccupyingHireStatus } from '../RecruitingHire/linkedHires';
export interface ILinkedHiresState {
hires: ILinkedHire[];
/** Сколько приёмов занимают вакансию (то же правило, что в выборе подбора в форме приёма). */
occupied: number;
isLoading: boolean;
/** Текст ошибки загрузки; '' — без ошибки. */
error: string;
}
const LOAD_ERROR = 'Не удалось загрузить заявки на приём';
/** «2 из 6»; без числа вакансий в подборе — просто «2». */
export function occupiedLabel(occupied: number, vacancies: number): string {
return vacancies > 0 ? `${occupied} из ${vacancies}` : String(occupied);
}
/** Все вакансии заняты — новый приём нужен разве что как замена или дополнительный. */
export function allVacanciesOccupied(occupied: number, vacancies: number): boolean {
return vacancies > 0 && occupied >= vacancies;
}
/**
* Заявки на приём по этому подбору — ОДИН запрос на просмотр подбора: их
* показывают и блок «Приём по заявке», и строка «Оформить приём» под вложениями.
* enabled=false (подбор ещё не согласован) — ничего не грузим.
*/
export function useLinkedHires(selectionId: number, selectionListId: string, enabled: boolean): ILinkedHiresState {
const [hires, setHires] = React.useState<ILinkedHire[]>([]);
const [isLoading, setIsLoading] = React.useState<boolean>(enabled);
const [error, setError] = React.useState<string>('');
React.useEffect(() => {
if (!enabled) return;
let alive = true;
setIsLoading(true);
getLinkedHires(selectionId, selectionListId)
.then((rows) => {
if (!alive) return;
setHires(rows);
setError('');
})
.catch((e) => {
if (alive) setError(`${LOAD_ERROR}: ${friendlyErrorMessage(e)}`);
})
.finally(() => {
if (alive) setIsLoading(false);
});
return () => {
alive = false;
};
}, [selectionId, selectionListId, enabled]);
return { hires, occupied: hires.filter((h) => isOccupyingHireStatus(h.status)).length, isLoading, error };
}
@@ -39,6 +39,12 @@ export interface IRequestFormProps {
* Формы, не поддерживающие производные заявки, игнорируют. * Формы, не поддерживающие производные заявки, игнорируют.
*/ */
sourceId?: number; sourceId?: number;
/**
* Партиция (серверный url списка), где лежит sourceId — заявки нумерованные,
* Id в разных партициях повторяются (см. shared/approval/listPartitions.ts).
* Не задан — исходная заявка ищется в текущей партиции.
*/
sourceListId?: string;
/** /**
* Параметры внешней ссылки (Dataverse «Строка заявки» + справочник * Параметры внешней ссылки (Dataverse «Строка заявки» + справочник
* номенклатуры). Использует только форма «СЗ на перемещение ТМЦ»: по * номенклатуры). Использует только форма «СЗ на перемещение ТМЦ»: по
@@ -90,6 +90,16 @@ describe('parseRoute', () => {
}); });
}); });
it('rplist при rpsource — партиция исходной заявки', () => {
expect(parseRoute('?rptype=recruiting-hire&rpsource=79&rplist=%2Fsites%2Frequests%2FLists%2Fsel1')).toEqual({
mode: 'create',
nav: 'new',
formKey: 'recruiting-hire',
sourceId: 79,
listId: '/sites/requests/Lists/sel1',
});
});
it('создание производной заявки всегда открывается в разделе «Новая заявка»', () => { it('создание производной заявки всегда открывается в разделе «Новая заявка»', () => {
expect(parseRoute('?rptype=workplace&rpsource=15&rpnav=own').nav).toBe('new'); expect(parseRoute('?rptype=workplace&rpsource=15&rpnav=own').nav).toBe('new');
}); });
@@ -567,6 +577,17 @@ describe('navigate', () => {
}); });
}); });
it('round-trip для производной заявки с партицией исходной', () => {
navigate({ mode: 'create', nav: 'new', formKey: 'recruiting-hire', sourceId: 79, listId: '/sites/requests/Lists/sel1' });
expect(parseRoute(window.location.search)).toEqual({
mode: 'create',
nav: 'new',
formKey: 'recruiting-hire',
sourceId: 79,
listId: '/sites/requests/Lists/sel1',
});
});
it('возврат к подаче заявки убирает параметры открытой заявки из адреса', () => { it('возврат к подаче заявки убирает параметры открытой заявки из адреса', () => {
navigate({ mode: 'approve', nav: 'new', formKey: 'hire', itemId: 42 }); navigate({ mode: 'approve', nav: 'new', formKey: 'hire', itemId: 42 });
navigate({ mode: 'submit', nav: 'new' }); navigate({ mode: 'submit', nav: 'new' });
+7 -3
View File
@@ -427,9 +427,10 @@ export function parseRoute(search: string = window.location.search): IRoute {
if (mode === 'edit' && formKey && itemId && !isNaN(itemId)) { if (mode === 'edit' && formKey && itemId && !isNaN(itemId)) {
return { mode: 'edit', nav, formKey, itemId, listId, ownTab, ownStatus, allTypeId, page, query }; return { mode: 'edit', nav, formKey, itemId, listId, ownTab, ownStatus, allTypeId, page, query };
} }
// Создание производной заявки с предзаполнением из исходной. // Создание производной заявки с предзаполнением из исходной. rplist здесь —
// партиция ИСХОДНОЙ заявки (Id в разных партициях повторяются).
if (formKey && sourceId && !isNaN(sourceId)) { if (formKey && sourceId && !isNaN(sourceId)) {
return { mode: 'create', nav: 'new', formKey, sourceId }; return { mode: 'create', nav: 'new', formKey, sourceId, listId };
} }
// Ссылка на пустую новую форму конкретного типа — без предзаполнения // Ссылка на пустую новую форму конкретного типа — без предзаполнения
// (клик по плитке внутри портала, либо явная ссылка ?rptype=<форма> без // (клик по плитке внутри портала, либо явная ссылка ?rptype=<форма> без
@@ -485,7 +486,10 @@ function buildQuery(route: IRoute): string {
if (route.listId) params.set('rplist', route.listId); if (route.listId) params.set('rplist', route.listId);
} else if (route.mode === 'create' && route.formKey) { } else if (route.mode === 'create' && route.formKey) {
params.set('rptype', route.formKey); params.set('rptype', route.formKey);
if (route.sourceId) params.set('rpsource', String(route.sourceId)); if (route.sourceId) {
params.set('rpsource', String(route.sourceId));
if (route.listId) params.set('rplist', route.listId);
}
} }
const query = params.toString(); const query = params.toString();