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:
@@ -468,7 +468,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
||||
<SubmitFeature
|
||||
key={
|
||||
route.mode === 'create' && route.formKey
|
||||
? `create-${route.formKey}-${route.sourceId ?? route.positionId ?? 'blank'}`
|
||||
? `create-${route.formKey}-${route.sourceId ?? route.positionId ?? 'blank'}-${route.listId ?? ''}`
|
||||
: 'blank'
|
||||
}
|
||||
context={context}
|
||||
@@ -478,6 +478,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
||||
? {
|
||||
formKey: route.formKey,
|
||||
sourceId: route.sourceId,
|
||||
sourceListId: route.listId,
|
||||
positionId: route.positionId,
|
||||
nomenclatureId: route.nomenclatureId,
|
||||
storeName: route.storeName,
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface ISubmitFeatureProps {
|
||||
initialForm?: {
|
||||
formKey: string;
|
||||
sourceId?: number;
|
||||
/** Партиция исходной заявки (rplist) — см. IRequestFormProps.sourceListId. */
|
||||
sourceListId?: string;
|
||||
positionId?: string;
|
||||
nomenclatureId?: string;
|
||||
storeName?: string;
|
||||
@@ -148,6 +150,7 @@ const SubmitFeature: React.FC<ISubmitFeatureProps> = ({
|
||||
onCancel={handleBack}
|
||||
onSaved={emitRequestsChanged}
|
||||
sourceId={sourceId}
|
||||
sourceListId={sourceId ? external?.sourceListId : undefined}
|
||||
editId={editId}
|
||||
positionId={external?.positionId}
|
||||
nomenclatureId={external?.nomenclatureId}
|
||||
|
||||
@@ -59,6 +59,9 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { draftIssues, submitIssues } from './validation';
|
||||
import { saveHireRequest, hireApprovalService } from './submit';
|
||||
import { loadHireRequest } from './load';
|
||||
import { isApprovedStatus } from '../../shared/approval/models/statuses';
|
||||
import { loadSelectionRequest } from '../RecruitingSelection/load';
|
||||
import { selectionPrefill } from './selectionPrefill';
|
||||
import {
|
||||
ATT_CATEGORY_ID,
|
||||
ATT_CATEGORY_RESUME,
|
||||
@@ -83,6 +86,8 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
||||
onSaved,
|
||||
editId,
|
||||
editListId,
|
||||
sourceId,
|
||||
sourceListId,
|
||||
onCodeLoaded
|
||||
}) => {
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
@@ -141,6 +146,31 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
||||
};
|
||||
}, [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 инициатора — текущего
|
||||
// пользователя (кандидата ещё нет в системе). У заявки, открытой на
|
||||
// редактирование, маршрут читается из уже записанных строк — планирование
|
||||
|
||||
+11
-33
@@ -5,7 +5,14 @@ import { routeUrl } from '../../../shared/routing';
|
||||
import { getApprovedSelectionRequests, ISelectionRequestOption } from '../selectionLookup';
|
||||
import { loadSelectionRequest } from '../../RecruitingSelection/load';
|
||||
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). */
|
||||
const SELECTION_FORM_KEY = 'recruiting-selection';
|
||||
@@ -91,40 +98,11 @@ export const SelectionRequestField: React.FC<ISelectionRequestFieldProps> = ({ d
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
|
||||
// Максимум полей, общих с заявкой на подбор, подтягиваем из неё — перезаписываем
|
||||
// даже уже заполненные вручную (пользователь осознанно выбрал/сменил связь, см.
|
||||
// обсуждение с Тимуром: перезапись — ожидаемое поведение при смене связанной заявки).
|
||||
// Максимум полей, общих с заявкой на подбор, подтягиваем из неё (см.
|
||||
// selectionPrefill — та же логика у «Оформить приём» из просмотра подбора).
|
||||
loadSelectionRequest(o.id, o.listId || undefined)
|
||||
.then(({ data: selection }) => {
|
||||
set('Companies', selection.Companies);
|
||||
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);
|
||||
}
|
||||
applyPatch(set, selectionPrefill({ id: o.id, listId: o.listId, code: o.code }, selection));
|
||||
})
|
||||
.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`
|
||||
- Режим согласующего (просмотр): есть (`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 { ConditionsSection } from './sections/ConditionsSection';
|
||||
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 { useStableCallback } from '../../shared/useStableCallback';
|
||||
|
||||
@@ -289,6 +294,22 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
||||
handleStepAdded,
|
||||
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).
|
||||
const canAddApprover = canRemoveApprover && status === REQUEST_STATUS.onApproval;
|
||||
@@ -374,11 +395,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
||||
<DepartmentSection {...sectionProps} />
|
||||
{/* Приём оформляют только по утверждённому подбору — до этого блоку показывать нечего. */}
|
||||
{isApprovedStatus(status) && (
|
||||
<HiresProgressSection
|
||||
selectionId={itemId}
|
||||
selectionListId={listId || ''}
|
||||
vacancies={parseVacanciesCount(data.OpenVacanciesCount)}
|
||||
/>
|
||||
<HiresProgressSection linked={linkedHires} vacancies={parseVacanciesCount(data.OpenVacanciesCount)} />
|
||||
)}
|
||||
<VacancySection {...sectionProps} />
|
||||
<HealthTripsSection {...sectionProps} />
|
||||
@@ -389,6 +406,15 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
||||
<div className="rp-no-print">
|
||||
<EditableAttachments editor={attachments} editable={fullEditable} />
|
||||
</div>
|
||||
|
||||
{/* После вложений, перед маршрутом: приём оформляют, уже просмотрев подбор целиком. */}
|
||||
{isApprovedStatus(status) && (
|
||||
<StartHireBar
|
||||
linked={linkedHires}
|
||||
vacancies={parseVacanciesCount(data.OpenVacanciesCount)}
|
||||
onStart={handleStartHire}
|
||||
/>
|
||||
)}
|
||||
</FormCard>
|
||||
|
||||
{routeError && <Alert variant="err">{routeError}</Alert>}
|
||||
|
||||
+7
-41
@@ -3,14 +3,13 @@ import styles from './HiresProgressSection.module.scss';
|
||||
import { Alert, CollapseSection, FormField } from '../../../shared/ui';
|
||||
import { StatusBadge } from '../../../shared/approval';
|
||||
import { routeUrl } from '../../../shared/routing';
|
||||
import { friendlyErrorMessage } from '../../../shared/diagnostics';
|
||||
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 {
|
||||
selectionId: number;
|
||||
/** Партиция, где лежит заявка на подбор ('' — неизвестна). */
|
||||
selectionListId: string;
|
||||
/** Заявки на приём по этому подбору (см. useLinkedHires — грузятся один раз на просмотр). */
|
||||
linked: ILinkedHiresState;
|
||||
/** Количество открытых вакансий из 2.2. 0 — не указано. */
|
||||
vacancies: number;
|
||||
}
|
||||
@@ -20,14 +19,9 @@ const TEXT = {
|
||||
occupied: 'Занято вакансий',
|
||||
hires: 'Заявки на приём',
|
||||
loading: 'Загрузка…',
|
||||
empty: 'Заявок на приём по этому подбору пока нет.',
|
||||
loadError: 'Не удалось загрузить заявки на приём'
|
||||
empty: 'Заявок на приём по этому подбору пока нет.'
|
||||
};
|
||||
|
||||
function occupiedLabel(occupied: number, vacancies: number): string {
|
||||
return vacancies > 0 ? `${occupied} из ${vacancies}` : String(occupied);
|
||||
}
|
||||
|
||||
function hireHref(hire: ILinkedHire): string {
|
||||
return routeUrl({
|
||||
mode: 'approve',
|
||||
@@ -44,36 +38,8 @@ function hireHref(hire: ILinkedHire): string {
|
||||
* Правило «приём занимает вакансию» — то же, что и в выборе подбора в форме
|
||||
* приёма (см. RecruitingHire/linkedHires.ts).
|
||||
*/
|
||||
export const HiresProgressSection: React.FC<IHiresProgressSectionProps> = ({
|
||||
selectionId,
|
||||
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;
|
||||
export const HiresProgressSection: React.FC<IHiresProgressSectionProps> = ({ linked, vacancies }) => {
|
||||
const { hires, occupied, isLoading, error } = linked;
|
||||
|
||||
const renderHires = (): React.ReactNode => {
|
||||
if (isLoading) return <span className={styles.note}>{TEXT.loading}</span>;
|
||||
|
||||
+41
@@ -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;
|
||||
/**
|
||||
* Партиция (серверный url списка), где лежит sourceId — заявки нумерованные,
|
||||
* Id в разных партициях повторяются (см. shared/approval/listPartitions.ts).
|
||||
* Не задан — исходная заявка ищется в текущей партиции.
|
||||
*/
|
||||
sourceListId?: string;
|
||||
/**
|
||||
* Параметры внешней ссылки (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('создание производной заявки всегда открывается в разделе «Новая заявка»', () => {
|
||||
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('возврат к подаче заявки убирает параметры открытой заявки из адреса', () => {
|
||||
navigate({ mode: 'approve', nav: 'new', formKey: 'hire', itemId: 42 });
|
||||
navigate({ mode: 'submit', nav: 'new' });
|
||||
|
||||
@@ -427,9 +427,10 @@ export function parseRoute(search: string = window.location.search): IRoute {
|
||||
if (mode === 'edit' && formKey && itemId && !isNaN(itemId)) {
|
||||
return { mode: 'edit', nav, formKey, itemId, listId, ownTab, ownStatus, allTypeId, page, query };
|
||||
}
|
||||
// Создание производной заявки с предзаполнением из исходной.
|
||||
// Создание производной заявки с предзаполнением из исходной. rplist здесь —
|
||||
// партиция ИСХОДНОЙ заявки (Id в разных партициях повторяются).
|
||||
if (formKey && sourceId && !isNaN(sourceId)) {
|
||||
return { mode: 'create', nav: 'new', formKey, sourceId };
|
||||
return { mode: 'create', nav: 'new', formKey, sourceId, listId };
|
||||
}
|
||||
// Ссылка на пустую новую форму конкретного типа — без предзаполнения
|
||||
// (клик по плитке внутри портала, либо явная ссылка ?rptype=<форма> без
|
||||
@@ -485,7 +486,10 @@ function buildQuery(route: IRoute): string {
|
||||
if (route.listId) params.set('rplist', route.listId);
|
||||
} else if (route.mode === 'create' && 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();
|
||||
|
||||
Reference in New Issue
Block a user