все переехало в карандашик

This commit is contained in:
gorbenko.pavel
2026-08-12 17:19:40 +05:00
parent 6daecd2e18
commit a92f2e4978
6 changed files with 88 additions and 165 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>
);
@@ -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}
@@ -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 — напоминать сразу.',