Files
tnsu-approval-route/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsApproverModal.tsx
T
gorbenko.pavel f3b0f0ad48
Tests / frontend-test (push) Successful in 1m39s
Tests / backend-test (push) Successful in 9m27s
Персист модалок: черновики, восстановление после F5, предупреждение при выходе (#30)
Модалки по жёлтым кнопкам теряли введённое при случайном клике мимо окна.

**Формы создания** (типы заявок, доступ к заявкам, маршруты, шаги маршрута)
- Черновик полей хранится в localStorage (useFormDraft) и переживает закрытие окна, «Отмену» и F5.
- Окно, открытое в момент F5, открывается снова (usePersistedOpen); закрытие сбрасывает только признак, черновик остаётся.
- Черновик стирается после успешного сохранения или кнопкой «Очистить» в подвале.

**Формы редактирования**
- Не персистятся — открываются с данными сервера.
- При выходе с несохранёнными правками (крестик, «Отмена», клик мимо) — диалог «Закрыть без сохранения?» (isDirty в FormModal).

**Устойчивость к старым данным в localStorage**
- coerceStored приводит сохранённое значение к форме текущего дефолта по полям: новое поле — дефолт, удалённое отбрасывается, значение другого типа и null заменяются дефолтом. Покрыто тестами.

**Прочее**
- Modal и ConfirmDialog рендерятся порталом в body поверх шапки SharePoint — на живой странице верх высокого окна уезжал под шапку.
- Убраны Sass-предупреждения mixed-decls в Card.module.scss.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/30
2026-10-19 04:46:16 +00:00

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>
);
};