Sla (#11)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/11
This commit was merged in pull request #11.
This commit is contained in:
+63
-8
@@ -3,30 +3,59 @@ import { Employee } from '../../models';
|
||||
import { texts } from '../../texts';
|
||||
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
|
||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||
import { Field, FormGrid, FormModal, SearchableSelect } from '../ui';
|
||||
import {
|
||||
boolToTriState,
|
||||
isValidSlaHours,
|
||||
parseSlaHours,
|
||||
slaHoursToInput,
|
||||
triStateToBool,
|
||||
TriState,
|
||||
TRI_STATE_OPTIONS
|
||||
} from '../../utils/sla';
|
||||
import { Field, FormGrid, FormModal, SearchableSelect, Select, TextInput } from '../ui';
|
||||
|
||||
export interface IManageRequestsApproverModalProps {
|
||||
title: string;
|
||||
initialEmail?: string;
|
||||
/** SLA строки в часах на момент открытия. null — в строке не задано, работает SLA типа заявки. */
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- null = «не задано», это не 0
|
||||
initialSlaHours: number | null;
|
||||
/** Опрос причины просрочки по строке. null — берётся от типа заявки. */
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- см. initialSlaHours
|
||||
initialAskOverdueReason: boolean | null;
|
||||
employees: Employee[];
|
||||
employeesLoading: boolean;
|
||||
/** Email'ы (в любом регистре), уже занятые другими шагами той же фазы — этот человек не может встать в фазу второй раз. */
|
||||
excludedEmails: string[];
|
||||
onSave: (email: string, position: string) => Promise<void>;
|
||||
onSave: (
|
||||
email: string,
|
||||
position: string,
|
||||
// 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<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Переназначение согласующего/исполнителя существующего шага (см.
|
||||
* ManageRequestsScreen). Должность НЕ вводится — берётся из справочника
|
||||
* сотрудников (api.tnsu.kz/Employee/All, см. Employee.position в
|
||||
* models/dictionaries.ts) на момент сохранения, отдельного поля для неё
|
||||
* в этой форме нет. Добавление НОВОГО шага через модалку не идёт — см.
|
||||
* Правка существующего шага: кто согласует и какой у шага SLA.
|
||||
*
|
||||
* Одна форма на оба вида правки намеренно — раньше SLA жил в отдельной модалке
|
||||
* со своей кнопкой-флажком в строке, и получалось два похожих диалога и лишняя
|
||||
* иконка в и без того плотном ряду действий. Правки применяются одним
|
||||
* сохранением (см. saveModal в ManageRequestsScreen).
|
||||
*
|
||||
* Должность НЕ вводится — берётся из справочника сотрудников
|
||||
* (api.tnsu.kz/Employee/All, см. Employee.position в models/dictionaries.ts) на
|
||||
* момент сохранения. Добавление НОВОГО шага через модалку не идёт — см.
|
||||
* плашку-пикер под таблицей в ManageRequestsPhaseTable.
|
||||
*/
|
||||
export const ManageRequestsApproverModal: React.FC<IManageRequestsApproverModalProps> = ({
|
||||
title,
|
||||
initialEmail = '',
|
||||
initialSlaHours,
|
||||
initialAskOverdueReason,
|
||||
employees,
|
||||
employeesLoading,
|
||||
excludedEmails,
|
||||
@@ -34,6 +63,10 @@ export const ManageRequestsApproverModal: React.FC<IManageRequestsApproverModalP
|
||||
onClose
|
||||
}) => {
|
||||
const [email, setEmail] = React.useState(initialEmail);
|
||||
const [slaHours, setSlaHours] = React.useState(() => slaHoursToInput(initialSlaHours));
|
||||
const [askOverdueReason, setAskOverdueReason] = React.useState<TriState>(
|
||||
() => boolToTriState(initialAskOverdueReason)
|
||||
);
|
||||
const submit = useAsyncSubmit();
|
||||
|
||||
// Тот, кто уже занят в этой фазе, исключён из списка выбора — дубликат
|
||||
@@ -51,8 +84,14 @@ export const ManageRequestsApproverModal: React.FC<IManageRequestsApproverModalP
|
||||
submit.fail(texts.manageRequests.validationDuplicateApprover);
|
||||
return;
|
||||
}
|
||||
if (!isValidSlaHours(slaHours)) {
|
||||
submit.fail(texts.manageRequests.validationSlaHours);
|
||||
return;
|
||||
}
|
||||
const position = findEmployeeByEmail(employees, trimmedEmail)?.position || '';
|
||||
submit.run(() => onSave(trimmedEmail, position));
|
||||
submit.run(() =>
|
||||
onSave(trimmedEmail, position, parseSlaHours(slaHours), triStateToBool(askOverdueReason))
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -69,6 +108,22 @@ export const ManageRequestsApproverModal: React.FC<IManageRequestsApproverModalP
|
||||
onChange={setEmail}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={texts.manageRequests.slaHoursLabel} hint={texts.manageRequests.slaHoursHint}>
|
||||
<TextInput
|
||||
type="number"
|
||||
min={0}
|
||||
value={slaHours}
|
||||
onChange={e => setSlaHours(e.target.value)}
|
||||
placeholder={texts.manageRequests.slaHoursPlaceholder}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={texts.manageRequests.askOverdueReasonLabel} hint={texts.manageRequests.askOverdueReasonHint}>
|
||||
<Select
|
||||
options={TRI_STATE_OPTIONS}
|
||||
value={askOverdueReason}
|
||||
onChange={e => setAskOverdueReason(e.target.value as TriState)}
|
||||
/>
|
||||
</Field>
|
||||
</FormGrid>
|
||||
</FormModal>
|
||||
);
|
||||
|
||||
@@ -6,11 +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 { 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;
|
||||
/**
|
||||
@@ -75,6 +87,9 @@ function diffOrders(original: ManagedApprovalRow[], renumbered: Row[]): Array<{
|
||||
export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps> = ({
|
||||
title,
|
||||
rows,
|
||||
allRows,
|
||||
requestCreatedIso,
|
||||
requestTypeSlaHours,
|
||||
readOnly,
|
||||
requestRejected,
|
||||
statusVariant,
|
||||
@@ -187,6 +202,7 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
|
||||
<th>{texts.manageRequests.columnPosition}</th>
|
||||
<th>{texts.manageRequests.columnStatus}</th>
|
||||
<th>{texts.manageRequests.columnDate}</th>
|
||||
<th>{texts.manageRequests.columnSla}</th>
|
||||
{!readOnly && <th />}
|
||||
{!readOnly && <th />}
|
||||
</tr>
|
||||
@@ -244,6 +260,22 @@ 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>
|
||||
{(() => {
|
||||
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 (
|
||||
<td className={overdue ? styles.slaOverdue : undefined}>
|
||||
{formatSlaHours(hours)}
|
||||
{r.slaHours === null && hours !== null && (
|
||||
<CellNote>{texts.manageRequests.slaInherited}</CellNote>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})()}
|
||||
{/* Стрелки перестановки — СЛЕВА от действий над строкой (объединить/
|
||||
переназначить/удалить), одна пара на весь блок (rowSpan): двигают
|
||||
шаг целиком, а не отдельного участника параллельной группы. */}
|
||||
|
||||
+8
@@ -97,3 +97,11 @@
|
||||
.addRow {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
// Ячейка SLA просроченного АКТИВНОГО шага (см. isRowOverdue в utils/sla.ts) —
|
||||
// только текст, без фона: фон был бы третьим одновременным акцентом в строке
|
||||
// (жёлтая подсветка hover уже есть) и мешал бы, а не помогал заметить.
|
||||
.slaOverdue {
|
||||
color: $red-500;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -366,21 +366,40 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
// загруженного справочника сотрудников (тот же источник, что подставляет
|
||||
// должность, — см. ManageRequestsApproverModal), чтобы не делать лишний
|
||||
// запрос только за отображаемым именем.
|
||||
const saveModal = (email: string, position: string): Promise<void> => {
|
||||
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<void> => {
|
||||
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<IManageRequestsScreenProps> = ({ 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<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}
|
||||
@@ -560,6 +587,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}
|
||||
@@ -582,6 +612,8 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
<ManageRequestsApproverModal
|
||||
title={texts.manageRequests.reassignTitle}
|
||||
initialEmail={modal.approverEmail}
|
||||
initialSlaHours={modal.slaHours}
|
||||
initialAskOverdueReason={modal.askOverdueReason}
|
||||
employees={employees.items}
|
||||
employeesLoading={employees.isLoading}
|
||||
excludedEmails={reassignExcludedEmails}
|
||||
|
||||
@@ -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<IRequestTypesScreenProps> = ({ 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<IRequestTypesScreenProps> = ({ 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<IRequestTypesScreenProps> = ({ 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<IRequestTypesScreenProps> = ({ api, sh
|
||||
{ key: 'name', header: texts.requestTypes.columnName, render: r => <PrimaryCell>{r.name}</PrimaryCell> },
|
||||
{ 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 => <ListIdCell client={sharePoint} siteUrl={r.siteUrl} listId={r.listId} /> },
|
||||
{ key: 'siteUrl', header: texts.requestTypes.columnSiteUrl, render: r => <SiteUrlCell client={sharePoint} siteUrl={r.siteUrl} /> },
|
||||
{
|
||||
@@ -238,6 +262,22 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
||||
onChange={e => setForm({ ...form, listId: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={texts.requestTypes.slaHoursLabel} hint={texts.requestTypes.slaHoursHint}>
|
||||
<TextInput
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.slaHours}
|
||||
onChange={e => setForm({ ...form, slaHours: e.target.value })}
|
||||
placeholder={texts.requestTypes.slaHoursPlaceholder}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={texts.requestTypes.askOverdueReasonLabel} hint={texts.requestTypes.askOverdueReasonHint}>
|
||||
<Select
|
||||
options={TRI_STATE_OPTIONS}
|
||||
value={form.askOverdueReason}
|
||||
onChange={e => setForm({ ...form, askOverdueReason: e.target.value as TriState })}
|
||||
/>
|
||||
</Field>
|
||||
</FormGrid>
|
||||
</FormModal>
|
||||
)}
|
||||
|
||||
@@ -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<IRouteStepFormModalProps> = ({
|
||||
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<IRouteStepFormModalProps> = ({
|
||||
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<IRouteStepFormModalProps> = ({
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={texts.routeSteps.slaHoursLabel} hint={texts.routeSteps.slaHoursHint}>
|
||||
<TextInput
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.slaHours}
|
||||
onChange={e => setForm({ ...form, slaHours: e.target.value })}
|
||||
placeholder={texts.routeSteps.slaHoursPlaceholder}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={texts.routeSteps.askOverdueReasonLabel} hint={texts.routeSteps.askOverdueReasonHint}>
|
||||
<Select
|
||||
options={TRI_STATE_OPTIONS}
|
||||
value={form.askOverdueReason}
|
||||
onChange={e => setForm({ ...form, askOverdueReason: e.target.value as TriState })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={texts.routeSteps.conditionLabel} full hint={!hasListId ? texts.routeSteps.conditionDisabledHint : undefined}>
|
||||
<Checkbox
|
||||
label={texts.routeSteps.conditionCheckbox}
|
||||
|
||||
@@ -62,6 +62,12 @@ export function AsyncSearchableSelect<T>({
|
||||
// Растущий счётчик запросов: если ответ пришёл не от последнего запроса
|
||||
// (например, пользователь допечатал ещё символ) — просто игнорируем его.
|
||||
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<T>({
|
||||
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);
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// Способ задать исполнителя шага: ровно одно из полей выше должно быть заполнено
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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<number>();
|
||||
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<ManagedRequestMatrix, 'siteUrl' | 'approvalListTitle'>,
|
||||
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<void> {
|
||||
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<ManagedRequestMatrix, 'siteUrl' | 'approvalListTitle'>,
|
||||
|
||||
@@ -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: 'Выберите сотрудника.',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<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;
|
||||
}
|
||||
@@ -15,6 +15,8 @@ function step(overrides: Partial<TemplateRouteStep>): TemplateRouteStep {
|
||||
fieldName: null,
|
||||
operation: null,
|
||||
value: null,
|
||||
slaHours: null,
|
||||
askOverdueReason: null,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user