основное сделано

This commit is contained in:
gorbenko.pavel
2026-08-12 15:12:11 +05:00
parent 4b83d666f1
commit 9fc089c37a
47 changed files with 2481 additions and 52 deletions
@@ -6,6 +6,7 @@ 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';
export interface IManageRequestsPhaseTableProps {
title: string;
@@ -26,6 +27,7 @@ export interface IManageRequestsPhaseTableProps {
requestRejected: boolean;
statusVariant: (status: string) => BadgeVariant;
onReassign: (row: ManagedApprovalRow) => void;
onEditSla: (row: ManagedApprovalRow) => void;
onRemove: (row: ManagedApprovalRow) => void;
onNotify: (row: ManagedApprovalRow) => void;
/** Id строки, для которой сейчас в процессе отправка уведомления — кнопка на ней отключена. */
@@ -79,6 +81,7 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
requestRejected,
statusVariant,
onReassign,
onEditSla,
onRemove,
onNotify,
notifyingRowId,
@@ -187,6 +190,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 +248,10 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
<td>{r.position || texts.common.empty}</td>
<td><Badge variant={statusVariant(r.status)}>{r.status || texts.common.empty}</Badge></td>
<td>{r.date || texts.common.empty}</td>
<td>
{formatSlaHours(r.slaHours)}
{r.slaHours === null && <CellNote>{texts.manageRequests.slaInherited}</CellNote>}
</td>
{/* Стрелки перестановки — СЛЕВА от действий над строкой (объединить/
переназначить/удалить), одна пара на весь блок (rowSpan): двигают
шаг целиком, а не отдельного участника параллельной группы. */}
@@ -293,6 +301,12 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
label={texts.manageRequests.reassign}
onClick={() => onReassign(r)}
/>
<IconButton
icon="flag"
className={styles.iconBtnVisible}
label={texts.manageRequests.editSla}
onClick={() => onEditSla(r)}
/>
<IconButton
icon="trash"
className={styles.iconBtnVisible}
@@ -2,6 +2,7 @@ import * as React from 'react';
import styles from './ManageRequestsScreen.module.scss';
import { ManageRequestsApproverModal } from './ManageRequestsApproverModal';
import { ManageRequestsPhaseTable } from './ManageRequestsPhaseTable';
import { ManageRequestsSlaModal } from './ManageRequestsSlaModal';
import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary';
import { RequestManageService } from '../../services/requestManageService';
@@ -178,6 +179,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
const [matrixLoading, setMatrixLoading] = React.useState(false);
const [matrixError, setMatrixError] = React.useState<string | undefined>();
const [modal, setModal] = React.useState<ManagedApprovalRow | undefined>();
const [slaRow, setSlaRow] = React.useState<ManagedApprovalRow | undefined>();
const [deleteRow, setDeleteRow] = React.useState<ManagedApprovalRow | undefined>();
const [saveError, setSaveError] = React.useState<string | undefined>();
const [notifyResult, setNotifyResult] = React.useState<{ message: string; kind: 'ok' | 'err' } | undefined>();
@@ -348,6 +350,31 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
const openReassign = (row: ManagedApprovalRow): void => setModal(row);
const closeModal = (): void => setModal(undefined);
const openSla = (row: ManagedApprovalRow): void => setSlaRow(row);
const closeSla = (): void => setSlaRow(undefined);
// SLA правится так же оптимистично, как переназначение: строка в матрице
// обновляется по ответу сервера, без перезагрузки всей матрицы.
const saveSla = (
// 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 || !slaRow) return Promise.resolve();
setSaveError(undefined);
const rowId = slaRow.id;
return requestManage
.updateRowSla(matrix, rowId, slaHours, askOverdueReason)
.then(() => {
setMatrix(prev => (prev ? patchRow(prev, rowId, { slaHours, askOverdueReason }) : prev));
closeSla();
})
.catch((e: Error) => {
setSaveError(reportError(e, 'saveSla: updateRowSla'));
throw e;
});
};
// Кого нельзя выбрать при переназначении — все остальные согласующие/
// исполнители ТОЙ ЖЕ фазы (сама строка modal исключена, иначе нельзя было
@@ -424,7 +451,11 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
comment: '',
order,
allowReassign: false,
phase
phase,
// Шаг, добавленный вручную, маршрутом-шаблоном не порождён и своего SLA не
// имеет — работает SLA типа заявки, пока его не зададут отдельно.
slaHours: null,
askOverdueReason: null
};
const previous = matrix;
@@ -548,6 +579,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
requestRejected={requestRejected}
statusVariant={statusVariant}
onReassign={openReassign}
onEditSla={openSla}
onRemove={askDelete}
onNotify={handleNotify}
notifyingRowId={notifyingRowId}
@@ -564,6 +596,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
requestRejected={requestRejected}
statusVariant={statusVariant}
onReassign={openReassign}
onEditSla={openSla}
onRemove={askDelete}
onNotify={handleNotify}
notifyingRowId={notifyingRowId}
@@ -590,6 +623,16 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
/>
)}
{slaRow && (
<ManageRequestsSlaModal
orderNo={slaRow.order}
slaHours={slaRow.slaHours}
askOverdueReason={slaRow.askOverdueReason}
onSave={saveSla}
onClose={closeSla}
/>
)}
{deleteRow && (
<ConfirmDialog
title={texts.manageRequests.deleteTitle}
@@ -0,0 +1,84 @@
import * as React from 'react';
import { texts } from '../../texts';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import {
boolToTriState,
isValidSlaHours,
parseSlaHours,
slaHoursToInput,
triStateToBool,
TriState,
TRI_STATE_OPTIONS
} from '../../utils/sla';
import { Field, FormGrid, FormModal, Select, TextInput } from '../ui';
export interface IManageRequestsSlaModalProps {
/** Номер шага — только для заголовка, чтобы было видно, что правим. */
orderNo: number;
// eslint-disable-next-line @rushstack/no-new-null -- null = «в строке не задано», это не 0
slaHours: number | null;
// eslint-disable-next-line @rushstack/no-new-null -- null = «в строке не задано»
askOverdueReason: boolean | null;
// eslint-disable-next-line @rushstack/no-new-null -- см. выше
onSave: (slaHours: number | null, askOverdueReason: boolean | null) => Promise<void>;
onClose: () => void;
}
/**
* SLA конкретной строки маршрута конкретной заявки.
*
* Правится отдельно от маршрута-шаблона: шаблон задаёт значение на момент
* создания заявки, а здесь его можно поменять для одной уже идущей заявки, не
* трогая остальные.
*
* Пустое поле сохраняется как «не задано» — тогда работает SLA типа заявки.
* Ноль пустотой не считается: это «напоминать сразу».
*/
export const ManageRequestsSlaModal: React.FC<IManageRequestsSlaModalProps> = ({
orderNo,
slaHours,
askOverdueReason,
onSave,
onClose
}) => {
const [hours, setHours] = React.useState(() => slaHoursToInput(slaHours));
const [ask, setAsk] = React.useState<TriState>(() => boolToTriState(askOverdueReason));
const submit = useAsyncSubmit();
const handleSubmit = (): void => {
if (!isValidSlaHours(hours)) {
submit.fail(texts.manageRequests.validationSlaHours);
return;
}
submit.run(() => onSave(parseSlaHours(hours), triStateToBool(ask)));
};
return (
<FormModal
title={texts.manageRequests.slaModalTitle(orderNo)}
error={submit.error}
isSaving={submit.isBusy}
onSubmit={handleSubmit}
onClose={onClose}
>
<FormGrid>
<Field label={texts.manageRequests.slaHoursLabel} full hint={texts.manageRequests.slaHoursHint}>
<TextInput
type="number"
min={0}
value={hours}
onChange={e => setHours(e.target.value)}
placeholder={texts.manageRequests.slaHoursPlaceholder}
/>
</Field>
<Field label={texts.manageRequests.askOverdueReasonLabel} full hint={texts.manageRequests.askOverdueReasonHint}>
<Select
options={TRI_STATE_OPTIONS}
value={ask}
onChange={e => setAsk(e.target.value as TriState)}
/>
</Field>
</FormGrid>
</FormModal>
);
};
@@ -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}
@@ -101,6 +101,17 @@ export interface ManagedApprovalRow {
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;
}
// Способ задать исполнителя шага: ровно одно из полей выше должно быть заполнено
@@ -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 {
@@ -220,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`
@@ -241,7 +246,11 @@ export class RequestManageService {
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
};
});
@@ -271,6 +280,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'>,
@@ -59,6 +59,12 @@ export const texts = {
loading: 'Загрузка...',
yes: 'Да',
no: 'Нет',
/**
* Третье состояние для настроек с наследованием (SLA, опрос причины
* просрочки): пусто значит «взять значение уровнем выше», и это НЕ то же
* самое, что «Нет» или «0» — их выбирают осознанно.
*/
inherited: 'Не задано',
/** Прочерк вместо пустого значения — чтобы ячейка таблицы не выглядела сломанной. */
empty: '—'
},
@@ -107,6 +113,7 @@ export const texts = {
columnSection: 'Раздел',
columnListId: 'Список SharePoint',
columnSiteUrl: 'Сайт SharePoint',
columnSla: 'SLA',
createTitle: 'Новый тип заявки',
editTitle: 'Редактировать тип заявки',
nameLabel: 'Название',
@@ -126,6 +133,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: 'Удалить тип заявки?',
@@ -209,6 +224,12 @@ export const texts = {
headTypeLabel: 'Тип руководителя',
reassignLabel: 'Переназначение',
reassignCheckbox: 'Разрешить переназначать другим согласующим',
slaHoursLabel: 'SLA шага, часов',
slaHoursHint:
'Пусто — берётся SLA типа заявки. Заданное здесь значение его перекрывает; 0 означает «напоминать сразу».',
slaHoursPlaceholder: 'Как у типа заявки',
askOverdueReasonLabel: 'Спрашивать причину просрочки',
askOverdueReasonHint: '«Не задано» — берётся значение типа заявки.',
conditionLabel: 'Условие видимости шага',
conditionCheckbox: 'Показывать шаг только при выполнении условия',
conditionDisabledHint: 'Недоступно: у типа заявки не указан список SharePoint (list_name).',
@@ -225,6 +246,7 @@ export const texts = {
validationPosition: 'Укажите должность.',
validationCondition: 'Заполните поле списка, оператор и значение условия.',
validationConditionNumber: 'Выбранное поле числовое — значение условия должно быть числом.',
validationSlaHours: 'SLA укажите целым числом часов (0 и больше) или оставьте поле пустым.',
deleteTitle: 'Удалить шаг?',
deleteText: (orderNo: number): string => `Шаг #${orderNo} будет удалён безвозвратно.`
},
@@ -318,6 +340,17 @@ export const texts = {
splitFromPrev: 'Отделить от предыдущего шага (выполнять после него)',
moveBlockedByDecision: 'Нельзя переставить — соседний шаг уже решён.',
reassignTitle: 'Переназначить шаг',
columnSla: 'SLA',
slaInherited: 'от типа заявки',
editSla: 'Настроить SLA шага',
slaModalTitle: (orderNo: number): string => `SLA шага ${orderNo}`,
slaHoursLabel: 'SLA, часов',
slaHoursHint:
'Через сколько часов простоя напоминать по этому шагу. Пусто — берётся SLA типа заявки; 0 — напоминать сразу.',
slaHoursPlaceholder: 'Как у типа заявки',
askOverdueReasonLabel: 'Спрашивать причину просрочки',
askOverdueReasonHint: '«Не задано» — берётся значение типа заявки.',
validationSlaHours: 'SLA укажите целым числом часов (0 и больше) или оставьте поле пустым.',
approverLabel: 'Сотрудник',
approverPlaceholder: 'Выберите сотрудника',
validationApprover: 'Выберите сотрудника.',
@@ -0,0 +1,86 @@
import {
boolToTriState,
formatSlaHours,
isValidSlaHours,
parseSlaHours,
slaHoursToInput,
triStateToBool
} from './sla';
// Смысл всех этих проверок один: «не задано» и «ноль/нет» — РАЗНЫЕ состояния.
// Если они где-то схлопнутся, шаг с настройкой «напоминать сразу» станет
// неотличим от шага без настройки, и напоминания по нему просто не придут.
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();
});
});
@@ -0,0 +1,70 @@
import { texts } from '../texts';
/**
* Настройки 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} ч`;
}
@@ -15,6 +15,8 @@ function step(overrides: Partial<TemplateRouteStep>): TemplateRouteStep {
fieldName: null,
operation: null,
value: null,
slaHours: null,
askOverdueReason: null,
...overrides
};
}