| {r.position || texts.common.empty} |
{r.status || texts.common.empty} |
{r.date || texts.common.empty} |
+
+ {formatSlaHours(r.slaHours)}
+ {r.slaHours === null && {texts.manageRequests.slaInherited}}
+ |
{/* Стрелки перестановки — СЛЕВА от действий над строкой (объединить/
переназначить/удалить), одна пара на весь блок (rowSpan): двигают
шаг целиком, а не отдельного участника параллельной группы. */}
@@ -293,6 +301,12 @@ export const ManageRequestsPhaseTable: React.FC
label={texts.manageRequests.reassign}
onClick={() => onReassign(r)}
/>
+ onEditSla(r)}
+ />
= ({ api
const [matrixLoading, setMatrixLoading] = React.useState(false);
const [matrixError, setMatrixError] = React.useState();
const [modal, setModal] = React.useState();
+ const [slaRow, setSlaRow] = React.useState();
const [deleteRow, setDeleteRow] = React.useState();
const [saveError, setSaveError] = React.useState();
const [notifyResult, setNotifyResult] = React.useState<{ message: string; kind: 'ok' | 'err' } | undefined>();
@@ -348,6 +350,31 @@ export const ManageRequestsScreen: React.FC = ({ api
const openReassign = (row: ManagedApprovalRow): void => setModal(row);
const closeModal = (): void => setModal(undefined);
+ const openSla = (row: ManagedApprovalRow): void => setSlaRow(row);
+ const closeSla = (): void => setSlaRow(undefined);
+
+ // SLA правится так же оптимистично, как переназначение: строка в матрице
+ // обновляется по ответу сервера, без перезагрузки всей матрицы.
+ const saveSla = (
+ // eslint-disable-next-line @rushstack/no-new-null -- null = «не задано», работает SLA типа заявки
+ slaHours: number | null,
+ // eslint-disable-next-line @rushstack/no-new-null -- см. выше
+ askOverdueReason: boolean | null
+ ): Promise => {
+ if (!matrix || !slaRow) return Promise.resolve();
+ setSaveError(undefined);
+ const rowId = slaRow.id;
+ return requestManage
+ .updateRowSla(matrix, rowId, slaHours, askOverdueReason)
+ .then(() => {
+ setMatrix(prev => (prev ? patchRow(prev, rowId, { slaHours, askOverdueReason }) : prev));
+ closeSla();
+ })
+ .catch((e: Error) => {
+ setSaveError(reportError(e, 'saveSla: updateRowSla'));
+ throw e;
+ });
+ };
// Кого нельзя выбрать при переназначении — все остальные согласующие/
// исполнители ТОЙ ЖЕ фазы (сама строка modal исключена, иначе нельзя было
@@ -424,7 +451,11 @@ export const ManageRequestsScreen: React.FC = ({ api
comment: '',
order,
allowReassign: false,
- phase
+ phase,
+ // Шаг, добавленный вручную, маршрутом-шаблоном не порождён и своего SLA не
+ // имеет — работает SLA типа заявки, пока его не зададут отдельно.
+ slaHours: null,
+ askOverdueReason: null
};
const previous = matrix;
@@ -548,6 +579,7 @@ export const ManageRequestsScreen: React.FC = ({ api
requestRejected={requestRejected}
statusVariant={statusVariant}
onReassign={openReassign}
+ onEditSla={openSla}
onRemove={askDelete}
onNotify={handleNotify}
notifyingRowId={notifyingRowId}
@@ -564,6 +596,7 @@ export const ManageRequestsScreen: React.FC = ({ api
requestRejected={requestRejected}
statusVariant={statusVariant}
onReassign={openReassign}
+ onEditSla={openSla}
onRemove={askDelete}
onNotify={handleNotify}
notifyingRowId={notifyingRowId}
@@ -590,6 +623,16 @@ export const ManageRequestsScreen: React.FC = ({ api
/>
)}
+ {slaRow && (
+
+ )}
+
{deleteRow && (
Promise;
+ onClose: () => void;
+}
+
+/**
+ * SLA конкретной строки маршрута конкретной заявки.
+ *
+ * Правится отдельно от маршрута-шаблона: шаблон задаёт значение на момент
+ * создания заявки, а здесь его можно поменять для одной уже идущей заявки, не
+ * трогая остальные.
+ *
+ * Пустое поле сохраняется как «не задано» — тогда работает SLA типа заявки.
+ * Ноль пустотой не считается: это «напоминать сразу».
+ */
+export const ManageRequestsSlaModal: React.FC = ({
+ orderNo,
+ slaHours,
+ askOverdueReason,
+ onSave,
+ onClose
+}) => {
+ const [hours, setHours] = React.useState(() => slaHoursToInput(slaHours));
+ const [ask, setAsk] = React.useState(() => boolToTriState(askOverdueReason));
+ const submit = useAsyncSubmit();
+
+ const handleSubmit = (): void => {
+ if (!isValidSlaHours(hours)) {
+ submit.fail(texts.manageRequests.validationSlaHours);
+ return;
+ }
+ submit.run(() => onSave(parseSlaHours(hours), triStateToBool(ask)));
+ };
+
+ return (
+
+
+
+ setHours(e.target.value)}
+ placeholder={texts.manageRequests.slaHoursPlaceholder}
+ />
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx
index f1678a3..a86cd58 100644
--- a/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx
+++ b/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx
@@ -3,6 +3,16 @@ import { ApprovalRouteApi } from '../../services/api';
import { formatSiteLabel, SharePointListsClient, SharePointSiteSuggestion } from '../../services/sharePointLists';
import { RequestType } from '../../models';
import { texts } from '../../texts';
+import {
+ boolToTriState,
+ formatSlaHours,
+ isValidSlaHours,
+ parseSlaHours,
+ slaHoursToInput,
+ triStateToBool,
+ TriState,
+ TRI_STATE_OPTIONS
+} from '../../utils/sla';
import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useSharePointLists } from '../../hooks/useSharePointLists';
import { useSharePointSiteTitle } from '../../hooks/useSharePointSiteTitle';
@@ -38,9 +48,14 @@ interface IFormState {
section: string;
siteUrl: string;
listId: string;
+ /** Строкой, а не числом: пустое поле — «SLA не настроен», и это не то же самое, что 0. */
+ slaHours: string;
+ askOverdueReason: TriState;
}
-const EMPTY_FORM: IFormState = { name: '', pageUrl: '', section: '', siteUrl: '', listId: '' };
+const EMPTY_FORM: IFormState = {
+ name: '', pageUrl: '', section: '', siteUrl: '', listId: '', slaHours: '', askOverdueReason: ''
+};
// siteUrl/listId — поля RequestType, зеркалящие nullable JSON-контракт бэкенда
// (см. models/requestTypes.ts) — здесь сразу приводим null к undefined,
@@ -88,7 +103,9 @@ export const RequestTypesScreen: React.FC = ({ api, sh
pageUrl: item.pageUrl,
section: item.section,
siteUrl: item.siteUrl ?? '',
- listId: item.listId ?? ''
+ listId: item.listId ?? '',
+ slaHours: slaHoursToInput(item.slaHours),
+ askOverdueReason: boolToTriState(item.askOverdueReason)
});
setShowForm(true);
};
@@ -104,6 +121,10 @@ export const RequestTypesScreen: React.FC = ({ api, sh
submit.fail(texts.requestTypes.validationListReference);
return;
}
+ if (!isValidSlaHours(form.slaHours)) {
+ submit.fail(texts.requestTypes.validationSlaHours);
+ return;
+ }
const input = {
name: form.name.trim(),
@@ -114,7 +135,9 @@ export const RequestTypesScreen: React.FC = ({ api, sh
// eslint-disable-next-line @rushstack/no-new-null
siteUrl: siteUrl || null,
// eslint-disable-next-line @rushstack/no-new-null
- listId: listId || null
+ listId: listId || null,
+ slaHours: parseSlaHours(form.slaHours),
+ askOverdueReason: triStateToBool(form.askOverdueReason)
};
const request = editing ? api.requestTypes.update(editing.id, input) : api.requestTypes.create(input);
@@ -147,6 +170,7 @@ export const RequestTypesScreen: React.FC = ({ api, sh
{ key: 'name', header: texts.requestTypes.columnName, render: r => {r.name} },
{ key: 'url', header: texts.requestTypes.columnPageUrl, render: r => r.pageUrl },
{ key: 'section', header: texts.requestTypes.columnSection, render: r => r.section },
+ { key: 'sla', header: texts.requestTypes.columnSla, render: r => formatSlaHours(r.slaHours) },
{ key: 'listId', header: texts.requestTypes.columnListId, render: r => },
{ key: 'siteUrl', header: texts.requestTypes.columnSiteUrl, render: r => },
{
@@ -238,6 +262,22 @@ export const RequestTypesScreen: React.FC = ({ api, sh
onChange={e => setForm({ ...form, listId: e.target.value })}
/>
+
+ setForm({ ...form, slaHours: e.target.value })}
+ placeholder={texts.requestTypes.slaHoursPlaceholder}
+ />
+
+
+
)}
diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx
index e20aec4..43b1643 100644
--- a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx
+++ b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx
@@ -18,6 +18,15 @@ import { useSharePointFields } from '../../hooks/useSharePointFields';
import { ApprovalRouteApi } from '../../services/api';
import { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists';
import { Checkbox, Field, FormGrid, FormModal, RadioGroup, SearchableSelect, Select, TextInput } from '../ui';
+import {
+ boolToTriState,
+ isValidSlaHours,
+ parseSlaHours,
+ slaHoursToInput,
+ triStateToBool,
+ TriState,
+ TRI_STATE_OPTIONS
+} from '../../utils/sla';
export interface IRouteStepFormModalProps {
api: ApprovalRouteApi;
@@ -47,6 +56,9 @@ interface IFormState {
fieldName: string;
operation: ComparisonOperator;
value: string;
+ /** Пусто — наследовать SLA от типа заявки; «0» — напоминать сразу. Это разные вещи. */
+ slaHours: string;
+ askOverdueReason: TriState;
}
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
@@ -78,7 +90,9 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
hasCondition: false,
fieldName: '',
operation: ComparisonOperator.GreaterThan,
- value: ''
+ value: '',
+ slaHours: '',
+ askOverdueReason: ''
};
}
return {
@@ -93,7 +107,9 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
hasCondition: Boolean(editing.fieldName),
fieldName: editing.fieldName ?? '',
operation: editing.operation && editing.operation !== ComparisonOperator.None ? editing.operation : ComparisonOperator.GreaterThan,
- value: editing.value ?? ''
+ value: editing.value ?? '',
+ slaHours: slaHoursToInput(editing.slaHours),
+ askOverdueReason: boolToTriState(editing.askOverdueReason)
};
}
@@ -164,6 +180,10 @@ export const RouteStepFormModal: React.FC = ({
submit.fail(texts.routeSteps.validationConditionNumber);
return;
}
+ if (!isValidSlaHours(form.slaHours)) {
+ submit.fail(texts.routeSteps.validationSlaHours);
+ return;
+ }
const payload = {
orderNo,
@@ -175,7 +195,9 @@ export const RouteStepFormModal: React.FC = ({
headType: form.kind === 'headType' ? form.headType : null,
fieldName: form.hasCondition ? form.fieldName.trim() : null,
operation: form.hasCondition ? form.operation : null,
- value: form.hasCondition ? form.value.trim() : null
+ value: form.hasCondition ? form.value.trim() : null,
+ slaHours: parseSlaHours(form.slaHours),
+ askOverdueReason: triStateToBool(form.askOverdueReason)
};
const request = editing
@@ -283,6 +305,23 @@ export const RouteStepFormModal: React.FC = ({
/>
+
+ setForm({ ...form, slaHours: e.target.value })}
+ placeholder={texts.routeSteps.slaHoursPlaceholder}
+ />
+
+
+
+
,
+ rowId: number,
+ // eslint-disable-next-line @rushstack/no-new-null -- null = «не задано», см. описание выше
+ slaHours: number | null,
+ // eslint-disable-next-line @rushstack/no-new-null -- null = «не задано»
+ askOverdueReason: boolean | null
+ ): Promise {
+ const sp = this.clientFor(matrix.siteUrl);
+ await sp.web.lists.getByTitle(matrix.approvalListTitle).items.getById(rowId).update({
+ [FIELDS.slaHours]: slaHours,
+ [FIELDS.askOverdueReason]: askOverdueReason
+ });
+ }
+
/** Удалить строку маршрута. */
public async removeRow(
matrix: Pick,
diff --git a/frontend/src/webparts/approvalAdmin/texts.ts b/frontend/src/webparts/approvalAdmin/texts.ts
index 7c22ef6..5bcc88f 100644
--- a/frontend/src/webparts/approvalAdmin/texts.ts
+++ b/frontend/src/webparts/approvalAdmin/texts.ts
@@ -59,6 +59,12 @@ export const texts = {
loading: 'Загрузка...',
yes: 'Да',
no: 'Нет',
+ /**
+ * Третье состояние для настроек с наследованием (SLA, опрос причины
+ * просрочки): пусто значит «взять значение уровнем выше», и это НЕ то же
+ * самое, что «Нет» или «0» — их выбирают осознанно.
+ */
+ inherited: 'Не задано',
/** Прочерк вместо пустого значения — чтобы ячейка таблицы не выглядела сломанной. */
empty: '—'
},
@@ -107,6 +113,7 @@ export const texts = {
columnSection: 'Раздел',
columnListId: 'Список SharePoint',
columnSiteUrl: 'Сайт SharePoint',
+ columnSla: 'SLA',
createTitle: 'Новый тип заявки',
editTitle: 'Редактировать тип заявки',
nameLabel: 'Название',
@@ -126,6 +133,14 @@ export const texts = {
listIdLoading: 'Загрузка списков сайта...',
listIdEmpty: 'На этом сайте нет доступных списков',
listIdError: (message: string): string => `Не удалось получить списки сайта: ${message}`,
+ slaHoursLabel: 'SLA, часов',
+ slaHoursHint:
+ 'Через сколько часов простоя шага напоминать. Пусто — напоминания по этому типу заявок не работают. ' +
+ 'Отсчёт идёт от момента, когда шаг стал активным.',
+ slaHoursPlaceholder: 'Например, 24',
+ askOverdueReasonLabel: 'Спрашивать причину просрочки',
+ askOverdueReasonHint: 'Значение по умолчанию для всех шагов этого типа заявок.',
+ validationSlaHours: 'SLA укажите целым числом часов (0 и больше) или оставьте поле пустым.',
validationRequired: 'Заполните имя, Url страницы и раздел.',
validationListReference: 'Сайт SharePoint и список должны быть заполнены оба вместе или не заполнены вовсе.',
deleteTitle: 'Удалить тип заявки?',
@@ -209,6 +224,12 @@ export const texts = {
headTypeLabel: 'Тип руководителя',
reassignLabel: 'Переназначение',
reassignCheckbox: 'Разрешить переназначать другим согласующим',
+ slaHoursLabel: 'SLA шага, часов',
+ slaHoursHint:
+ 'Пусто — берётся SLA типа заявки. Заданное здесь значение его перекрывает; 0 означает «напоминать сразу».',
+ slaHoursPlaceholder: 'Как у типа заявки',
+ askOverdueReasonLabel: 'Спрашивать причину просрочки',
+ askOverdueReasonHint: '«Не задано» — берётся значение типа заявки.',
conditionLabel: 'Условие видимости шага',
conditionCheckbox: 'Показывать шаг только при выполнении условия',
conditionDisabledHint: 'Недоступно: у типа заявки не указан список SharePoint (list_name).',
@@ -225,6 +246,7 @@ export const texts = {
validationPosition: 'Укажите должность.',
validationCondition: 'Заполните поле списка, оператор и значение условия.',
validationConditionNumber: 'Выбранное поле числовое — значение условия должно быть числом.',
+ validationSlaHours: 'SLA укажите целым числом часов (0 и больше) или оставьте поле пустым.',
deleteTitle: 'Удалить шаг?',
deleteText: (orderNo: number): string => `Шаг #${orderNo} будет удалён безвозвратно.`
},
@@ -318,6 +340,17 @@ export const texts = {
splitFromPrev: 'Отделить от предыдущего шага (выполнять после него)',
moveBlockedByDecision: 'Нельзя переставить — соседний шаг уже решён.',
reassignTitle: 'Переназначить шаг',
+ columnSla: 'SLA',
+ slaInherited: 'от типа заявки',
+ editSla: 'Настроить SLA шага',
+ slaModalTitle: (orderNo: number): string => `SLA шага ${orderNo}`,
+ slaHoursLabel: 'SLA, часов',
+ slaHoursHint:
+ 'Через сколько часов простоя напоминать по этому шагу. Пусто — берётся SLA типа заявки; 0 — напоминать сразу.',
+ slaHoursPlaceholder: 'Как у типа заявки',
+ askOverdueReasonLabel: 'Спрашивать причину просрочки',
+ askOverdueReasonHint: '«Не задано» — берётся значение типа заявки.',
+ validationSlaHours: 'SLA укажите целым числом часов (0 и больше) или оставьте поле пустым.',
approverLabel: 'Сотрудник',
approverPlaceholder: 'Выберите сотрудника',
validationApprover: 'Выберите сотрудника.',
diff --git a/frontend/src/webparts/approvalAdmin/utils/sla.test.ts b/frontend/src/webparts/approvalAdmin/utils/sla.test.ts
new file mode 100644
index 0000000..3be6686
--- /dev/null
+++ b/frontend/src/webparts/approvalAdmin/utils/sla.test.ts
@@ -0,0 +1,86 @@
+import {
+ boolToTriState,
+ formatSlaHours,
+ isValidSlaHours,
+ parseSlaHours,
+ slaHoursToInput,
+ triStateToBool
+} from './sla';
+
+// Смысл всех этих проверок один: «не задано» и «ноль/нет» — РАЗНЫЕ состояния.
+// Если они где-то схлопнутся, шаг с настройкой «напоминать сразу» станет
+// неотличим от шага без настройки, и напоминания по нему просто не придут.
+describe('parseSlaHours', () => {
+ it('пустая строка — «не задано», а не ноль', () => {
+ expect(parseSlaHours('')).toBeNull();
+ expect(parseSlaHours(' ')).toBeNull();
+ });
+
+ it('ноль остаётся нулём', () => {
+ expect(parseSlaHours('0')).toBe(0);
+ });
+
+ it('число разбирается', () => {
+ expect(parseSlaHours('24')).toBe(24);
+ expect(parseSlaHours(' 48 ')).toBe(48);
+ });
+
+ it('мусор не превращается в ноль', () => {
+ expect(parseSlaHours('abc')).toBeNull();
+ expect(parseSlaHours('-5')).toBeNull();
+ expect(parseSlaHours('1.5')).toBeNull();
+ });
+});
+
+describe('isValidSlaHours', () => {
+ it('пусто — допустимо, это «не задано»', () => {
+ expect(isValidSlaHours('')).toBe(true);
+ });
+
+ it('ноль и целые положительные — допустимы', () => {
+ expect(isValidSlaHours('0')).toBe(true);
+ expect(isValidSlaHours('24')).toBe(true);
+ });
+
+ it('отрицательное, дробное и нечисловое — недопустимы', () => {
+ expect(isValidSlaHours('-1')).toBe(false);
+ expect(isValidSlaHours('1.5')).toBe(false);
+ expect(isValidSlaHours('abc')).toBe(false);
+ });
+});
+
+describe('slaHoursToInput', () => {
+ it('ноль показывается нулём, а не пустотой', () => {
+ expect(slaHoursToInput(0)).toBe('0');
+ });
+
+ it('«не задано» показывается пустым полем', () => {
+ expect(slaHoursToInput(null)).toBe('');
+ expect(slaHoursToInput(undefined)).toBe('');
+ });
+});
+
+describe('formatSlaHours', () => {
+ it('ноль часов остаётся нулём, а не прочерком', () => {
+ expect(formatSlaHours(0)).toBe('0 ч');
+ });
+
+ it('не задано — прочерк', () => {
+ expect(formatSlaHours(null)).toBe('—');
+ });
+});
+
+describe('triState', () => {
+ it('false и «не задано» — разные значения', () => {
+ expect(triStateToBool('no')).toBe(false);
+ expect(triStateToBool('')).toBeNull();
+ });
+
+ it('преобразование в обе стороны сохраняет все три состояния', () => {
+ expect(boolToTriState(true)).toBe('yes');
+ expect(boolToTriState(false)).toBe('no');
+ expect(boolToTriState(null)).toBe('');
+ expect(triStateToBool(boolToTriState(false))).toBe(false);
+ expect(triStateToBool(boolToTriState(null))).toBeNull();
+ });
+});
diff --git a/frontend/src/webparts/approvalAdmin/utils/sla.ts b/frontend/src/webparts/approvalAdmin/utils/sla.ts
new file mode 100644
index 0000000..5a8879c
--- /dev/null
+++ b/frontend/src/webparts/approvalAdmin/utils/sla.ts
@@ -0,0 +1,70 @@
+import { texts } from '../texts';
+
+/**
+ * Настройки SLA существуют в ТРЁХ состояниях, и это главное, что здесь важно:
+ * «не задано» (наследовать уровнем выше), «задано значение» и — отдельно —
+ * осознанные 0 часов / «Нет». Если бы пустое поле схлопывалось в 0 или false,
+ * шаг с настройкой «напоминать сразу» стал бы неотличим от шага без настройки.
+ *
+ * Формы хранят эти поля строками (так работают TextInput/Select), поэтому
+ * преобразование строка ↔ nullable-значение бэкенда собрано в одном месте, а не
+ * повторено в каждой форме.
+ */
+
+/** Значение выпадающего списка для nullable-флага. */
+export type TriState = '' | 'yes' | 'no';
+
+export const TRI_STATE_OPTIONS: Array<{ value: TriState; label: string }> = [
+ { value: '', label: texts.common.inherited },
+ { value: 'yes', label: texts.common.yes },
+ { value: 'no', label: texts.common.no }
+];
+
+// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
+export function triStateToBool(value: TriState): boolean | null {
+ if (value === 'yes') return true;
+ if (value === 'no') return false;
+ return null;
+}
+
+// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
+export function boolToTriState(value: boolean | null | undefined): TriState {
+ if (value === true) return 'yes';
+ if (value === false) return 'no';
+ return '';
+}
+
+/**
+ * Строка поля ввода → часы. Пустая строка — «не задано» (null), а не 0.
+ * Некорректный ввод тоже даёт null: значение проверяется отдельно
+ * (см. isValidSlaHours), молча превращать «abc» в 0 нельзя.
+ */
+// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
+export function parseSlaHours(value: string): number | null {
+ const trimmed = value.trim();
+ if (!trimmed) return null;
+
+ const parsed = Number(trimmed);
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
+}
+
+/** Пусто — допустимо («не задано»); заполнено — должно быть целым неотрицательным числом. */
+export function isValidSlaHours(value: string): boolean {
+ const trimmed = value.trim();
+ if (!trimmed) return true;
+
+ const parsed = Number(trimmed);
+ return Number.isInteger(parsed) && parsed >= 0;
+}
+
+/** Часы из бэкенда → строка поля ввода. Ноль обязан остаться нулём, а не стать пустотой. */
+// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
+export function slaHoursToInput(value: number | null | undefined): string {
+ return value === null || value === undefined ? '' : String(value);
+}
+
+/** Часы для показа в таблице: «—» вместо пустого значения, «0 ч» остаётся нулём. */
+// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
+export function formatSlaHours(value: number | null | undefined): string {
+ return value === null || value === undefined ? texts.common.empty : `${value} ч`;
+}
diff --git a/frontend/src/webparts/approvalAdmin/utils/stepExecutor.test.ts b/frontend/src/webparts/approvalAdmin/utils/stepExecutor.test.ts
index bb8adc6..4816b7d 100644
--- a/frontend/src/webparts/approvalAdmin/utils/stepExecutor.test.ts
+++ b/frontend/src/webparts/approvalAdmin/utils/stepExecutor.test.ts
@@ -15,6 +15,8 @@ function step(overrides: Partial): TemplateRouteStep {
fieldName: null,
operation: null,
value: null,
+ slaHours: null,
+ askOverdueReason: null,
...overrides
};
}