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
|
<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 инициатора — текущего
|
||||||
// пользователя (кандидата ещё нет в системе). У заявки, открытой на
|
// пользователя (кандидата ещё нет в системе). У заявки, открытой на
|
||||||
// редактирование, маршрут читается из уже записанных строк — планирование
|
// редактирование, маршрут читается из уже записанных строк — планирование
|
||||||
|
|||||||
+11
-33
@@ -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>}
|
||||||
|
|||||||
+7
-41
@@ -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>;
|
||||||
|
|||||||
+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;
|
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' });
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user