Sla (#11)
Tests / backend-test (push) Successful in 1m50s
Tests / frontend-test (push) Successful in 49s

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:
2026-08-13 04:44:08 +00:00
parent 7f2aabbf29
commit d076f4e98d
103 changed files with 2606 additions and 1265 deletions
@@ -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): двигают
шаг целиком, а не отдельного участника параллельной группы. */}
@@ -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);
})