From f3b0f0ad48e81d81a165e969583a4e0ab51484ee Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 19 Oct 2026 04:46:16 +0000 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D1=81=D0=B8=D1=81=D1=82=20?= =?UTF-8?q?=D0=BC=D0=BE=D0=B4=D0=B0=D0=BB=D0=BE=D0=BA:=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=BD=D0=BE=D0=B2=D0=B8=D0=BA=D0=B8,=20=D0=B2=D0=BE?= =?UTF-8?q?=D1=81=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20F5,=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D1=83=D0=BF=D1=80=D0=B5=D0=B6=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=B8=20=D0=B2=D1=8B=D1=85?= =?UTF-8?q?=D0=BE=D0=B4=D0=B5=20(#30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Модалки по жёлтым кнопкам теряли введённое при случайном клике мимо окна. **Формы создания** (типы заявок, доступ к заявкам, маршруты, шаги маршрута) - Черновик полей хранится в 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 Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/30 --- .../Common/StepLabels.cs | 44 +++++++++++ .../Reports/RouteExportService.cs | 26 +------ .../TemplateRouteStepService.cs | 7 +- .../TemplateRoutes/TemplateRouteService.cs | 14 +++- .../TemplateRouteServiceTests.cs | 9 ++- .../screens/ManageRequestsApproverModal.tsx | 7 +- .../screens/RequestTypeAccessScreen.tsx | 38 ++++++--- .../components/screens/RequestTypesScreen.tsx | 55 +++++++++---- .../components/screens/RouteFormModal.tsx | 13 +++- .../components/screens/RouteStepFormModal.tsx | 13 +++- .../components/screens/RouteStepsScreen.tsx | 16 +++- .../components/screens/RoutesScreen.tsx | 17 +++- .../components/ui/Card.module.scss | 3 +- .../components/ui/ConfirmDialog.tsx | 10 ++- .../approvalAdmin/components/ui/FormModal.tsx | 78 +++++++++++++++---- .../components/ui/Modal.module.scss | 13 +++- .../approvalAdmin/components/ui/Modal.tsx | 6 +- .../components/ui/ModalOverlay.tsx | 25 ++++++ .../components/ui/ProgressOverlay.module.scss | 2 +- .../ui/SearchableSelect.module.scss | 2 +- .../approvalAdmin/components/ui/index.ts | 1 + .../approvalAdmin/hooks/useFormDraft.ts | 58 ++++++++++++++ .../hooks/useLocalStorageState.test.ts | 44 +++++++++++ .../hooks/useLocalStorageState.ts | 58 ++++++++++---- .../approvalAdmin/hooks/usePersistedOpen.ts | 17 ++++ frontend/src/webparts/approvalAdmin/texts.ts | 7 ++ 26 files changed, 474 insertions(+), 109 deletions(-) create mode 100644 backend/Tnsu.ApprovalRoute.Application/Common/StepLabels.cs create mode 100644 frontend/src/webparts/approvalAdmin/components/ui/ModalOverlay.tsx create mode 100644 frontend/src/webparts/approvalAdmin/hooks/useFormDraft.ts create mode 100644 frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.test.ts create mode 100644 frontend/src/webparts/approvalAdmin/hooks/usePersistedOpen.ts diff --git a/backend/Tnsu.ApprovalRoute.Application/Common/StepLabels.cs b/backend/Tnsu.ApprovalRoute.Application/Common/StepLabels.cs new file mode 100644 index 0000000..8eb2b8b --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Common/StepLabels.cs @@ -0,0 +1,44 @@ +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 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 => "Руководитель проекта", + 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..3b3895f 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.InSentence(step.ProjectRole.Value)}."; } // DepartmentHead — единственный HeadType, который резолвится НЕ по цепочке // подчинённости инициатора, а по отделу, переданному явно в запросе (см. @@ -469,7 +470,14 @@ public sealed class TemplateRouteService( _ => null }; if (string.IsNullOrWhiteSpace(resolvedEmail)) - warning = $"Не найден руководитель типа «{step.HeadType}»."; + { + var headLabel = StepLabels.InSentence(step.HeadType.Value); + // Директор Tansu и управляющий директор берутся из настроек сервиса, а не из + // справочника сотрудников — там email не «не найден», а «не проставлен». + warning = step.HeadType is HeadType.DirectorTansu or HeadType.ManagingDirectorTansu + ? $"Не проставлен {headLabel}." + : $"Не найден {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..4ef4b38 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 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, dictionary, @@ -51,9 +64,11 @@ export const RequestTypeAccessScreen: React.FC = const access = useRequestTypeAccess(api); const employees = usePortalEmployees(dictionary); - const [showForm, setShowForm] = React.useState(false); - const [requestTypeId, setRequestTypeId] = React.useState(''); - const [emails, setEmails] = React.useState([]); + 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 => + draft.setValue(f => ({ ...f, emails: update(f.emails) })); const submit = useAsyncSubmit(); const [filterRequestTypeId, setFilterRequestTypeId] = useLocalStorageState(ACCESS_FILTER_TYPE_STORAGE_KEY, ''); @@ -96,9 +111,7 @@ export const RequestTypeAccessScreen: React.FC = : item.sharePointLists.map(l => l.title ?? l.listId).join(', '); const openCreate = (): void => { - setRequestTypeId(''); - setEmails([]); - setShowForm(true); + createOpen.open(); }; const submitForm = (): void => { @@ -128,10 +141,11 @@ export const RequestTypeAccessScreen: React.FC = if (failedEmails.length < emails.length) access.reload(); if (failures.length === 0) { - setShowForm(false); + draft.reset(); + createOpen.close(); return; } - setEmails(failedEmails); + setEmails(() => failedEmails); throw new Error(texts.requestTypeAccess.createFailed(failures)); }); }; @@ -228,13 +242,15 @@ export const RequestTypeAccessScreen: React.FC = } /> - {showForm && ( + {createOpen.isOpen && ( setShowForm(false)} + onClose={createOpen.close} + onReset={draft.reset} + canReset={draft.isDirty} > @@ -242,7 +258,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..101bd9a 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx @@ -19,6 +19,8 @@ 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 { usePersistedOpen } from '../../hooks/usePersistedOpen'; import { ActionsCell, Alert, @@ -57,6 +59,22 @@ const EMPTY_FORM: IFormState = { name: '', pageUrl: '', section: '', siteUrl: '', listId: '', slaHours: '', askOverdueReason: '' }; +/** Черновик формы создания — переживает закрытие модалки, см. useFormDraft. */ +const CREATE_DRAFT_STORAGE_KEY = 'approvalAdmin.draft.requestType'; +const CREATE_OPEN_STORAGE_KEY = 'approvalAdmin.draftOpen.requestType'; + +function toFormState(item: RequestType): IFormState { + return { + name: item.name, + pageUrl: item.pageUrl, + section: item.section, + siteUrl: item.siteUrl ?? '', + listId: item.listId ?? '', + slaHours: slaHoursToInput(item.slaHours), + askOverdueReason: boolToTriState(item.askOverdueReason) + }; +} + // siteUrl/listId — поля RequestType, зеркалящие nullable JSON-контракт бэкенда // (см. models/requestTypes.ts) — здесь сразу приводим null к undefined, // внутренний код (хуки/кэш SharePointListsClient) с null не работает нигде. @@ -78,8 +96,18 @@ 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 [form, setForm] = React.useState(EMPTY_FORM); + 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; + 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); const siteTitle = useSharePointSiteTitle(sharePoint, form.siteUrl); @@ -92,21 +120,14 @@ export const RequestTypesScreen: React.FC = ({ api, sh const openCreate = (): void => { setEditing(undefined); - setForm(EMPTY_FORM); - setShowForm(true); + setShowForm(false); + createOpen.open(); }; const openEdit = (item: RequestType): void => { setEditing(item); - setForm({ - 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)); + createOpen.close(); setShowForm(true); }; @@ -143,7 +164,8 @@ export const RequestTypesScreen: React.FC = ({ api, sh submit.run(() => request.then(() => { - setShowForm(false); + if (!editing) draft.reset(); + closeForm(); list.reload(); }) ); @@ -192,13 +214,16 @@ 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/RouteFormModal.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RouteFormModal.tsx index 082e15d..b98c5d9 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,9 @@ export const RouteFormModal: React.FC = ({ isSaving={submit.isBusy} onSubmit={submitForm} 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 4918eb7..6766944 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,9 @@ export const RouteStepFormModal: React.FC = ({ isSaving={submit.isBusy} onSubmit={submitForm} onClose={onClose} + onReset={editing ? undefined : draft.reset} + canReset={draft.isDirty} + isDirty={Boolean(editing) && draft.isDirty} > 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/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/ConfirmDialog.tsx b/frontend/src/webparts/approvalAdmin/components/ui/ConfirmDialog.tsx index e90a50c..289f13d 100644 --- a/frontend/src/webparts/approvalAdmin/components/ui/ConfirmDialog.tsx +++ b/frontend/src/webparts/approvalAdmin/components/ui/ConfirmDialog.tsx @@ -1,12 +1,15 @@ import * as React from 'react'; import styles from './Modal.module.scss'; import { Button } from './Button'; +import { ModalOverlay } from './ModalOverlay'; import { texts } from '../../texts'; export interface IConfirmDialogProps { title: string; text: string; confirmLabel: string; + /** Подпись кнопки отказа — по умолчанию «Отмена». */ + cancelLabel?: string; onConfirm: () => void; onCancel: () => void; busy?: boolean; @@ -16,18 +19,19 @@ export const ConfirmDialog: React.FC = ({ title, text, confirmLabel, + cancelLabel = texts.common.cancel, onConfirm, onCancel, busy }) => ( -
{ if (e.target === e.currentTarget) onCancel(); }}> +
{title}

{text}

- +
-
+ ); diff --git a/frontend/src/webparts/approvalAdmin/components/ui/FormModal.tsx b/frontend/src/webparts/approvalAdmin/components/ui/FormModal.tsx index d8175a0..e1cc175 100644 --- a/frontend/src/webparts/approvalAdmin/components/ui/FormModal.tsx +++ b/frontend/src/webparts/approvalAdmin/components/ui/FormModal.tsx @@ -1,7 +1,9 @@ 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'; export interface IFormModalProps { @@ -15,6 +17,18 @@ export interface IFormModalProps { savingLabel?: string; onSubmit: () => void; onClose: () => void; + /** + * Сбросить черновик формы (см. useFormDraft). Кнопка «Очистить» показывается, + * только если передан и canReset — «Отмена» и клик мимо окна черновик не трогают. + */ + onReset?: () => void; + canReset?: boolean; + /** + * В форме есть несохранённые правки существующей записи: выход (крестик, + * «Отмена», клик мимо окна) сначала спрашивает подтверждение. Для форм создания + * не нужен — их черновик сохраняется сам (см. useFormDraft). + */ + isDirty?: boolean; children: React.ReactNode; } @@ -30,21 +44,51 @@ export const FormModal: React.FC = ({ savingLabel = texts.common.saving, onSubmit, onClose, + onReset, + canReset, + isDirty, children -}) => ( - - - - - } - > - {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/components/ui/Modal.module.scss b/frontend/src/webparts/approvalAdmin/components/ui/Modal.module.scss index 855ab6e..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 { @@ -63,4 +71,7 @@ padding: 16px 26px 22px; } +/** Кнопка в подвале, прижатая влево, подальше от «Отмена/Сохранить» (см. «Очистить» в FormModal). */ +.footerStart { margin-right: auto; } + .narrowFooter { justify-content: center; padding: 0; margin-top: 24px; } 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'; diff --git a/frontend/src/webparts/approvalAdmin/hooks/useFormDraft.ts b/frontend/src/webparts/approvalAdmin/hooks/useFormDraft.ts new file mode 100644 index 0000000..d3f18be --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/hooks/useFormDraft.ts @@ -0,0 +1,58 @@ +import * as React from 'react'; +import { readLocalStorage } from './useLocalStorageState'; + +export interface IFormDraft { + value: T; + setValue: React.Dispatch>; + /** Форма отличается от initial — есть что сбрасывать. */ + isDirty: boolean; + /** Вернуть initial и забыть черновик (кнопка «Очистить», успешное сохранение). */ + reset: () => void; +} + +function removeDraft(key: string): void { + try { + window.localStorage.removeItem(key); + } catch { + // приватный режим — черновика и не было + } +} + +/** + * Состояние формы создания, которое переживает закрытие модалки (случайный клик + * по подложке, «Отмена», F5): черновик лежит в localStorage под key, пока его + * не сбросят явно — reset() после успешного сохранения или по кнопке «Очистить». + * + * key === undefined — обычный useState без персиста: так форма редактирования + * существующей записи всегда открывается с актуальными данными с сервера, а не + * с устаревшей правкой. + */ +export function useFormDraft(key: string | undefined, initial: T): IFormDraft { + const [value, setValue] = React.useState(() => (key ? readLocalStorage(key, initial) : initial)); + + // initial у вызывающих — литерал, пересоздаётся каждый рендер; сравниваем по содержимому. + const initialJson = JSON.stringify(initial); + const isDirty = JSON.stringify(value) !== initialJson; + + React.useEffect(() => { + if (!key) return; + if (!isDirty) { + removeDraft(key); + return; + } + try { + window.localStorage.setItem(key, JSON.stringify(value)); + } catch { + // приватный режим / переполненная квота — не критично, просто не персистим + } + }, [key, value, isDirty]); + + // Ключ удаляем синхронно, а не через эффект: после успешного сохранения модалка + // тут же размонтируется, и эффект на новое значение уже не отработает. + const reset = (): void => { + if (key) removeDraft(key); + setValue(JSON.parse(initialJson) as T); + }; + + return { value, setValue, isDirty, reset }; +} 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..4cd879f --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.test.ts @@ -0,0 +1,44 @@ +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('для 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/useLocalStorageState.ts b/frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.ts index d68842c..8524115 100644 --- a/frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.ts +++ b/frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.ts @@ -4,28 +4,56 @@ function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +/** + * Сохранённое значение, приведённое к форме 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 { + try { + const raw = window.localStorage.getItem(key); + if (!raw) return initial; + + return coerceStored(initial, JSON.parse(raw)); + } catch { + return initial; + } +} + /** * Как React.useState, но значение переживает перезагрузку страницы — хранится * в 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(() => { - try { - 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; - } catch { - return initial; - } - }); + const [value, setValue] = React.useState(() => readLocalStorage(key, initial)); React.useEffect(() => { try { 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) }; +} diff --git a/frontend/src/webparts/approvalAdmin/texts.ts b/frontend/src/webparts/approvalAdmin/texts.ts index ba2f123..df91bcb 100644 --- a/frontend/src/webparts/approvalAdmin/texts.ts +++ b/frontend/src/webparts/approvalAdmin/texts.ts @@ -76,6 +76,13 @@ export const texts = { remove: 'Удалить', edit: 'Редактировать', close: 'Закрыть', + /** Сброс черновика формы создания — сам черновик переживает закрытие окна. */ + resetDraft: 'Очистить', + /** Предупреждение при выходе из формы редактирования с несохранёнными правками. */ + discardTitle: 'Закрыть без сохранения?', + discardText: 'Внесённые изменения не сохранятся.', + discardConfirm: 'Закрыть без сохранения', + discardKeepEditing: 'Продолжить редактирование', loading: 'Загрузка...', yes: 'Да', no: 'Нет',