From 3d970bf461af99c3cc46b4ab703a37a8879bbbeb Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 15 Oct 2026 17:35:26 +0500 Subject: [PATCH 1/7] =?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 && ( + + + + )} - + ); diff --git a/frontend/src/webparts/approvalAdmin/components/ui/Modal.module.scss b/frontend/src/webparts/approvalAdmin/components/ui/Modal.module.scss index 3ea7bf3..880e239 100644 --- a/frontend/src/webparts/approvalAdmin/components/ui/Modal.module.scss +++ b/frontend/src/webparts/approvalAdmin/components/ui/Modal.module.scss @@ -7,9 +7,17 @@ display: flex; align-items: center; justify-content: center; - z-index: 300; + // Выше шапки SharePoint, но ниже слоёв Fluent UI (1000000). Оверлей рендерится + // порталом в body (см. ModalOverlay), поэтому сравнивается с шапкой напрямую. + z-index: 100000; backdrop-filter: blur(6px); padding: 20px; + // Портал вне корня веб-части — шрифт и метрики .shell (AdminApp.module.scss) + // сюда не наследуются, задаём заново. + font-family: $font-sans; + font-size: $fs-body; + line-height: 1.55; + box-sizing: border-box; } .box { diff --git a/frontend/src/webparts/approvalAdmin/components/ui/Modal.tsx b/frontend/src/webparts/approvalAdmin/components/ui/Modal.tsx index 25cf093..338f974 100644 --- a/frontend/src/webparts/approvalAdmin/components/ui/Modal.tsx +++ b/frontend/src/webparts/approvalAdmin/components/ui/Modal.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import styles from './Modal.module.scss'; import { IconButton } from './IconButton'; +import { ModalOverlay } from './ModalOverlay'; import { texts } from '../../texts'; export interface IModalProps { @@ -11,8 +12,7 @@ export interface IModalProps { } export const Modal: React.FC = ({ title, onClose, footer, children }) => ( - // Клик по подложке закрывает окно, клик внутри — нет (проверяем сам currentTarget). -
{ if (e.target === e.currentTarget) onClose(); }}> +
{title}
@@ -21,5 +21,5 @@ export const Modal: React.FC = ({ title, onClose, footer, children
{children}
{footer &&
{footer}
}
-
+
); diff --git a/frontend/src/webparts/approvalAdmin/components/ui/ModalOverlay.tsx b/frontend/src/webparts/approvalAdmin/components/ui/ModalOverlay.tsx new file mode 100644 index 0000000..5588166 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/components/ui/ModalOverlay.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import styles from './Modal.module.scss'; + +export interface IModalOverlayProps { + /** Клик по подложке (не по содержимому окна). */ + onBackdropClick: () => void; + children: React.ReactNode; +} + +/** + * Затемнение под модалкой — порталом в document.body, а не внутри веб-части. + * На живой странице SharePoint канва веб-части лежит в своём контексте наложения + * ниже шапки (suite bar), и position: fixed-оверлей изнутри неё оказывался ПОД + * шапкой: верх окна, центрированного по высоте экрана, уезжал за неё. В workbench + * шапки нет — там было незаметно. + */ +export const ModalOverlay: React.FC = ({ onBackdropClick, children }) => + ReactDOM.createPortal( + // Клик по подложке закрывает окно, клик внутри — нет (проверяем сам currentTarget). +
{ if (e.target === e.currentTarget) onBackdropClick(); }}> + {children} +
, + document.body + ); diff --git a/frontend/src/webparts/approvalAdmin/components/ui/ProgressOverlay.module.scss b/frontend/src/webparts/approvalAdmin/components/ui/ProgressOverlay.module.scss index a6068ca..5b722c6 100644 --- a/frontend/src/webparts/approvalAdmin/components/ui/ProgressOverlay.module.scss +++ b/frontend/src/webparts/approvalAdmin/components/ui/ProgressOverlay.module.scss @@ -1,6 +1,6 @@ @import '../../../../styles/design-tokens'; -// Ниже Modal.module.scss/.overlay (z-index 300) — диалоги, которые может +// Ниже Modal.module.scss/.overlay (z-index 100000) — диалоги, которые может // открыть сама длительная операция (например, разрешение конфликта), должны // быть ПОВЕРХ этой подложки, а не под ней. .overlay { diff --git a/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.module.scss b/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.module.scss index 01fd05e..806c5f4 100644 --- a/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.module.scss +++ b/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.module.scss @@ -73,7 +73,7 @@ box-shadow: $sh-modal; border: 1px solid $gray-200; padding: 6px; - z-index: 400; + z-index: 100001; // над оверлеем модалки (Modal.module.scss) max-height: 320px; display: flex; flex-direction: column; diff --git a/frontend/src/webparts/approvalAdmin/components/ui/index.ts b/frontend/src/webparts/approvalAdmin/components/ui/index.ts index 6dd1176..0cc9a0c 100644 --- a/frontend/src/webparts/approvalAdmin/components/ui/index.ts +++ b/frontend/src/webparts/approvalAdmin/components/ui/index.ts @@ -17,6 +17,7 @@ export * from './PrimaryCell'; export * from './ActionsCell'; export * from './CellNote'; export * from './Modal'; +export * from './ModalOverlay'; export * from './ConfirmDialog'; export * from './FormModal'; export * from './FormGrid'; -- 2.34.1 From 872686e855c9501be9f76a5d5d888d9535e766fe Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 16 Oct 2026 10:02:40 +0500 Subject: [PATCH 3/7] =?UTF-8?q?=D0=9F=D1=80=D0=B5=D0=B4=D1=83=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=20=D0=B2=D1=8B=D1=85=D0=BE=D0=B4=D0=B5=20=D0=B8=D0=B7=20=D1=84?= =?UTF-8?q?=D0=BE=D1=80=D0=BC=D1=8B=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20=D1=81=20?= =?UTF-8?q?=D0=BD=D0=B5=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D1=91=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=BC=D0=B8=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0?= =?UTF-8?q?=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FormModal получил isDirty: если в форме редактирования существующей записи что-то изменили, крестик, «Отмена» и клик мимо окна сначала показывают диалог «Закрыть без сохранения?». Подключено к редактированию типа заявки, маршрута, шага маршрута и согласующего в заявке. Формы создания не затронуты — их черновик и так сохраняется. Co-Authored-By: Claude Sonnet 5.5 --- .../screens/ManageRequestsApproverModal.tsx | 7 +- .../components/screens/RequestTypesScreen.tsx | 24 +++--- .../components/screens/RouteFormModal.tsx | 1 + .../components/screens/RouteStepFormModal.tsx | 1 + .../components/ui/ConfirmDialog.tsx | 5 +- .../approvalAdmin/components/ui/FormModal.tsx | 74 +++++++++++++------ frontend/src/webparts/approvalAdmin/texts.ts | 5 ++ 7 files changed, 84 insertions(+), 33 deletions(-) diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsApproverModal.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsApproverModal.tsx index 6a6c119..ec19f29 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsApproverModal.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsApproverModal.tsx @@ -74,6 +74,11 @@ export const ManageRequestsApproverModal: React.FC 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) { @@ -95,7 +100,7 @@ export const ManageRequestsApproverModal: React.FC + = ({ api, sh const draft = useFormDraft(CREATE_DRAFT_STORAGE_KEY, EMPTY_FORM); const [editForm, setEditForm] = React.useState(EMPTY_FORM); const form = editing ? editForm : draft.value; + const isEditDirty = Boolean(editing) && JSON.stringify(editForm) !== JSON.stringify(toFormState(editing as RequestType)); const setForm = editing ? setEditForm : draft.setValue; const submit = useAsyncSubmit(); const sharePointLists = useSharePointLists(sharePoint, form.siteUrl); @@ -104,15 +117,7 @@ export const RequestTypesScreen: React.FC = ({ api, sh const openEdit = (item: RequestType): void => { setEditing(item); - setEditForm({ - name: item.name, - pageUrl: item.pageUrl, - section: item.section, - siteUrl: item.siteUrl ?? '', - listId: item.listId ?? '', - slaHours: slaHoursToInput(item.slaHours), - askOverdueReason: boolToTriState(item.askOverdueReason) - }); + setEditForm(toFormState(item)); setShowForm(true); }; @@ -208,6 +213,7 @@ export const RequestTypesScreen: React.FC = ({ api, sh onClose={() => setShowForm(false)} onReset={editing ? undefined : draft.reset} canReset={draft.isDirty} + isDirty={isEditDirty} > diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RouteFormModal.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RouteFormModal.tsx index 0e74cb8..b98c5d9 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RouteFormModal.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RouteFormModal.tsx @@ -116,6 +116,7 @@ export const RouteFormModal: React.FC = ({ onClose={onClose} onReset={editing ? undefined : draft.reset} canReset={draft.isDirty} + isDirty={Boolean(editing) && draft.isDirty} > diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx index f73b7d6..6766944 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx @@ -423,6 +423,7 @@ export const RouteStepFormModal: React.FC = ({ onClose={onClose} onReset={editing ? undefined : draft.reset} canReset={draft.isDirty} + isDirty={Boolean(editing) && draft.isDirty} > diff --git a/frontend/src/webparts/approvalAdmin/components/ui/ConfirmDialog.tsx b/frontend/src/webparts/approvalAdmin/components/ui/ConfirmDialog.tsx index 57d1f03..289f13d 100644 --- a/frontend/src/webparts/approvalAdmin/components/ui/ConfirmDialog.tsx +++ b/frontend/src/webparts/approvalAdmin/components/ui/ConfirmDialog.tsx @@ -8,6 +8,8 @@ export interface IConfirmDialogProps { title: string; text: string; confirmLabel: string; + /** Подпись кнопки отказа — по умолчанию «Отмена». */ + cancelLabel?: string; onConfirm: () => void; onCancel: () => void; busy?: boolean; @@ -17,6 +19,7 @@ export const ConfirmDialog: React.FC = ({ title, text, confirmLabel, + cancelLabel = texts.common.cancel, onConfirm, onCancel, busy @@ -26,7 +29,7 @@ export const ConfirmDialog: React.FC = ({
{title}

