From 3d970bf461af99c3cc46b4ab703a37a8879bbbeb Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 15 Oct 2026 17:35:26 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A7=D0=B5=D1=80=D0=BD=D0=BE=D0=B2=D0=B8?= =?UTF-8?q?=D0=BA=D0=B8=20=D1=84=D0=BE=D1=80=D0=BC=20=D1=81=D0=BE=D0=B7?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=20=D1=81=D0=BE=D1=85=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D1=8F=D1=8E=D1=82=D1=81=D1=8F=20=D0=B2=20localStorage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Модалки по жёлтым кнопкам «Добавить» (типы заявок, доступ к заявкам, маршруты, шаги маршрута) теряли введённое при случайном клике по подложке. Теперь черновик живёт в localStorage (useFormDraft) и переживает закрытие окна, «Отмену» и F5. Сбрасывается после успешного сохранения или кнопкой «Очистить» в подвале модалки. Формы редактирования не персистятся — всегда открываются с данными сервера. Попутно убраны Sass-предупреждения mixed-decls в Card.module.scss. Co-Authored-By: Claude Opus 5.5 --- .../screens/RequestTypeAccessScreen.tsx | 26 +++++++-- .../components/screens/RequestTypesScreen.tsx | 15 ++++- .../components/screens/RouteFormModal.tsx | 12 +++- .../components/screens/RouteStepFormModal.tsx | 12 +++- .../components/ui/Card.module.scss | 3 +- .../approvalAdmin/components/ui/FormModal.tsx | 14 +++++ .../components/ui/Modal.module.scss | 3 + .../approvalAdmin/hooks/useFormDraft.ts | 58 +++++++++++++++++++ .../hooks/useLocalStorageState.ts | 28 +++++---- frontend/src/webparts/approvalAdmin/texts.ts | 2 + 10 files changed, 150 insertions(+), 23 deletions(-) create mode 100644 frontend/src/webparts/approvalAdmin/hooks/useFormDraft.ts diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RequestTypeAccessScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RequestTypeAccessScreen.tsx index 8faecc3..b6c13f4 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RequestTypeAccessScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RequestTypeAccessScreen.tsx @@ -9,6 +9,7 @@ import { usePortalEmployees } from '../../hooks/usePortalEmployees'; import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; import { useDeleteTarget } from '../../hooks/useDeleteTarget'; import { useLocalStorageState } from '../../hooks/useLocalStorageState'; +import { useFormDraft } from '../../hooks/useFormDraft'; import { toEmployeeOption } from '../../utils/employee'; import { replaceRoute } from '../../routing'; import { RequestTypeAccess } from '../../models'; @@ -41,6 +42,16 @@ export interface IRequestTypeAccessScreenProps { const ACCESS_FILTER_TYPE_STORAGE_KEY = 'approvalAdmin.accessFilterRequestTypeId'; const ACCESS_FILTER_EMAIL_STORAGE_KEY = 'approvalAdmin.accessFilterEmail'; +/** Черновик формы выдачи доступа — переживает закрытие модалки, см. useFormDraft. */ +const CREATE_DRAFT_STORAGE_KEY = 'approvalAdmin.draft.requestTypeAccess'; + +interface IFormState { + requestTypeId: string; + emails: string[]; +} + +const EMPTY_FORM: IFormState = { requestTypeId: '', emails: [] }; + export const RequestTypeAccessScreen: React.FC = ({ api, dictionary, @@ -52,8 +63,10 @@ export const RequestTypeAccessScreen: React.FC = const employees = usePortalEmployees(dictionary); const [showForm, setShowForm] = React.useState(false); - const [requestTypeId, setRequestTypeId] = React.useState(''); - const [emails, setEmails] = React.useState([]); + const draft = useFormDraft(CREATE_DRAFT_STORAGE_KEY, EMPTY_FORM); + const { requestTypeId, emails } = draft.value; + const setEmails = (update: (current: string[]) => string[]): void => + draft.setValue(f => ({ ...f, emails: update(f.emails) })); const submit = useAsyncSubmit(); const [filterRequestTypeId, setFilterRequestTypeId] = useLocalStorageState(ACCESS_FILTER_TYPE_STORAGE_KEY, ''); @@ -96,8 +109,6 @@ export const RequestTypeAccessScreen: React.FC = : item.sharePointLists.map(l => l.title ?? l.listId).join(', '); const openCreate = (): void => { - setRequestTypeId(''); - setEmails([]); setShowForm(true); }; @@ -128,10 +139,11 @@ export const RequestTypeAccessScreen: React.FC = if (failedEmails.length < emails.length) access.reload(); if (failures.length === 0) { + draft.reset(); setShowForm(false); return; } - setEmails(failedEmails); + setEmails(() => failedEmails); throw new Error(texts.requestTypeAccess.createFailed(failures)); }); }; @@ -235,6 +247,8 @@ export const RequestTypeAccessScreen: React.FC = isSaving={submit.isBusy} onSubmit={submitForm} onClose={() => setShowForm(false)} + onReset={draft.reset} + canReset={draft.isDirty} > @@ -242,7 +256,7 @@ export const RequestTypeAccessScreen: React.FC = options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))} placeholder={texts.requestTypeAccess.requestTypePlaceholder} value={requestTypeId} - onChange={e => setRequestTypeId(e.target.value)} + onChange={e => draft.setValue(f => ({ ...f, requestTypeId: e.target.value }))} /> diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx index a86cd58..ece9045 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx @@ -19,6 +19,7 @@ import { useSharePointSiteTitle } from '../../hooks/useSharePointSiteTitle'; import { useSharePointListTitle } from '../../hooks/useSharePointListTitle'; import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; import { useDeleteTarget } from '../../hooks/useDeleteTarget'; +import { useFormDraft } from '../../hooks/useFormDraft'; import { ActionsCell, Alert, @@ -57,6 +58,9 @@ const EMPTY_FORM: IFormState = { name: '', pageUrl: '', section: '', siteUrl: '', listId: '', slaHours: '', askOverdueReason: '' }; +/** Черновик формы создания — переживает закрытие модалки, см. useFormDraft. */ +const CREATE_DRAFT_STORAGE_KEY = 'approvalAdmin.draft.requestType'; + // siteUrl/listId — поля RequestType, зеркалящие nullable JSON-контракт бэкенда // (см. models/requestTypes.ts) — здесь сразу приводим null к undefined, // внутренний код (хуки/кэш SharePointListsClient) с null не работает нигде. @@ -79,7 +83,10 @@ export const RequestTypesScreen: React.FC = ({ api, sh const [editing, setEditing] = React.useState(); const [showForm, setShowForm] = React.useState(false); - const [form, setForm] = React.useState(EMPTY_FORM); + const draft = useFormDraft(CREATE_DRAFT_STORAGE_KEY, EMPTY_FORM); + const [editForm, setEditForm] = React.useState(EMPTY_FORM); + const form = editing ? editForm : draft.value; + const setForm = editing ? setEditForm : draft.setValue; const submit = useAsyncSubmit(); const sharePointLists = useSharePointLists(sharePoint, form.siteUrl); const siteTitle = useSharePointSiteTitle(sharePoint, form.siteUrl); @@ -92,13 +99,12 @@ export const RequestTypesScreen: React.FC = ({ api, sh const openCreate = (): void => { setEditing(undefined); - setForm(EMPTY_FORM); setShowForm(true); }; const openEdit = (item: RequestType): void => { setEditing(item); - setForm({ + setEditForm({ name: item.name, pageUrl: item.pageUrl, section: item.section, @@ -143,6 +149,7 @@ export const RequestTypesScreen: React.FC = ({ api, sh submit.run(() => request.then(() => { + if (!editing) draft.reset(); setShowForm(false); list.reload(); }) @@ -199,6 +206,8 @@ export const RequestTypesScreen: React.FC = ({ api, sh isSaving={submit.isBusy} onSubmit={submitForm} onClose={() => setShowForm(false)} + onReset={editing ? undefined : draft.reset} + canReset={draft.isDirty} > diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RouteFormModal.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RouteFormModal.tsx index 082e15d..0e74cb8 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RouteFormModal.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RouteFormModal.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { Company, Department, RequestType, TemplateRoute } from '../../models'; import { texts } from '../../texts'; import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; +import { useFormDraft } from '../../hooks/useFormDraft'; import { ApprovalRouteApi } from '../../services/api'; import { Checkbox, Field, FormGrid, FormModal, SearchableSelect, Select } from '../ui'; @@ -27,6 +28,9 @@ interface IFormState { requiresControl: boolean; } +/** Черновик формы создания — переживает закрытие модалки, см. useFormDraft. */ +const CREATE_DRAFT_STORAGE_KEY = 'approvalAdmin.draft.route'; + function toFormState(editing: TemplateRoute | undefined, initialRequestTypeId: string): IFormState { if (!editing) { return { @@ -58,7 +62,10 @@ export const RouteFormModal: React.FC = ({ onSaved, onClose }) => { - const [form, setForm] = React.useState(() => toFormState(editing, initialRequestTypeId)); + // Редактирование не персистим — форма всегда открывается с данными маршрута с сервера. + const draft = useFormDraft(editing ? undefined : CREATE_DRAFT_STORAGE_KEY, toFormState(editing, initialRequestTypeId)); + const form = draft.value; + const setForm = draft.setValue; const submit = useAsyncSubmit(); // Отдел принадлежит компании, поэтому список сужаем под выбранную: @@ -93,6 +100,7 @@ export const RouteFormModal: React.FC = ({ submit.run(() => request.then(() => { + draft.reset(); onSaved(); onClose(); }) @@ -106,6 +114,8 @@ export const RouteFormModal: React.FC = ({ isSaving={submit.isBusy} onSubmit={submitForm} onClose={onClose} + onReset={editing ? undefined : draft.reset} + canReset={draft.isDirty} > diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx index 4918eb7..f73b7d6 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx @@ -24,6 +24,7 @@ import { import { detectExecutorKind } from '../../utils/stepExecutor'; import { matchEmployeesByPosition, toEmployeeOption } from '../../utils/employee'; import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; +import { useFormDraft } from '../../hooks/useFormDraft'; import { useSharePointFields } from '../../hooks/useSharePointFields'; import { ApprovalRouteApi } from '../../services/api'; import { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists'; @@ -119,6 +120,9 @@ const IS_MANAGER_VALUE_OPTIONS = [ { value: 'false', label: texts.common.no } ]; +/** Черновик формы создания шага — свой у каждого маршрута, переживает закрытие модалки (см. useFormDraft). */ +const createDraftStorageKey = (routeId: number): string => `approvalAdmin.draft.routeStep.${routeId}`; + function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number): IFormState { if (!editing) { return { @@ -170,7 +174,10 @@ export const RouteStepFormModal: React.FC = ({ onSaved, onClose }) => { - const [form, setForm] = React.useState(() => toFormState(editing, nextOrderNo)); + // Редактирование не персистим — форма всегда открывается с данными шага с сервера. + const draft = useFormDraft(editing ? undefined : createDraftStorageKey(route.id), toFormState(editing, nextOrderNo)); + const form = draft.value; + const setForm = draft.setValue; const submit = useAsyncSubmit(); const hasListId = Boolean(requestType?.listId); const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl ?? undefined, requestType?.listId ?? undefined); @@ -400,6 +407,7 @@ export const RouteStepFormModal: React.FC = ({ submit.run(() => request.then(step => { + draft.reset(); onSaved(step); onClose(); }) @@ -413,6 +421,8 @@ export const RouteStepFormModal: React.FC = ({ isSaving={submit.isBusy} onSubmit={submitForm} onClose={onClose} + onReset={editing ? undefined : draft.reset} + canReset={draft.isDirty} > diff --git a/frontend/src/webparts/approvalAdmin/components/ui/Card.module.scss b/frontend/src/webparts/approvalAdmin/components/ui/Card.module.scss index 3109914..e13b974 100644 --- a/frontend/src/webparts/approvalAdmin/components/ui/Card.module.scss +++ b/frontend/src/webparts/approvalAdmin/components/ui/Card.module.scss @@ -16,12 +16,13 @@ gap: 16px; box-shadow: $sh-1; cursor: pointer; - @include card-hover-gold; border: 1px solid $gray-200; text-decoration: none; color: inherit; position: relative; overflow: hidden; + // Миксин с вложенным &:hover — после всех объявлений, иначе Sass ругается mixed-decls. + @include card-hover-gold; &::before { content: ''; diff --git a/frontend/src/webparts/approvalAdmin/components/ui/FormModal.tsx b/frontend/src/webparts/approvalAdmin/components/ui/FormModal.tsx index d8175a0..6466e90 100644 --- a/frontend/src/webparts/approvalAdmin/components/ui/FormModal.tsx +++ b/frontend/src/webparts/approvalAdmin/components/ui/FormModal.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { Alert } from './Alert'; import { Button } from './Button'; import { Modal } from './Modal'; +import styles from './Modal.module.scss'; import { texts } from '../../texts'; export interface IFormModalProps { @@ -15,6 +16,12 @@ export interface IFormModalProps { savingLabel?: string; onSubmit: () => void; onClose: () => void; + /** + * Сбросить черновик формы (см. useFormDraft). Кнопка «Очистить» показывается, + * только если передан и canReset — «Отмена» и клик мимо окна черновик не трогают. + */ + onReset?: () => void; + canReset?: boolean; children: React.ReactNode; } @@ -30,6 +37,8 @@ export const FormModal: React.FC = ({ savingLabel = texts.common.saving, onSubmit, onClose, + onReset, + canReset, children }) => ( = ({ onClose={onClose} footer={ <> + {onReset && canReset && ( + + + + )}