fix: в «Моих заявках» только заявки с сайта портала
Tests / frontend-test (push) Successful in 1m13s
Tests / frontend-test (push) Successful in 1m13s
This commit is contained in:
@@ -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<ISelectionRef[]> {
|
||||||
|
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<ILinkedHire[]> {
|
||||||
|
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) || ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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<string, unknown>;
|
||||||
|
|
||||||
|
/** Строка вместе с партицией, из которой она прочитана ('' — партиция неизвестна). */
|
||||||
|
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<ListRow[]>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryList(list: IListLike, filter: string, fields: string[]): Promise<ListRow[]> {
|
||||||
|
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<IPartitionRow[]> {
|
||||||
|
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 ');
|
||||||
|
}
|
||||||
@@ -22,6 +22,11 @@ function optionLabel(o: ISelectionRequestOption): string {
|
|||||||
return o.position ? `${head} — ${o.position}` : head;
|
return o.position ? `${head} — ${o.position}` : head;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** «Занято 2 из 6» — только если в подборе указано количество вакансий. */
|
||||||
|
function vacanciesLabel(o: ISelectionRequestOption): string {
|
||||||
|
return o.vacancies > 0 ? `Занято ${o.hired} из ${o.vacancies}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Поле «Заявка на подбор» в форме приёма: поиск-выбор согласованной заявки на
|
* Поле «Заявка на подбор» в форме приёма: поиск-выбор согласованной заявки на
|
||||||
* подбор + ссылка на неё (открывается в новой вкладке через RecruitingSelectionView).
|
* подбор + ссылка на неё (открывается в новой вкладке через RecruitingSelectionView).
|
||||||
@@ -206,11 +211,12 @@ export const SelectionRequestField: React.FC<ISelectionRequestFieldProps> = ({ d
|
|||||||
>
|
>
|
||||||
{optionLabel(o)}
|
{optionLabel(o)}
|
||||||
{o.title && <span className={styles.meta}>{o.title}</span>}
|
{o.title && <span className={styles.meta}>{o.title}</span>}
|
||||||
|
{vacanciesLabel(o) && <span className={styles.meta}>{vacanciesLabel(o)}</span>}
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.note}>
|
<div className={styles.note}>
|
||||||
{loaded ? 'Нет утверждённых заявок на подбор.' : 'Загрузка…'}
|
{loaded ? 'Нет утверждённых заявок на подбор со свободными вакансиями.' : 'Загрузка…'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getSP } from '../../shared/pnp/pnpjs';
|
import { REQUEST_STATUS } from '../../shared/approval';
|
||||||
import { dataListTitle } from '../../shared/listTitles';
|
|
||||||
import { REQUEST_STATUS, partitionsForPageUrl } from '../../shared/approval';
|
|
||||||
import { RequestTypeId } from '../../models/types';
|
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 умеет ссылаться ровно на один
|
* shared/approval/listPartitions.ts), а Lookup умеет ссылаться ровно на один
|
||||||
* список и через границу партиции ломается. Поэтому здесь мы читаем список(и)
|
* список и через границу партиции ломается. Поэтому здесь мы читаем список(и)
|
||||||
* подбора «руками» и отдаём ровно то, что нужно для deep-link портала (rpid/rplist).
|
* подбора «руками» и отдаём ровно то, что нужно для deep-link портала (rpid/rplist).
|
||||||
|
*
|
||||||
|
* Подборы, у которых все вакансии уже заняты приёмами (см. linkedHires.ts), в
|
||||||
|
* выборку не попадают.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Слаг формы подбора — ключ реестра форм и pageUrl типа заявки в approval-route. */
|
/** Слаг формы подбора — ключ реестра форм и pageUrl типа заявки в approval-route. */
|
||||||
@@ -26,79 +29,50 @@ export interface ISelectionRequestOption {
|
|||||||
position: string;
|
position: string;
|
||||||
/** Системный Title элемента — фолбэк подписи, если код/должность пусты. */
|
/** Системный Title элемента — фолбэк подписи, если код/должность пусты. */
|
||||||
title: string;
|
title: string;
|
||||||
|
/** Количество открытых вакансий из подбора. 0 — не указано (старые заявки), лимита нет. */
|
||||||
|
vacancies: number;
|
||||||
|
/** Сколько заявок на приём уже заняли вакансии этого подбора. */
|
||||||
|
hired: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Только утверждённые заявки на подбор: приём оформляют по уже утверждённому подбору.
|
// Только утверждённые заявки на подбор: приём оформляют по уже утверждённому подбору.
|
||||||
// После согласования подбор уходит исполнителю («Выполнение»), затем «Закрыта» —
|
// После согласования подбор уходит исполнителю («Выполнение»), затем «Закрыта» —
|
||||||
// это тот же утверждённый подбор, поэтому берём все три статуса (как isApprovedStatus).
|
// это тот же утверждённый подбор, поэтому берём все три статуса (как isApprovedStatus).
|
||||||
const APPROVED_STATUSES: string[] = [REQUEST_STATUS.approved, REQUEST_STATUS.execution, REQUEST_STATUS.closed];
|
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 ');
|
|
||||||
|
|
||||||
// Предохранитель: партиция всё равно ограничена порогом элементов, но берём
|
/** У подбора указано количество вакансий, и все они уже заняты приёмами. */
|
||||||
// разумный потолок, чтобы одиночный запрос не тянул весь список целиком.
|
function isSelectionFilled(o: ISelectionRequestOption): boolean {
|
||||||
const MAX_ITEMS = 500;
|
return o.vacancies > 0 && o.hired >= o.vacancies;
|
||||||
|
|
||||||
interface IListLike {
|
|
||||||
items: {
|
|
||||||
filter: (f: string) => {
|
|
||||||
select: (...fields: string[]) => {
|
|
||||||
orderBy: (field: string, ascending: boolean) => {
|
|
||||||
top: (n: number) => () => Promise<Record<string, unknown>[]>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function queryPartition(list: IListLike, listId: string): Promise<ISelectionRequestOption[]> {
|
|
||||||
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) || ''
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Все утверждённые (Согласовано / Выполнение / Закрыта) заявки на подбор из всех партиций серии (архив + текущая).
|
* Утверждённые (Согласовано / Выполнение / Закрыта) заявки на подбор из всех
|
||||||
*
|
* партиций серии, у которых остались свободные вакансии.
|
||||||
* Реестр партиций знает серию → читаем каждую партицию по её listId (url),
|
|
||||||
* заодно получая правильный rplist для ссылки. Реестр недоступен (approval-route
|
|
||||||
* молчал при старте) → откатываемся на текущий список по имени; ссылка тогда
|
|
||||||
* пойдёт без rplist и просмотр откроется в текущей партиции (см. routing.ts).
|
|
||||||
* Сбой чтения одной партиции не роняет остальные.
|
|
||||||
*/
|
*/
|
||||||
export async function getApprovedSelectionRequests(): Promise<ISelectionRequestOption[]> {
|
export async function getApprovedSelectionRequests(): Promise<ISelectionRequestOption[]> {
|
||||||
const sp = getSP();
|
const [selections, hireRefs] = await Promise.all([
|
||||||
const partitions = partitionsForPageUrl(SELECTION_FORM_ID);
|
queryAllPartitions(SELECTION_FORM_ID, statusFilter(APPROVED_STATUSES), [
|
||||||
|
'Id',
|
||||||
|
'Title',
|
||||||
|
'RequestUniqueCode',
|
||||||
|
'Position',
|
||||||
|
'OpenVacanciesCount'
|
||||||
|
]),
|
||||||
|
getOccupyingHireRefs()
|
||||||
|
]);
|
||||||
|
|
||||||
if (partitions.length === 0) {
|
const options = selections.map(({ listId, row }) => {
|
||||||
return queryPartition(
|
const id = Number(row.Id);
|
||||||
sp.web.lists.getByTitle(dataListTitle(SELECTION_FORM_ID)) as unknown as IListLike,
|
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(
|
return options.filter((o) => !isSelectionFilled(o));
|
||||||
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), []);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ import { DepartmentSection } from './sections/DepartmentSection';
|
|||||||
import { VacancySection } from './sections/VacancySection';
|
import { VacancySection } from './sections/VacancySection';
|
||||||
import { HealthTripsSection } from './sections/HealthTripsSection';
|
import { HealthTripsSection } from './sections/HealthTripsSection';
|
||||||
import { ConditionsSection } from './sections/ConditionsSection';
|
import { ConditionsSection } from './sections/ConditionsSection';
|
||||||
|
import { HiresProgressSection } from './sections/HiresProgressSection';
|
||||||
|
import { parseVacanciesCount } from './vacancyReasons';
|
||||||
import { useStableCallback } from '../../shared/useStableCallback';
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
interface IRecruitingSelectionViewProps {
|
interface IRecruitingSelectionViewProps {
|
||||||
@@ -287,6 +289,14 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
<FormModeProvider readOnly={!fullEditable}>
|
<FormModeProvider readOnly={!fullEditable}>
|
||||||
<ApplicationSection {...sectionProps} />
|
<ApplicationSection {...sectionProps} />
|
||||||
<DepartmentSection {...sectionProps} />
|
<DepartmentSection {...sectionProps} />
|
||||||
|
{/* Приём оформляют только по утверждённому подбору — до этого блоку показывать нечего. */}
|
||||||
|
{isApprovedStatus(status) && (
|
||||||
|
<HiresProgressSection
|
||||||
|
selectionId={itemId}
|
||||||
|
selectionListId={listId || ''}
|
||||||
|
vacancies={parseVacanciesCount(data.OpenVacanciesCount)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<VacancySection {...sectionProps} />
|
<VacancySection {...sectionProps} />
|
||||||
<HealthTripsSection {...sectionProps} />
|
<HealthTripsSection {...sectionProps} />
|
||||||
<ConditionsSection {...sectionProps} />
|
<ConditionsSection {...sectionProps} />
|
||||||
|
|||||||
+53
@@ -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;
|
||||||
|
}
|
||||||
+110
@@ -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<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;
|
||||||
|
|
||||||
|
const renderHires = (): React.ReactNode => {
|
||||||
|
if (isLoading) return <span className={styles.note}>{TEXT.loading}</span>;
|
||||||
|
if (error) return <Alert variant="err">{error}</Alert>;
|
||||||
|
if (hires.length === 0) return <span className={styles.note}>{TEXT.empty}</span>;
|
||||||
|
return (
|
||||||
|
<ul className={styles.list}>
|
||||||
|
{hires.map((h) => (
|
||||||
|
<li key={`${h.listId}:${h.id}`} className={styles.row}>
|
||||||
|
<a className={styles.link} href={hireHref(h)} target="_blank" rel="noopener noreferrer">
|
||||||
|
№ {h.code || h.id}
|
||||||
|
<span aria-hidden="true">↗</span>
|
||||||
|
</a>
|
||||||
|
<span className={styles.name}>{h.candidateName || '—'}</span>
|
||||||
|
{h.status && <StatusBadge status={h.status} />}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rp-no-print">
|
||||||
|
<CollapseSection title={TEXT.title} icon="people">
|
||||||
|
<FormField label={TEXT.occupied}>
|
||||||
|
<span className={styles.value}>{isLoading || error ? '—' : occupiedLabel(occupied, vacancies)}</span>
|
||||||
|
</FormField>
|
||||||
|
<FormField label={TEXT.hires} full>
|
||||||
|
{renderHires()}
|
||||||
|
</FormField>
|
||||||
|
</CollapseSection>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user