Sla #11
+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>
|
||||
);
|
||||
|
||||
+1
-17
@@ -38,7 +38,6 @@ 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 строки, для которой сейчас в процессе отправка уведомления — кнопка на ней отключена. */
|
||||
@@ -95,7 +94,6 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
|
||||
requestRejected,
|
||||
statusVariant,
|
||||
onReassign,
|
||||
onEditSla,
|
||||
onRemove,
|
||||
onNotify,
|
||||
notifyingRowId,
|
||||
@@ -269,16 +267,8 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
|
||||
const overdue =
|
||||
(r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution) &&
|
||||
isRowOverdue(r, allRows, requestCreatedIso, requestTypeSlaHours, new Date());
|
||||
const editable = !readOnly && rowEditable && !isGhost;
|
||||
return (
|
||||
<td
|
||||
className={[
|
||||
overdue ? styles.slaOverdue : '',
|
||||
editable ? styles.slaEditable : ''
|
||||
].filter(Boolean).join(' ')}
|
||||
title={editable ? texts.manageRequests.editSla : undefined}
|
||||
onClick={editable ? () => onEditSla(r) : undefined}
|
||||
>
|
||||
<td className={overdue ? styles.slaOverdue : undefined}>
|
||||
{formatSlaHours(hours)}
|
||||
{r.slaHours === null && hours !== null && (
|
||||
<CellNote>{texts.manageRequests.slaInherited}</CellNote>
|
||||
@@ -335,12 +325,6 @@ 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}
|
||||
|
||||
-12
@@ -105,15 +105,3 @@
|
||||
color: $red-500;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// Ячейка SLA, по которой можно кликнуть и открыть редактирование (см. onEditSla
|
||||
// в ManageRequestsPhaseTable) — курсор и подчёркивание по пунктиру как явный
|
||||
// намёк на кликабельность, без которого колонка неотличима от обычного текста.
|
||||
.slaEditable {
|
||||
cursor: pointer;
|
||||
border-bottom: 1px dashed $gray-400;
|
||||
|
||||
&:hover {
|
||||
background: $gray-50;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ 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';
|
||||
@@ -179,7 +178,6 @@ 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>();
|
||||
@@ -350,31 +348,6 @@ 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 исключена, иначе нельзя было
|
||||
@@ -393,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;
|
||||
});
|
||||
};
|
||||
@@ -583,7 +575,6 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
requestRejected={requestRejected}
|
||||
statusVariant={statusVariant}
|
||||
onReassign={openReassign}
|
||||
onEditSla={openSla}
|
||||
onRemove={askDelete}
|
||||
onNotify={handleNotify}
|
||||
notifyingRowId={notifyingRowId}
|
||||
@@ -603,7 +594,6 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
requestRejected={requestRejected}
|
||||
statusVariant={statusVariant}
|
||||
onReassign={openReassign}
|
||||
onEditSla={openSla}
|
||||
onRemove={askDelete}
|
||||
onNotify={handleNotify}
|
||||
notifyingRowId={notifyingRowId}
|
||||
@@ -622,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}
|
||||
@@ -630,16 +622,6 @@ 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}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -342,8 +342,6 @@ export const texts = {
|
||||
reassignTitle: 'Переназначить шаг',
|
||||
columnSla: 'SLA',
|
||||
slaInherited: 'от типа заявки',
|
||||
editSla: 'Настроить SLA шага',
|
||||
slaModalTitle: (orderNo: number): string => `SLA шага ${orderNo}`,
|
||||
slaHoursLabel: 'SLA, часов',
|
||||
slaHoursHint:
|
||||
'Через сколько часов простоя напоминать по этому шагу. Пусто — берётся SLA типа заявки; 0 — напоминать сразу.',
|
||||
|
||||
Reference in New Issue
Block a user