прописал статусы
This commit is contained in:
@@ -26,7 +26,7 @@
|
|||||||
"TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a",
|
"TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a",
|
||||||
"ClientId": "9232d9e2-9807-4753-808f-07250e5474b0",
|
"ClientId": "9232d9e2-9807-4753-808f-07250e5474b0",
|
||||||
"CertificateSettingName": "AzurePfx",
|
"CertificateSettingName": "AzurePfx",
|
||||||
"CertificatePassword": ""
|
"CertificatePassword": "Rk.xDjccnfyjdktybz1"
|
||||||
},
|
},
|
||||||
"TnsuPortal": {
|
"TnsuPortal": {
|
||||||
"BaseUrl": "https://api.tnsu.kz",
|
"BaseUrl": "https://api.tnsu.kz",
|
||||||
|
|||||||
+39
-5
@@ -6,12 +6,23 @@ import { texts } from '../../texts';
|
|||||||
import { toEmployeeOption } from '../../utils/employee';
|
import { toEmployeeOption } from '../../utils/employee';
|
||||||
import { blockIndexOf, blockRanges, markParallel, withOrdersFrom } from '../../utils/orderBlocks';
|
import { blockIndexOf, blockRanges, markParallel, withOrdersFrom } from '../../utils/orderBlocks';
|
||||||
import { ActionsCell, Badge, BadgeVariant, CellNote, IconButton, PrimaryCell, SearchableSelect } from '../ui';
|
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 {
|
export interface IManageRequestsPhaseTableProps {
|
||||||
title: string;
|
title: string;
|
||||||
/** Строки ОДНОЙ фазы, уже отсортированные по order (см. ManageRequestsScreen). */
|
/** Строки ОДНОЙ фазы, уже отсортированные по order (см. ManageRequestsScreen). */
|
||||||
rows: ManagedApprovalRow[];
|
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;
|
readOnly: boolean;
|
||||||
/**
|
/**
|
||||||
@@ -77,6 +88,9 @@ function diffOrders(original: ManagedApprovalRow[], renumbered: Row[]): Array<{
|
|||||||
export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps> = ({
|
export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps> = ({
|
||||||
title,
|
title,
|
||||||
rows,
|
rows,
|
||||||
|
allRows,
|
||||||
|
requestCreatedIso,
|
||||||
|
requestTypeSlaHours,
|
||||||
readOnly,
|
readOnly,
|
||||||
requestRejected,
|
requestRejected,
|
||||||
statusVariant,
|
statusVariant,
|
||||||
@@ -248,10 +262,30 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
|
|||||||
<td>{r.position || texts.common.empty}</td>
|
<td>{r.position || texts.common.empty}</td>
|
||||||
<td><Badge variant={statusVariant(r.status)}>{r.status || texts.common.empty}</Badge></td>
|
<td><Badge variant={statusVariant(r.status)}>{r.status || texts.common.empty}</Badge></td>
|
||||||
<td>{r.date || texts.common.empty}</td>
|
<td>{r.date || texts.common.empty}</td>
|
||||||
<td>
|
{(() => {
|
||||||
{formatSlaHours(r.slaHours)}
|
const hours = effectiveSlaHours(r, requestTypeSlaHours);
|
||||||
{r.slaHours === null && <CellNote>{texts.manageRequests.slaInherited}</CellNote>}
|
// Просроченным может быть только шаг, у которого прямо сейчас
|
||||||
</td>
|
// идёт очередь — решённый уже не тикает, ждущий ещё не начался.
|
||||||
|
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): двигают
|
переназначить/удалить), одна пара на весь блок (rowSpan): двигают
|
||||||
шаг целиком, а не отдельного участника параллельной группы. */}
|
шаг целиком, а не отдельного участника параллельной группы. */}
|
||||||
|
|||||||
+20
@@ -97,3 +97,23 @@
|
|||||||
.addRow {
|
.addRow {
|
||||||
margin-top: 10px;
|
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,
|
position,
|
||||||
status: initialStatus,
|
status: initialStatus,
|
||||||
date: '',
|
date: '',
|
||||||
|
dateIso: '',
|
||||||
comment: '',
|
comment: '',
|
||||||
order,
|
order,
|
||||||
allowReassign: false,
|
allowReassign: false,
|
||||||
@@ -575,6 +576,9 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
<ManageRequestsPhaseTable
|
<ManageRequestsPhaseTable
|
||||||
title={texts.manageRequests.approvalPhaseTitle}
|
title={texts.manageRequests.approvalPhaseTitle}
|
||||||
rows={approvalRows}
|
rows={approvalRows}
|
||||||
|
allRows={matrix ? matrix.rows : []}
|
||||||
|
requestCreatedIso={matrix ? matrix.request.created : ''}
|
||||||
|
requestTypeSlaHours={requestType?.slaHours ?? null}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
requestRejected={requestRejected}
|
requestRejected={requestRejected}
|
||||||
statusVariant={statusVariant}
|
statusVariant={statusVariant}
|
||||||
@@ -592,6 +596,9 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
<ManageRequestsPhaseTable
|
<ManageRequestsPhaseTable
|
||||||
title={texts.manageRequests.executionPhaseTitle}
|
title={texts.manageRequests.executionPhaseTitle}
|
||||||
rows={executionRows}
|
rows={executionRows}
|
||||||
|
allRows={matrix ? matrix.rows : []}
|
||||||
|
requestCreatedIso={matrix ? matrix.request.created : ''}
|
||||||
|
requestTypeSlaHours={requestType?.slaHours ?? null}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
requestRejected={requestRejected}
|
requestRejected={requestRejected}
|
||||||
statusVariant={statusVariant}
|
statusVariant={statusVariant}
|
||||||
|
|||||||
@@ -97,6 +97,13 @@ export interface ManagedApprovalRow {
|
|||||||
position: string;
|
position: string;
|
||||||
status: string;
|
status: string;
|
||||||
date: string;
|
date: string;
|
||||||
|
/**
|
||||||
|
* Та же дата, что в `date`, но необработанная ISO-строка (или '', если решения
|
||||||
|
* ещё не было) — нужна для расчёта просрочки (см. utils/sla.ts/isRowOverdue):
|
||||||
|
* `date` отформатирована в «дд.мм.гггг» и назад в Date уже не превращается
|
||||||
|
* надёжно (зависит от локали при парсинге).
|
||||||
|
*/
|
||||||
|
dateIso: string;
|
||||||
comment: string;
|
comment: string;
|
||||||
order: number;
|
order: number;
|
||||||
allowReassign: boolean;
|
allowReassign: boolean;
|
||||||
|
|||||||
@@ -243,6 +243,7 @@ export class RequestManageService {
|
|||||||
position: (r.Title as string) || '',
|
position: (r.Title as string) || '',
|
||||||
status: (r[FIELDS.status] as string) || '',
|
status: (r[FIELDS.status] as string) || '',
|
||||||
date: formatDateIso((r[FIELDS.date] as string) || ''),
|
date: formatDateIso((r[FIELDS.date] as string) || ''),
|
||||||
|
dateIso: (r[FIELDS.date] as string) || '',
|
||||||
comment: (r[FIELDS.comment] as string) || '',
|
comment: (r[FIELDS.comment] as string) || '',
|
||||||
order: (r[FIELDS.order] as number) ?? i + 1,
|
order: (r[FIELDS.order] as number) ?? i + 1,
|
||||||
allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
|
allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
|
activatedAt,
|
||||||
boolToTriState,
|
boolToTriState,
|
||||||
|
effectiveSlaHours,
|
||||||
formatSlaHours,
|
formatSlaHours,
|
||||||
|
isRowOverdue,
|
||||||
isValidSlaHours,
|
isValidSlaHours,
|
||||||
parseSlaHours,
|
parseSlaHours,
|
||||||
slaHoursToInput,
|
slaHoursToInput,
|
||||||
triStateToBool
|
triStateToBool
|
||||||
} from './sla';
|
} from './sla';
|
||||||
|
import { APPROVAL_PHASE } from '../models';
|
||||||
|
|
||||||
// Смысл всех этих проверок один: «не задано» и «ноль/нет» — РАЗНЫЕ состояния.
|
// Смысл всех этих проверок один: «не задано» и «ноль/нет» — РАЗНЫЕ состояния.
|
||||||
// Если они где-то схлопнутся, шаг с настройкой «напоминать сразу» станет
|
// Если они где-то схлопнутся, шаг с настройкой «напоминать сразу» станет
|
||||||
@@ -84,3 +88,98 @@ describe('triState', () => {
|
|||||||
expect(triStateToBool(boolToTriState(null))).toBeNull();
|
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 { texts } from '../texts';
|
||||||
|
import { APPROVAL_PHASE, ManagedApprovalRow } from '../models';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Настройки SLA существуют в ТРЁХ состояниях, и это главное, что здесь важно:
|
* Настройки SLA существуют в ТРЁХ состояниях, и это главное, что здесь важно:
|
||||||
@@ -68,3 +69,86 @@ export function slaHoursToInput(value: number | null | undefined): string {
|
|||||||
export function formatSlaHours(value: number | null | undefined): string {
|
export function formatSlaHours(value: number | null | undefined): string {
|
||||||
return value === null || value === undefined ? texts.common.empty : `${value} ч`;
|
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