правочки

This commit is contained in:
gorbenko.pavel
2026-07-31 14:50:33 +05:00
parent de60fd0fdb
commit 7566da06c2
6 changed files with 222 additions and 94 deletions
@@ -7,6 +7,7 @@ import { routeStepStatusLabels, texts } from '../../texts';
import { useRequestTypes } from '../../hooks/useRequestTypes'; import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useResolveRoute } from '../../hooks/useResolveRoute'; import { useResolveRoute } from '../../hooks/useResolveRoute';
import { usePortalEmployees } from '../../hooks/usePortalEmployees'; import { usePortalEmployees } from '../../hooks/usePortalEmployees';
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee'; import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
import { import {
Alert, Alert,
@@ -34,13 +35,31 @@ export interface IResolveScreenProps {
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution] const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
.map(v => ({ value: v, label: routeStepStatusLabels[v] })); .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<IResolveScreenProps> = ({ api, dictionary, currentUserEmail }) => { export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary, currentUserEmail }) => {
const requestTypes = useRequestTypes(api); const requestTypes = useRequestTypes(api);
const [requestTypeId, setRequestTypeId] = React.useState(''); const [form, setForm] = useLocalStorageState<IResolveFormState>(RESOLVE_FORM_STORAGE_KEY, EMPTY_RESOLVE_FORM);
const [userEmail, setUserEmail] = React.useState(''); const { requestTypeId, userEmail, projectId, projectLabel, status } = form;
const [projectId, setProjectId] = React.useState('');
const [projectLabel, setProjectLabel] = React.useState('');
const [status, setStatus] = React.useState<RouteStepStatus>(RouteStepStatus.Approval);
const resolved = useResolveRoute(api); const resolved = useResolveRoute(api);
const employees = usePortalEmployees(dictionary); const employees = usePortalEmployees(dictionary);
@@ -83,7 +102,7 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))} options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
placeholder={texts.resolve.requestTypePlaceholder} placeholder={texts.resolve.requestTypePlaceholder}
value={requestTypeId} value={requestTypeId}
onChange={e => setRequestTypeId(e.target.value)} onChange={e => setForm({ ...form, requestTypeId: e.target.value })}
/> />
</Field> </Field>
<Field <Field
@@ -99,7 +118,7 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
placeholder={texts.resolve.userEmailPlaceholder} placeholder={texts.resolve.userEmailPlaceholder}
searchPlaceholder={texts.select.employeeSearchPlaceholder} searchPlaceholder={texts.select.employeeSearchPlaceholder}
emptyMessage={texts.select.employeesEmpty} emptyMessage={texts.select.employeesEmpty}
onChange={setUserEmail} onChange={userEmail => setForm({ ...form, userEmail })}
/> />
</Field> </Field>
<Field label={texts.resolve.projectLabel} hint={texts.resolve.projectHint}> <Field label={texts.resolve.projectLabel} hint={texts.resolve.projectHint}>
@@ -115,14 +134,15 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
getValue={p => p.oid} getValue={p => p.oid}
getLabel={p => p.name} getLabel={p => p.name}
getSublabel={p => [p.code, p.status].filter(Boolean).join(' · ') || undefined} getSublabel={p => [p.code, p.status].filter(Boolean).join(' · ') || undefined}
onChange={(nextId, item) => { onChange={(nextId, item) => setForm({ ...form, projectId: nextId, projectLabel: item ? item.name : '' })}
setProjectId(nextId);
setProjectLabel(item ? item.name : '');
}}
/> />
</Field> </Field>
<Field label={texts.resolve.statusLabel} required> <Field label={texts.resolve.statusLabel} required>
<Select options={STATUS_OPTIONS} value={status} onChange={e => setStatus(e.target.value as RouteStepStatus)} /> <Select
options={STATUS_OPTIONS}
value={status}
onChange={e => setForm({ ...form, status: e.target.value as RouteStepStatus })}
/>
</Field> </Field>
</FormGrid> </FormGrid>
<div className={styles.runButtonRow}> <div className={styles.runButtonRow}>
@@ -1,5 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import styles from './SearchableSelect.module.scss'; import styles from './SearchableSelect.module.scss';
import { DropdownPortal } from './DropdownPortal';
import { Icon } from './Icon'; import { Icon } from './Icon';
import { texts } from '../../texts'; import { texts } from '../../texts';
@@ -51,6 +52,7 @@ export function AsyncSearchableSelect<T>({
const [error, setError] = React.useState<string | undefined>(); const [error, setError] = React.useState<string | undefined>();
const rootRef = React.useRef<HTMLDivElement>(null); const rootRef = React.useRef<HTMLDivElement>(null);
const menuRef = React.useRef<HTMLDivElement>(null);
const searchRef = React.useRef<HTMLInputElement>(null); const searchRef = React.useRef<HTMLInputElement>(null);
const listRef = React.useRef<HTMLDivElement>(null); const listRef = React.useRef<HTMLDivElement>(null);
const sentinelRef = React.useRef<HTMLDivElement>(null); const sentinelRef = React.useRef<HTMLDivElement>(null);
@@ -103,7 +105,10 @@ export function AsyncSearchableSelect<T>({
if (!open) return; if (!open) return;
const onDocumentMouseDown = (e: MouseEvent): void => { 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 => { const onKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'Escape') setOpen(false); if (e.key === 'Escape') setOpen(false);
@@ -167,51 +172,49 @@ export function AsyncSearchableSelect<T>({
<Icon name="chevron-down" size="sm" className={styles.chevron} /> <Icon name="chevron-down" size="sm" className={styles.chevron} />
</button> </button>
{open && ( <DropdownPortal anchorRef={rootRef} open={open} className={styles.menu} menuRef={menuRef}>
<div className={styles.menu}> <input
<input ref={searchRef}
ref={searchRef} className={styles.search}
className={styles.search} placeholder={searchPlaceholder}
placeholder={searchPlaceholder} value={query}
value={query} onChange={e => setQuery(e.target.value)}
onChange={e => setQuery(e.target.value)} />
/> <div className={styles.list} ref={listRef}>
<div className={styles.list} ref={listRef}> {loading && <div className={styles.empty}>{texts.select.searching}</div>}
{loading && <div className={styles.empty}>{texts.select.searching}</div>} {!loading && error && <div className={styles.errorText}>{error}</div>}
{!loading && error && <div className={styles.errorText}>{error}</div>} {!loading && !error && items.length === 0 && (
{!loading && !error && items.length === 0 && ( <div className={styles.empty}>{texts.select.nothingFound}</div>
<div className={styles.empty}>{texts.select.nothingFound}</div> )}
)} {!loading && !error && items.map(item => {
{!loading && !error && items.map(item => { const itemValue = getValue(item);
const itemValue = getValue(item); const sub = getSublabel ? getSublabel(item) : undefined;
const sub = getSublabel ? getSublabel(item) : undefined; return (
return ( <button
<button type="button"
type="button" key={itemValue}
key={itemValue} className={`${styles.item} ${itemValue === value ? styles.selected : ''}`}
className={`${styles.item} ${itemValue === value ? styles.selected : ''}`} onClick={() => pick(item)}
onClick={() => pick(item)} >
> {getLabel(item)}
{getLabel(item)} {sub && <span className={styles.itemSub}>{sub}</span>}
{sub && <span className={styles.itemSub}>{sub}</span>}
</button>
);
})}
{!loading && !error && hasMore && (
<div ref={sentinelRef} className={styles.sentinel}>
{loadingMore && 'Загрузка...'}
</div>
)}
</div>
{allowClear && value && (
<div className={styles.clear}>
<button type="button" className={styles.item} onClick={() => pick(undefined)}>
{clearLabel}
</button> </button>
);
})}
{!loading && !error && hasMore && (
<div ref={sentinelRef} className={styles.sentinel}>
{loadingMore && 'Загрузка...'}
</div> </div>
)} )}
</div> </div>
)} {allowClear && value && (
<div className={styles.clear}>
<button type="button" className={styles.item} onClick={() => pick(undefined)}>
{clearLabel}
</button>
</div>
)}
</DropdownPortal>
</div> </div>
); );
} }
@@ -0,0 +1,78 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
export interface IDropdownPortalProps {
/** Элемент, под которым (или над которым) рисуется меню. */
anchorRef: React.RefObject<HTMLElement>;
open: boolean;
className?: string;
/** Прокидывается наружу — нужен обработчику клика вне меню (портал живёт вне DOM-дерева root). */
menuRef?: React.Ref<HTMLDivElement>;
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<IDropdownPortalProps> = ({ anchorRef, open, className, menuRef, children }) => {
const [position, setPosition] = React.useState<IPosition | undefined>();
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(
<div ref={menuRef} className={className} style={style}>
{children}
</div>,
document.body
);
};
@@ -40,10 +40,9 @@
.chevron { color: $gray-400; flex-shrink: 0; } .chevron { color: $gray-400; flex-shrink: 0; }
.menu { .menu {
position: absolute; // position/top/left/width задаются инлайн из DropdownPortal (position: fixed
top: calc(100% + 6px); // по координатам триггера) — портал рендерится в document.body, чтобы список
left: 0; // не обрезался overflow-y: auto модалки (Modal.module.scss .box).
right: 0;
background: #fff; background: #fff;
border-radius: $r-md; border-radius: $r-md;
box-shadow: $sh-modal; box-shadow: $sh-modal;
@@ -1,5 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import styles from './SearchableSelect.module.scss'; import styles from './SearchableSelect.module.scss';
import { DropdownPortal } from './DropdownPortal';
import { Icon } from './Icon'; import { Icon } from './Icon';
import { texts } from '../../texts'; import { texts } from '../../texts';
@@ -40,6 +41,7 @@ export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState(''); const [query, setQuery] = React.useState('');
const rootRef = React.useRef<HTMLDivElement>(null); const rootRef = React.useRef<HTMLDivElement>(null);
const menuRef = React.useRef<HTMLDivElement>(null);
const searchRef = React.useRef<HTMLInputElement>(null); const searchRef = React.useRef<HTMLInputElement>(null);
// Сохранённое значение может отсутствовать в справочнике (запись удалили в 1С // Сохранённое значение может отсутствовать в справочнике (запись удалили в 1С
@@ -68,9 +70,10 @@ export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
if (!open) return; if (!open) return;
const onDocumentMouseDown = (e: MouseEvent): void => { const onDocumentMouseDown = (e: MouseEvent): void => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) { const target = e.target as Node;
setOpen(false); if (rootRef.current?.contains(target)) return;
} if (menuRef.current?.contains(target)) return;
setOpen(false);
}; };
const onKeyDown = (e: KeyboardEvent): void => { const onKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'Escape') setOpen(false); if (e.key === 'Escape') setOpen(false);
@@ -113,42 +116,40 @@ export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
<Icon name="chevron-down" size="sm" className={styles.chevron} /> <Icon name="chevron-down" size="sm" className={styles.chevron} />
</button> </button>
{open && ( <DropdownPortal anchorRef={rootRef} open={open} className={styles.menu} menuRef={menuRef}>
<div className={styles.menu}> <input
<input ref={searchRef}
ref={searchRef} className={styles.search}
className={styles.search} placeholder={searchPlaceholder}
placeholder={searchPlaceholder} value={query}
value={query} onChange={e => setQuery(e.target.value)}
onChange={e => setQuery(e.target.value)} />
/> <div className={styles.list}>
<div className={styles.list}> {filtered.length === 0 && (
{filtered.length === 0 && ( <div className={styles.empty}>
<div className={styles.empty}> {effectiveOptions.length === 0 ? emptyMessage : texts.select.nothingFound}
{effectiveOptions.length === 0 ? emptyMessage : texts.select.nothingFound}
</div>
)}
{filtered.map(opt => (
<button
type="button"
key={opt.value}
className={`${styles.item} ${opt.value === value ? styles.selected : ''}`}
onClick={() => pick(opt.value)}
>
{opt.label}
{opt.sublabel && <span className={styles.itemSub}>{opt.sublabel}</span>}
</button>
))}
</div>
{allowClear && value && (
<div className={styles.clear}>
<button type="button" className={styles.item} onClick={() => pick('')}>
{clearLabel}
</button>
</div> </div>
)} )}
{filtered.map(opt => (
<button
type="button"
key={opt.value}
className={`${styles.item} ${opt.value === value ? styles.selected : ''}`}
onClick={() => pick(opt.value)}
>
{opt.label}
{opt.sublabel && <span className={styles.itemSub}>{opt.sublabel}</span>}
</button>
))}
</div> </div>
)} {allowClear && value && (
<div className={styles.clear}>
<button type="button" className={styles.item} onClick={() => pick('')}>
{clearLabel}
</button>
</div>
)}
</DropdownPortal>
</div> </div>
); );
}; };
@@ -0,0 +1,27 @@
import * as React from 'react';
/**
* Как React.useState, но значение переживает перезагрузку страницы — хранится
* в localStorage под key. Падение доступа к localStorage (приватный режим,
* квота) не ломает форму — просто не персистит.
*/
export function useLocalStorageState<T>(key: string, initial: T): [T, React.Dispatch<React.SetStateAction<T>>] {
const [value, setValue] = React.useState<T>(() => {
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];
}