diff --git a/backend/Tnsu.ApprovalRoute.Api/appsettings.json b/backend/Tnsu.ApprovalRoute.Api/appsettings.json index d0390ea..8062415 100644 --- a/backend/Tnsu.ApprovalRoute.Api/appsettings.json +++ b/backend/Tnsu.ApprovalRoute.Api/appsettings.json @@ -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", diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx index ee91a8b..1d055e0 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx @@ -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 = ({ title, rows, + allRows, + requestCreatedIso, + requestTypeSlaHours, readOnly, requestRejected, statusVariant, @@ -248,10 +262,30 @@ export const ManageRequestsPhaseTable: React.FC {r.position || texts.common.empty} {r.status || texts.common.empty} {r.date || texts.common.empty} - - {formatSlaHours(r.slaHours)} - {r.slaHours === null && {texts.manageRequests.slaInherited}} - + {(() => { + 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 ( + onEditSla(r) : undefined} + > + {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..0ae1274 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.module.scss +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.module.scss @@ -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; + } +} diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx index f851955..73b87b8 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx @@ -448,6 +448,7 @@ export const ManageRequestsScreen: React.FC = ({ api position, status: initialStatus, date: '', + dateIso: '', comment: '', order, allowReassign: false, @@ -575,6 +576,9 @@ export const ManageRequestsScreen: React.FC = ({ api = ({ api { 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 index 5a8879c..675e566 100644 --- a/frontend/src/webparts/approvalAdmin/utils/sla.ts +++ b/frontend/src/webparts/approvalAdmin/utils/sla.ts @@ -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, + 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; +}