3 Commits

Author SHA1 Message Date
Тимур Городецкий 8330790bf9 feat: отметка «Сдельная оплата труда» в заявке на приём
Tests / frontend-test (push) Successful in 1m25s
Пункт 4.5 в «Условиях оплаты и оформления» — информационная отметка, на
обязательность окладов не влияет. Та же колонка сайта PieceworkPay
(Boolean), что в заявке на подбор, и оттуда же подставляется при связи с
подбором (поле 1.1 и «Оформить приём по этой заявке»).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 14:16:21 +05:00
Тимур Городецкий af93ae57f4 feat: оформление приёма прямо из согласованной заявки на подбор
Просили создавать заявку на приём из самого подбора, а не искать его в
поле 1.1. Под вложениями согласованного подбора — «+ Оформить приём по этой
заявке» со счётчиком «занято X из Y» (и предупреждением, если вакансии
заняты). Новая заявка на приём сразу связана с подбором и заполнена из
него: логика вынесена в selectionPrefill и общая с полем 1.1. Вместе с
rpsource в адресе передаётся партиция подбора (rplist): Id в партициях
повторяются. Приёмы по подбору грузятся один раз на оба блока (useLinkedHires).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 14:12:33 +05:00
Тимур Городецкий 79963e4035 feat: отметка «Согласован СБ» с примечанием в заявке на приём
Инициатор при подаче отмечает, что кандидат согласован со службой
безопасности; при отметке обязательно примечание СБ (без отметки поле
скрыто и очищается). Поле информационное, на маршрут не влияет. Раздел
«6. Служба безопасности» — перед вложениями, «Примечание для согласующих»
стало разделом 7. Колонки HireSecurityApproved (Boolean), HireSecurityNote (Note).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 13:00:55 +05:00
23 changed files with 417 additions and 89 deletions
@@ -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,
@@ -73,6 +76,7 @@ import { PpeSection } from './sections/PpeSection';
import { ConditionsSection } from './sections/ConditionsSection';
import { AgencySection } from './sections/AgencySection';
import { NoteSection } from './sections/NoteSection';
import { SecuritySection } from './sections/SecuritySection';
import { useStableCallback } from '../../shared/useStableCallback';
const RecruitingHireForm: React.FC<IRequestFormProps> = ({
@@ -82,6 +86,8 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
onSaved,
editId,
editListId,
sourceId,
sourceListId,
onCodeLoaded
}) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA);
@@ -140,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 инициатора — текущего
// пользователя (кандидата ещё нет в системе). У заявки, открытой на
// редактирование, маршрут читается из уже записанных строк — планирование
@@ -409,6 +440,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
<PpeSection {...sectionProps} />
<ConditionsSection {...sectionProps} />
<AgencySection {...sectionProps} />
<SecuritySection {...sectionProps} />
</FormModeProvider>
</FieldChangesProvider>
@@ -61,6 +61,7 @@ import { PpeSection } from './sections/PpeSection';
import { ConditionsSection } from './sections/ConditionsSection';
import { AgencySection } from './sections/AgencySection';
import { NoteSection } from './sections/NoteSection';
import { SecuritySection } from './sections/SecuritySection';
import { ApproverNoteBanner } from '../../shared/approverNote';
import { useStableCallback } from '../../shared/useStableCallback';
@@ -416,6 +417,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
<PpeSection {...sectionProps} />
<ConditionsSection {...sectionProps} />
<AgencySection {...sectionProps} />
<SecuritySection {...sectionProps} />
</FormModeProvider>
</FieldChangesProvider>
@@ -25,7 +25,7 @@ const SCALAR_FIELDS: (keyof IFormData)[] = [
'HireCandidateName', 'HireHireDate', 'Position',
'Department', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod', 'ProbationPeriodOther',
'SalaryProbation', 'SalaryAfterProbation', 'WorkSchedule', 'WorkScheduleOther',
'AgencyName', 'AgencyTerms', 'HireApproverNote',
'AgencyName', 'AgencyTerms', 'HireSecurityNote', 'HireApproverNote',
'SelectionRequestId', 'SelectionRequestListId', 'SelectionRequestCode'
];
@@ -62,6 +62,8 @@ export async function loadHireRequest(itemId: number, listId?: string): Promise<
'ProjectCode',
'RecruitingAgency',
'HireOnArrival',
'HireSecurityApproved',
'PieceworkPay',
'HireApproverNoteImportant',
APPROVER_CHANGES_FIELD,
'RequestStatus',
@@ -79,6 +81,8 @@ export async function loadHireRequest(itemId: number, listId?: string): Promise<
// Флаг агентства — булево поле (Yes/No), не строка.
(data as unknown as Record<string, unknown>).RecruitingAgency = !!item.RecruitingAgency;
(data as unknown as Record<string, unknown>).HireOnArrival = !!item.HireOnArrival;
data.HireSecurityApproved = !!item.HireSecurityApproved;
data.PieceworkPay = !!item.PieceworkPay;
data.HireApproverNoteImportant = !!item.HireApproverNoteImportant;
SCALAR_FIELDS.forEach((key) => {
@@ -75,10 +75,21 @@ export interface IFormData {
SalaryAfterProbation: string;
WorkSchedule: string;
WorkScheduleOther: string;
/**
* Сдельная оплата труда — информационная отметка, на обязательность окладов не
* влияет. Та же колонка сайта PieceworkPay (Boolean), что в заявке на подбор:
* оттуда и подставляется (см. selectionPrefill).
*/
PieceworkPay: boolean;
// Рекрутинговое агентство (условный блок): при включённом флаге — название + условия.
RecruitingAgency: boolean;
AgencyName: string;
AgencyTerms: string;
// Служба безопасности: инициатор при подаче отмечает, что кандидат согласован
// с СБ; при отметке обязательно примечание (см. validation.ts). Информационное —
// на маршрут и решения не влияет.
HireSecurityApproved: boolean;
HireSecurityNote: string;
// Примечание для согласующих (shared/approverNote): текст + «Важное».
// Префикс Hire — чтобы internalName не совпал с одноимёнными колонками других форм.
HireApproverNote: string;
@@ -121,9 +132,12 @@ export const EMPTY_DATA: IFormData = {
SalaryAfterProbation: '',
WorkSchedule: '',
WorkScheduleOther: '',
PieceworkPay: false,
RecruitingAgency: false,
AgencyName: '',
AgencyTerms: '',
HireSecurityApproved: false,
HireSecurityNote: '',
HireApproverNote: '',
HireApproverNoteImportant: false
};
@@ -41,11 +41,15 @@
{ "internalName": "SalaryAfterProbation", "title": "Оклад по завершении ИС", "type": "Number", "decimals": 2 },
{ "internalName": "WorkSchedule", "title": "График работы", "type": "Text" },
{ "internalName": "WorkScheduleOther", "title": "График работы (другое)", "type": "Text" },
{ "internalName": "PieceworkPay", "title": "Сдельная оплата труда", "type": "Boolean" },
{ "internalName": "RecruitingAgency", "title": "Рекрутинговое агентство", "type": "Boolean" },
{ "internalName": "AgencyName", "title": "Название компании (агентство)", "type": "Text" },
{ "internalName": "AgencyTerms", "title": "Условия (агентство)", "type": "Note" },
{ "internalName": "HireSecurityApproved", "title": "Согласован СБ", "type": "Boolean" },
{ "internalName": "HireSecurityNote", "title": "Примечание СБ", "type": "Note" },
{ "internalName": "HireApproverNote", "title": "Примечание для согласующих", "type": "Note" },
{ "internalName": "HireApproverNoteImportant", "title": "Примечание — важное", "type": "Boolean" }
]
@@ -1,5 +1,5 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput, MoneyInput, Select } from '../../../shared/ui';
import { CollapseSection, FormField, TextInput, MoneyInput, Select, Toggle } from '../../../shared/ui';
import { ISectionProps, OTHER_VALUE, WORK_SCHEDULE_OPTIONS, PROBATION_PERIOD_OPTIONS } from '../model';
interface IConditionsSectionProps extends ISectionProps {
@@ -67,5 +67,9 @@ export const ConditionsSection: React.FC<IConditionsSectionProps> = ({ data, set
/>
</FormField>
)}
{/* Просто отметка: обязательность окладов (4.2, 4.3) от неё не зависит. */}
<FormField label="4.5 Сдельная оплата труда" fieldKey="PieceworkPay">
<Toggle checked={data.PieceworkPay} onChange={(v) => set('PieceworkPay', v)} onLabel="Да" />
</FormField>
</CollapseSection>
);
@@ -8,10 +8,10 @@ const NOTE_FIELD_KEYS: IApproverNoteFieldKeys = {
important: 'HireApproverNoteImportant'
};
/** 6. Примечание для согласующих — общий раздел shared/approverNote на полях приёма. */
/** 7. Примечание для согласующих — общий раздел shared/approverNote на полях приёма. */
export const NoteSection: React.FC<ISectionProps> = ({ data, set }) => (
<ApproverNoteSection
title="6. Примечание для согласующих"
title="7. Примечание для согласующих"
value={{ text: data.HireApproverNote, important: data.HireApproverNoteImportant }}
onChange={(note) => {
set('HireApproverNote', note.text);
@@ -0,0 +1,34 @@
import * as React from 'react';
import { CollapseSection, FormField, TextArea, Toggle } from '../../../shared/ui';
import { ISectionProps } from '../model';
/**
* 6. Служба безопасности: инициатор отмечает, что кандидат согласован с СБ, и
* тогда обязательно пишет примечание (см. validation.ts). Поле информационное —
* на маршрут и решения согласующих не влияет. При снятии отметки примечание
* очищается, чтобы в заявку не ушёл текст скрытого поля (как в AgencySection).
*/
export const SecuritySection: React.FC<ISectionProps> = ({ data, set }) => {
const handleToggle = (value: boolean): void => {
set('HireSecurityApproved', value);
if (!value) set('HireSecurityNote', '');
};
return (
<CollapseSection title="6. Служба безопасности" icon="user-check">
<FormField label="6.1 Согласован СБ" fieldKey="HireSecurityApproved">
<Toggle checked={data.HireSecurityApproved} onChange={handleToggle} onLabel="Да" />
</FormField>
{data.HireSecurityApproved && (
<FormField label="6.2 Примечание СБ" htmlFor="HireSecurityNote" required full>
<TextArea
id="HireSecurityNote"
value={data.HireSecurityNote}
onChange={(v) => set('HireSecurityNote', v)}
placeholder="Кто и когда согласовал, замечания СБ"
/>
</FormField>
)}
</CollapseSection>
);
};
@@ -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,56 @@
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,
PieceworkPay: selection.PieceworkPay,
};
// Тип приёма однозначен, только если в подборе ОДНА причина; ФИО «взамен
// кого» — только если под ней ОДИН сотрудник. Иначе инициатор выбирает сам.
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;
}
@@ -38,6 +38,16 @@ describe('RecruitingHire validation', () => {
});
});
it('«Согласован СБ» требует примечание СБ, без отметки — не требует', () => {
expect(labels(submitIssues(DRAFT_READY))).not.toContain('6.2 Примечание СБ');
expect(labels(submitIssues({ ...DRAFT_READY, HireSecurityApproved: true }))).toContain('6.2 Примечание СБ');
expect(
labels(submitIssues({ ...DRAFT_READY, HireSecurityApproved: true, HireSecurityNote: 'Проверка пройдена' }))
).not.toContain('6.2 Примечание СБ');
// Черновик без примечания сохранить можно.
expect(draftIssues({ ...DRAFT_READY, HireSecurityApproved: true })).toEqual([]);
});
it('«По приезду на объект» снимает обязательность даты приёма', () => {
expect(labels(submitIssues({ ...DRAFT_READY, HireOnArrival: true }))).not.toContain('2.2 Дата приёма');
});
@@ -19,6 +19,7 @@ interface IRequiredRule extends IFieldIssue {
* агентство» не проверяется — у булевых нет пустого состояния.
* 2.2 «Дата приёма» не нужна, если отмечено «По приезду на объект»
* (HireOnArrival) — тогда дату проставляет исполнитель на своём шаге.
* 6.2 «Примечание СБ» обязательно только при отметке 6.1 «Согласован СБ».
*
* 1.3 «Проект» — хотя бы один проект из справочника: первый уходит в resolve
* маршрута (projectId), а без него approval-route отвечал 404 и заявка ушла бы
@@ -48,7 +49,13 @@ const REQUIRED_RULES: ReadonlyArray<IRequiredRule> = [
{ 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 }
{ field: 'WorkSchedule', label: '4.4 График работы', ok: (d) => !!d.WorkSchedule },
{
field: 'HireSecurityNote',
label: '6.2 Примечание СБ',
message: 'отмечено «Согласован СБ» — укажите примечание',
ok: (d) => !d.HireSecurityApproved || !!d.HireSecurityNote.trim()
}
];
function toIssue({ field, label, message }: IRequiredRule): IFieldIssue {
@@ -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>}
@@ -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>;
@@ -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' });
+7 -3
View File
@@ -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();