основное сделано
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user