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;
|
||||
}
|
||||
|
||||
/** «Занято 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<ISelectionRequestFieldProps> = ({ d
|
||||
>
|
||||
{optionLabel(o)}
|
||||
{o.title && <span className={styles.meta}>{o.title}</span>}
|
||||
{vacanciesLabel(o) && <span className={styles.meta}>{vacanciesLabel(o)}</span>}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className={styles.note}>
|
||||
{loaded ? 'Нет утверждённых заявок на подбор.' : 'Загрузка…'}
|
||||
{loaded ? 'Нет утверждённых заявок на подбор со свободными вакансиями.' : 'Загрузка…'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<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) || ''
|
||||
}));
|
||||
/** У подбора указано количество вакансий, и все они уже заняты приёмами. */
|
||||
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<ISelectionRequestOption[]> {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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<IRecruitingSelectionViewProps> = ({ cont
|
||||
<FormModeProvider readOnly={!fullEditable}>
|
||||
<ApplicationSection {...sectionProps} />
|
||||
<DepartmentSection {...sectionProps} />
|
||||
{/* Приём оформляют только по утверждённому подбору — до этого блоку показывать нечего. */}
|
||||
{isApprovedStatus(status) && (
|
||||
<HiresProgressSection
|
||||
selectionId={itemId}
|
||||
selectionListId={listId || ''}
|
||||
vacancies={parseVacanciesCount(data.OpenVacanciesCount)}
|
||||
/>
|
||||
)}
|
||||
<VacancySection {...sectionProps} />
|
||||
<HealthTripsSection {...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