правочки
This commit is contained in:
@@ -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<IResolveScreenProps> = ({ 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>(RouteStepStatus.Approval);
|
||||
const [form, setForm] = useLocalStorageState<IResolveFormState>(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<IResolveScreenProps> = ({ 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 })}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
@@ -99,7 +118,7 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
|
||||
placeholder={texts.resolve.userEmailPlaceholder}
|
||||
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||||
emptyMessage={texts.select.employeesEmpty}
|
||||
onChange={setUserEmail}
|
||||
onChange={userEmail => setForm({ ...form, userEmail })}
|
||||
/>
|
||||
</Field>
|
||||
<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}
|
||||
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 : '' })}
|
||||
/>
|
||||
</Field>
|
||||
<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>
|
||||
</FormGrid>
|
||||
<div className={styles.runButtonRow}>
|
||||
|
||||
@@ -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<T>({
|
||||
const [error, setError] = React.useState<string | undefined>();
|
||||
|
||||
const rootRef = React.useRef<HTMLDivElement>(null);
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||
const listRef = React.useRef<HTMLDivElement>(null);
|
||||
const sentinelRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -103,7 +105,10 @@ export function AsyncSearchableSelect<T>({
|
||||
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<T>({
|
||||
<Icon name="chevron-down" size="sm" className={styles.chevron} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className={styles.menu}>
|
||||
<input
|
||||
ref={searchRef}
|
||||
className={styles.search}
|
||||
placeholder={searchPlaceholder}
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
<div className={styles.list} ref={listRef}>
|
||||
{loading && <div className={styles.empty}>{texts.select.searching}</div>}
|
||||
{!loading && error && <div className={styles.errorText}>{error}</div>}
|
||||
{!loading && !error && items.length === 0 && (
|
||||
<div className={styles.empty}>{texts.select.nothingFound}</div>
|
||||
)}
|
||||
{!loading && !error && items.map(item => {
|
||||
const itemValue = getValue(item);
|
||||
const sub = getSublabel ? getSublabel(item) : undefined;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={itemValue}
|
||||
className={`${styles.item} ${itemValue === value ? styles.selected : ''}`}
|
||||
onClick={() => pick(item)}
|
||||
>
|
||||
{getLabel(item)}
|
||||
{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}
|
||||
<DropdownPortal anchorRef={rootRef} open={open} className={styles.menu} menuRef={menuRef}>
|
||||
<input
|
||||
ref={searchRef}
|
||||
className={styles.search}
|
||||
placeholder={searchPlaceholder}
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
<div className={styles.list} ref={listRef}>
|
||||
{loading && <div className={styles.empty}>{texts.select.searching}</div>}
|
||||
{!loading && error && <div className={styles.errorText}>{error}</div>}
|
||||
{!loading && !error && items.length === 0 && (
|
||||
<div className={styles.empty}>{texts.select.nothingFound}</div>
|
||||
)}
|
||||
{!loading && !error && items.map(item => {
|
||||
const itemValue = getValue(item);
|
||||
const sub = getSublabel ? getSublabel(item) : undefined;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={itemValue}
|
||||
className={`${styles.item} ${itemValue === value ? styles.selected : ''}`}
|
||||
onClick={() => pick(item)}
|
||||
>
|
||||
{getLabel(item)}
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
</DropdownPortal>
|
||||
</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; }
|
||||
|
||||
.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;
|
||||
|
||||
@@ -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<ISearchableSelectProps> = ({
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState('');
|
||||
const rootRef = React.useRef<HTMLDivElement>(null);
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
// Сохранённое значение может отсутствовать в справочнике (запись удалили в 1С
|
||||
@@ -68,9 +70,10 @@ export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
|
||||
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<ISearchableSelectProps> = ({
|
||||
<Icon name="chevron-down" size="sm" className={styles.chevron} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className={styles.menu}>
|
||||
<input
|
||||
ref={searchRef}
|
||||
className={styles.search}
|
||||
placeholder={searchPlaceholder}
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
<div className={styles.list}>
|
||||
{filtered.length === 0 && (
|
||||
<div className={styles.empty}>
|
||||
{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>
|
||||
<DropdownPortal anchorRef={rootRef} open={open} className={styles.menu} menuRef={menuRef}>
|
||||
<input
|
||||
ref={searchRef}
|
||||
className={styles.search}
|
||||
placeholder={searchPlaceholder}
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
<div className={styles.list}>
|
||||
{filtered.length === 0 && (
|
||||
<div className={styles.empty}>
|
||||
{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>
|
||||
)}
|
||||
</DropdownPortal>
|
||||
</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];
|
||||
}
|
||||
Reference in New Issue
Block a user