diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ResolveScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ResolveScreen.tsx index e64310a..7973d78 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ResolveScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/ResolveScreen.tsx @@ -7,6 +7,7 @@ import { routeStepStatusLabels, texts } from '../../texts'; import { useRequestTypes } from '../../hooks/useRequestTypes'; import { useResolveRoute } from '../../hooks/useResolveRoute'; import { usePortalEmployees } from '../../hooks/usePortalEmployees'; +import { useLocalStorageState } from '../../hooks/useLocalStorageState'; import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee'; import { Alert, @@ -34,13 +35,31 @@ export interface IResolveScreenProps { const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution] .map(v => ({ value: v, label: routeStepStatusLabels[v] })); +// Форма проверки маршрута заполняется одними и теми же параметрами раз за разом +// (тип заявки, инициатор, проект, статус) — персистим её в localStorage, чтобы +// не вводить всё заново при каждом открытии экрана. +const RESOLVE_FORM_STORAGE_KEY = 'approvalAdmin.resolveForm'; + +interface IResolveFormState { + requestTypeId: string; + userEmail: string; + projectId: string; + projectLabel: string; + status: RouteStepStatus; +} + +const EMPTY_RESOLVE_FORM: IResolveFormState = { + requestTypeId: '', + userEmail: '', + projectId: '', + projectLabel: '', + status: RouteStepStatus.Approval +}; + export const ResolveScreen: React.FC = ({ api, dictionary, currentUserEmail }) => { const requestTypes = useRequestTypes(api); - const [requestTypeId, setRequestTypeId] = React.useState(''); - const [userEmail, setUserEmail] = React.useState(''); - const [projectId, setProjectId] = React.useState(''); - const [projectLabel, setProjectLabel] = React.useState(''); - const [status, setStatus] = React.useState(RouteStepStatus.Approval); + const [form, setForm] = useLocalStorageState(RESOLVE_FORM_STORAGE_KEY, EMPTY_RESOLVE_FORM); + const { requestTypeId, userEmail, projectId, projectLabel, status } = form; const resolved = useResolveRoute(api); const employees = usePortalEmployees(dictionary); @@ -83,7 +102,7 @@ export const ResolveScreen: React.FC = ({ api, dictionary, options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))} placeholder={texts.resolve.requestTypePlaceholder} value={requestTypeId} - onChange={e => setRequestTypeId(e.target.value)} + onChange={e => setForm({ ...form, requestTypeId: e.target.value })} /> = ({ api, dictionary, placeholder={texts.resolve.userEmailPlaceholder} searchPlaceholder={texts.select.employeeSearchPlaceholder} emptyMessage={texts.select.employeesEmpty} - onChange={setUserEmail} + onChange={userEmail => setForm({ ...form, userEmail })} /> @@ -115,14 +134,15 @@ export const ResolveScreen: React.FC = ({ api, dictionary, getValue={p => p.oid} getLabel={p => p.name} getSublabel={p => [p.code, p.status].filter(Boolean).join(' · ') || undefined} - onChange={(nextId, item) => { - setProjectId(nextId); - setProjectLabel(item ? item.name : ''); - }} + onChange={(nextId, item) => setForm({ ...form, projectId: nextId, projectLabel: item ? item.name : '' })} /> - setForm({ ...form, status: e.target.value as RouteStepStatus })} + />
diff --git a/frontend/src/webparts/approvalAdmin/components/ui/AsyncSearchableSelect.tsx b/frontend/src/webparts/approvalAdmin/components/ui/AsyncSearchableSelect.tsx index 2c5ece1..1cd9cf8 100644 --- a/frontend/src/webparts/approvalAdmin/components/ui/AsyncSearchableSelect.tsx +++ b/frontend/src/webparts/approvalAdmin/components/ui/AsyncSearchableSelect.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import styles from './SearchableSelect.module.scss'; +import { DropdownPortal } from './DropdownPortal'; import { Icon } from './Icon'; import { texts } from '../../texts'; @@ -51,6 +52,7 @@ export function AsyncSearchableSelect({ const [error, setError] = React.useState(); const rootRef = React.useRef(null); + const menuRef = React.useRef(null); const searchRef = React.useRef(null); const listRef = React.useRef(null); const sentinelRef = React.useRef(null); @@ -103,7 +105,10 @@ export function AsyncSearchableSelect({ if (!open) return; const onDocumentMouseDown = (e: MouseEvent): void => { - if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false); + const target = e.target as Node; + if (rootRef.current?.contains(target)) return; + if (menuRef.current?.contains(target)) return; + setOpen(false); }; const onKeyDown = (e: KeyboardEvent): void => { if (e.key === 'Escape') setOpen(false); @@ -167,51 +172,49 @@ export function AsyncSearchableSelect({ - {open && ( -
- setQuery(e.target.value)} - /> -
- {loading &&
{texts.select.searching}
} - {!loading && error &&
{error}
} - {!loading && !error && items.length === 0 && ( -
{texts.select.nothingFound}
- )} - {!loading && !error && items.map(item => { - const itemValue = getValue(item); - const sub = getSublabel ? getSublabel(item) : undefined; - return ( - - ); - })} - {!loading && !error && hasMore && ( -
- {loadingMore && 'Загрузка...'} -
- )} -
- {allowClear && value && ( -
- + ); + })} + {!loading && !error && hasMore && ( +
+ {loadingMore && 'Загрузка...'}
)}
- )} + {allowClear && value && ( +
+ +
+ )} +
); } diff --git a/frontend/src/webparts/approvalAdmin/components/ui/DropdownPortal.tsx b/frontend/src/webparts/approvalAdmin/components/ui/DropdownPortal.tsx new file mode 100644 index 0000000..0e2f0c3 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/components/ui/DropdownPortal.tsx @@ -0,0 +1,78 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; + +export interface IDropdownPortalProps { + /** Элемент, под которым (или над которым) рисуется меню. */ + anchorRef: React.RefObject; + open: boolean; + className?: string; + /** Прокидывается наружу — нужен обработчику клика вне меню (портал живёт вне DOM-дерева root). */ + menuRef?: React.Ref; + children: React.ReactNode; +} + +interface IPosition { + top?: number; + bottom?: number; + left: number; + width: number; +} + +const GAP = 6; +const MIN_SPACE_BELOW = 240; + +/** + * Рендерит выпадающее меню в document.body с position: fixed по координатам + * anchor-элемента, а не как обычный absolute-потомок. Внутри модалок наши формы + * скроллятся через overflow-y: auto на .box (Modal.module.scss) — обычный + * position: absolute-дочерний элемент обрезается этим скроллом, если список не + * помещается. Портал избегает этого, т.к. живёт вне скроллящегося поддерева. + */ +export const DropdownPortal: React.FC = ({ anchorRef, open, className, menuRef, children }) => { + const [position, setPosition] = React.useState(); + + const updatePosition = React.useCallback(() => { + const el = anchorRef.current; + if (!el) return; + + const rect = el.getBoundingClientRect(); + const spaceBelow = window.innerHeight - rect.bottom; + const openUp = spaceBelow < MIN_SPACE_BELOW && rect.top > spaceBelow; + + setPosition( + openUp + ? { bottom: window.innerHeight - rect.top + GAP, left: rect.left, width: rect.width } + : { top: rect.bottom + GAP, left: rect.left, width: rect.width } + ); + }, [anchorRef]); + + React.useLayoutEffect(() => { + if (!open) return undefined; + updatePosition(); + + // capture: true — ловим скролл любого предка (в т.ч. .box модалки), не только window. + window.addEventListener('scroll', updatePosition, true); + window.addEventListener('resize', updatePosition); + return () => { + window.removeEventListener('scroll', updatePosition, true); + window.removeEventListener('resize', updatePosition); + }; + }, [open, updatePosition]); + + if (!open || !position) return null; + + const style: React.CSSProperties = { + position: 'fixed', + left: position.left, + width: position.width, + top: position.top, + bottom: position.bottom + }; + + return ReactDOM.createPortal( +
+ {children} +
, + document.body + ); +}; diff --git a/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.module.scss b/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.module.scss index b587b26..9995862 100644 --- a/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.module.scss +++ b/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.module.scss @@ -40,10 +40,9 @@ .chevron { color: $gray-400; flex-shrink: 0; } .menu { - position: absolute; - top: calc(100% + 6px); - left: 0; - right: 0; + // position/top/left/width задаются инлайн из DropdownPortal (position: fixed + // по координатам триггера) — портал рендерится в document.body, чтобы список + // не обрезался overflow-y: auto модалки (Modal.module.scss .box). background: #fff; border-radius: $r-md; box-shadow: $sh-modal; diff --git a/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.tsx b/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.tsx index 7f99b0e..055e0e9 100644 --- a/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.tsx +++ b/frontend/src/webparts/approvalAdmin/components/ui/SearchableSelect.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import styles from './SearchableSelect.module.scss'; +import { DropdownPortal } from './DropdownPortal'; import { Icon } from './Icon'; import { texts } from '../../texts'; @@ -40,6 +41,7 @@ export const SearchableSelect: React.FC = ({ const [open, setOpen] = React.useState(false); const [query, setQuery] = React.useState(''); const rootRef = React.useRef(null); + const menuRef = React.useRef(null); const searchRef = React.useRef(null); // Сохранённое значение может отсутствовать в справочнике (запись удалили в 1С @@ -68,9 +70,10 @@ export const SearchableSelect: React.FC = ({ if (!open) return; const onDocumentMouseDown = (e: MouseEvent): void => { - if (rootRef.current && !rootRef.current.contains(e.target as Node)) { - setOpen(false); - } + const target = e.target as Node; + if (rootRef.current?.contains(target)) return; + if (menuRef.current?.contains(target)) return; + setOpen(false); }; const onKeyDown = (e: KeyboardEvent): void => { if (e.key === 'Escape') setOpen(false); @@ -113,42 +116,40 @@ export const SearchableSelect: React.FC = ({ - {open && ( -
- setQuery(e.target.value)} - /> -
- {filtered.length === 0 && ( -
- {effectiveOptions.length === 0 ? emptyMessage : texts.select.nothingFound} -
- )} - {filtered.map(opt => ( - - ))} -
- {allowClear && value && ( -
- + + setQuery(e.target.value)} + /> +
+ {filtered.length === 0 && ( +
+ {effectiveOptions.length === 0 ? emptyMessage : texts.select.nothingFound}
)} + {filtered.map(opt => ( + + ))}
- )} + {allowClear && value && ( +
+ +
+ )} +
); }; diff --git a/frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.ts b/frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.ts new file mode 100644 index 0000000..e080796 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.ts @@ -0,0 +1,27 @@ +import * as React from 'react'; + +/** + * Как React.useState, но значение переживает перезагрузку страницы — хранится + * в localStorage под key. Падение доступа к localStorage (приватный режим, + * квота) не ломает форму — просто не персистит. + */ +export function useLocalStorageState(key: string, initial: T): [T, React.Dispatch>] { + const [value, setValue] = React.useState(() => { + try { + const raw = window.localStorage.getItem(key); + return raw ? (JSON.parse(raw) as T) : initial; + } catch { + return initial; + } + }); + + React.useEffect(() => { + try { + window.localStorage.setItem(key, JSON.stringify(value)); + } catch { + // приватный режим / переполненная квота — не критично, просто не персистим + } + }, [key, value]); + + return [value, setValue]; +}