fix: внес изменения в заявках рекрутинга от 22.09.2026
Tests / frontend-test (push) Successful in 1m19s

This commit is contained in:
Тимур Городецкий
2026-09-22 12:38:40 +05:00
parent b6708a7e72
commit 64f53b7814
35 changed files with 836 additions and 124 deletions
@@ -300,6 +300,7 @@ export class ApprovalListService {
const approver = r[FIELDS.approver] as { Title?: string; EMail?: string } | undefined;
const delegatedBy = r[FIELDS.delegatedBy] as { Title?: string } | undefined;
return {
id: r.Id as number,
order: (r[FIELDS.order] as number) ?? i + 1,
who: approver?.Title || '',
email: approver?.EMail || '',
@@ -399,6 +400,14 @@ export class ApprovalListService {
await execute();
}
// Удаляет ОДНУ строку маршрута — см. ApproverService.deleteQueuedStep
// (удаление ещё не решённого шага с экрана согласования/составления, см.
// routeEditRights.ts). В отличие от deleteForRequest выше, здесь ровно один
// элемент — без батча, он тут не даёт выигрыша.
public async deleteRow(rowId: number): Promise<void> {
await this.list().items.getById(rowId).delete();
}
// Id текущего пользователя на сайте (для сравнения с полем «Согласующий»).
// При отладочной подмене личности (см. shared/impersonation.ts) отдаём Id
// подменённого сотрудника: иначе «моя очередь согласования» и делегирование
@@ -123,6 +123,23 @@ export class ApproverService {
emitRequestsChanged();
}
/**
* Удалить ЕЩЁ НЕ РЕШЁННЫЙ шаг маршрута (статус «Ждёт очереди») — отдельная от
* обычного решения возможность (см. routeEditRights.ts): не «я согласовал/
* отклонил свой шаг», а «этого шага в маршруте вообще быть не должно».
* Активный («На согласовании») или уже решённый шаг не трогаем — это уже
* история решений, удалять её нельзя даже тем, у кого есть это право.
*/
public async deleteQueuedStep(requestId: number, rowId: number): Promise<void> {
const rows = await this.approval.getForRequest(requestId);
const row = rows.filter((r) => r.id === rowId)[0];
if (!row || row.status !== REQUEST_STATUS.queued) {
throw new Error('Шаг уже в работе или решён — удалить нельзя.');
}
await this.approval.deleteRow(rowId);
emitRequestsChanged();
}
// Применяет решение: обновляет строку, продвигает очередь (на следующего
// согласующего внутри фазы либо на первого исполнителя, если фаза согласования
// только что завершилась целиком) и пересчитывает статус заявки.
@@ -0,0 +1,26 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { getEmployeeOrganizations } from '../dictionary';
/** Отдел + организация согласующего — для колонок «Отдел»/«Организация» матрицы. */
export interface IApproverOrgInfo {
department: string;
organization: string;
}
/**
* Резолвер для useMatrixEditor: отдел и организация — ОДНИМ источником
* (Employee/ByEmail, тот же, что и «авто-Организация» в Vacation/UnpaidLeave/…),
* а не двумя разными API (иначе строка матрицы собиралась бы по кускам). Один
* сотрудник может числиться в нескольких организациях — берём первую запись,
* как и остальные формы с авто-подстановкой (см. shared/dictionary).
*
* email не найден в кадровом справочнике (404) или пуст — getEmployeeOrganizations
* отдаёт [], здесь это просто пустые отдел/организация, не ошибка матрицы.
*/
export function resolveApproverOrgInfo(context: WebPartContext) {
return async (email: string): Promise<IApproverOrgInfo> => {
const records = await getEmployeeOrganizations(context, email);
const first = records[0];
return { department: first?.section || '', organization: first?.company || '' };
};
}
@@ -60,6 +60,9 @@ export { notifyActiveStep } from './notify.service';
export type { INotifyTarget } from './notify.service';
export { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor';
export type { IUseMatrixEditor } from './useMatrixEditor';
export { resolveApproverOrgInfo } from './approverOrgInfo';
export type { IApproverOrgInfo } from './approverOrgInfo';
export { canEditRoute } from './routeEditRights';
export { ApprovalMatrix } from './ui/ApprovalMatrix';
export { UserPicker } from './ui/UserPicker';
export { DecisionPanel } from './ui/DecisionPanel';
@@ -56,4 +56,21 @@ export interface IMatrixRow {
*/
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда (IResolvedRouteStep.warning)
warning?: string | null;
/**
* Отдел/организация согласующего (см. resolveApproverOrgInfo) — подтягиваются
* ОДНИМ источником (Employee/ByEmail) вместе с самой матрицей, а не отдельным
* запросом на каждую строку в компоненте отображения. Не заданы — резолвер не
* передан useMatrixEditor (форма ещё не подключила колонки) или у сотрудника
* нет карточки в кадровом справочнике.
*/
department?: string;
organization?: string;
/**
* Id элемента списка согласования (SharePoint) для ЭТОЙ строки — нужен, чтобы
* удалить конкретную ещё не решённую строку с сервера (см.
* ApproverService.deleteQueuedStep/routeEditRights.ts). Не задан у строк
* ПЛАНИРУЕМОГО маршрута (getPlannedMatrix — их ещё не существует в SharePoint)
* и у только что добавленных в редакторе (появится после сохранения формы).
*/
id?: number;
}
@@ -0,0 +1,21 @@
/**
* Кто может удалять согласующих из маршрута (и при составлении заявки, и на
* своём шаге согласования) — сверх обычных прав инициатора/согласующего.
*
* Пока фиксированный email одного человека (Алтынай Бултурукова, начальник
* отдела подбора и адаптации персонала) — группы SharePoint для этого ещё
* нет, заведут отдельно (см. [[request-portal-approver-field-rights]] в
* памяти). ВРЕМЕННО, как и reportsAccess.ts.
*
* Подключено только в 3 формах рекрутинга (Заявка на подбор, Заявка на прием,
* Реферальная рекомендация) — каждая форма сама решает, передавать ли
* canRemoveRow в ApprovalMatrix; здесь только сама проверка личности.
*/
const ROUTE_EDITOR_EMAILS: ReadonlySet<string> = new Set([
'a.bulturukova@tnsu.kz' // Алтынай Бултурукова
]);
/** Может ли этот email удалять согласующих из маршрута (см. описание выше). */
export function canEditRoute(email: string | undefined): boolean {
return !!email && ROUTE_EDITOR_EMAILS.has(email.trim().toLowerCase());
}
@@ -3,6 +3,7 @@ import styles from './approval.module.scss';
import { Icon } from '../../ui';
import { IUseMatrixEditor, matrixRowKeys } from '../useMatrixEditor';
import { ISiteUser } from '../models/ISiteUser';
import { IMatrixRow } from '../models/IMatrixRow';
import { StatusBadge } from './StatusBadge';
import { UserPicker } from './UserPicker';
import { useFlipRows } from './useFlipRows';
@@ -27,6 +28,22 @@ export interface IApprovalMatrixProps {
waiting?: boolean;
/** Текст заглушки в режиме waiting. */
waitingText?: string;
/**
* Может ли ТЕКУЩИЙ пользователь удалить именно ЭТУ строку — НЕЗАВИСИМО от
* editable (см. shared/approval/routeEditRights.ts: право удалять
* согласующих есть у отдельных людей даже вне режима правки черновика,
* напр. на экране согласования). Не задан — кнопки удаления нет вообще
* (как было по умолчанию).
*/
canRemoveRow?: (row: IMatrixRow, index: number) => boolean;
/**
* Что делать по кнопке «Удалить», когда canRemoveRow разрешил. Не задан —
* поведение по умолчанию: editor.remove(index) (чисто локально — годится,
* когда строку в любом случае перезапишет сохранение формы). Строку УЖЕ
* поданной заявки так не удалить — там нужна своя реализация с записью на
* сервер (см. RecruitingHireView.tsx и ApproverService.deleteQueuedStep).
*/
onRemoveRow?: (row: IMatrixRow, index: number) => void;
}
/** Таблица маршрута (согласование или исполнение) + (если editable) правка состава. */
@@ -39,6 +56,8 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
pickerPlaceholder = 'Добавить согласующего: начните вводить ФИО…',
waiting = false,
waitingText = '',
canRemoveRow,
onRemoveRow,
}) => {
const { rows, loading, error, editable, blocks, retry } = editor;
@@ -82,6 +101,16 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
editor.toggleParallel(rowIndex);
};
// Удаление строки, на которую есть право (canRemoveRow) — независимо от
// editable. onRemoveRow не задан (обычный редактируемый черновик) —
// достаточно локально убрать строку, сохранение формы перезапишет маршрут
// целиком; задан (см. RecruitingHireView.tsx) — там своя запись на сервер.
const removeRow = (row: IMatrixRow, rowIndex: number): void => {
setActiveKeys([]);
if (onRemoveRow) onRemoveRow(row, rowIndex);
else editor.remove(rowIndex);
};
const addApprover = (user: ISiteUser): void => {
setActiveKeys([]);
editor.addApprover(user);
@@ -96,6 +125,13 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
// отправке (см. writeRoute). Здесь его достаточно просто показать, повторно
// резолвить нечего.
const hasSla = rows.some((r) => r.slaHours !== undefined && r.slaHours !== null);
// Отдел/организация — колонки появляются только у форм, передавших резолвер
// в useMatrixEditor (см. resolveApproverOrgInfo); остальные формы их не видят.
const hasDepartment = rows.some((r) => !!r.department);
const hasOrganization = rows.some((r) => !!r.organization);
// Колонка действий с кнопкой удаления нужна даже вне editable, если право
// удалять есть хотя бы у одной строки (см. canRemoveRow/routeEditRights.ts).
const hasRemovable = rows.some((r, i) => !!canRemoveRow?.(r, i));
// Число колонок (для colSpan) — на случай будущих нужд не используется: комментарий
// выводим внутри ячейки согласующего, чтобы не ломать rowSpan колонки «Шаг».
@@ -124,11 +160,13 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
<th className={styles.matrixStepCol}>Шаг</th>
<th>{roleLabel}</th>
<th>Должность</th>
{hasDepartment && <th>Отдел</th>}
{hasOrganization && <th>Организация</th>}
{hasStatuses && <th>Статус</th>}
{hasDates && <th>Дата</th>}
{hasSla && <th>SLA</th>}
{editable && <th />}
{editable && <th />}
{(editable || hasRemovable) && <th />}
</tr>
</thead>
<tbody>
@@ -178,6 +216,8 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
)}
</td>
<td data-label="Должность">{r.position}</td>
{hasDepartment && <td data-label="Отдел">{r.department || '—'}</td>}
{hasOrganization && <td data-label="Организация">{r.organization || '—'}</td>}
{hasStatuses && (
<td data-label="Статус">
{r.status && r.status !== '—' ? <StatusBadge status={r.status} /> : '—'}
@@ -216,21 +256,33 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
</button>
</td>
)}
{editable && (
{(editable || hasRemovable) && (
<td className={styles.matrixActions}>
<button
type="button"
className={`${styles.iconBtn} ${r.parallelWithPrev ? styles.iconBtnActive : ''}`}
disabled={i === 0}
onClick={() => toggleParallel(i)}
title={
r.parallelWithPrev
? 'Отцепить: выполнять после предыдущего шага'
: 'Объединить: выполнять одновременно с предыдущим шагом'
}
>
<Icon name="user-check" size="sm" />
</button>
{editable && (
<button
type="button"
className={`${styles.iconBtn} ${r.parallelWithPrev ? styles.iconBtnActive : ''}`}
disabled={i === 0}
onClick={() => toggleParallel(i)}
title={
r.parallelWithPrev
? 'Отцепить: выполнять после предыдущего шага'
: 'Объединить: выполнять одновременно с предыдущим шагом'
}
>
<Icon name="user-check" size="sm" />
</button>
)}
{canRemoveRow?.(r, i) && (
<button
type="button"
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
onClick={() => removeRow(r, i)}
title="Удалить"
>
<Icon name="trash" size="sm" />
</button>
)}
</td>
)}
</tr>
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { friendlyErrorMessage } from '../diagnostics';
import { IMatrixRow } from './models/IMatrixRow';
import { ISiteUser } from './models/ISiteUser';
import { IApproverOrgInfo } from './approverOrgInfo';
export interface IUseMatrixEditor {
rows: IMatrixRow[];
@@ -147,13 +148,18 @@ export function unassignedStepMessage(
* @param editable доступны ли правки (порядок/добавление/удаление).
* @param ready можно ли грузить (false — ждём готовности данных).
* @param getJobTitle должность добавляемого согласующего из профиля.
* @param resolveOrgInfo необязательный резолвер отдела/организации (см.
* approverOrgInfo.ts) — колонки «Отдел»/«Организация» в ApprovalMatrix
* появляются, только если он передан. Не передан — поведение как раньше,
* ни одного лишнего запроса.
*/
export function useMatrixEditor(
load: () => Promise<IMatrixRow[]>,
reloadKey: string,
editable: boolean,
ready: boolean,
getJobTitle: (loginName: string) => Promise<string>
getJobTitle: (loginName: string) => Promise<string>,
resolveOrgInfo?: (email: string) => Promise<IApproverOrgInfo>
): IUseMatrixEditor {
const [rows, setRows] = useState<IMatrixRow[]>([]);
const [loading, setLoading] = useState<boolean>(true);
@@ -163,11 +169,35 @@ export function useMatrixEditor(
// повторных попытках, это внутренняя механика самого хука.
const [retrySeq, setRetrySeq] = useState<number>(0);
// Отдел/организация — ОДНИМ проходом по уникальным email СРАЗУ после загрузки
// строк, а не по мере рендера каждой строки в ApprovalMatrix (иначе те же
// email запрашивались бы повторно у параллельных шагов с одним согласующим).
// Сбой резолва одного email не должен уронить всю матрицу — просто у той
// строки останутся пустые отдел/организация (getEmployeeOrganizations и так
// не бросает на 404, но сетевой сбой возможен).
const enrichWithOrgInfo = async (loaded: IMatrixRow[]): Promise<IMatrixRow[]> => {
if (!resolveOrgInfo) return loaded;
const emails = Array.from(new Set(loaded.map((r) => r.email).filter(Boolean)));
const infoByEmail = new Map<string, IApproverOrgInfo>();
await Promise.all(
emails.map((email) =>
resolveOrgInfo(email)
.then((info) => infoByEmail.set(email, info))
.catch(() => undefined)
)
);
return loaded.map((r) => {
const info = r.email ? infoByEmail.get(r.email) : undefined;
return info ? { ...r, department: info.department, organization: info.organization } : r;
});
};
useEffect(() => {
if (!ready) return;
let active = true;
setLoading(true);
load()
.then((r) => enrichWithOrgInfo(r))
.then((r) => {
if (!active) return;
setRows(markParallel(r));
@@ -4,6 +4,7 @@ import '@pnp/sp/lists';
import '@pnp/sp/items';
import '@pnp/sp/attachments';
import '@pnp/sp/site-users/web';
import '@pnp/sp/site-groups';
import { BrowserFetchWithRetry } from '@pnp/queryable';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { DATA_SITE_URL } from '../config';
@@ -15,6 +15,7 @@ const ALLOWED_EMAILS: ReadonlySet<string> = new Set([
'trishina.mariya@tnsu.kz', // Мария Тришина
'gorodetskiy.timur@tnsu.kz', // Тимур Городецкий
'gorbenko.pavel@tnsu.kz', // Павел Горбенко
'sarniyazova.assilya@tnsu.kz', // Асилья Сарниязова
]);
// TODO: заменить точечный список на членство в SharePoint-группе HR, когда
@@ -1,10 +1,34 @@
import * as React from 'react';
import { Button, Icon, FormActions } from '@tnsu/ui-kit';
import { Button, Icon, FormActions, Alert } from '@tnsu/ui-kit';
import { IRequestSubmit } from '../useRequestSubmit';
import { isCurrentUserTester } from './testerGroupAccess';
/**
* ВРЕМЕННАЯ ЗАМОРОЗКА (включена 2026-09-21 по просьбе Тимура Городецкого):
* приём заявок отключён на ВСЕХ формах портала — и черновик, и отправка на
* согласование заблокированы независимо от заполненности полей. Единая точка
* отключения — здесь, а не по формам, чтобы снять заморозку можно было одной
* правкой (вернуть в false) и не забыть ни одну форму.
*
* Исключение — участники SharePoint-группы «Тестирование» (см.
* testerGroupAccess.ts, добавлено 2026-09-22): для них заморозка не действует,
* чтобы можно было проверять формы, пока для всех остальных приём закрыт.
*
* TODO: снять заморозку (вернуть false), когда будет команда — см. память
* request-portal-submission-freeze.
*/
const SUBMISSION_FROZEN = true;
export interface ISubmitActionsProps {
submit: IRequestSubmit;
isValid: boolean;
/**
* Доп. условие для кнопки «Отправить», когда у отправки более строгие
* требования, чем у черновика (например, поля обязательные только при
* отправке на согласование). Не задано — отправка гейтится тем же isValid,
* что и черновик (поведение по умолчанию для всех остальных форм).
*/
submitValid?: boolean;
/** Заявка в нередактируемом статусе (На согласовании/…): сохранять нельзя. */
readOnly: boolean;
onCancel: () => void;
@@ -21,6 +45,7 @@ export interface ISubmitActionsProps {
export const SubmitActions: React.FC<ISubmitActionsProps> = ({
submit,
isValid,
submitValid,
readOnly,
onCancel,
onSaveDraft,
@@ -28,21 +53,43 @@ export const SubmitActions: React.FC<ISubmitActionsProps> = ({
submitLabel = 'Отправить на согласование',
}) => {
const { saving, kind } = submit;
const locked = saving || readOnly;
// false, пока не пришёл ответ о членстве в группе, — безопасный дефолт: не
// мигать разблокированными кнопками до подтверждения, что это тестировщик.
const [isTester, setIsTester] = React.useState<boolean>(false);
React.useEffect(() => {
if (!SUBMISSION_FROZEN) return;
let alive = true;
isCurrentUserTester()
.then((v) => {
if (alive) setIsTester(v);
})
.catch(() => undefined);
return () => {
alive = false;
};
}, []);
const frozen = SUBMISSION_FROZEN && !isTester;
const locked = saving || readOnly || frozen;
const canSubmit = submitValid ?? isValid;
return (
<FormActions>
<Button variant="secondary" onClick={onCancel} disabled={saving}>
Отмена
</Button>
<Button variant="secondary" onClick={onSaveDraft} disabled={!isValid || locked}>
<Icon name="document" size="sm" />
Сохранить черновик
</Button>
<Button variant="accent" onClick={onSubmit} disabled={!isValid || locked}>
<Icon name="send" size="sm" />
{saving && kind === 'submit' ? 'Отправка…' : submitLabel}
</Button>
</FormActions>
<>
{frozen && !readOnly && <Alert variant="warn">Подача заявок временно недоступна.</Alert>}
<FormActions>
<Button variant="secondary" onClick={onCancel} disabled={saving}>
Отмена
</Button>
<Button variant="secondary" onClick={onSaveDraft} disabled={!isValid || locked}>
<Icon name="document" size="sm" />
Сохранить черновик
</Button>
<Button variant="accent" onClick={onSubmit} disabled={!canSubmit || locked}>
<Icon name="send" size="sm" />
{saving && kind === 'submit' ? 'Отправка…' : submitLabel}
</Button>
</FormActions>
</>
);
};
@@ -0,0 +1,31 @@
import { getSP } from '../../pnp/pnpjs';
/**
* SharePoint-группа «Тестирование» на сайте данных (/sites/requests) — её
* участники исключены из портальной заморозки подачи (см. SUBMISSION_FROZEN в
* SubmitActions.tsx): могут сохранять черновик и отправлять заявки, пока для
* всех остальных это заблокировано. Id группы, а не Title — Title можно
* переименовать в SharePoint без последствий для кода.
*/
const TESTER_GROUP_ID = 124;
// Кешируем на весь сеанс — группа тестировщиков не меняется настолько часто,
// чтобы перезапрашивать её на каждый ремонт SubmitActions (переключение вкладок
// заявок и т.п.). Ошибку не кешируем — временный сбой не должен «залипнуть».
let cached: Promise<boolean> | undefined;
async function fetchIsTester(): Promise<boolean> {
const groups = await getSP().web.currentUser.groups();
return groups.some((g) => g.Id === TESTER_GROUP_ID);
}
/** Состоит ли ТЕКУЩИЙ пользователь в группе «Тестирование» (см. описание выше). */
export function isCurrentUserTester(): Promise<boolean> {
if (!cached) {
cached = fetchIsTester().catch((e) => {
cached = undefined;
throw e;
});
}
return cached;
}