From bfe0b95f26a2afddbc96ef93c8beb73fb34b1fbe 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: Mon, 28 Sep 2026 11:27:30 +0500 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=B2=20=C2=AB=D0=9C=D0=BE=D0=B8=D1=85?= =?UTF-8?q?=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BA=D0=B0=D1=85=C2=BB=20=D1=82?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=81=20=D1=81=D0=B0=D0=B9=D1=82=D0=B0=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D1=82=D0=B0=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../forms/RecruitingHire/linkedHires.ts | 105 +++++++++++++++++ .../forms/RecruitingHire/partitionQuery.ts | 80 +++++++++++++ .../sections/SelectionRequestField.tsx | 8 +- .../forms/RecruitingHire/selectionLookup.ts | 102 ++++++---------- .../RecruitingSelectionView.tsx | 10 ++ .../sections/HiresProgressSection.module.scss | 53 +++++++++ .../sections/HiresProgressSection.tsx | 110 ++++++++++++++++++ 7 files changed, 403 insertions(+), 65 deletions(-) create mode 100644 src/webparts/requestPortal/forms/RecruitingHire/linkedHires.ts create mode 100644 src/webparts/requestPortal/forms/RecruitingHire/partitionQuery.ts create mode 100644 src/webparts/requestPortal/forms/RecruitingSelection/sections/HiresProgressSection.module.scss create mode 100644 src/webparts/requestPortal/forms/RecruitingSelection/sections/HiresProgressSection.tsx diff --git a/src/webparts/requestPortal/forms/RecruitingHire/linkedHires.ts b/src/webparts/requestPortal/forms/RecruitingHire/linkedHires.ts new file mode 100644 index 0000000..fd8df36 --- /dev/null +++ b/src/webparts/requestPortal/forms/RecruitingHire/linkedHires.ts @@ -0,0 +1,105 @@ +import { REQUEST_STATUS } from '../../shared/approval'; +import { FORM_ID as HIRE_FORM_ID } from './model'; +import { queryAllPartitions, statusFilter } from './partitionQuery'; + +/** + * Заявки на приём, связанные с заявкой на подбор, и занятость вакансий подбора. + * + * Одна заявка на приём = один принятый человек, поэтому подбор на N вакансий + * закрывается не больше чем N приёмами. Связь хранится в самом приёме + * (SelectionRequestId + SelectionRequestListId, см. selectionLookup.ts), так + * что считаем по списку приёмов — отдельных колонок в подборе нет. + */ + +// Приём занимает вакансию, когда дошёл до исполнения (так решил заказчик, +// 2026-09-28). «Закрыта» идёт после «Выполнения» — закрытие приёма вакансию не +// освобождает. «Согласовано» — конечный статус приёма, если в маршруте нет шагов +// исполнения (при исполнителях заявка его минует и сразу уходит в «Выполнение», +// см. approverDecision.service.ts) — без него такие приёмы не занимали бы +// вакансию никогда. Черновики, приёмы на согласовании, возвращённые и +// отклонённые вакансию не занимают. +const OCCUPYING_HIRE_STATUSES: string[] = [REQUEST_STATUS.approved, REQUEST_STATUS.execution, REQUEST_STATUS.closed]; + +export function isOccupyingHireStatus(status: string): boolean { + return OCCUPYING_HIRE_STATUSES.indexOf(status) !== -1; +} + +/** Ссылка приёма на подбор: Id подбора + его партиция ('' — партиция неизвестна). */ +export interface ISelectionRef { + selectionId: number; + selectionListId: string; +} + +/** + * Относится ли ссылка приёма к подбору. Id повторяются в разных партициях + * серии, поэтому сравниваем и партицию. Но если она неизвестна хоть с одной + * стороны (реестр партиций не загрузился — при выборе подбора в приёме или + * сейчас), сопоставляем по одному Id: иначе такой приём не был бы виден в + * подборе и не занимал бы вакансию вовсе. + */ +function belongsToSelection(ref: ISelectionRef, selectionId: number, selectionListId: string): boolean { + if (ref.selectionId !== selectionId) return false; + if (!ref.selectionListId || !selectionListId) return true; + return ref.selectionListId.toLowerCase() === selectionListId.toLowerCase(); +} + +/** Ссылки на подбор всех приёмов, которые уже заняли вакансию. */ +export async function getOccupyingHireRefs(): Promise { + const rows = await queryAllPartitions(HIRE_FORM_ID, statusFilter(OCCUPYING_HIRE_STATUSES), [ + 'Id', + 'SelectionRequestId', + 'SelectionRequestListId' + ]); + + return ( + rows + .map(({ row }) => ({ + selectionId: Number(row.SelectionRequestId) || 0, + selectionListId: (row.SelectionRequestListId as string) || '' + })) + // Приём без связи с подбором (связь необязательна) вакансий не занимает. + .filter((ref) => ref.selectionId > 0) + ); +} + +/** Сколько приёмов из refs заняли вакансии конкретного подбора. */ +export function countOccupied(refs: ISelectionRef[], selectionId: number, selectionListId: string): number { + return refs.filter((ref) => belongsToSelection(ref, selectionId, selectionListId)).length; +} + +export interface ILinkedHire { + id: number; + /** Партиция, где лежит приём (→ rplist в ссылке). '' — партиция неизвестна. */ + listId: string; + code: string; + candidateName: string; + status: string; +} + +/** + * Все поданные приёмы по подбору, свежие сверху. Черновики не показываем — это + * ещё не поданные заявки инициатора, в подборе им не место. + */ +export async function getLinkedHires(selectionId: number, selectionListId: string): Promise { + const rows = await queryAllPartitions( + HIRE_FORM_ID, + `SelectionRequestId eq ${selectionId} and RequestStatus ne '${REQUEST_STATUS.draft}'`, + ['Id', 'RequestUniqueCode', 'HireCandidateName', 'RequestStatus', 'SelectionRequestListId'] + ); + + return rows + .filter(({ row }) => + belongsToSelection( + { selectionId, selectionListId: (row.SelectionRequestListId as string) || '' }, + selectionId, + selectionListId + ) + ) + .map(({ listId, row }) => ({ + id: Number(row.Id), + listId, + code: (row.RequestUniqueCode as string) || '', + candidateName: (row.HireCandidateName as string) || '', + status: (row.RequestStatus as string) || '' + })); +} diff --git a/src/webparts/requestPortal/forms/RecruitingHire/partitionQuery.ts b/src/webparts/requestPortal/forms/RecruitingHire/partitionQuery.ts new file mode 100644 index 0000000..4a573d8 --- /dev/null +++ b/src/webparts/requestPortal/forms/RecruitingHire/partitionQuery.ts @@ -0,0 +1,80 @@ +import { getSP } from '../../shared/pnp/pnpjs'; +import { dataListTitle } from '../../shared/listTitles'; +import { partitionsForPageUrl } from '../../shared/approval'; +import { RequestTypeId } from '../../models/types'; + +/** Строка списка данных как её отдаёт SharePoint REST. */ +export type ListRow = Record; + +/** Строка вместе с партицией, из которой она прочитана ('' — партиция неизвестна). */ +export interface IPartitionRow { + listId: string; + row: ListRow; +} + +// Предохранитель: партиция всё равно ограничена порогом элементов, но берём +// разумный потолок, чтобы одиночный запрос не тянул весь список целиком. +const MAX_ITEMS = 500; + +interface IListLike { + items: { + filter: (f: string) => { + select: (...fields: string[]) => { + orderBy: (field: string, ascending: boolean) => { + top: (n: number) => () => Promise; + }; + }; + }; + }; +} + +async function queryList(list: IListLike, filter: string, fields: string[]): Promise { + return list.items + .filter(filter) + .select(...fields) + .orderBy('Id', false) + .top(MAX_ITEMS)(); +} + +/** + * Прочитать все партиции серии формы (архив + текущая); строки помечаются + * listId своей партиции. Реестр недоступен (approval-route молчал при старте) → + * откатываемся на текущий список по имени, listId тогда ''. Сбой чтения одной + * партиции не роняет остальные. + */ +export async function queryAllPartitions( + formId: RequestTypeId, + filter: string, + fields: string[] +): Promise { + const sp = getSP(); + const partitions = partitionsForPageUrl(formId); + + if (partitions.length === 0) { + const rows = await queryList( + sp.web.lists.getByTitle(dataListTitle(formId)) as unknown as IListLike, + filter, + fields + ); + return rows.map((row) => ({ listId: '', row })); + } + + const perPartition = await Promise.all( + partitions.map((p) => + queryList(sp.web.getList(p.listId) as unknown as IListLike, filter, fields) + .then((rows) => rows.map((row) => ({ listId: p.listId, row }))) + .catch((e) => { + console.error(`[${formId}] партиция «${p.title}» (${p.listId}) не прочитана:`, (e as Error).message); + return [] as IPartitionRow[]; + }) + ) + ); + + // Партиции идут по возрастанию номера, внутри уже отсортировано по Id убыв. + return perPartition.reduce((acc, cur) => acc.concat(cur), []); +} + +/** OData-условие «RequestStatus — один из». */ +export function statusFilter(statuses: string[]): string { + return statuses.map((s) => `RequestStatus eq '${s}'`).join(' or '); +} diff --git a/src/webparts/requestPortal/forms/RecruitingHire/sections/SelectionRequestField.tsx b/src/webparts/requestPortal/forms/RecruitingHire/sections/SelectionRequestField.tsx index 1c3ac1b..e06a5a3 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/sections/SelectionRequestField.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/sections/SelectionRequestField.tsx @@ -22,6 +22,11 @@ function optionLabel(o: ISelectionRequestOption): string { return o.position ? `${head} — ${o.position}` : head; } +/** «Занято 2 из 6» — только если в подборе указано количество вакансий. */ +function vacanciesLabel(o: ISelectionRequestOption): string { + return o.vacancies > 0 ? `Занято ${o.hired} из ${o.vacancies}` : ''; +} + /** * Поле «Заявка на подбор» в форме приёма: поиск-выбор согласованной заявки на * подбор + ссылка на неё (открывается в новой вкладке через RecruitingSelectionView). @@ -206,11 +211,12 @@ export const SelectionRequestField: React.FC = ({ d > {optionLabel(o)} {o.title && {o.title}} + {vacanciesLabel(o) && {vacanciesLabel(o)}} )) ) : (
- {loaded ? 'Нет утверждённых заявок на подбор.' : 'Загрузка…'} + {loaded ? 'Нет утверждённых заявок на подбор со свободными вакансиями.' : 'Загрузка…'}
)} diff --git a/src/webparts/requestPortal/forms/RecruitingHire/selectionLookup.ts b/src/webparts/requestPortal/forms/RecruitingHire/selectionLookup.ts index 2b2389e..acb390f 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/selectionLookup.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/selectionLookup.ts @@ -1,7 +1,7 @@ -import { getSP } from '../../shared/pnp/pnpjs'; -import { dataListTitle } from '../../shared/listTitles'; -import { REQUEST_STATUS, partitionsForPageUrl } from '../../shared/approval'; +import { REQUEST_STATUS } from '../../shared/approval'; import { RequestTypeId } from '../../models/types'; +import { queryAllPartitions, statusFilter } from './partitionQuery'; +import { countOccupied, getOccupyingHireRefs } from './linkedHires'; /** * Выборка «Заявок на подбор» для связывания из «Заявки на прием». @@ -11,6 +11,9 @@ import { RequestTypeId } from '../../models/types'; * shared/approval/listPartitions.ts), а Lookup умеет ссылаться ровно на один * список и через границу партиции ломается. Поэтому здесь мы читаем список(и) * подбора «руками» и отдаём ровно то, что нужно для deep-link портала (rpid/rplist). + * + * Подборы, у которых все вакансии уже заняты приёмами (см. linkedHires.ts), в + * выборку не попадают. */ /** Слаг формы подбора — ключ реестра форм и pageUrl типа заявки в approval-route. */ @@ -26,79 +29,50 @@ export interface ISelectionRequestOption { position: string; /** Системный Title элемента — фолбэк подписи, если код/должность пусты. */ title: string; + /** Количество открытых вакансий из подбора. 0 — не указано (старые заявки), лимита нет. */ + vacancies: number; + /** Сколько заявок на приём уже заняли вакансии этого подбора. */ + hired: number; } // Только утверждённые заявки на подбор: приём оформляют по уже утверждённому подбору. // После согласования подбор уходит исполнителю («Выполнение»), затем «Закрыта» — // это тот же утверждённый подбор, поэтому берём все три статуса (как isApprovedStatus). const APPROVED_STATUSES: string[] = [REQUEST_STATUS.approved, REQUEST_STATUS.execution, REQUEST_STATUS.closed]; -const APPROVED_FILTER = APPROVED_STATUSES.map((s) => `RequestStatus eq '${s}'`).join(' or '); -// Предохранитель: партиция всё равно ограничена порогом элементов, но берём -// разумный потолок, чтобы одиночный запрос не тянул весь список целиком. -const MAX_ITEMS = 500; - -interface IListLike { - items: { - filter: (f: string) => { - select: (...fields: string[]) => { - orderBy: (field: string, ascending: boolean) => { - top: (n: number) => () => Promise[]>; - }; - }; - }; - }; -} - -async function queryPartition(list: IListLike, listId: string): Promise { - const rows = await list.items - .filter(APPROVED_FILTER) - .select('Id', 'Title', 'RequestUniqueCode', 'Position') - .orderBy('Id', false) - .top(MAX_ITEMS)(); - - return rows.map((row) => ({ - id: Number(row.Id), - listId, - code: (row.RequestUniqueCode as string) || '', - position: (row.Position as string) || '', - title: (row.Title as string) || '' - })); +/** У подбора указано количество вакансий, и все они уже заняты приёмами. */ +function isSelectionFilled(o: ISelectionRequestOption): boolean { + return o.vacancies > 0 && o.hired >= o.vacancies; } /** - * Все утверждённые (Согласовано / Выполнение / Закрыта) заявки на подбор из всех партиций серии (архив + текущая). - * - * Реестр партиций знает серию → читаем каждую партицию по её listId (url), - * заодно получая правильный rplist для ссылки. Реестр недоступен (approval-route - * молчал при старте) → откатываемся на текущий список по имени; ссылка тогда - * пойдёт без rplist и просмотр откроется в текущей партиции (см. routing.ts). - * Сбой чтения одной партиции не роняет остальные. + * Утверждённые (Согласовано / Выполнение / Закрыта) заявки на подбор из всех + * партиций серии, у которых остались свободные вакансии. */ export async function getApprovedSelectionRequests(): Promise { - const sp = getSP(); - const partitions = partitionsForPageUrl(SELECTION_FORM_ID); + const [selections, hireRefs] = await Promise.all([ + queryAllPartitions(SELECTION_FORM_ID, statusFilter(APPROVED_STATUSES), [ + 'Id', + 'Title', + 'RequestUniqueCode', + 'Position', + 'OpenVacanciesCount' + ]), + getOccupyingHireRefs() + ]); - if (partitions.length === 0) { - return queryPartition( - sp.web.lists.getByTitle(dataListTitle(SELECTION_FORM_ID)) as unknown as IListLike, - '' - ); - } + const options = selections.map(({ listId, row }) => { + const id = Number(row.Id); + return { + id, + listId, + code: (row.RequestUniqueCode as string) || '', + position: (row.Position as string) || '', + title: (row.Title as string) || '', + vacancies: Number(row.OpenVacanciesCount) || 0, + hired: countOccupied(hireRefs, id, listId) + }; + }); - const perPartition = await Promise.all( - partitions.map((p) => - queryPartition(sp.web.getList(p.listId) as unknown as IListLike, p.listId).catch((e) => { - console.error( - `[Связь с подбором] партиция «${p.title}» (${p.listId}) не прочитана:`, - (e as Error).message - ); - return [] as ISelectionRequestOption[]; - }) - ) - ); - - // Новые заявки (больший Id) — выше; между партициями сортировать не нужно: - // партиции идут по возрастанию номера, внутри уже отсортировано по Id убыв. - return perPartition.reduce((acc, cur) => acc.concat(cur), []); + return options.filter((o) => !isSelectionFilled(o)); } diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx index 5b7d700..3bb1d21 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx @@ -52,6 +52,8 @@ import { DepartmentSection } from './sections/DepartmentSection'; import { VacancySection } from './sections/VacancySection'; import { HealthTripsSection } from './sections/HealthTripsSection'; import { ConditionsSection } from './sections/ConditionsSection'; +import { HiresProgressSection } from './sections/HiresProgressSection'; +import { parseVacanciesCount } from './vacancyReasons'; import { useStableCallback } from '../../shared/useStableCallback'; interface IRecruitingSelectionViewProps { @@ -287,6 +289,14 @@ const RecruitingSelectionView: React.FC = ({ cont + {/* Приём оформляют только по утверждённому подбору — до этого блоку показывать нечего. */} + {isApprovedStatus(status) && ( + + )} diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/sections/HiresProgressSection.module.scss b/src/webparts/requestPortal/forms/RecruitingSelection/sections/HiresProgressSection.module.scss new file mode 100644 index 0000000..25f5bda --- /dev/null +++ b/src/webparts/requestPortal/forms/RecruitingSelection/sections/HiresProgressSection.module.scss @@ -0,0 +1,53 @@ +@import '../../../../../styles/design-tokens'; + +.value { + font-family: $font-sans; + font-size: 13.5px; + font-weight: 600; + color: $gray-900; +} + +.note { + font-family: $font-sans; + font-size: $fs-sm; + color: $gray-500; +} + +.list { + display: flex; + flex-direction: column; + gap: $sp-2; + margin: 0; + padding: 0; + list-style: none; +} + +// На узких экранах строка переносится: ссылка и статус остаются, ФИО уходит ниже. +.row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: $sp-2 $sp-3; + font-family: $font-sans; + font-size: $fs-sm; +} + +.link { + display: inline-flex; + align-items: center; + gap: 4px; + font-weight: 600; + color: $navy-700; + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +.name { + flex: 1 1 160px; + min-width: 0; + color: $gray-900; + overflow-wrap: anywhere; +} diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/sections/HiresProgressSection.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/sections/HiresProgressSection.tsx new file mode 100644 index 0000000..d6f063a --- /dev/null +++ b/src/webparts/requestPortal/forms/RecruitingSelection/sections/HiresProgressSection.tsx @@ -0,0 +1,110 @@ +import * as React from 'react'; +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'; + +interface IHiresProgressSectionProps { + selectionId: number; + /** Партиция, где лежит заявка на подбор ('' — неизвестна). */ + selectionListId: string; + /** Количество открытых вакансий из 2.2. 0 — не указано. */ + vacancies: number; +} + +const TEXT = { + title: 'Приём по заявке', + occupied: 'Занято вакансий', + hires: 'Заявки на приём', + loading: 'Загрузка…', + empty: 'Заявок на приём по этому подбору пока нет.', + loadError: 'Не удалось загрузить заявки на приём' +}; + +function occupiedLabel(occupied: number, vacancies: number): string { + return vacancies > 0 ? `${occupied} из ${vacancies}` : String(occupied); +} + +function hireHref(hire: ILinkedHire): string { + return routeUrl({ + mode: 'approve', + nav: 'new', + formKey: HIRE_FORM_ID, + itemId: hire.id, + listId: hire.listId || undefined + }); +} + +/** + * Просмотр подбора: сколько вакансий уже заняли приёмы и какие приёмы поданы. + * Данные живые (читаются из списков приёма), поэтому на печать не выводятся. + * Правило «приём занимает вакансию» — то же, что и в выборе подбора в форме + * приёма (см. 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; + + const renderHires = (): React.ReactNode => { + if (isLoading) return {TEXT.loading}; + if (error) return {error}; + if (hires.length === 0) return {TEXT.empty}; + return ( + + ); + }; + + return ( +
+ + + {isLoading || error ? '—' : occupiedLabel(occupied, vacancies)} + + + {renderHires()} + + +
+ ); +};