Compare commits
3 Commits
cdabc3f40b
...
8330790bf9
| Author | SHA1 | Date | |
|---|---|---|---|
| 8330790bf9 | |||
| af93ae57f4 | |||
| 79963e4035 |
@@ -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>
|
||||
);
|
||||
};
|
||||
+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,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>}
|
||||
|
||||
+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