From af93ae57f48a93d442ce180f0a5d0e221942d15e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Fri, 9 Oct 2026 14:12:33 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BE=D1=84=D0=BE=D1=80=D0=BC=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=B8=D1=91=D0=BC=D0=B0?= =?UTF-8?q?=20=D0=BF=D1=80=D1=8F=D0=BC=D0=BE=20=D0=B8=D0=B7=20=D1=81=D0=BE?= =?UTF-8?q?=D0=B3=D0=BB=D0=B0=D1=81=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BA=D0=B8=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D0=B1=D0=BE=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Просили создавать заявку на приём из самого подбора, а не искать его в поле 1.1. Под вложениями согласованного подбора — «+ Оформить приём по этой заявке» со счётчиком «занято X из Y» (и предупреждением, если вакансии заняты). Новая заявка на приём сразу связана с подбором и заполнена из него: логика вынесена в selectionPrefill и общая с полем 1.1. Вместе с rpsource в адресе передаётся партиция подбора (rplist): Id в партициях повторяются. Приёмы по подбору грузятся один раз на оба блока (useLinkedHires). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../components/RequestPortal.tsx | 3 +- .../features/submit/SubmitFeature.tsx | 3 + .../RecruitingHire/RecruitingHireForm.tsx | 30 ++++++++++ .../sections/SelectionRequestField.tsx | 44 ++++---------- .../forms/RecruitingHire/selectionPrefill.ts | 55 ++++++++++++++++++ .../forms/RecruitingSelection/README.md | 5 +- .../RecruitingSelectionView.tsx | 36 ++++++++++-- .../sections/HiresProgressSection.tsx | 48 +++------------ .../sections/StartHireBar.module.scss | 41 +++++++++++++ .../sections/StartHireBar.tsx | 54 +++++++++++++++++ .../RecruitingSelection/useLinkedHires.ts | 58 +++++++++++++++++++ src/webparts/requestPortal/models/types.ts | 6 ++ .../requestPortal/shared/routing.test.ts | 21 +++++++ src/webparts/requestPortal/shared/routing.ts | 10 +++- 14 files changed, 330 insertions(+), 84 deletions(-) create mode 100644 src/webparts/requestPortal/forms/RecruitingHire/selectionPrefill.ts create mode 100644 src/webparts/requestPortal/forms/RecruitingSelection/sections/StartHireBar.module.scss create mode 100644 src/webparts/requestPortal/forms/RecruitingSelection/sections/StartHireBar.tsx create mode 100644 src/webparts/requestPortal/forms/RecruitingSelection/useLinkedHires.ts diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index dd579e7..07e373c 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -468,7 +468,7 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context = ({ hasTeamsContext, context ? { formKey: route.formKey, sourceId: route.sourceId, + sourceListId: route.listId, positionId: route.positionId, nomenclatureId: route.nomenclatureId, storeName: route.storeName, diff --git a/src/webparts/requestPortal/features/submit/SubmitFeature.tsx b/src/webparts/requestPortal/features/submit/SubmitFeature.tsx index 4e63079..73a266e 100644 --- a/src/webparts/requestPortal/features/submit/SubmitFeature.tsx +++ b/src/webparts/requestPortal/features/submit/SubmitFeature.tsx @@ -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 = ({ onCancel={handleBack} onSaved={emitRequestsChanged} sourceId={sourceId} + sourceListId={sourceId ? external?.sourceListId : undefined} editId={editId} positionId={external?.positionId} nomenclatureId={external?.nomenclatureId} diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx index 3644ea4..81af10c 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx @@ -59,6 +59,9 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { draftIssues, submitIssues } from './validation'; import { saveHireRequest, hireApprovalService } from './submit'; import { loadHireRequest } from './load'; +import { isApprovedStatus } from '../../shared/approval/models/statuses'; +import { loadSelectionRequest } from '../RecruitingSelection/load'; +import { selectionPrefill } from './selectionPrefill'; import { ATT_CATEGORY_ID, ATT_CATEGORY_RESUME, @@ -83,6 +86,8 @@ const RecruitingHireForm: React.FC = ({ onSaved, editId, editListId, + sourceId, + sourceListId, onCodeLoaded }) => { const [data, setData] = useState(EMPTY_DATA); @@ -141,6 +146,31 @@ const RecruitingHireForm: React.FC = ({ }; }, [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 инициатора — текущего // пользователя (кандидата ещё нет в системе). У заявки, открытой на // редактирование, маршрут читается из уже записанных строк — планирование diff --git a/src/webparts/requestPortal/forms/RecruitingHire/sections/SelectionRequestField.tsx b/src/webparts/requestPortal/forms/RecruitingHire/sections/SelectionRequestField.tsx index 84106be..0181d40 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/sections/SelectionRequestField.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/sections/SelectionRequestField.tsx @@ -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): 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 = ({ 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/коду — сбой обогащения не критичен }; diff --git a/src/webparts/requestPortal/forms/RecruitingHire/selectionPrefill.ts b/src/webparts/requestPortal/forms/RecruitingHire/selectionPrefill.ts new file mode 100644 index 0000000..d91903d --- /dev/null +++ b/src/webparts/requestPortal/forms/RecruitingHire/selectionPrefill.ts @@ -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 { + const patch: Partial = { + 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; +} diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/README.md b/src/webparts/requestPortal/forms/RecruitingSelection/README.md index e29d139..f3f49b9 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/README.md +++ b/src/webparts/requestPortal/forms/RecruitingSelection/README.md @@ -27,4 +27,7 @@ ApplicationSection · DepartmentSection · VacancySection · HealthTripsSection ## Точки входа - rptype: `recruiting-selection` - Режим согласующего (просмотр): есть (`RecruitingSelectionView`) -- «Создать на основе»: — +- «Создать на основе»: из согласованного подбора — «+ Оформить приём по этой заявке» + (`sections/StartHireBar`, под вложениями) → `recruiting-hire` с `rpsource`=Id подбора и + `rplist`=его партиция; форма приёма заполняет поля через `RecruitingHire/selectionPrefill.ts` + (та же логика, что при выборе подбора в поле 1.1) diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx index 114218e..3048641 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx @@ -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 = ({ 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 = ({ cont {/* Приём оформляют только по утверждённому подбору — до этого блоку показывать нечего. */} {isApprovedStatus(status) && ( - + )} @@ -389,6 +406,15 @@ const RecruitingSelectionView: React.FC = ({ cont
+ + {/* После вложений, перед маршрутом: приём оформляют, уже просмотрев подбор целиком. */} + {isApprovedStatus(status) && ( + + )} {routeError && {routeError}} diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/sections/HiresProgressSection.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/sections/HiresProgressSection.tsx index d6f063a..7761097 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/sections/HiresProgressSection.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/sections/HiresProgressSection.tsx @@ -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 = ({ - selectionId, - selectionListId, - vacancies -}) => { - const [hires, setHires] = React.useState([]); - const [isLoading, setIsLoading] = React.useState(true); - const [error, setError] = React.useState(''); - - 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 = ({ linked, vacancies }) => { + const { hires, occupied, isLoading, error } = linked; const renderHires = (): React.ReactNode => { if (isLoading) return {TEXT.loading}; diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/sections/StartHireBar.module.scss b/src/webparts/requestPortal/forms/RecruitingSelection/sections/StartHireBar.module.scss new file mode 100644 index 0000000..d81b233 --- /dev/null +++ b/src/webparts/requestPortal/forms/RecruitingSelection/sections/StartHireBar.module.scss @@ -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; +} diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/sections/StartHireBar.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/sections/StartHireBar.tsx new file mode 100644 index 0000000..5606213 --- /dev/null +++ b/src/webparts/requestPortal/forms/RecruitingSelection/sections/StartHireBar.tsx @@ -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 = ({ linked, vacancies, onStart }) => { + const { occupied, isLoading, error } = linked; + const known = !isLoading && !error; + + return ( +
+
+ {TEXT.title} + {known && ( + + {TEXT.occupied} {occupiedLabel(occupied, vacancies)} + + )} +
+
+ +
+ {known && allVacanciesOccupied(occupied, vacancies) && ( +
+ + {TEXT.allOccupied} +
+ )} +
+ ); +}; diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/useLinkedHires.ts b/src/webparts/requestPortal/forms/RecruitingSelection/useLinkedHires.ts new file mode 100644 index 0000000..d72bfcb --- /dev/null +++ b/src/webparts/requestPortal/forms/RecruitingSelection/useLinkedHires.ts @@ -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([]); + const [isLoading, setIsLoading] = React.useState(enabled); + const [error, setError] = React.useState(''); + + 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 }; +} diff --git a/src/webparts/requestPortal/models/types.ts b/src/webparts/requestPortal/models/types.ts index 5bb89d4..e706230 100644 --- a/src/webparts/requestPortal/models/types.ts +++ b/src/webparts/requestPortal/models/types.ts @@ -39,6 +39,12 @@ export interface IRequestFormProps { * Формы, не поддерживающие производные заявки, игнорируют. */ sourceId?: number; + /** + * Партиция (серверный url списка), где лежит sourceId — заявки нумерованные, + * Id в разных партициях повторяются (см. shared/approval/listPartitions.ts). + * Не задан — исходная заявка ищется в текущей партиции. + */ + sourceListId?: string; /** * Параметры внешней ссылки (Dataverse «Строка заявки» + справочник * номенклатуры). Использует только форма «СЗ на перемещение ТМЦ»: по diff --git a/src/webparts/requestPortal/shared/routing.test.ts b/src/webparts/requestPortal/shared/routing.test.ts index f7535d9..ac15686 100644 --- a/src/webparts/requestPortal/shared/routing.test.ts +++ b/src/webparts/requestPortal/shared/routing.test.ts @@ -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' }); diff --git a/src/webparts/requestPortal/shared/routing.ts b/src/webparts/requestPortal/shared/routing.ts index b45e0c7..96475bf 100644 --- a/src/webparts/requestPortal/shared/routing.ts +++ b/src/webparts/requestPortal/shared/routing.ts @@ -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();