2 Commits

Author SHA1 Message Date
gorbenko.pavel ec5c2a8d86 воринги теперь кнопка отрьрадаетвя
Tests / frontend-test (pull_request) Successful in 1m15s
2026-08-19 11:26:26 +05:00
gorbenko.pavel 4825443a9d воринги теперь кнопка отрьрадаетвя 2026-08-19 10:11:36 +05:00
14 changed files with 125 additions and 57 deletions
@@ -23,7 +23,7 @@ import {
isEditableStatus, isEditableStatus,
IApproverToWrite, IApproverToWrite,
APPROVAL_PHASE, APPROVAL_PHASE,
findUnassignedStep unassignedStepMessage
} from '../../shared/approval'; } from '../../shared/approval';
import { IRequestFormProps } from '../../models/types'; import { IRequestFormProps } from '../../models/types';
import { import {
@@ -236,14 +236,16 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({
if (matrix.error) { if (matrix.error) {
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`; return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
} }
const unassigned = findUnassignedStep(matrix.rows); const unassignedApproverError = unassignedStepMessage(
if (unassigned) { matrix.rows,
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`; (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
} );
const unassignedExecutor = findUnassignedStep(executors.rows); if (unassignedApproverError) return unassignedApproverError;
if (unassignedExecutor) { const unassignedExecutorError = unassignedStepMessage(
return `Шаг ${unassignedExecutor.order} исполнения («${unassignedExecutor.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.`; executors.rows,
} (r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.`
);
if (unassignedExecutorError) return unassignedExecutorError;
return undefined; return undefined;
}; };
@@ -27,7 +27,7 @@ import {
isEditableStatus, isEditableStatus,
IApproverToWrite, IApproverToWrite,
APPROVAL_PHASE, APPROVAL_PHASE,
findUnassignedStep unassignedStepMessage
} from '../../shared/approval'; } from '../../shared/approval';
import { import {
uploadAttachments, uploadAttachments,
@@ -237,14 +237,16 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
const validateSubmit = (): string | undefined => { const validateSubmit = (): string | undefined => {
const formError = validate(); const formError = validate();
if (formError) return formError; if (formError) return formError;
const unassigned = findUnassignedStep(matrix.rows); const unassignedApproverError = unassignedStepMessage(
if (unassigned) { matrix.rows,
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`; (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
} );
const unassignedExecutor = findUnassignedStep(executors.rows); if (unassignedApproverError) return unassignedApproverError;
if (unassignedExecutor) { const unassignedExecutorError = unassignedStepMessage(
return `Шаг ${unassignedExecutor.order} исполнения («${unassignedExecutor.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.`; executors.rows,
} (r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.`
);
if (unassignedExecutorError) return unassignedExecutorError;
return undefined; return undefined;
}; };
@@ -26,7 +26,7 @@ import {
REQUEST_STATUS, REQUEST_STATUS,
isEditableStatus, isEditableStatus,
IApproverToWrite, IApproverToWrite,
findUnassignedStep unassignedStepMessage
} from '../../shared/approval'; } from '../../shared/approval';
import { import {
uploadAttachments, uploadAttachments,
@@ -211,10 +211,11 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
if (matrix.error) { if (matrix.error) {
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`; return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
} }
const unassigned = findUnassignedStep(matrix.rows); const unassignedError = unassignedStepMessage(
if (unassigned) { matrix.rows,
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`; (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
} );
if (unassignedError) return unassignedError;
return undefined; return undefined;
}; };
@@ -26,7 +26,7 @@ import {
REQUEST_STATUS, REQUEST_STATUS,
isEditableStatus, isEditableStatus,
IApproverToWrite, IApproverToWrite,
findUnassignedStep unassignedStepMessage
} from '../../shared/approval'; } from '../../shared/approval';
import { import {
uploadAttachments, uploadAttachments,
@@ -296,10 +296,11 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
// Матрица нередактируема (маршрут задаётся видом работ, а не заявителем) — // Матрица нередактируема (маршрут задаётся видом работ, а не заявителем) —
// незаполненный шаг заявитель исправить сам не может, только администратор // незаполненный шаг заявитель исправить сам не может, только администратор
// маршрутов (роль в шаге не разрешилась в конкретного человека). // маршрутов (роль в шаге не разрешилась в конкретного человека).
const unassigned = findUnassignedStep(matrix.rows); const unassignedError = unassignedStepMessage(
if (unassigned) { matrix.rows,
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») не назначен на конкретного человека. Обратитесь к администратору маршрутов согласования.`; (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») не назначен на конкретного человека. Обратитесь к администратору маршрутов согласования.`
} );
if (unassignedError) return unassignedError;
return undefined; return undefined;
}; };
@@ -26,7 +26,7 @@ import {
isEditableStatus, isEditableStatus,
IApproverToWrite, IApproverToWrite,
APPROVAL_PHASE, APPROVAL_PHASE,
findUnassignedStep, unassignedStepMessage,
resolveWriteListId resolveWriteListId
} from '../../shared/approval'; } from '../../shared/approval';
import { import {
@@ -245,14 +245,16 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
const validateSubmit = (): string | undefined => { const validateSubmit = (): string | undefined => {
const formError = validate(); const formError = validate();
if (formError) return formError; if (formError) return formError;
const unassigned = findUnassignedStep(matrix.rows); const unassignedApproverError = unassignedStepMessage(
if (unassigned) { matrix.rows,
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`; (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
} );
const unassignedExecutor = findUnassignedStep(executors.rows); if (unassignedApproverError) return unassignedApproverError;
if (unassignedExecutor) { const unassignedExecutorError = unassignedStepMessage(
return `Шаг ${unassignedExecutor.order} исполнения («${unassignedExecutor.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.`; executors.rows,
} (r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.`
);
if (unassignedExecutorError) return unassignedExecutorError;
return undefined; return undefined;
}; };
@@ -21,7 +21,7 @@ import {
REQUEST_STATUS, REQUEST_STATUS,
isEditableStatus, isEditableStatus,
IApproverToWrite, IApproverToWrite,
findUnassignedStep, unassignedStepMessage,
resolveWriteListId resolveWriteListId
} from '../../shared/approval'; } from '../../shared/approval';
import { IRequestFormProps } from '../../models/types'; import { IRequestFormProps } from '../../models/types';
@@ -217,10 +217,11 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
if (matrix.error) { if (matrix.error) {
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`; return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
} }
const unassigned = findUnassignedStep(matrix.rows); const unassignedError = unassignedStepMessage(
if (unassigned) { matrix.rows,
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`; (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
} );
if (unassignedError) return unassignedError;
return undefined; return undefined;
}; };
@@ -22,7 +22,7 @@ import {
REQUEST_STATUS, REQUEST_STATUS,
isEditableStatus, isEditableStatus,
IApproverToWrite, IApproverToWrite,
findUnassignedStep unassignedStepMessage
} from '../../shared/approval'; } from '../../shared/approval';
import { IRequestFormProps } from '../../models/types'; import { IRequestFormProps } from '../../models/types';
import { import {
@@ -215,10 +215,11 @@ const VacationForm: React.FC<IRequestFormProps> = ({
if (matrix.error) { if (matrix.error) {
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`; return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
} }
const unassigned = findUnassignedStep(matrix.rows); const unassignedError = unassignedStepMessage(
if (unassigned) { matrix.rows,
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`; (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
} );
if (unassignedError) return unassignedError;
return undefined; return undefined;
}; };
@@ -27,7 +27,7 @@ import {
isEditableStatus, isEditableStatus,
IApproverToWrite, IApproverToWrite,
APPROVAL_PHASE, APPROVAL_PHASE,
findUnassignedStep unassignedStepMessage
} from '../../shared/approval'; } from '../../shared/approval';
import { import {
uploadAttachments, uploadAttachments,
@@ -262,14 +262,16 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
const validateSubmit = (): string | undefined => { const validateSubmit = (): string | undefined => {
const formError = validate(); const formError = validate();
if (formError) return formError; if (formError) return formError;
const unassigned = findUnassignedStep(matrix.rows); const unassignedApproverError = unassignedStepMessage(
if (unassigned) { matrix.rows,
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`; (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
} );
const unassignedExecutor = findUnassignedStep(executors.rows); if (unassignedApproverError) return unassignedApproverError;
if (unassignedExecutor) { const unassignedExecutorError = unassignedStepMessage(
return `Шаг ${unassignedExecutor.order} исполнения («${unassignedExecutor.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.`; executors.rows,
} (r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.`
);
if (unassignedExecutorError) return unassignedExecutorError;
return undefined; return undefined;
}; };
@@ -38,6 +38,7 @@ export async function getPlannedMatrix(
phase, phase,
slaHours: s.slaHours, slaHours: s.slaHours,
askOverdueReason: s.askOverdueReason, askOverdueReason: s.askOverdueReason,
warning: s.warning,
})) }))
); );
} }
@@ -50,7 +50,7 @@ export type { ApproverDecision, IPendingRow } from './approverDecision.service';
export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service'; export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service';
export { notifyActiveStep } from './notify.service'; export { notifyActiveStep } from './notify.service';
export type { INotifyTarget } from './notify.service'; export type { INotifyTarget } from './notify.service';
export { useMatrixEditor, findUnassignedStep } from './useMatrixEditor'; export { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor';
export type { IUseMatrixEditor } from './useMatrixEditor'; export type { IUseMatrixEditor } from './useMatrixEditor';
export { ApprovalMatrix } from './ui/ApprovalMatrix'; export { ApprovalMatrix } from './ui/ApprovalMatrix';
export { UserPicker } from './ui/UserPicker'; export { UserPicker } from './ui/UserPicker';
@@ -31,4 +31,16 @@ export interface IMatrixRow {
slaHours?: number | null; slaHours?: number | null;
// eslint-disable-next-line @rushstack/no-new-null -- см. slaHours // eslint-disable-next-line @rushstack/no-new-null -- см. slaHours
askOverdueReason?: boolean | null; askOverdueReason?: boolean | null;
/**
* Предупреждение approval-route по ЭТОМУ шагу (см. IResolvedRouteStep.warning) —
* например, роль не разрешилась в конкретного человека, и почему именно.
* Только у ПЛАНИРУЕМОГО маршрута (см. getPlannedMatrix): у уже записанных строк
* (loadXxxRequest) неоткуда взяться — резолв запускался один раз при подаче.
*
* findUnassignedStep показывает его вместо своего общего текста, когда он есть —
* бэкенд знает причину точнее («роль не назначена в справочнике отдела» и т.п.),
* а не просто «нет назначенного человека».
*/
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда (IResolvedRouteStep.warning)
warning?: string | null;
} }
@@ -144,6 +144,17 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
)} )}
<td data-label={roleLabel}> <td data-label={roleLabel}>
{r.who} {r.who}
{/* Причина, по которой approval-route не назначил на этот шаг
конкретного человека (роль не разрешилась и т.п., см.
IResolvedRouteStep.warning/IMatrixRow.warning) — та же строка,
что уходит в ошибку валидации при отправке (см.
unassignedStepMessage), но видна СРАЗУ в таблице, а не только
на попытке отправить: пустая строка «Согласующий» сама по себе
не объясняет, что делать — вручную выбрать человека здесь
нельзя, шаг заведомо требует правки маршрута в админке. */}
{!r.who && r.warning && (
<span className={styles.warningInline}>{r.warning}</span>
)}
{/* Доп. согласующий, добавленный другим согласующим (с правом {/* Доп. согласующий, добавленный другим согласующим (с правом
переназначения) — помечаем, кто его добавил. Пусто у обычных переназначения) — помечаем, кто его добавил. Пусто у обычных
строк маршрута. Как и комментарий — inline под именем, чтобы строк маршрута. Как и комментарий — inline под именем, чтобы
@@ -169,6 +169,19 @@
font-weight: 500; font-weight: 500;
} }
// Причина, по которой approval-route не назначил человека на шаг (см.
// ApprovalMatrix.tsx/r.warning) — красным, как настоящая проблема маршрута
// (в отличие от нейтрального addedByInline/commentInline): пустая строка
// «Согласующий» без этого текста выглядит как баг, а не как «маршрут просит
// вмешательства администратора».
.warningInline {
display: block;
margin-top: 3px;
font-size: $fs-caption;
color: $red-500;
line-height: 1.45;
}
// ВАЖНО: не display:flex — это сняло бы с ячейки display:table-cell, и она // ВАЖНО: не display:flex — это сняло бы с ячейки display:table-cell, и она
// перестала бы вставать в строку рядом с соседней (кнопки уезжали на вторую // перестала бы вставать в строку рядом с соседней (кнопки уезжали на вторую
// строку). Ячейка остаётся табличной, кнопки держатся в строке через nowrap. // строку). Ячейка остаётся табличной, кнопки держатся в строке через nowrap.
@@ -118,6 +118,25 @@ export function findUnassignedStep(rows: IMatrixRow[]): IMatrixRow | undefined {
return rows.filter((r) => !r.email).sort((a, b) => a.order - b.order)[0]; return rows.filter((r) => !r.email).sort((a, b) => a.order - b.order)[0];
} }
/**
* Текст ошибки под первый незанятый шаг — то, что раньше было скопировано в
* validateSubmit каждой формы (findUnassignedStep + `row.warning || <шаблон>`).
* fallback принимает саму строку и решает, что писать, если approval-route не
* прислал свою причину (warning) — у большинства форм это общий шаблон «без
* назначенного человека — выберите …», но у некоторых (см. Ticket1CForm) текст
* другой, поэтому шаблон не зашит сюда, а передаётся вызывающей стороной.
*
* undefined — назначенных не хватает не для этих rows (или все шаги заняты).
*/
export function unassignedStepMessage(
rows: IMatrixRow[],
fallback: (row: IMatrixRow) => string
): string | undefined {
const row = findUnassignedStep(rows);
if (!row) return undefined;
return row.warning || fallback(row);
}
/** /**
* Загружает матрицу согласования и (если editable) позволяет её редактировать. * Загружает матрицу согласования и (если editable) позволяет её редактировать.
* @param load загрузчик строк (планируемый маршрут из resolve или строки заявки). * @param load загрузчик строк (планируемый маршрут из resolve или строки заявки).