fix: внес изменения в заявках рекрутинга от 22.09.2026
Tests / frontend-test (push) Successful in 1m19s
Tests / frontend-test (push) Successful in 1m19s
This commit is contained in:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user