| {r.position || texts.common.empty} |
{r.status || texts.common.empty} |
{r.date || texts.common.empty} |
+ {(() => {
+ const hours = effectiveSlaHours(r, requestTypeSlaHours);
+ // Просроченным может быть только шаг, у которого прямо сейчас
+ // идёт очередь — решённый уже не тикает, ждущий ещё не начался.
+ const overdue =
+ (r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution) &&
+ isRowOverdue(r, allRows, requestCreatedIso, requestTypeSlaHours, new Date());
+ return (
+
+ {formatSlaHours(hours)}
+ {r.slaHours === null && hours !== null && (
+ {texts.manageRequests.slaInherited}
+ )}
+ |
+ );
+ })()}
{/* Стрелки перестановки — СЛЕВА от действий над строкой (объединить/
переназначить/удалить), одна пара на весь блок (rowSpan): двигают
шаг целиком, а не отдельного участника параллельной группы. */}
diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.module.scss b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.module.scss
index dc89086..a3aa861 100644
--- a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.module.scss
+++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.module.scss
@@ -97,3 +97,11 @@
.addRow {
margin-top: 10px;
}
+
+// Ячейка SLA просроченного АКТИВНОГО шага (см. isRowOverdue в utils/sla.ts) —
+// только текст, без фона: фон был бы третьим одновременным акцентом в строке
+// (жёлтая подсветка hover уже есть) и мешал бы, а не помогал заметить.
+.slaOverdue {
+ color: $red-500;
+ font-weight: 600;
+}
diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx
index a928f40..757bc97 100644
--- a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx
+++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx
@@ -366,21 +366,40 @@ export const ManageRequestsScreen: React.FC = ({ api
// загруженного справочника сотрудников (тот же источник, что подставляет
// должность, — см. ManageRequestsApproverModal), чтобы не делать лишний
// запрос только за отображаемым именем.
- const saveModal = (email: string, position: string): Promise => {
+ const saveModal = (
+ email: string,
+ position: string,
+ // 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 || !modal) return Promise.resolve();
setSaveError(undefined);
const rowId = modal.id;
+ // Два запроса последовательно, а не параллельно: оба правят ОДНУ строку
+ // SharePoint, и параллельные update по одному элементу списка могут
+ // затереть друг друга (последний выигрывает).
return requestManage
.reassignApprover(matrix, rowId, email, position)
+ .then(() => requestManage.updateRowSla(matrix, rowId, slaHours, askOverdueReason))
.then(() => {
const found = findEmployeeByEmail(employees.items, email);
setMatrix(prev =>
- prev ? patchRow(prev, rowId, { approverEmail: email, approverName: found?.fio || email, position }) : prev
+ prev
+ ? patchRow(prev, rowId, {
+ approverEmail: email,
+ approverName: found?.fio || email,
+ position,
+ slaHours,
+ askOverdueReason
+ })
+ : prev
);
closeModal();
})
.catch((e: Error) => {
- setSaveError(reportError(e, 'saveModal: reassignApprover'));
+ setSaveError(reportError(e, 'saveModal: reassignApprover/updateRowSla'));
throw e;
});
};
@@ -421,10 +440,15 @@ export const ManageRequestsScreen: React.FC = ({ api
position,
status: initialStatus,
date: '',
+ dateIso: '',
comment: '',
order,
allowReassign: false,
- phase
+ phase,
+ // Шаг, добавленный вручную, маршрутом-шаблоном не порождён и своего SLA не
+ // имеет — работает SLA типа заявки, пока его не зададут отдельно.
+ slaHours: null,
+ askOverdueReason: null
};
const previous = matrix;
@@ -544,6 +568,9 @@ export const ManageRequestsScreen: React.FC = ({ api
= ({ api
= ({ api
= ({ 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}
+ />
+
+
+
+
({
// Растущий счётчик запросов: если ответ пришёл не от последнего запроса
// (например, пользователь допечатал ещё символ) — просто игнорируем его.
const requestIdRef = React.useRef(0);
+ // getValue нужен внутри runSearch (дедуп при доклейке страницы), но в deps его
+ // класть нельзя: пропс приходит инлайновой стрелкой и меняет ссылку на каждом
+ // рендере — runSearch пересоздавался бы постоянно и перезапускал эффект
+ // бесконечной прокрутки. Держим последнюю версию в ref.
+ const getValueRef = React.useRef(getValue);
+ getValueRef.current = getValue;
const runSearch = React.useCallback((q: string, targetPage: number, append: boolean) => {
const requestId = ++requestIdRef.current;
@@ -71,7 +77,16 @@ export function AsyncSearchableSelect({
search(q, targetPage)
.then(result => {
if (requestIdRef.current !== requestId) return; // устарел
- setItems(prev => (append ? [...prev, ...result.items] : result.items));
+ // Дедуп при доклейке: соседние страницы могут пересекаться, если индекс
+ // переупорядочился между запросами (StartRow смещает окно по РАНГУ, а не
+ // по устойчивому курсору). Дубликат здесь = два пункта с одинаковым
+ // React-ключом, то есть заявка в списке дважды. Внутри одной страницы
+ // дубли отсекает уже источник (см. searchRequests в requestManageService).
+ setItems(prev => {
+ if (!append) return result.items;
+ const shown = new Set(prev.map(i => getValueRef.current(i)));
+ return [...prev, ...result.items.filter(i => !shown.has(getValueRef.current(i)))];
+ });
setHasMore(result.hasMore);
setPage(targetPage);
})
diff --git a/frontend/src/webparts/approvalAdmin/models/requestManage.ts b/frontend/src/webparts/approvalAdmin/models/requestManage.ts
index 5cba3dd..c02fec6 100644
--- a/frontend/src/webparts/approvalAdmin/models/requestManage.ts
+++ b/frontend/src/webparts/approvalAdmin/models/requestManage.ts
@@ -97,10 +97,28 @@ export interface ManagedApprovalRow {
position: string;
status: string;
date: string;
+ /**
+ * Та же дата, что в `date`, но необработанная ISO-строка (или '', если решения
+ * ещё не было) — нужна для расчёта просрочки (см. utils/sla.ts/isRowOverdue):
+ * `date` отформатирована в «дд.мм.гггг» и назад в Date уже не превращается
+ * надёжно (зависит от локали при парсинге).
+ */
+ dateIso: string;
comment: string;
order: number;
allowReassign: boolean;
phase: string;
+ /**
+ * SLA именно ЭТОЙ строки в часах. Значение проставляется при создании
+ * маршрута (портал берёт его из resolve, где наследование «шаг перекрывает
+ * тип» уже применено), и здесь его можно поправить для конкретной заявки.
+ * null — в строке не задано, работает SLA типа заявки.
+ */
+ // eslint-disable-next-line @rushstack/no-new-null -- пусто в SharePoint приходит как null и значит «не задано», это не 0
+ slaHours: number | null;
+ /** Спрашивать ли причину просрочки по этой строке. null — берётся от типа заявки. */
+ // eslint-disable-next-line @rushstack/no-new-null -- см. slaHours
+ askOverdueReason: boolean | null;
}
export interface ManagedRequestMatrix {
diff --git a/frontend/src/webparts/approvalAdmin/models/requestTypes.ts b/frontend/src/webparts/approvalAdmin/models/requestTypes.ts
index 6700000..5060b0f 100644
--- a/frontend/src/webparts/approvalAdmin/models/requestTypes.ts
+++ b/frontend/src/webparts/approvalAdmin/models/requestTypes.ts
@@ -10,6 +10,14 @@ export interface RequestType {
/** Сайт SharePoint, где живёт listId. Заполнен тогда и только тогда, когда заполнен listId. */
siteUrl: string | null;
section: string;
+ /**
+ * SLA по умолчанию для всех шагов заявок этого типа, в часах.
+ * null — SLA не настроен, напоминания по типу не строятся.
+ * Отличать null от 0 обязательно: 0 значит «напоминать сразу».
+ */
+ slaHours: number | null;
+ /** Спрашивать ли причину просрочки — значение по умолчанию для шагов. null — не задано. */
+ askOverdueReason: boolean | null;
}
export interface CreateRequestTypeInput {
@@ -18,6 +26,8 @@ export interface CreateRequestTypeInput {
listId: string | null;
siteUrl: string | null;
section: string;
+ slaHours: number | null;
+ askOverdueReason: boolean | null;
}
export type UpdateRequestTypeInput = CreateRequestTypeInput;
@@ -28,4 +38,6 @@ export interface PatchRequestTypeInput {
listId?: string | null;
siteUrl?: string | null;
section?: string;
+ slaHours?: number | null;
+ askOverdueReason?: boolean | null;
}
diff --git a/frontend/src/webparts/approvalAdmin/models/templateRouteSteps.ts b/frontend/src/webparts/approvalAdmin/models/templateRouteSteps.ts
index e569aca..f9b0459 100644
--- a/frontend/src/webparts/approvalAdmin/models/templateRouteSteps.ts
+++ b/frontend/src/webparts/approvalAdmin/models/templateRouteSteps.ts
@@ -19,6 +19,13 @@ export interface TemplateRouteStep {
operation: ComparisonOperator | null;
/** Значение для сравнения с fieldName. */
value: string | null;
+ /**
+ * SLA шага в часах — перекрывает SLA типа заявки.
+ * null — «не задано, наследовать»; 0 — «напоминать сразу».
+ */
+ slaHours: number | null;
+ /** Спрашивать ли причину просрочки на этом шаге. null — наследовать от типа. */
+ askOverdueReason: boolean | null;
/** Предупреждение бэкенда о сохранённом шаге (например, должность не найдена). */
warning?: string | null;
}
@@ -34,6 +41,8 @@ export interface CreateTemplateRouteStepInput {
fieldName: string | null;
operation: ComparisonOperator | null;
value: string | null;
+ slaHours: number | null;
+ askOverdueReason: boolean | null;
}
export type UpdateTemplateRouteStepInput = CreateTemplateRouteStepInput;
@@ -49,6 +58,8 @@ export interface PatchTemplateRouteStepInput {
fieldName?: string | null;
operation?: ComparisonOperator | null;
value?: string | null;
+ slaHours?: number | null;
+ askOverdueReason?: boolean | null;
}
// Способ задать исполнителя шага: ровно одно из полей выше должно быть заполнено
diff --git a/frontend/src/webparts/approvalAdmin/services/api.ts b/frontend/src/webparts/approvalAdmin/services/api.ts
index 2283beb..42a254f 100644
--- a/frontend/src/webparts/approvalAdmin/services/api.ts
+++ b/frontend/src/webparts/approvalAdmin/services/api.ts
@@ -17,7 +17,7 @@ import {
} from '../models';
// Тонкий 1:1 фасад над контроллерами бэкенда:
-// backend/src/Tnsu.ApprovalRoute.Api/Controllers/{RequestTypes,TemplateRoutes,TemplateRouteSteps}Controller.cs
+// backend/Tnsu.ApprovalRoute.Api/Controllers/{RequestTypes,TemplateRoutes,TemplateRouteSteps}Controller.cs
export class ApprovalRouteApi {
constructor(private readonly http: IHttpClient) {}
diff --git a/frontend/src/webparts/approvalAdmin/services/requestManageService.ts b/frontend/src/webparts/approvalAdmin/services/requestManageService.ts
index d975c56..cbdd7e8 100644
--- a/frontend/src/webparts/approvalAdmin/services/requestManageService.ts
+++ b/frontend/src/webparts/approvalAdmin/services/requestManageService.ts
@@ -42,7 +42,10 @@ const FIELDS = {
comment: 'Comment',
order: 'StepOrder',
allowReassign: 'AllowReassignToOtherApprovers',
- phase: 'Phase'
+ phase: 'Phase',
+ // Колонки SLA заводятся провижинингом в request-portal вместе с остальными.
+ slaHours: 'SLAhours',
+ askOverdueReason: 'AskOverdueReason'
};
function siteOrigin(siteUrl: string): string {
@@ -152,7 +155,22 @@ export class RequestManageService {
});
const rawRows = (result.PrimarySearchResults ?? []) as Array<{ ListItemID?: string }>;
- const ids = rawRows.map(r => Number(r.ListItemID)).filter(id => !isNaN(id) && id > 0);
+ // Дедуп по Id обязателен: TrimDuplicates: false выше выключает штатную
+ // дедупликацию Search (она иногда схлопывает нужные нам заявки), и тогда
+ // индекс может отдать один и тот же элемент несколькими хитами
+ // (переиндексация, несколько «версий» в crawl). Без этого фильтра хиты
+ // превращались в пункты комбобокса один-в-один, и заявка показывалась в
+ // списке дважды — с одинаковым React-ключом. Та же причина и то же лечение,
+ // что в request-portal (features/myRequests/myRequests.service.ts/hitsToEntries).
+ const seen = new Set();
+ const ids = rawRows
+ .map(r => Number(r.ListItemID))
+ .filter(id => !isNaN(id) && id > 0)
+ .filter(id => {
+ if (seen.has(id)) return false;
+ seen.add(id);
+ return true;
+ });
if (ids.length === 0) return { items: [], hasMore: false };
const list = sp.web.getList(requestType.listId);
@@ -205,6 +223,8 @@ export class RequestManageService {
FIELDS.order,
FIELDS.allowReassign,
FIELDS.phase,
+ FIELDS.slaHours,
+ FIELDS.askOverdueReason,
`${FIELDS.approver}/Id`,
`${FIELDS.approver}/Title`,
`${FIELDS.approver}/EMail`
@@ -223,10 +243,15 @@ export class RequestManageService {
position: (r.Title as string) || '',
status: (r[FIELDS.status] as string) || '',
date: formatDateIso((r[FIELDS.date] as string) || ''),
+ dateIso: (r[FIELDS.date] as string) || '',
comment: (r[FIELDS.comment] as string) || '',
order: (r[FIELDS.order] as number) ?? i + 1,
allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
- phase: (r[FIELDS.phase] as string) || APPROVAL_PHASE.approval
+ phase: (r[FIELDS.phase] as string) || APPROVAL_PHASE.approval,
+ // ?? null, а не || null: ноль здесь — значимое значение («напоминать
+ // сразу»), и превращать его в «не задано» нельзя.
+ slaHours: (r[FIELDS.slaHours] as number | null) ?? null,
+ askOverdueReason: (r[FIELDS.askOverdueReason] as boolean | null) ?? null
};
});
@@ -256,6 +281,28 @@ export class RequestManageService {
});
}
+ /**
+ * Поправить SLA конкретной строки маршрута.
+ *
+ * null пишется в SharePoint как есть — это «в строке не задано», и тогда
+ * работает SLA типа заявки. Отдельный метод, а не флаг в reassignApprover:
+ * SLA правят независимо от того, кто на шаге, и в любом статусе строки.
+ */
+ public async updateRowSla(
+ matrix: Pick,
+ 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 cffd565..2fe38e3 100644
--- a/frontend/src/webparts/approvalAdmin/texts.ts
+++ b/frontend/src/webparts/approvalAdmin/texts.ts
@@ -60,6 +60,12 @@ export const texts = {
loading: 'Загрузка...',
yes: 'Да',
no: 'Нет',
+ /**
+ * Третье состояние для настроек с наследованием (SLA, опрос причины
+ * просрочки): пусто значит «взять значение уровнем выше», и это НЕ то же
+ * самое, что «Нет» или «0» — их выбирают осознанно.
+ */
+ inherited: 'Не задано',
/** Прочерк вместо пустого значения — чтобы ячейка таблицы не выглядела сломанной. */
empty: '—'
},
@@ -108,6 +114,7 @@ export const texts = {
columnSection: 'Раздел',
columnListId: 'Список SharePoint',
columnSiteUrl: 'Сайт SharePoint',
+ columnSla: 'SLA',
createTitle: 'Новый тип заявки',
editTitle: 'Редактировать тип заявки',
nameLabel: 'Название',
@@ -127,6 +134,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: 'Удалить тип заявки?',
@@ -210,6 +225,12 @@ export const texts = {
headTypeLabel: 'Тип руководителя',
reassignLabel: 'Переназначение',
reassignCheckbox: 'Разрешить переназначать другим согласующим',
+ slaHoursLabel: 'SLA шага, часов',
+ slaHoursHint:
+ 'Пусто — берётся SLA типа заявки. Заданное здесь значение его перекрывает; 0 означает «напоминать сразу».',
+ slaHoursPlaceholder: 'Как у типа заявки',
+ askOverdueReasonLabel: 'Спрашивать причину просрочки',
+ askOverdueReasonHint: '«Не задано» — берётся значение типа заявки.',
conditionLabel: 'Условие видимости шага',
conditionCheckbox: 'Показывать шаг только при выполнении условия',
conditionDisabledHint: 'Недоступно: у типа заявки не указан список SharePoint (list_name).',
@@ -226,6 +247,7 @@ export const texts = {
validationPosition: 'Укажите должность.',
validationCondition: 'Заполните поле списка, оператор и значение условия.',
validationConditionNumber: 'Выбранное поле числовое — значение условия должно быть числом.',
+ validationSlaHours: 'SLA укажите целым числом часов (0 и больше) или оставьте поле пустым.',
deleteTitle: 'Удалить шаг?',
deleteText: (orderNo: number): string => `Шаг #${orderNo} будет удалён безвозвратно.`
},
@@ -319,6 +341,15 @@ export const texts = {
splitFromPrev: 'Отделить от предыдущего шага (выполнять после него)',
moveBlockedByDecision: 'Нельзя переставить — соседний шаг уже решён.',
reassignTitle: 'Переназначить шаг',
+ columnSla: 'SLA',
+ slaInherited: 'от типа заявки',
+ 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..639cb35
--- /dev/null
+++ b/frontend/src/webparts/approvalAdmin/utils/sla.test.ts
@@ -0,0 +1,185 @@
+import {
+ activatedAt,
+ boolToTriState,
+ effectiveSlaHours,
+ formatSlaHours,
+ isRowOverdue,
+ isValidSlaHours,
+ parseSlaHours,
+ slaHoursToInput,
+ triStateToBool
+} from './sla';
+import { APPROVAL_PHASE } from '../models';
+
+// Смысл всех этих проверок один: «не задано» и «ноль/нет» — РАЗНЫЕ состояния.
+// Если они где-то схлопнутся, шаг с настройкой «напоминать сразу» станет
+// неотличим от шага без настройки, и напоминания по нему просто не придут.
+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();
+ });
+});
+
+describe('effectiveSlaHours', () => {
+ it('своё значение строки перекрывает тип', () => {
+ expect(effectiveSlaHours({ slaHours: 8 }, 48)).toBe(8);
+ });
+
+ it('0 на строке — это не "не задано", тип не подставляется', () => {
+ expect(effectiveSlaHours({ slaHours: 0 }, 48)).toBe(0);
+ });
+
+ it('строка не задана — берётся SLA типа', () => {
+ expect(effectiveSlaHours({ slaHours: null }, 48)).toBe(48);
+ });
+
+ it('не задано нигде — null', () => {
+ expect(effectiveSlaHours({ slaHours: null }, null)).toBeNull();
+ });
+});
+
+// Повторяет ReminderService.ActivatedAt на бэкенде — та же формула, поэтому и
+// проверяется на тех же случаях (параллельные шаги, переход между фазами).
+describe('activatedAt', () => {
+ const row = (order: number, phase: string, dateIso: string) => ({ order, phase, dateIso });
+
+ it('первый шаг согласования — от создания заявки', () => {
+ const rows = [row(1, APPROVAL_PHASE.approval, '')];
+ const result = activatedAt(rows[0], rows, '2026-08-01T00:00:00Z');
+
+ expect(result?.toISOString()).toBe('2026-08-01T00:00:00.000Z');
+ });
+
+ it('второй шаг — от решения предыдущего, а не от создания заявки', () => {
+ const rows = [
+ row(1, APPROVAL_PHASE.approval, '2026-08-10T12:00:00Z'),
+ row(2, APPROVAL_PHASE.approval, '')
+ ];
+ const result = activatedAt(rows[1], rows, '2026-01-01T00:00:00Z');
+
+ expect(result?.toISOString()).toBe('2026-08-10T12:00:00.000Z');
+ });
+
+ it('параллельный предыдущий шаг — от ПОСЛЕДНЕГО решения, а не от первого', () => {
+ const rows = [
+ row(1, APPROVAL_PHASE.approval, '2026-08-10T08:00:00Z'),
+ row(1, APPROVAL_PHASE.approval, '2026-08-10T20:00:00Z'),
+ row(2, APPROVAL_PHASE.approval, '')
+ ];
+ const result = activatedAt(rows[2], rows, '2026-01-01T00:00:00Z');
+
+ expect(result?.toISOString()).toBe('2026-08-10T20:00:00.000Z');
+ });
+
+ it('первый шаг исполнения — от последнего решения фазы согласования', () => {
+ const rows = [
+ row(1, APPROVAL_PHASE.approval, '2026-08-10T08:00:00Z'),
+ row(2, APPROVAL_PHASE.approval, '2026-08-11T09:00:00Z'),
+ row(1, APPROVAL_PHASE.execution, '')
+ ];
+ const result = activatedAt(rows[2], rows, '2026-01-01T00:00:00Z');
+
+ expect(result?.toISOString()).toBe('2026-08-11T09:00:00.000Z');
+ });
+
+ it('предыдущий шаг ещё не решён — активации нет (undefined)', () => {
+ const rows = [
+ row(1, APPROVAL_PHASE.approval, ''),
+ row(2, APPROVAL_PHASE.approval, '')
+ ];
+ expect(activatedAt(rows[1], rows, '2026-01-01T00:00:00Z')).toBeUndefined();
+ });
+});
+
+describe('isRowOverdue', () => {
+ const now = new Date('2026-08-12T12:00:00Z');
+
+ it('срок вышел — просрочено', () => {
+ const rows = [{ order: 1, phase: APPROVAL_PHASE.approval, dateIso: '', slaHours: 24 as number | null }];
+ expect(isRowOverdue(rows[0], rows, '2026-08-10T00:00:00Z', null, now)).toBe(true);
+ });
+
+ it('срок не вышел — не просрочено', () => {
+ const rows = [{ order: 1, phase: APPROVAL_PHASE.approval, dateIso: '', slaHours: 24 as number | null }];
+ expect(isRowOverdue(rows[0], rows, '2026-08-12T00:00:00Z', null, now)).toBe(false);
+ });
+
+ it('SLA не настроен нигде — не просрочено, хотя заявка старая', () => {
+ const rows = [{ order: 1, phase: APPROVAL_PHASE.approval, dateIso: '', slaHours: null as number | null }];
+ expect(isRowOverdue(rows[0], rows, '2020-01-01T00:00:00Z', null, now)).toBe(false);
+ });
+
+ it('SLA типа используется, если на строке не задан', () => {
+ const rows = [{ order: 1, phase: APPROVAL_PHASE.approval, dateIso: '', slaHours: null as number | null }];
+ expect(isRowOverdue(rows[0], rows, '2026-08-10T00:00:00Z', 24, now)).toBe(true);
+ });
+});
diff --git a/frontend/src/webparts/approvalAdmin/utils/sla.ts b/frontend/src/webparts/approvalAdmin/utils/sla.ts
new file mode 100644
index 0000000..675e566
--- /dev/null
+++ b/frontend/src/webparts/approvalAdmin/utils/sla.ts
@@ -0,0 +1,154 @@
+import { texts } from '../texts';
+import { APPROVAL_PHASE, ManagedApprovalRow } from '../models';
+
+/**
+ * Настройки 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} ч`;
+}
+
+/**
+ * Действующий SLA строки: своё значение, а если не задано — SLA типа заявки.
+ * То же правило наследования, что и на бэкенде при resolve (шаг перекрывает тип) —
+ * здесь его нужно повторить, чтобы посчитать просрочку и показать реальное число
+ * часов, а не только «от типа заявки» без цифры.
+ */
+// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
+export function effectiveSlaHours(
+ row: Pick,
+ requestTypeSlaHours: number | null | undefined
+): number | null {
+ if (row.slaHours !== null) return row.slaHours;
+ return requestTypeSlaHours ?? null;
+}
+
+/**
+ * Момент, с которого шаг считается активным, — начало отсчёта SLA.
+ *
+ * Отдельной колонки «когда шаг стал активным» в списке нет (активация — это
+ * точечное обновление статуса, дату оно не пишет), поэтому момент выводится из
+ * соседних строк маршрута — ТОЧНО ТА ЖЕ формула, что в backend/ReminderService.cs
+ * (ActivatedAt), продублирована здесь для показа в админке:
+ * • первый шаг согласования — дата создания заявки;
+ * • первый шаг исполнения — последнее решение по фазе согласования (фаза
+ * исполнения активируется только когда согласование пройдено целиком);
+ * • остальные шаги — последнее решение по предыдущему (по order) шагу СВОЕЙ фазы.
+ *
+ * MAX даты, а не «дата предыдущей строки»: шаг могут делить несколько
+ * параллельных согласующих с одинаковым order, и следующий шаг активируется
+ * только после решения последнего из них. «Предыдущий шаг» — ближайший меньший
+ * СУЩЕСТВУЮЩИЙ order, а не order-1: в нумерации бывают пропуски.
+ */
+export function activatedAt(
+ row: Pick,
+ allRows: readonly Pick[],
+ requestCreatedIso: string
+): Date | undefined {
+ const samePhase = allRows.filter((r) => r.phase === row.phase);
+ const isFirstInPhase = samePhase.every((r) => r.order >= row.order);
+
+ const lastDecisionOf = (rows: readonly Pick[]): Date | undefined => {
+ const dates = rows
+ .map((r) => (r.dateIso ? new Date(r.dateIso) : undefined))
+ .filter((d): d is Date => !!d && !isNaN(d.getTime()));
+ if (dates.length === 0) return undefined;
+ return new Date(Math.max(...dates.map((d) => d.getTime())));
+ };
+
+ if (isFirstInPhase) {
+ if (row.phase === APPROVAL_PHASE.execution) {
+ return lastDecisionOf(allRows.filter((r) => r.phase === APPROVAL_PHASE.approval));
+ }
+ const created = requestCreatedIso ? new Date(requestCreatedIso) : undefined;
+ return created && !isNaN(created.getTime()) ? created : undefined;
+ }
+
+ const previousOrder = Math.max(...samePhase.filter((r) => r.order < row.order).map((r) => r.order));
+ return lastDecisionOf(samePhase.filter((r) => r.order === previousOrder));
+}
+
+/**
+ * Строка сейчас просрочена: она активна (на согласовании/исполнении — проверяет
+ * вызывающая сторона по статусу, здесь неважно) и текущее время уже прошло
+ * дедлайн (момент активации + действующий SLA). SLA не настроен нигде (ни на
+ * строке, ни на типе) — просрочки не бывает, что проверять.
+ */
+export function isRowOverdue(
+ row: Pick,
+ allRows: readonly Pick[],
+ requestCreatedIso: string,
+ requestTypeSlaHours: number | null | undefined,
+ now: Date
+): boolean {
+ const hours = effectiveSlaHours(row, requestTypeSlaHours);
+ if (hours === null) return false;
+
+ const activated = activatedAt(row, allRows, requestCreatedIso);
+ if (!activated) return false;
+
+ const deadline = new Date(activated.getTime() + hours * 60 * 60 * 1000);
+ return deadline < now;
+}
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
};
}