{text}

- +
diff --git a/frontend/src/webparts/approvalAdmin/components/ui/FormModal.tsx b/frontend/src/webparts/approvalAdmin/components/ui/FormModal.tsx index 6466e90..e1cc175 100644 --- a/frontend/src/webparts/approvalAdmin/components/ui/FormModal.tsx +++ b/frontend/src/webparts/approvalAdmin/components/ui/FormModal.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { Alert } from './Alert'; import { Button } from './Button'; +import { ConfirmDialog } from './ConfirmDialog'; import { Modal } from './Modal'; import styles from './Modal.module.scss'; import { texts } from '../../texts'; @@ -22,6 +23,12 @@ export interface IFormModalProps { */ onReset?: () => void; canReset?: boolean; + /** + * В форме есть несохранённые правки существующей записи: выход (крестик, + * «Отмена», клик мимо окна) сначала спрашивает подтверждение. Для форм создания + * не нужен — их черновик сохраняется сам (см. useFormDraft). + */ + isDirty?: boolean; children: React.ReactNode; } @@ -39,26 +46,49 @@ export const FormModal: React.FC = ({ onClose, onReset, canReset, + isDirty, children -}) => ( - - {onReset && canReset && ( - - - - )} - - - - } - > - {error && {error}} - {children} - -); +}) => { + const [confirmingClose, setConfirmingClose] = React.useState(false); + // Во время сохранения не закрываем и не спрашиваем: запрос уже ушёл. + const requestClose = (): void => { + if (isSaving) return; + if (isDirty) setConfirmingClose(true); + else onClose(); + }; + + return ( + <> + + {onReset && canReset && ( + + + + )} + + + + } + > + {error && {error}} + {children} + + {confirmingClose && ( + setConfirmingClose(false)} + /> + )} + + ); +}; diff --git a/frontend/src/webparts/approvalAdmin/texts.ts b/frontend/src/webparts/approvalAdmin/texts.ts index 4fbbe43..df91bcb 100644 --- a/frontend/src/webparts/approvalAdmin/texts.ts +++ b/frontend/src/webparts/approvalAdmin/texts.ts @@ -78,6 +78,11 @@ export const texts = { close: 'Закрыть', /** Сброс черновика формы создания — сам черновик переживает закрытие окна. */ resetDraft: 'Очистить', + /** Предупреждение при выходе из формы редактирования с несохранёнными правками. */ + discardTitle: 'Закрыть без сохранения?', + discardText: 'Внесённые изменения не сохранятся.', + discardConfirm: 'Закрыть без сохранения', + discardKeepEditing: 'Продолжить редактирование', loading: 'Загрузка...', yes: 'Да', no: 'Нет', -- 2.34.1 From 6deebf950789731b0efb6f2b040826907ca1e248 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 16 Oct 2026 10:16:36 +0500 Subject: [PATCH 4/7] =?UTF-8?q?=D0=A1=D1=82=D0=B0=D1=80=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D1=8C=20=D0=B2=20localStorage=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=B2=D0=BE=D0=B4=D0=B8=D1=82=D1=81=D1=8F=20?= =?UTF-8?q?=D0=BA=20=D1=84=D0=BE=D1=80=D0=BC=D0=B5=20=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D1=83=D1=89=D0=B5=D0=B9=20=D1=84=D0=BE=D1=80=D0=BC=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше сохранённое значение только мёржилось с дефолтом: удалённые поля оставались в состоянии, а значение другого типа (или null вместо объекта) роняло рендер. coerceStored отбрасывает лишние ключи и заменяет значения не того типа дефолтом; покрыто тестами. Co-Authored-By: Claude Sonnet 5.5 --- .../hooks/useLocalStorageState.test.ts | 39 +++++++++++++++++++ .../hooks/useLocalStorageState.ts | 36 +++++++++++++---- 2 files changed, 68 insertions(+), 7 deletions(-) create mode 100644 frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.test.ts diff --git a/frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.test.ts b/frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.test.ts new file mode 100644 index 0000000..3c27a8c --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.test.ts @@ -0,0 +1,39 @@ +import { coerceStored } from './useLocalStorageState'; + +describe('coerceStored', () => { + const initial = { name: '', count: 0, flag: false, tags: [] as string[], nested: { a: '' } }; + + it('подставляет дефолт для полей, добавленных после сохранения', () => { + expect(coerceStored(initial, { name: 'x' })).toEqual({ ...initial, name: 'x' }); + }); + + it('отбрасывает ключи, которых в форме больше нет', () => { + expect(coerceStored(initial, { name: 'x', removed: 1 })).not.toHaveProperty('removed'); + }); + + it('заменяет значение другого типа дефолтом', () => { + expect(coerceStored(initial, { name: 5, tags: { a: 1 }, flag: 'yes', nested: 'oops' })) + .toEqual(initial); + }); + + it('принимает массив нужного типа и мёржит вложенный объект', () => { + expect(coerceStored(initial, { tags: ['a'], nested: {} })) + .toEqual({ ...initial, tags: ['a'] }); + }); + + it('не падает на null и примитиве вместо объекта', () => { + expect(coerceStored(initial, null)).toEqual(initial); + expect(coerceStored(initial, 'x')).toEqual(initial); + }); + + it('для примитивного initial проверяет тип', () => { + expect(coerceStored('', 5)).toBe(''); + expect(coerceStored('', 'abc')).toBe('abc'); + expect(coerceStored([], [1, 2])).toEqual([1, 2]); + }); + + it('при initial === undefined принимает сохранённое как есть', () => { + expect(coerceStored(undefined, 'a')).toBe('a'); + expect(coerceStored({ x: undefined as string | undefined }, { x: 'a' })).toEqual({ x: 'a' }); + }); +}); diff --git a/frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.ts b/frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.ts index b24785b..8524115 100644 --- a/frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.ts +++ b/frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.ts @@ -5,7 +5,30 @@ function isPlainObject(value: unknown): value is Record { } /** - * Значение из localStorage под key, мелко смёрженное с initial (см. useLocalStorageState), + * Сохранённое значение, приведённое к форме initial. Запись могла быть сделана + * старой версией формы, поэтому ей нельзя доверять слепо: + * - объект мёржится с initial — добавленные с тех пор поля получают дефолт; + * - ключи, которых в initial нет (поле удалили), отбрасываются; + * - значение, чей тип не совпал с initial (было число — стала строка, было + * [] — стал объект), заменяется дефолтом: иначе .map/.trim по нему уронил бы рендер; + * - null/undefined в initial типа не задают — там принимаем любое сохранённое. + */ +export function coerceStored(initial: T, stored: unknown): T { + if (initial === null || initial === undefined) return stored as T; + if (Array.isArray(initial)) return (Array.isArray(stored) ? stored : initial) as unknown as T; + if (isPlainObject(initial)) { + if (!isPlainObject(stored)) return initial; + const result: Record = { ...initial }; + Object.keys(initial).forEach(k => { + if (k in stored) result[k] = coerceStored(initial[k], stored[k]); + }); + return result as T; + } + return (typeof stored === typeof initial ? stored : initial) as T; +} + +/** + * Значение из localStorage под key, приведённое к форме initial (см. coerceStored), * или initial, если записи нет / она битая / localStorage недоступен. */ export function readLocalStorage(key: string, initial: T): T { @@ -13,8 +36,7 @@ export function readLocalStorage(key: string, initial: T): T { const raw = window.localStorage.getItem(key); if (!raw) return initial; - const parsed = JSON.parse(raw) as T; - return isPlainObject(initial) && isPlainObject(parsed) ? { ...initial, ...parsed } as T : parsed; + return coerceStored(initial, JSON.parse(raw)); } catch { return initial; } @@ -25,10 +47,10 @@ export function readLocalStorage(key: string, initial: T): T { * в localStorage под key. Падение доступа к localStorage (приватный режим, * квота) не ломает форму — просто не персистит. * - * Сохранённое значение мелко мёржится с initial (для объектных T): если после - * обновления в форму добавили новое поле, у пользователей со старой записью в - * localStorage оно не потеряется как undefined (что уронило бы рендер на - * первом же .map/.trim по этому полю), а подставится дефолт из initial. + * Сохранённое значение приводится к форме initial (см. coerceStored): новое поле + * получает дефолт, удалённое отбрасывается, значение не того типа заменяется + * дефолтом — иначе старая запись в localStorage уронила бы рендер на первом же + * .map/.trim по этому полю. */ export function useLocalStorageState(key: string, initial: T): [T, React.Dispatch>] { const [value, setValue] = React.useState(() => readLocalStorage(key, initial)); -- 2.34.1 From 08dc484d035189d1f99eccfb3485194aefc520de Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 16 Oct 2026 10:30:42 +0500 Subject: [PATCH 5/7] =?UTF-8?q?=D0=9E=D0=BA=D0=BD=D0=BE=20=D1=81=D0=BE?= =?UTF-8?q?=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B2=D0=BE=D1=81=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=D0=B0=D0=B2=D0=BB=D0=B8=D0=B2=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=D1=81=D1=8F=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20F5=20?= =?UTF-8?q?=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=B5=20=D1=81=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=BD=D0=BE=D0=B2=D0=B8=D0=BA=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit usePersistedOpen хранит в localStorage признак «модалка создания открыта» для типов заявок, доступа к заявкам, маршрутов и шагов маршрута (ключ шага — по id маршрута). После F5 окно открывается снова с полями из черновика; любое закрытие сбрасывает признак, а сам черновик остаётся до сохранения или «Очистить». Редактирование не восстанавливается. Co-Authored-By: Claude Sonnet 5.5 --- .../screens/RequestTypeAccessScreen.tsx | 12 +++++++----- .../components/screens/RequestTypesScreen.tsx | 18 ++++++++++++++---- .../components/screens/RouteStepsScreen.tsx | 16 +++++++++++++--- .../components/screens/RoutesScreen.tsx | 17 ++++++++++++++--- .../hooks/useLocalStorageState.test.ts | 5 +++++ .../approvalAdmin/hooks/usePersistedOpen.ts | 17 +++++++++++++++++ 6 files changed, 70 insertions(+), 15 deletions(-) create mode 100644 frontend/src/webparts/approvalAdmin/hooks/usePersistedOpen.ts diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RequestTypeAccessScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RequestTypeAccessScreen.tsx index b6c13f4..82f907a 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RequestTypeAccessScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RequestTypeAccessScreen.tsx @@ -10,6 +10,7 @@ import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; import { useDeleteTarget } from '../../hooks/useDeleteTarget'; import { useLocalStorageState } from '../../hooks/useLocalStorageState'; import { useFormDraft } from '../../hooks/useFormDraft'; +import { usePersistedOpen } from '../../hooks/usePersistedOpen'; import { toEmployeeOption } from '../../utils/employee'; import { replaceRoute } from '../../routing'; import { RequestTypeAccess } from '../../models'; @@ -44,6 +45,7 @@ const ACCESS_FILTER_EMAIL_STORAGE_KEY = 'approvalAdmin.accessFilterEmail'; /** Черновик формы выдачи доступа — переживает закрытие модалки, см. useFormDraft. */ const CREATE_DRAFT_STORAGE_KEY = 'approvalAdmin.draft.requestTypeAccess'; +const CREATE_OPEN_STORAGE_KEY = 'approvalAdmin.draftOpen.requestTypeAccess'; interface IFormState { requestTypeId: string; @@ -62,7 +64,7 @@ export const RequestTypeAccessScreen: React.FC = const access = useRequestTypeAccess(api); const employees = usePortalEmployees(dictionary); - const [showForm, setShowForm] = React.useState(false); + const createOpen = usePersistedOpen(CREATE_OPEN_STORAGE_KEY); const draft = useFormDraft(CREATE_DRAFT_STORAGE_KEY, EMPTY_FORM); const { requestTypeId, emails } = draft.value; const setEmails = (update: (current: string[]) => string[]): void => @@ -109,7 +111,7 @@ export const RequestTypeAccessScreen: React.FC = : item.sharePointLists.map(l => l.title ?? l.listId).join(', '); const openCreate = (): void => { - setShowForm(true); + createOpen.open(); }; const submitForm = (): void => { @@ -140,7 +142,7 @@ export const RequestTypeAccessScreen: React.FC = if (failedEmails.length < emails.length) access.reload(); if (failures.length === 0) { draft.reset(); - setShowForm(false); + createOpen.close(); return; } setEmails(() => failedEmails); @@ -240,13 +242,13 @@ export const RequestTypeAccessScreen: React.FC = } /> - {showForm && ( + {createOpen.isOpen && ( setShowForm(false)} + onClose={createOpen.close} onReset={draft.reset} canReset={draft.isDirty} > diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx index 92f1b81..101bd9a 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx @@ -20,6 +20,7 @@ import { useSharePointListTitle } from '../../hooks/useSharePointListTitle'; import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; import { useDeleteTarget } from '../../hooks/useDeleteTarget'; import { useFormDraft } from '../../hooks/useFormDraft'; +import { usePersistedOpen } from '../../hooks/usePersistedOpen'; import { ActionsCell, Alert, @@ -60,6 +61,7 @@ const EMPTY_FORM: IFormState = { /** Черновик формы создания — переживает закрытие модалки, см. useFormDraft. */ const CREATE_DRAFT_STORAGE_KEY = 'approvalAdmin.draft.requestType'; +const CREATE_OPEN_STORAGE_KEY = 'approvalAdmin.draftOpen.requestType'; function toFormState(item: RequestType): IFormState { return { @@ -94,7 +96,13 @@ export const RequestTypesScreen: React.FC = ({ api, sh const list = useRequestTypes(api); const [editing, setEditing] = React.useState(); + // showForm — только редактирование; окно создания восстанавливается после F5 (usePersistedOpen). const [showForm, setShowForm] = React.useState(false); + const createOpen = usePersistedOpen(CREATE_OPEN_STORAGE_KEY); + const closeForm = (): void => { + setShowForm(false); + createOpen.close(); + }; const draft = useFormDraft(CREATE_DRAFT_STORAGE_KEY, EMPTY_FORM); const [editForm, setEditForm] = React.useState(EMPTY_FORM); const form = editing ? editForm : draft.value; @@ -112,12 +120,14 @@ export const RequestTypesScreen: React.FC = ({ api, sh const openCreate = (): void => { setEditing(undefined); - setShowForm(true); + setShowForm(false); + createOpen.open(); }; const openEdit = (item: RequestType): void => { setEditing(item); setEditForm(toFormState(item)); + createOpen.close(); setShowForm(true); }; @@ -155,7 +165,7 @@ export const RequestTypesScreen: React.FC = ({ api, sh submit.run(() => request.then(() => { if (!editing) draft.reset(); - setShowForm(false); + closeForm(); list.reload(); }) ); @@ -204,13 +214,13 @@ export const RequestTypesScreen: React.FC = ({ api, sh emptyMessage={list.isLoading ? texts.common.loading : texts.requestTypes.emptyList} /> - {showForm && ( + {(showForm || createOpen.isOpen) && ( setShowForm(false)} + onClose={closeForm} onReset={editing ? undefined : draft.reset} canReset={draft.isDirty} isDirty={isEditDirty} diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepsScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepsScreen.tsx index 7846f19..ed3a260 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepsScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepsScreen.tsx @@ -13,6 +13,7 @@ import { blockIndexOf, blockRanges, markParallel, withOrdersFrom } from '../../u import { reportError } from '../../utils/errors'; import { useRouteSteps } from '../../hooks/useRouteSteps'; import { useDeleteTarget } from '../../hooks/useDeleteTarget'; +import { usePersistedOpen } from '../../hooks/usePersistedOpen'; import { usePortalEmployees } from '../../hooks/usePortalEmployees'; import { usePortalDictionaries } from '../../hooks/usePortalDictionaries'; import { @@ -53,7 +54,14 @@ export const RouteStepsScreen: React.FC = ({ api, route, const requestTypeName = requestType?.name ?? `#${route.requestTypeId}`; const [editing, setEditing] = React.useState(); + // showForm — только редактирование; окно создания восстанавливается после F5 (usePersistedOpen), + // ключ свой у каждого маршрута — как и у черновика (см. RouteStepFormModal). const [showForm, setShowForm] = React.useState(false); + const createOpen = usePersistedOpen(`approvalAdmin.draftOpen.routeStep.${route.id}`); + const closeForm = (): void => { + setShowForm(false); + createOpen.close(); + }; const deleteTarget = useDeleteTarget( item => api.templateRouteSteps.remove(route.id, item.id), @@ -71,11 +79,13 @@ export const RouteStepsScreen: React.FC = ({ api, route, const openCreate = (): void => { setEditing(undefined); - setShowForm(true); + setShowForm(false); + createOpen.open(); }; const openEdit = (item: TemplateRouteStep): void => { setEditing(item); + createOpen.close(); setShowForm(true); }; @@ -278,7 +288,7 @@ export const RouteStepsScreen: React.FC = ({ api, route, )} - {showForm && ( + {(showForm || createOpen.isOpen) && ( = ({ api, route, employees={employees.items} employeesLoading={employees.isLoading} onSaved={handleSaved} - onClose={() => setShowForm(false)} + onClose={closeForm} /> )} diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RoutesScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RoutesScreen.tsx index 8f612f8..5015bc3 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RoutesScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RoutesScreen.tsx @@ -11,6 +11,7 @@ import { useRequestTypes } from '../../hooks/useRequestTypes'; import { useTemplateRoutes } from '../../hooks/useTemplateRoutes'; import { usePortalDictionaries } from '../../hooks/usePortalDictionaries'; import { useDeleteTarget } from '../../hooks/useDeleteTarget'; +import { usePersistedOpen } from '../../hooks/usePersistedOpen'; import { useLocalStorageState } from '../../hooks/useLocalStorageState'; import { replaceRoute } from '../../routing'; import { @@ -39,6 +40,8 @@ export interface IRoutesScreenProps { // Фильтр по типу заявки переживает перезагрузку: администратор обычно работает // с одним типом заявок подряд, и сбрасывать выбор на каждый заход неудобно. const ROUTES_FILTER_STORAGE_KEY = 'approvalAdmin.routesFilter'; +/** Окно создания маршрута переживает F5 — поля подставляются из черновика (см. RouteFormModal). */ +const CREATE_OPEN_STORAGE_KEY = 'approvalAdmin.draftOpen.route'; export const RoutesScreen: React.FC = ({ api, @@ -69,7 +72,13 @@ export const RoutesScreen: React.FC = ({ const routes = useTemplateRoutes(api, filterRequestTypeId ? Number(filterRequestTypeId) : undefined); const [editing, setEditing] = React.useState(); + // showForm — только редактирование; окно создания восстанавливается после F5 (usePersistedOpen). const [showForm, setShowForm] = React.useState(false); + const createOpen = usePersistedOpen(CREATE_OPEN_STORAGE_KEY); + const closeForm = (): void => { + setShowForm(false); + createOpen.close(); + }; const [copying, setCopying] = React.useState(); const deleteTarget = useDeleteTarget(item => api.templateRoutes.remove(item.id), routes.reload); @@ -83,11 +92,13 @@ export const RoutesScreen: React.FC = ({ const openCreate = (): void => { setEditing(undefined); - setShowForm(true); + setShowForm(false); + createOpen.open(); }; const openEdit = (item: TemplateRoute): void => { setEditing(item); + createOpen.close(); setShowForm(true); }; @@ -159,7 +170,7 @@ export const RoutesScreen: React.FC = ({ emptyMessage={routes.isLoading ? texts.common.loading : texts.routes.emptyList} /> - {showForm && ( + {(showForm || createOpen.isOpen) && ( = ({ departments={portalDictionaries.departments} dictionaryLoading={portalDictionaries.isLoading} onSaved={routes.reload} - onClose={() => setShowForm(false)} + onClose={closeForm} /> )} diff --git a/frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.test.ts b/frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.test.ts index 3c27a8c..4cd879f 100644 --- a/frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.test.ts +++ b/frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.test.ts @@ -32,6 +32,11 @@ describe('coerceStored', () => { expect(coerceStored([], [1, 2])).toEqual([1, 2]); }); + it('для boolean-флага отбрасывает значение другого типа', () => { + expect(coerceStored(false, true)).toBe(true); + expect(coerceStored(false, 'true')).toBe(false); + }); + it('при initial === undefined принимает сохранённое как есть', () => { expect(coerceStored(undefined, 'a')).toBe('a'); expect(coerceStored({ x: undefined as string | undefined }, { x: 'a' })).toEqual({ x: 'a' }); diff --git a/frontend/src/webparts/approvalAdmin/hooks/usePersistedOpen.ts b/frontend/src/webparts/approvalAdmin/hooks/usePersistedOpen.ts new file mode 100644 index 0000000..d5f82a1 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/hooks/usePersistedOpen.ts @@ -0,0 +1,17 @@ +import { useLocalStorageState } from './useLocalStorageState'; + +export interface IPersistedOpen { + isOpen: boolean; + open: () => void; + close: () => void; +} + +/** + * Признак «модалка создания открыта», переживающий F5: после перезагрузки окно + * открывается снова, а поля подставляются из черновика (см. useFormDraft). + * close() сбрасывает только признак — черновик остаётся до сохранения или «Очистить». + */ +export function usePersistedOpen(key: string): IPersistedOpen { + const [isOpen, setIsOpen] = useLocalStorageState(key, false); + return { isOpen, open: () => setIsOpen(true), close: () => setIsOpen(false) }; +} -- 2.34.1 From 547c51ca3cd94f57d494eab2e974a1cfc15f047c Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 16 Oct 2026 10:39:55 +0500 Subject: [PATCH 6/7] =?UTF-8?q?=D0=9F=D1=80=D0=B5=D0=B4=D1=83=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=D0=BE=D0=BB=D0=B2=D0=B0=20=D0=BC=D0=B0=D1=80=D1=88=D1=80=D1=83?= =?UTF-8?q?=D1=82=D0=B0=20=E2=80=94=20=D0=BD=D0=B0=20=D1=80=D1=83=D1=81?= =?UTF-8?q?=D1=81=D0=BA=D0=BE=D0=BC=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE?= =?UTF-8?q?=20=D0=B8=D0=BC=D1=91=D0=BD=20enum?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В сообщениях «не найден руководитель типа DirectionHead» и в конфликте ролей шага пользователь видел имена enum'ов. Теперь везде русские названия из общего справочника StepLabels (его же использует выгрузка маршрутов): «Не найден руководитель «Руководитель направления»: не указан у инициатора в справочнике сотрудников». Для директора Tansu и управляющего директора, которые берутся из настроек сервиса, — «Не проставлен «…»: email не задан в настройках сервиса». Так же исправлены ошибки о дубле шага с типом руководителя. Co-Authored-By: Claude Sonnet 5.5 --- .../Common/StepLabels.cs | 32 +++++++++++++++++++ .../Reports/RouteExportService.cs | 26 +++------------ .../TemplateRouteStepService.cs | 7 ++-- .../TemplateRoutes/TemplateRouteService.cs | 14 ++++++-- .../TemplateRouteServiceTests.cs | 5 +-- 5 files changed, 54 insertions(+), 30 deletions(-) create mode 100644 backend/Tnsu.ApprovalRoute.Application/Common/StepLabels.cs diff --git a/backend/Tnsu.ApprovalRoute.Application/Common/StepLabels.cs b/backend/Tnsu.ApprovalRoute.Application/Common/StepLabels.cs new file mode 100644 index 0000000..5e92a8f --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Common/StepLabels.cs @@ -0,0 +1,32 @@ +using Tnsu.ApprovalRoute.Domain.Enums; + +namespace Tnsu.ApprovalRoute.Application.Common; + +/// +/// Русские названия типов руководителя и ролей в проекте — для всего, что видит +/// человек: предупреждения резолва, ошибки валидации, выгрузка маршрутов. Имя +/// enum'а («DirectionHead») в тексте для пользователя недопустимо. +/// +public static class StepLabels +{ + public static string Of(HeadType headType) => headType switch + { + HeadType.Chief => "Непосредственный руководитель", + HeadType.DirectionHead => "Руководитель направления", + HeadType.DirectorTansu => "Директор Tansu", + HeadType.ManagingDirectorTansu => "Управляющий директор Tansu", + HeadType.DepartmentHead => "Руководитель указанного отдела", + HeadType.CompanyDirectors => "Директора компаний инициатора", + _ => headType.ToString() + }; + + public static string Of(ProjectRole projectRole) => projectRole switch + { + ProjectRole.ProjectManager => "Руководитель проекта", + ProjectRole.Planner => "Планировщик", + ProjectRole.ManagerHSE => "Менеджер HSE", + ProjectRole.Security => "Безопасность", + ProjectRole.Keeper => "Заведующий склада", + _ => projectRole.ToString() + }; +} diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportService.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportService.cs index a8f04ff..785f5d7 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportService.cs @@ -1,6 +1,7 @@ using FluentResults; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; +using Tnsu.ApprovalRoute.Application.Common; using Tnsu.ApprovalRoute.Domain.Entities; using Tnsu.ApprovalRoute.Domain.Enums; using Tnsu.ApprovalRoute.Infrastructure.Persistence; @@ -44,25 +45,6 @@ public sealed class RouteExportService( ["CompaniesJson"] = "организация" }; - private static readonly Dictionary ProjectRoleLabels = new() - { - [ProjectRole.ProjectManager] = "Руководитель проекта", - [ProjectRole.Planner] = "Планировщик", - [ProjectRole.ManagerHSE] = "Менеджер HSE", - [ProjectRole.Security] = "Безопасность", - [ProjectRole.Keeper] = "Заведующий склада" - }; - - private static readonly Dictionary HeadTypeLabels = new() - { - [HeadType.Chief] = "Непосредственный руководитель", - [HeadType.DirectionHead] = "Руководитель направления", - [HeadType.DirectorTansu] = "Директор Tansu", - [HeadType.ManagingDirectorTansu] = "Управляющий директор Tansu", - [HeadType.DepartmentHead] = "Руководитель указанного отдела", - [HeadType.CompanyDirectors] = "Директора компаний инициатора" - }; - private static readonly Dictionary OperatorSymbols = new() { [ComparisonOperator.GreaterThan] = ">", @@ -228,7 +210,7 @@ public sealed class RouteExportService( } if (step.ProjectRole is { } projectRole && projectRole != ProjectRole.None) - return (ProjectRoleLabels[projectRole], ByRequest, Empty); + return (StepLabels.Of(projectRole), ByRequest, Empty); if (step.HeadType is { } headType && headType != HeadType.None) { @@ -240,9 +222,9 @@ public sealed class RouteExportService( }; if (fixedEmail is not null) // Нет в справочнике — лучше показать email, чем пустую ячейку. - return (HeadTypeLabels[headType], directory.FindByEmail(fixedEmail)?.Fio ?? fixedEmail, directory.CompaniesOf(fixedEmail)); + return (StepLabels.Of(headType), directory.FindByEmail(fixedEmail)?.Fio ?? fixedEmail, directory.CompaniesOf(fixedEmail)); - return (HeadTypeLabels[headType], ByRequest, Empty); + return (StepLabels.Of(headType), ByRequest, Empty); } return (Empty, Empty, Empty); diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs index d1d9d29..f877a8a 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs @@ -1,6 +1,7 @@ using FluentResults; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using Tnsu.ApprovalRoute.Application.Common; using Tnsu.ApprovalRoute.Application.Common.Errors; using Tnsu.ApprovalRoute.Application.Common.Interfaces; using Tnsu.ApprovalRoute.Domain.Entities; @@ -82,7 +83,7 @@ public sealed class TemplateRouteStepService( if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: null, cancellationToken)) return Result.Fail(new ConflictError( - $"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим.")); + $"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(r.HeadType!.Value)}» и таким же условием появления — второй такой шаг недопустим.")); var entity = new TemplateRouteStep { @@ -141,7 +142,7 @@ public sealed class TemplateRouteStepService( if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: entity.Id, cancellationToken)) return Result.Fail(new ConflictError( - $"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим.")); + $"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(r.HeadType!.Value)}» и таким же условием появления — второй такой шаг недопустим.")); entity.OrderNo = r.OrderNo; entity.ExecutorEmail = r.ExecutorEmail; @@ -210,7 +211,7 @@ public sealed class TemplateRouteStepService( if (await HasDuplicateHeadTypeAsync(route.Id, headType, conditions, conditionsLogic, excludeId: entity.Id, cancellationToken)) return Result.Fail(new ConflictError( - $"В маршруте уже есть шаг с типом руководителя «{headType}» и таким же условием появления — второй такой шаг недопустим.")); + $"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(headType!.Value)}» и таким же условием появления — второй такой шаг недопустим.")); entity.OrderNo = r.OrderNo ?? entity.OrderNo; entity.ExecutorEmail = executorEmail; diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index 657f00d..16c686b 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -3,6 +3,7 @@ using FluentResults; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Tnsu.ApprovalRoute.Application.Common; using Tnsu.ApprovalRoute.Application.Common.Errors; using Tnsu.ApprovalRoute.Application.Common.Interfaces; using Tnsu.ApprovalRoute.Domain.Entities; @@ -437,7 +438,7 @@ public sealed class TemplateRouteService( { resolvedEmail = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole)?.Email; if (string.IsNullOrWhiteSpace(resolvedEmail)) - warning = $"Не найден исполнитель с ролью в проекте «{step.ProjectRole}»."; + warning = $"Не найден исполнитель с ролью в проекте «{StepLabels.Of(step.ProjectRole.Value)}»."; } // DepartmentHead — единственный HeadType, который резолвится НЕ по цепочке // подчинённости инициатора, а по отделу, переданному явно в запросе (см. @@ -469,7 +470,14 @@ public sealed class TemplateRouteService( _ => null }; if (string.IsNullOrWhiteSpace(resolvedEmail)) - warning = $"Не найден руководитель типа «{step.HeadType}»."; + { + var headLabel = StepLabels.Of(step.HeadType.Value); + // Директор Tansu и управляющий директор берутся из настроек сервиса, а не из + // справочника сотрудников — там email не «не найден», а «не проставлен». + warning = step.HeadType is HeadType.DirectorTansu or HeadType.ManagingDirectorTansu + ? $"Не проставлен «{headLabel}»: email не задан в настройках сервиса." + : $"Не найден руководитель «{headLabel}»: не указан у инициатора в справочнике сотрудников."; + } } else { @@ -612,7 +620,7 @@ public sealed class TemplateRouteService( else if (existing.PositionCode is not null && step.PositionCode is not null && existing.PositionCode != step.PositionCode) { var conflictWarning = - $"Исполнитель «{step.ExecutorEmail}» получен через разные роли шага ({existing.PositionCode} и {step.PositionCode}) — оставлен {existing.PositionCode}."; + $"Исполнитель «{step.ExecutorEmail}» получен через разные роли шага ({StepLabels.Of(existing.PositionCode.Value)} и {StepLabels.Of(step.PositionCode.Value)}) — оставлен «{StepLabels.Of(existing.PositionCode.Value)}»."; var combinedWarning = string.Join(" ", new[] { existing.Warning, conflictWarning } .Where(w => !string.IsNullOrWhiteSpace(w))); result[index] = existing with { Warning = combinedWarning }; diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index b3c3857..8b7c2af 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -361,8 +361,9 @@ public sealed class TemplateRouteServiceTests : IClassFixture Date: Fri, 16 Oct 2026 10:44:21 +0500 Subject: [PATCH 7/7] =?UTF-8?q?=D0=9A=D1=80=D0=B0=D1=82=D0=BA=D0=B8=D0=B5?= =?UTF-8?q?=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D1=8B=20=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=D1=83=D0=BF=D1=80=D0=B5=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B9:=20=C2=AB=D0=9D=D0=B5=20=D0=BD=D0=B0=D0=B9=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=20=D1=80=D1=83=D0=BA=D0=BE=D0=B2=D0=BE=D0=B4=D0=B8=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=20=D0=BD=D0=B0=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Вместо длинных пояснений — короткие фразы: «Не найден руководитель направления.», «В проекте не найден руководитель проекта.», «Не проставлен директор Tansu.». Для роли «Безопасность» — «сотрудник службы безопасности», чтобы не получилось «найден безопасность». Co-Authored-By: Claude Sonnet 5.5 --- .../Common/StepLabels.cs | 12 ++++++++++++ .../TemplateRoutes/TemplateRouteService.cs | 8 ++++---- .../ApplicationServices/TemplateRouteServiceTests.cs | 4 ++-- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/backend/Tnsu.ApprovalRoute.Application/Common/StepLabels.cs b/backend/Tnsu.ApprovalRoute.Application/Common/StepLabels.cs index 5e92a8f..8eb2b8b 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Common/StepLabels.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Common/StepLabels.cs @@ -20,6 +20,18 @@ public static class StepLabels _ => headType.ToString() }; + /// Название с маленькой буквы — для середины фразы («Не найден руководитель направления»). + public static string InSentence(HeadType headType) => Lower(Of(headType)); + + /// + /// Все названия мужского рода («найден руководитель проекта»), кроме «Безопасность» — + /// для неё нужна отдельная формулировка, иначе выйдет «найден безопасность». + /// + public static string InSentence(ProjectRole projectRole) => + projectRole == ProjectRole.Security ? "сотрудник службы безопасности" : Lower(Of(projectRole)); + + private static string Lower(string label) => char.ToLowerInvariant(label[0]) + label[1..]; + public static string Of(ProjectRole projectRole) => projectRole switch { ProjectRole.ProjectManager => "Руководитель проекта", diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index 16c686b..3b3895f 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -438,7 +438,7 @@ public sealed class TemplateRouteService( { resolvedEmail = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole)?.Email; if (string.IsNullOrWhiteSpace(resolvedEmail)) - warning = $"Не найден исполнитель с ролью в проекте «{StepLabels.Of(step.ProjectRole.Value)}»."; + warning = $"В проекте не найден {StepLabels.InSentence(step.ProjectRole.Value)}."; } // DepartmentHead — единственный HeadType, который резолвится НЕ по цепочке // подчинённости инициатора, а по отделу, переданному явно в запросе (см. @@ -471,12 +471,12 @@ public sealed class TemplateRouteService( }; if (string.IsNullOrWhiteSpace(resolvedEmail)) { - var headLabel = StepLabels.Of(step.HeadType.Value); + var headLabel = StepLabels.InSentence(step.HeadType.Value); // Директор Tansu и управляющий директор берутся из настроек сервиса, а не из // справочника сотрудников — там email не «не найден», а «не проставлен». warning = step.HeadType is HeadType.DirectorTansu or HeadType.ManagingDirectorTansu - ? $"Не проставлен «{headLabel}»: email не задан в настройках сервиса." - : $"Не найден руководитель «{headLabel}»: не указан у инициатора в справочнике сотрудников."; + ? $"Не проставлен {headLabel}." + : $"Не найден {headLabel}."; } } else diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index 8b7c2af..4ef4b38 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -1051,7 +1051,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture