Sla #11
@@ -26,7 +26,7 @@
|
||||
"TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a",
|
||||
"ClientId": "9232d9e2-9807-4753-808f-07250e5474b0",
|
||||
"CertificateSettingName": "AzurePfx",
|
||||
"CertificatePassword": ""
|
||||
"CertificatePassword": "Rk.xDjccnfyjdktybz1"
|
||||
},
|
||||
"TnsuPortal": {
|
||||
"BaseUrl": "https://api.tnsu.kz",
|
||||
|
||||
+39
-5
@@ -6,12 +6,23 @@ import { texts } from '../../texts';
|
||||
import { toEmployeeOption } from '../../utils/employee';
|
||||
import { blockIndexOf, blockRanges, markParallel, withOrdersFrom } from '../../utils/orderBlocks';
|
||||
import { ActionsCell, Badge, BadgeVariant, CellNote, IconButton, PrimaryCell, SearchableSelect } from '../ui';
|
||||
import { formatSlaHours } from '../../utils/sla';
|
||||
import { effectiveSlaHours, formatSlaHours, isRowOverdue } from '../../utils/sla';
|
||||
|
||||
export interface IManageRequestsPhaseTableProps {
|
||||
title: string;
|
||||
/** Строки ОДНОЙ фазы, уже отсортированные по order (см. ManageRequestsScreen). */
|
||||
rows: ManagedApprovalRow[];
|
||||
/**
|
||||
* ВСЕ строки заявки (обе фазы) — нужны для расчёта момента активации шага
|
||||
* (см. utils/sla.ts/activatedAt): первый шаг исполнения отсчитывается от
|
||||
* последнего решения фазы согласования, а той фазы в `rows` этой таблицы нет.
|
||||
*/
|
||||
allRows: ManagedApprovalRow[];
|
||||
/** Дата создания заявки (ISO) — точка отсчёта для самого первого шага согласования. */
|
||||
requestCreatedIso: string;
|
||||
/** SLA типа заявки в часах — то, что действует, если на строке своё значение не задано. */
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
|
||||
requestTypeSlaHours: number | null;
|
||||
/** Заявка целиком только для просмотра (закрыта) — контролов нет вообще. */
|
||||
readOnly: boolean;
|
||||
/**
|
||||
@@ -77,6 +88,9 @@ function diffOrders(original: ManagedApprovalRow[], renumbered: Row[]): Array<{
|
||||
export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps> = ({
|
||||
title,
|
||||
rows,
|
||||
allRows,
|
||||
requestCreatedIso,
|
||||
requestTypeSlaHours,
|
||||
readOnly,
|
||||
requestRejected,
|
||||
statusVariant,
|
||||
@@ -248,10 +262,30 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
|
||||
<td>{r.position || texts.common.empty}</td>
|
||||
<td><Badge variant={statusVariant(r.status)}>{r.status || texts.common.empty}</Badge></td>
|
||||
<td>{r.date || texts.common.empty}</td>
|
||||
<td>
|
||||
{formatSlaHours(r.slaHours)}
|
||||
{r.slaHours === null && <CellNote>{texts.manageRequests.slaInherited}</CellNote>}
|
||||
</td>
|
||||
{(() => {
|
||||
const hours = effectiveSlaHours(r, requestTypeSlaHours);
|
||||
// Просроченным может быть только шаг, у которого прямо сейчас
|
||||
// идёт очередь — решённый уже не тикает, ждущий ещё не начался.
|
||||
const overdue =
|
||||
(r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution) &&
|
||||
isRowOverdue(r, allRows, requestCreatedIso, requestTypeSlaHours, new Date());
|
||||
const editable = !readOnly && rowEditable && !isGhost;
|
||||
return (
|
||||
<td
|
||||
className={[
|
||||
overdue ? styles.slaOverdue : '',
|
||||
editable ? styles.slaEditable : ''
|
||||
].filter(Boolean).join(' ')}
|
||||
title={editable ? texts.manageRequests.editSla : undefined}
|
||||
onClick={editable ? () => onEditSla(r) : undefined}
|
||||
>
|
||||
{formatSlaHours(hours)}
|
||||
{r.slaHours === null && hours !== null && (
|
||||
<CellNote>{texts.manageRequests.slaInherited}</CellNote>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})()}
|
||||
{/* Стрелки перестановки — СЛЕВА от действий над строкой (объединить/
|
||||
переназначить/удалить), одна пара на весь блок (rowSpan): двигают
|
||||
шаг целиком, а не отдельного участника параллельной группы. */}
|
||||
|
||||
+20
@@ -97,3 +97,23 @@
|
||||
.addRow {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
// Ячейка SLA просроченного АКТИВНОГО шага (см. isRowOverdue в utils/sla.ts) —
|
||||
// только текст, без фона: фон был бы третьим одновременным акцентом в строке
|
||||
// (жёлтая подсветка hover уже есть) и мешал бы, а не помогал заметить.
|
||||
.slaOverdue {
|
||||
color: $red-500;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// Ячейка SLA, по которой можно кликнуть и открыть редактирование (см. onEditSla
|
||||
// в ManageRequestsPhaseTable) — курсор и подчёркивание по пунктиру как явный
|
||||
// намёк на кликабельность, без которого колонка неотличима от обычного текста.
|
||||
.slaEditable {
|
||||
cursor: pointer;
|
||||
border-bottom: 1px dashed $gray-400;
|
||||
|
||||
&:hover {
|
||||
background: $gray-50;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,6 +448,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
position,
|
||||
status: initialStatus,
|
||||
date: '',
|
||||
dateIso: '',
|
||||
comment: '',
|
||||
order,
|
||||
allowReassign: false,
|
||||
@@ -575,6 +576,9 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
<ManageRequestsPhaseTable
|
||||
title={texts.manageRequests.approvalPhaseTitle}
|
||||
rows={approvalRows}
|
||||
allRows={matrix ? matrix.rows : []}
|
||||
requestCreatedIso={matrix ? matrix.request.created : ''}
|
||||
requestTypeSlaHours={requestType?.slaHours ?? null}
|
||||
readOnly={readOnly}
|
||||
requestRejected={requestRejected}
|
||||
statusVariant={statusVariant}
|
||||
@@ -592,6 +596,9 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
<ManageRequestsPhaseTable
|
||||
title={texts.manageRequests.executionPhaseTitle}
|
||||
rows={executionRows}
|
||||
allRows={matrix ? matrix.rows : []}
|
||||
requestCreatedIso={matrix ? matrix.request.created : ''}
|
||||
requestTypeSlaHours={requestType?.slaHours ?? null}
|
||||
readOnly={readOnly}
|
||||
requestRejected={requestRejected}
|
||||
statusVariant={statusVariant}
|
||||
|
||||
@@ -97,6 +97,13 @@ 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;
|
||||
|
||||
@@ -243,6 +243,7 @@ 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,
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import {
|
||||
activatedAt,
|
||||
boolToTriState,
|
||||
effectiveSlaHours,
|
||||
formatSlaHours,
|
||||
isRowOverdue,
|
||||
isValidSlaHours,
|
||||
parseSlaHours,
|
||||
slaHoursToInput,
|
||||
triStateToBool
|
||||
} from './sla';
|
||||
import { APPROVAL_PHASE } from '../models';
|
||||
|
||||
// Смысл всех этих проверок один: «не задано» и «ноль/нет» — РАЗНЫЕ состояния.
|
||||
// Если они где-то схлопнутся, шаг с настройкой «напоминать сразу» станет
|
||||
@@ -84,3 +88,98 @@ describe('triState', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { texts } from '../texts';
|
||||
import { APPROVAL_PHASE, ManagedApprovalRow } from '../models';
|
||||
|
||||
/**
|
||||
* Настройки SLA существуют в ТРЁХ состояниях, и это главное, что здесь важно:
|
||||
@@ -68,3 +69,86 @@ export function slaHoursToInput(value: number | null | undefined): string {
|
||||
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<ManagedApprovalRow, 'slaHours'>,
|
||||
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<ManagedApprovalRow, 'order' | 'phase'>,
|
||||
allRows: readonly Pick<ManagedApprovalRow, 'order' | 'phase' | 'dateIso'>[],
|
||||
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<ManagedApprovalRow, 'dateIso'>[]): 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<ManagedApprovalRow, 'order' | 'phase' | 'dateIso' | 'slaHours'>,
|
||||
allRows: readonly Pick<ManagedApprovalRow, 'order' | 'phase' | 'dateIso'>[],
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user