872686e855
FormModal получил isDirty: если в форме редактирования существующей записи что-то изменили, крестик, «Отмена» и клик мимо окна сначала показывают диалог «Закрыть без сохранения?». Подключено к редактированию типа заявки, маршрута, шага маршрута и согласующего в заявке. Формы создания не затронуты — их черновик и так сохраняется. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
136 lines
5.8 KiB
TypeScript
136 lines
5.8 KiB
TypeScript
import * as React from 'react';
|
|
import { Employee } from '../../models';
|
|
import { texts } from '../../texts';
|
|
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
|
|
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
|
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,
|
|
// 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;
|
|
}
|
|
|
|
/**
|
|
* Правка существующего шага: кто согласует и какой у шага 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,
|
|
onSave,
|
|
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();
|
|
|
|
// Тот, кто уже занят в этой фазе, исключён из списка выбора — дубликат
|
|
// физически нельзя выбрать (см. excludedEmails в ManageRequestsScreen).
|
|
const excludedSet = new Set(excludedEmails.map(e => e.toLowerCase()));
|
|
const selectableEmployees = employees.filter(e => !excludedSet.has(e.email.toLowerCase()));
|
|
|
|
const isDirty =
|
|
email !== initialEmail ||
|
|
slaHours !== slaHoursToInput(initialSlaHours) ||
|
|
askOverdueReason !== boolToTriState(initialAskOverdueReason);
|
|
|
|
const handleSubmit = (): void => {
|
|
const trimmedEmail = email.trim();
|
|
if (!trimmedEmail) {
|
|
submit.fail(texts.manageRequests.validationApprover);
|
|
return;
|
|
}
|
|
if (excludedSet.has(trimmedEmail.toLowerCase())) {
|
|
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, parseSlaHours(slaHours), triStateToBool(askOverdueReason))
|
|
);
|
|
};
|
|
|
|
return (
|
|
<FormModal title={title} error={submit.error} isSaving={submit.isBusy} onSubmit={handleSubmit} onClose={onClose} isDirty={isDirty}>
|
|
<FormGrid>
|
|
<Field label={texts.manageRequests.approverLabel} required full>
|
|
<SearchableSelect
|
|
options={selectableEmployees.map(toEmployeeOption)}
|
|
value={email}
|
|
loading={employeesLoading}
|
|
placeholder={texts.manageRequests.approverPlaceholder}
|
|
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
|
emptyMessage={texts.select.employeesEmpty}
|
|
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>
|
|
);
|
|
};
|