Новый виды условных шагов (#6)
Tests / test (push) Successful in 4m19s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/6
This commit was merged in pull request #6.
This commit is contained in:
2026-07-31 12:15:15 +00:00
parent 3e91ecdd96
commit 13db1388a3
60 changed files with 2151 additions and 406 deletions
@@ -5,6 +5,7 @@ import { AdminApp } from './app/AdminApp';
import { ApprovalRouteApi } from '../services/api';
import { AadApiHttpClient, DevApiHttpClient, IHttpClient } from '../services/httpClient';
import { PortalDictionaryClient } from '../services/portalDictionary';
import { SharePointListsClient } from '../services/sharePointLists';
import { texts } from '../texts';
const FONTS_LINK_ID = 'tnsu-approval-admin-fonts';
@@ -30,6 +31,7 @@ const ApprovalAdmin: React.FC<IApprovalAdminProps> = props => {
const [api, setApi] = React.useState<ApprovalRouteApi | undefined>();
const [dictionary, setDictionary] = React.useState<PortalDictionaryClient | undefined>();
const [error, setError] = React.useState<string | undefined>();
const sharePoint = React.useMemo(() => new SharePointListsClient(props.context), [props.context]);
React.useEffect(() => {
ensureFontsLoaded();
@@ -80,6 +82,7 @@ const ApprovalAdmin: React.FC<IApprovalAdminProps> = props => {
<AdminApp
api={api}
dictionary={dictionary}
sharePoint={sharePoint}
currentUserEmail={props.context.pageContext.user.email}
/>
)}
@@ -4,7 +4,8 @@ import { TopNav } from './TopNav';
import { ScreenKey } from './types';
import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary';
import { TemplateRoute } from '../../models';
import { SharePointListsClient } from '../../services/sharePointLists';
import { RequestType, TemplateRoute } from '../../models';
import { DashboardScreen } from '../screens/DashboardScreen';
import { RequestTypesScreen } from '../screens/RequestTypesScreen';
import { RoutesScreen } from '../screens/RoutesScreen';
@@ -15,13 +16,14 @@ export interface IAdminAppProps {
api: ApprovalRouteApi;
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
dictionary?: PortalDictionaryClient;
sharePoint: SharePointListsClient;
currentUserEmail?: string;
}
export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, currentUserEmail }) => {
export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint, currentUserEmail }) => {
const [screen, setScreen] = React.useState<ScreenKey>('dashboard');
const [selectedRoute, setSelectedRoute] = React.useState<TemplateRoute | undefined>();
const [selectedRequestTypeName, setSelectedRequestTypeName] = React.useState('');
const [selectedRequestType, setSelectedRequestType] = React.useState<RequestType | undefined>();
const navigate = (key: ScreenKey): void => {
setScreen(key);
@@ -29,9 +31,10 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, currentUse
const openSteps = (route: TemplateRoute): void => {
setSelectedRoute(route);
setSelectedRequestType(undefined);
api.requestTypes.get(route.requestTypeId)
.then(rt => setSelectedRequestTypeName(rt.name))
.catch(() => setSelectedRequestTypeName(`#${route.requestTypeId}`));
.then(setSelectedRequestType)
.catch(() => setSelectedRequestType(undefined));
setScreen('routeSteps');
};
@@ -40,19 +43,20 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, currentUse
<TopNav active={screen} onSelect={navigate} />
<div className={styles.content}>
{screen === 'dashboard' && <DashboardScreen api={api} onNavigate={navigate} />}
{screen === 'requestTypes' && <RequestTypesScreen api={api} />}
{screen === 'requestTypes' && <RequestTypesScreen api={api} sharePoint={sharePoint} />}
{screen === 'routes' && <RoutesScreen api={api} dictionary={dictionary} onOpenSteps={openSteps} />}
{screen === 'routeSteps' && selectedRoute && (
<RouteStepsScreen
api={api}
route={selectedRoute}
requestTypeName={selectedRequestTypeName}
requestType={selectedRequestType}
dictionary={dictionary}
sharePoint={sharePoint}
onBack={() => setScreen('routes')}
/>
)}
{screen === 'resolve' && (
<ResolveScreen api={api} dictionary={dictionary} currentUserEmail={currentUserEmail} />
<ResolveScreen api={api} dictionary={dictionary} sharePoint={sharePoint} currentUserEmail={currentUserEmail} />
)}
</div>
</div>
@@ -23,20 +23,6 @@
flex-shrink: 0;
}
.mark {
width: 30px;
height: 30px;
border-radius: 7px;
background: $grad-gold-v;
display: flex;
align-items: center;
justify-content: center;
color: $navy-950;
font-weight: 800;
font-size: 13px;
font-family: $font-serif;
}
.brandText {
color: #fff;
font-weight: 600;
@@ -72,7 +72,6 @@ export const TopNav: React.FC<ITopNavProps> = ({ active, onSelect }) => {
return (
<nav className={styles.topnav}>
<div className={styles.brand}>
<div className={styles.mark}>{texts.nav.markLetter}</div>
<div className={styles.brandText}>{texts.nav.title}</div>
</div>
<div className={styles.tabs} ref={tabsRef}>
@@ -1,13 +1,17 @@
import * as React from 'react';
import { ApprovalRouteApi } from '../../services/api';
import { formatSiteLabel, SharePointListsClient, SharePointSiteSuggestion } from '../../services/sharePointLists';
import { RequestType } from '../../models';
import { texts } from '../../texts';
import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useSharePointLists } from '../../hooks/useSharePointLists';
import { useSharePointSiteTitle } from '../../hooks/useSharePointSiteTitle';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
import {
ActionsCell,
Alert,
AsyncSearchableSelect,
Breadcrumbs,
Button,
ConfirmDialog,
@@ -18,27 +22,44 @@ import {
IconButton,
PageHeader,
PrimaryCell,
Select,
TextInput
} from '../ui';
export interface IRequestTypesScreenProps {
api: ApprovalRouteApi;
sharePoint: SharePointListsClient;
}
interface IFormState {
name: string;
pageUrl: string;
section: string;
siteUrl: string;
listName: string;
}
const EMPTY_FORM: IFormState = { name: '', pageUrl: '' };
const EMPTY_FORM: IFormState = { name: '', pageUrl: '', section: '', siteUrl: '', listName: '' };
export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api }) => {
/** Ячейка «Сайт SharePoint» — подтягивает заголовок сайта по url, хранящемуся на бэкенде. */
const SiteUrlCell: React.FC<{ client: SharePointListsClient; siteUrl: string | null }> = ({ client, siteUrl }) => {
const { title } = useSharePointSiteTitle(client, siteUrl);
return <>{siteUrl ? formatSiteLabel(title, siteUrl) : texts.common.empty}</>;
};
export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sharePoint }) => {
const list = useRequestTypes(api);
const [editing, setEditing] = React.useState<RequestType | undefined>();
const [showForm, setShowForm] = React.useState(false);
const [form, setForm] = React.useState<IFormState>(EMPTY_FORM);
const submit = useAsyncSubmit();
const sharePointLists = useSharePointLists(sharePoint, form.siteUrl);
const siteTitle = useSharePointSiteTitle(sharePoint, form.siteUrl);
const searchSites = React.useCallback(
(query: string, page: number) => sharePoint.searchSites(query, page),
[sharePoint]
);
const deleteTarget = useDeleteTarget<RequestType>(item => api.requestTypes.remove(item.id), list.reload);
@@ -50,16 +71,35 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api })
const openEdit = (item: RequestType): void => {
setEditing(item);
setForm({ name: item.name, pageUrl: item.pageUrl });
setForm({
name: item.name,
pageUrl: item.pageUrl,
section: item.section,
siteUrl: item.siteUrl ?? '',
listName: item.listName ?? ''
});
setShowForm(true);
};
const submitForm = (): void => {
if (!form.name.trim() || !form.pageUrl.trim()) {
if (!form.name.trim() || !form.pageUrl.trim() || !form.section.trim()) {
submit.fail(texts.requestTypes.validationRequired);
return;
}
const input = { name: form.name.trim(), pageUrl: form.pageUrl.trim() };
const siteUrl = form.siteUrl.trim();
const listName = form.listName.trim();
if (Boolean(siteUrl) !== Boolean(listName)) {
submit.fail(texts.requestTypes.validationListReference);
return;
}
const input = {
name: form.name.trim(),
pageUrl: form.pageUrl.trim(),
section: form.section.trim(),
siteUrl: siteUrl || null,
listName: listName || null
};
const request = editing ? api.requestTypes.update(editing.id, input) : api.requestTypes.create(input);
submit.run(() =>
@@ -70,6 +110,8 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api })
);
};
const listNameOptions = sharePointLists.items.map(l => ({ value: l.title, label: l.title }));
return (
<div>
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.requestTypes.breadcrumb }]} />
@@ -87,6 +129,9 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api })
columns={[
{ key: 'name', header: texts.requestTypes.columnName, render: r => <PrimaryCell>{r.name}</PrimaryCell> },
{ key: 'url', header: texts.requestTypes.columnPageUrl, render: r => r.pageUrl },
{ key: 'section', header: texts.requestTypes.columnSection, render: r => r.section },
{ key: 'listName', header: texts.requestTypes.columnListName, render: r => r.listName ?? texts.common.empty },
{ key: 'siteUrl', header: texts.requestTypes.columnSiteUrl, render: r => <SiteUrlCell client={sharePoint} siteUrl={r.siteUrl} /> },
{
key: 'actions',
header: '',
@@ -129,6 +174,53 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api })
placeholder={texts.requestTypes.pageUrlPlaceholder}
/>
</Field>
<Field label={texts.requestTypes.sectionLabel} required full>
<TextInput
value={form.section}
onChange={e => setForm({ ...form, section: e.target.value })}
placeholder={texts.requestTypes.sectionPlaceholder}
/>
</Field>
<Field label={texts.requestTypes.siteUrlLabel} full hint={texts.requestTypes.siteUrlHint}>
<AsyncSearchableSelect<SharePointSiteSuggestion>
value={form.siteUrl}
selectedLabel={siteTitle.title ?? form.siteUrl}
selectedSublabel={siteTitle.title ? form.siteUrl : undefined}
allowClear
clearLabel={texts.select.notSelected}
placeholder={texts.requestTypes.siteUrlPlaceholder}
searchPlaceholder={texts.requestTypes.siteUrlSearchPlaceholder}
search={searchSites}
getValue={s => s.url}
getLabel={s => s.title}
getSublabel={s => s.url}
onChange={(siteUrl, item) => {
if (siteUrl && item) sharePoint.cacheSiteTitle(siteUrl, item.title);
setForm({ ...form, siteUrl, listName: '' });
}}
/>
</Field>
<Field
label={texts.requestTypes.listNameLabel}
full
hint={sharePointLists.error ? texts.requestTypes.listNameError(sharePointLists.error) : texts.requestTypes.listNameHint}
>
<Select
options={listNameOptions}
value={form.listName}
disabled={!form.siteUrl.trim() || sharePointLists.isLoading}
placeholder={
!form.siteUrl.trim()
? texts.requestTypes.listNamePlaceholder
: sharePointLists.isLoading
? texts.requestTypes.listNameLoading
: listNameOptions.length === 0
? texts.requestTypes.listNameEmpty
: texts.select.placeholder
}
onChange={e => setForm({ ...form, listName: e.target.value })}
/>
</Field>
</FormGrid>
</FormModal>
)}
@@ -1,3 +1,37 @@
@import '../../../../styles/design-tokens';
.conditions {
margin-top: 20px;
padding-top: 18px;
border-top: 1px solid $gray-200;
}
.conditionsHeader {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 12px;
}
.conditionsTitle {
font-size: 13px;
font-weight: 600;
color: $gray-700;
}
.conditionsHint {
font-size: 11.5px;
color: $gray-400;
}
.conditionRow {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 10px;
align-items: center;
margin-bottom: 10px;
}
.runButtonRow {
margin-top: 18px;
}
@@ -2,11 +2,14 @@ import * as React from 'react';
import styles from './ResolveScreen.module.scss';
import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary';
import { Project, RouteStepStatus } from '../../models';
import { routeStepStatusLabels, routeTypeLabels, texts } from '../../texts';
import { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists';
import { Project, ResolveFieldValue, RouteStepStatus } from '../../models';
import { routeStepStatusLabels, texts } from '../../texts';
import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useResolveRoute } from '../../hooks/useResolveRoute';
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
import { useSharePointFields } from '../../hooks/useSharePointFields';
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
import {
Alert,
@@ -17,30 +20,58 @@ import {
DataTable,
Field,
FormGrid,
IconButton,
PageHeader,
Panel,
PrimaryCell,
SearchableSelect,
Select
Select,
TextInput
} from '../ui';
export interface IResolveScreenProps {
api: ApprovalRouteApi;
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
dictionary?: PortalDictionaryClient;
sharePoint: SharePointListsClient;
currentUserEmail?: string;
}
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
.map(v => ({ value: v, label: routeStepStatusLabels[v] }));
export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary, currentUserEmail }) => {
// Форма проверки маршрута заполняется одними и теми же параметрами раз за разом
// (тип заявки, инициатор, проект, статус) — персистим её в localStorage, чтобы
// не вводить всё заново при каждом открытии экрана.
const RESOLVE_FORM_STORAGE_KEY = 'approvalAdmin.resolveForm';
interface IResolveFormState {
requestTypeId: string;
userEmail: string;
projectId: string;
projectLabel: string;
status: RouteStepStatus;
fieldValues: ResolveFieldValue[];
}
const EMPTY_RESOLVE_FORM: IResolveFormState = {
requestTypeId: '',
userEmail: '',
projectId: '',
projectLabel: '',
status: RouteStepStatus.Approval,
fieldValues: []
};
export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary, sharePoint, 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, fieldValues = [] } = form;
const requestType = requestTypes.items.find(rt => rt.id === Number(requestTypeId));
const hasListName = Boolean(requestType?.listName);
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl, requestType?.listName);
const fieldNameOptions = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
const resolved = useResolveRoute(api);
const employees = usePortalEmployees(dictionary);
@@ -55,16 +86,35 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
[dictionary]
);
const updateFieldValue = (index: number, patch: Partial<ResolveFieldValue>): void => {
setForm({ ...form, fieldValues: fieldValues.map((fv, i) => (i === index ? { ...fv, ...patch } : fv)) });
};
const addFieldValue = (): void => {
setForm({ ...form, fieldValues: [...fieldValues, { fieldName: '', value: '' }] });
};
const removeFieldValue = (index: number): void => {
setForm({ ...form, fieldValues: fieldValues.filter((_, i) => i !== index) });
};
const run = (): void => {
if (!requestTypeId) {
resolved.fail(texts.resolve.validationRequestType);
return;
}
const conditions = fieldValues.filter(fv => fv.fieldName.trim() || fv.value.trim());
if (conditions.some(fv => !fv.fieldName.trim() || !fv.value.trim())) {
resolved.fail(texts.resolve.validationConditionField);
return;
}
resolved.resolve({
requestTypeId: Number(requestTypeId),
userEmail: userEmail.trim() || undefined,
projectId: projectId.trim() || undefined,
status
status,
fieldValues: conditions.length > 0 ? conditions : undefined
});
};
@@ -83,7 +133,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, fieldValues: [] })}
/>
</Field>
<Field
@@ -99,7 +149,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,16 +165,62 @@ 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.conditions}>
<div className={styles.conditionsHeader}>
<span className={styles.conditionsTitle}>{texts.resolve.conditionsLabel}</span>
{requestTypeId && !hasListName && (
<span className={styles.conditionsHint}>{texts.resolve.conditionsDisabledHint}</span>
)}
</div>
{hasListName && (
<>
{fieldValues.map((fv, index) => {
const field = sharePointFields.items.find(f => f.internalName === fv.fieldName);
const isNumeric = field ? isNumericFieldType(field.typeAsString) : false;
return (
// eslint-disable-next-line react/no-array-index-key -- строки не переупорядочиваются, только добавляются/удаляются с конца или явным индексом
<div className={styles.conditionRow} key={index}>
<Select
options={fieldNameOptions}
value={fv.fieldName}
disabled={sharePointFields.isLoading}
placeholder={
sharePointFields.isLoading
? texts.resolve.conditionFieldLoading
: texts.resolve.conditionFieldPlaceholder
}
onChange={e => updateFieldValue(index, { fieldName: e.target.value })}
/>
<TextInput
type={isNumeric ? 'number' : 'text'}
value={fv.value}
placeholder={texts.resolve.conditionValuePlaceholder}
onChange={e => updateFieldValue(index, { value: e.target.value })}
/>
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => removeFieldValue(index)} />
</div>
);
})}
<Button variant="secondary" size="sm" icon="plus" onClick={addFieldValue}>
{texts.resolve.conditionAdd}
</Button>
</>
)}
</div>
<div className={styles.runButtonRow}>
<Button variant="accent" icon="search" onClick={run} disabled={resolved.isBusy}>
{resolved.isBusy ? texts.resolve.running : texts.resolve.run}
@@ -137,7 +233,7 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
{result && (
<>
<Alert kind="ok">
{texts.resolve.summary(result.routeId, routeTypeLabels[result.routeType], result.requiresControl)}
{texts.resolve.summary(result.routeId, result.requiresControl)}
</Alert>
{unresolvedCount > 0 && <Alert kind="warn">{texts.resolve.unresolved(unresolvedCount)}</Alert>}
<DataTable
@@ -1,6 +1,6 @@
import * as React from 'react';
import { Company, Department, RequestType, RouteType, TemplateRoute } from '../../models';
import { routeTypeLabels, texts } from '../../texts';
import { Company, Department, RequestType, TemplateRoute } from '../../models';
import { texts } from '../../texts';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { ApprovalRouteApi } from '../../services/api';
import { Checkbox, Field, FormGrid, FormModal, SearchableSelect, Select } from '../ui';
@@ -21,22 +21,16 @@ export interface IRouteFormModalProps {
interface IFormState {
requestTypeId: string;
routeType: RouteType;
companyId: string;
departmentId: string;
byProject: boolean;
requiresControl: boolean;
}
const ROUTE_TYPE_OPTIONS = Object.values(RouteType)
.filter(v => v !== RouteType.None)
.map(v => ({ value: v, label: routeTypeLabels[v] }));
function toFormState(editing: TemplateRoute | undefined, initialRequestTypeId: string): IFormState {
if (!editing) {
return {
requestTypeId: initialRequestTypeId,
routeType: RouteType.Sequential,
companyId: '',
departmentId: '',
byProject: false,
@@ -45,8 +39,7 @@ function toFormState(editing: TemplateRoute | undefined, initialRequestTypeId: s
}
return {
requestTypeId: String(editing.requestTypeId),
routeType: editing.routeType,
companyId: editing.companyId,
companyId: editing.companyId ?? '',
departmentId: editing.departmentId ?? '',
byProject: editing.byProject,
requiresControl: editing.requiresControl
@@ -80,24 +73,19 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
submit.fail(texts.routes.validationRequestType);
return;
}
if (!form.companyId.trim()) {
submit.fail(texts.routes.validationCompany);
return;
}
const companyId = form.companyId.trim() ? form.companyId.trim() : null;
const departmentId = form.departmentId.trim() ? form.departmentId.trim() : null;
const request = editing
? api.templateRoutes.update(editing.id, {
routeType: form.routeType,
companyId: form.companyId.trim(),
companyId,
departmentId,
byProject: form.byProject,
requiresControl: form.requiresControl
})
: api.templateRoutes.create({
routeType: form.routeType,
requestTypeId: Number(form.requestTypeId),
companyId: form.companyId.trim(),
companyId,
departmentId,
byProject: form.byProject,
requiresControl: form.requiresControl
@@ -129,18 +117,13 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
onChange={e => setForm({ ...form, requestTypeId: e.target.value })}
/>
</Field>
<Field label={texts.routes.routeTypeLabel} required full>
<Select
options={ROUTE_TYPE_OPTIONS}
value={form.routeType}
onChange={e => setForm({ ...form, routeType: e.target.value as RouteType })}
/>
</Field>
<Field label={texts.routes.companyLabel} required full hint={texts.routes.companyHint}>
<Field label={texts.routes.companyLabel} full hint={texts.routes.companyHint}>
<SearchableSelect
options={companies.map(c => ({ value: c.id, label: c.name }))}
value={form.companyId}
loading={dictionaryLoading}
allowClear
clearLabel={texts.routes.companyClear}
placeholder={texts.routes.companyPlaceholder}
searchPlaceholder={texts.routes.companySearchPlaceholder}
onChange={companyId =>
@@ -1,23 +1,30 @@
import * as React from 'react';
import {
ComparisonOperator,
Employee,
HeadType,
ProjectRole,
RequestType,
RouteStepStatus,
StepExecutorKind,
TemplateRoute,
TemplateRouteStep
} from '../../models';
import { headTypeLabels, projectRoleLabels, routeStepStatusLabels, texts } from '../../texts';
import { comparisonOperatorLabels, headTypeLabels, projectRoleLabels, routeStepStatusLabels, texts } from '../../texts';
import { detectExecutorKind } from '../../utils/stepExecutor';
import { toEmployeeOption } from '../../utils/employee';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { useSharePointFields } from '../../hooks/useSharePointFields';
import { ApprovalRouteApi } from '../../services/api';
import { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists';
import { Checkbox, Field, FormGrid, FormModal, RadioGroup, SearchableSelect, Select, TextInput } from '../ui';
export interface IRouteStepFormModalProps {
api: ApprovalRouteApi;
route: TemplateRoute;
/** Undefined, пока грузится тип заявки маршрута. */
requestType?: RequestType;
sharePoint: SharePointListsClient;
/** Undefined — создание нового шага, иначе редактирование существующего. */
editing?: TemplateRouteStep;
nextOrderNo: number;
@@ -36,6 +43,10 @@ interface IFormState {
projectRole: ProjectRole;
headType: HeadType;
allowReassignToOtherApprovers: boolean;
hasCondition: boolean;
fieldName: string;
operation: ComparisonOperator;
value: string;
}
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
@@ -49,6 +60,10 @@ const HEAD_TYPE_OPTIONS = Object.values(HeadType)
.filter(v => v !== HeadType.None)
.map(v => ({ value: v, label: headTypeLabels[v] }));
const OPERATION_OPTIONS = Object.values(ComparisonOperator)
.filter(v => v !== ComparisonOperator.None)
.map(v => ({ value: v, label: comparisonOperatorLabels[v] }));
function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number): IFormState {
if (!editing) {
return {
@@ -59,7 +74,11 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
position: '',
projectRole: ProjectRole.ProjectManager,
headType: HeadType.Chief,
allowReassignToOtherApprovers: false
allowReassignToOtherApprovers: false,
hasCondition: false,
fieldName: '',
operation: ComparisonOperator.GreaterThan,
value: ''
};
}
return {
@@ -70,7 +89,11 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
position: editing.position ?? '',
projectRole: editing.projectRole && editing.projectRole !== ProjectRole.None ? editing.projectRole : ProjectRole.ProjectManager,
headType: editing.headType && editing.headType !== HeadType.None ? editing.headType : HeadType.Chief,
allowReassignToOtherApprovers: editing.allowReassignToOtherApprovers
allowReassignToOtherApprovers: editing.allowReassignToOtherApprovers,
hasCondition: Boolean(editing.fieldName),
fieldName: editing.fieldName ?? '',
operation: editing.operation && editing.operation !== ComparisonOperator.None ? editing.operation : ComparisonOperator.GreaterThan,
value: editing.value ?? ''
};
}
@@ -78,6 +101,8 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
api,
route,
requestType,
sharePoint,
editing,
nextOrderNo,
employees,
@@ -87,6 +112,35 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
}) => {
const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, nextOrderNo));
const submit = useAsyncSubmit();
const hasListName = Boolean(requestType?.listName);
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl, requestType?.listName);
const fieldNameOptions = React.useMemo(() => {
const options = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
if (form.fieldName && !options.some(o => o.value === form.fieldName)) {
options.unshift({ value: form.fieldName, label: form.fieldName });
}
return options;
}, [sharePointFields.items, form.fieldName]);
// Тип столбца известен, только если он есть среди подгруженных полей списка —
// для значения, введённого до появления поля в списке (или после его удаления),
// ограничения ниже не действуют, чтобы не блокировать уже сохранённые данные.
const selectedField = sharePointFields.items.find(f => f.internalName === form.fieldName);
const isNumericField = selectedField ? isNumericFieldType(selectedField.typeAsString) : true;
const operationOptions = isNumericField
? OPERATION_OPTIONS
: OPERATION_OPTIONS.filter(o => o.value === ComparisonOperator.Equal);
// Строковая колонка допускает только «равно» — если пользователь сменил
// field_name на нечисловую колонку, а раньше был выбран «больше»/«меньше», сбрасываем.
React.useEffect(() => {
if (!isNumericField && form.operation !== ComparisonOperator.Equal) {
setForm(f => ({ ...f, operation: ComparisonOperator.Equal }));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isNumericField]);
const submitForm = (): void => {
const orderNo = Number(form.orderNo);
@@ -102,6 +156,14 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
submit.fail(texts.routeSteps.validationPosition);
return;
}
if (form.hasCondition && (!form.fieldName.trim() || !form.value.trim())) {
submit.fail(texts.routeSteps.validationCondition);
return;
}
if (form.hasCondition && isNumericField && !Number.isFinite(Number(form.value.trim()))) {
submit.fail(texts.routeSteps.validationConditionNumber);
return;
}
const payload = {
orderNo,
@@ -110,7 +172,10 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
executorEmail: form.kind === 'email' ? form.executorEmail.trim() : null,
position: form.kind === 'position' ? form.position.trim() : null,
projectRole: form.kind === 'projectRole' ? form.projectRole : null,
headType: form.kind === 'headType' ? form.headType : null
headType: form.kind === 'headType' ? form.headType : null,
fieldName: form.hasCondition ? form.fieldName.trim() : null,
operation: form.hasCondition ? form.operation : null,
value: form.hasCondition ? form.value.trim() : null
};
const request = editing
@@ -217,6 +282,55 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
onChange={v => setForm({ ...form, allowReassignToOtherApprovers: v })}
/>
</Field>
<Field label={texts.routeSteps.conditionLabel} full hint={!hasListName ? texts.routeSteps.conditionDisabledHint : undefined}>
<Checkbox
label={texts.routeSteps.conditionCheckbox}
checked={form.hasCondition}
disabled={!hasListName}
onChange={v => setForm({ ...form, hasCondition: v })}
/>
</Field>
{form.hasCondition && hasListName && (
<>
<Field
label={texts.routeSteps.conditionFieldNameLabel}
required
full
hint={sharePointFields.error ? texts.routeSteps.conditionFieldNameError(sharePointFields.error) : undefined}
>
<Select
options={fieldNameOptions}
value={form.fieldName}
disabled={sharePointFields.isLoading}
placeholder={
sharePointFields.isLoading
? texts.routeSteps.conditionFieldNameLoading
: fieldNameOptions.length === 0
? texts.routeSteps.conditionFieldNameEmpty
: texts.routeSteps.conditionFieldNamePlaceholder
}
onChange={e => setForm({ ...form, fieldName: e.target.value })}
/>
</Field>
<Field label={texts.routeSteps.conditionOperationLabel} required>
<Select
options={operationOptions}
value={form.operation}
onChange={e => setForm({ ...form, operation: e.target.value as ComparisonOperator })}
/>
</Field>
<Field label={texts.routeSteps.conditionValueLabel} required>
<TextInput
type={isNumericField ? 'number' : 'text'}
value={form.value}
onChange={e => setForm({ ...form, value: e.target.value })}
placeholder={texts.routeSteps.conditionValuePlaceholder}
/>
</Field>
</>
)}
</FormGrid>
</FormModal>
);
@@ -2,9 +2,10 @@ import * as React from 'react';
import { RouteStepFormModal } from './RouteStepFormModal';
import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary';
import { RouteStepStatus, TemplateRoute, TemplateRouteStep } from '../../models';
import { routeStepStatusLabels, routeTypeLabels, texts } from '../../texts';
import { describeStepExecutor } from '../../utils/stepExecutor';
import { SharePointListsClient } from '../../services/sharePointLists';
import { RequestType, RouteStepStatus, TemplateRoute, TemplateRouteStep } from '../../models';
import { routeStepStatusLabels, texts } from '../../texts';
import { describeStepCondition, describeStepExecutor } from '../../utils/stepExecutor';
import { useRouteSteps } from '../../hooks/useRouteSteps';
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
@@ -25,16 +26,19 @@ import {
export interface IRouteStepsScreenProps {
api: ApprovalRouteApi;
route: TemplateRoute;
requestTypeName: string;
/** Undefined, пока грузится тип заявки (см. AdminApp.openSteps). */
requestType?: RequestType;
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
dictionary?: PortalDictionaryClient;
sharePoint: SharePointListsClient;
onBack: () => void;
}
export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route, requestTypeName, dictionary, onBack }) => {
export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route, requestType, dictionary, sharePoint, onBack }) => {
const steps = useRouteSteps(api, route.id);
const employees = usePortalEmployees(dictionary);
const [savedWarning, setSavedWarning] = React.useState<string | undefined>();
const requestTypeName = requestType?.name ?? `#${route.requestTypeId}`;
const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>();
const [showForm, setShowForm] = React.useState(false);
@@ -72,7 +76,7 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
/>
<PageHeader
icon="flag-check"
subtitle={texts.routeSteps.subtitle(requestTypeName, routeTypeLabels[route.routeType])}
subtitle={texts.routeSteps.subtitle(requestTypeName)}
title={texts.routeSteps.title(route.id)}
actions={
<>
@@ -106,7 +110,7 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
key: 'status',
header: texts.routeSteps.columnStatus,
render: r => (
<Badge variant={r.status === RouteStepStatus.Approval ? 'gold' : 'blue'}>
<Badge variant={r.status === RouteStepStatus.Approval ? 'neutral' : 'blue'}>
{routeStepStatusLabels[r.status]}
</Badge>
)
@@ -117,6 +121,11 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
align: 'center',
render: r => (r.allowReassignToOtherApprovers ? <Badge variant="ok">{texts.routeSteps.reassignAllowed}</Badge> : <Badge variant="neutral">{texts.common.no}</Badge>)
},
{
key: 'condition',
header: texts.routeSteps.columnCondition,
render: r => describeStepCondition(r) ?? texts.common.empty
},
{
key: 'actions',
header: '',
@@ -140,6 +149,8 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
<RouteStepFormModal
api={api}
route={route}
requestType={requestType}
sharePoint={sharePoint}
editing={editing}
nextOrderNo={nextOrderNo}
employees={employees.items}
@@ -3,8 +3,8 @@ import styles from './RoutesScreen.module.scss';
import { RouteFormModal } from './RouteFormModal';
import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary';
import { RouteType, TemplateRoute } from '../../models';
import { routeTypeLabels, texts } from '../../texts';
import { TemplateRoute } from '../../models';
import { texts } from '../../texts';
import { shortGuid } from '../../utils/guid';
import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useTemplateRoutes } from '../../hooks/useTemplateRoutes';
@@ -92,12 +92,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, dictionary, on
header: texts.routes.columnRequestType,
render: r => <PrimaryCell>{requestTypeName(r.requestTypeId)}</PrimaryCell>
},
{
key: 'routeType',
header: texts.routes.columnRouteType,
render: r => <Badge variant={r.routeType === RouteType.Parallel ? 'blue' : 'gold'}>{routeTypeLabels[r.routeType]}</Badge>
},
{ key: 'company', header: texts.routes.columnCompany, render: r => companyName(r.companyId) },
{ key: 'company', header: texts.routes.columnCompany, render: r => (r.companyId ? companyName(r.companyId) : texts.common.empty) },
{ key: 'department', header: texts.routes.columnDepartment, render: r => (r.departmentId ? departmentName(r.departmentId) : texts.common.empty) },
{
key: 'byProject',
@@ -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';
@@ -12,6 +13,8 @@ export interface IAsyncSearchableSelectProps<T> {
value: string;
/** Название выбранного значения — показываем в триггере без похода на сервер. */
selectedLabel?: string;
/** Вторая строка триггера мельче и серым — например, url под названием сайта. */
selectedSublabel?: string;
onChange: (value: string, item: T | undefined) => void;
search: (query: string, page: number) => Promise<IAsyncSearchResult<T>>;
getValue: (item: T) => string;
@@ -29,6 +32,7 @@ export interface IAsyncSearchableSelectProps<T> {
export function AsyncSearchableSelect<T>({
value,
selectedLabel,
selectedSublabel,
onChange,
search,
getValue,
@@ -51,6 +55,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 +108,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);
@@ -161,57 +169,62 @@ export function AsyncSearchableSelect<T>({
onClick={toggle}
disabled={disabled}
>
<span className={`${styles.value} ${value ? '' : styles.placeholder}`}>
{value ? (selectedLabel ?? value) : placeholder}
</span>
{value && selectedSublabel ? (
<span className={styles.valueStack}>
<span className={styles.valueMain}>{selectedLabel ?? value}</span>
<span className={styles.valueSub}>{selectedSublabel}</span>
</span>
) : (
<span className={`${styles.value} ${value ? '' : styles.placeholder}`}>
{value ? (selectedLabel ?? value) : placeholder}
</span>
)}
<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
);
};
@@ -29,21 +29,10 @@
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: $sh-2;
box-shadow: $sh-1;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 14px;
&::after {
content: '';
position: absolute;
right: -60px;
top: -60px;
width: 220px;
height: 220px;
border-radius: 50%;
background: radial-gradient(circle, rgba(212,160,30,.14), transparent 65%);
}
}
.phLeft { display: flex; align-items: center; gap: 18px; position: relative; z-index: 1; }
@@ -52,9 +41,9 @@
width: 46px;
height: 46px;
border-radius: $r-md;
background: rgba(212,160,30,.14);
border: 1px solid rgba(212,160,30,.3);
color: $gold-300;
background: $gold-100;
border: 1px solid $gold-300;
color: $gold-600;
display: flex;
align-items: center;
justify-content: center;
@@ -63,7 +52,7 @@
.phSub {
font-size: 10.5px;
color: rgba(255,255,255,.55);
color: $gray-500;
text-transform: uppercase;
letter-spacing: 1.8px;
font-weight: 500;
@@ -73,7 +62,7 @@
font-family: $font-serif;
font-size: $fs-page-title;
font-weight: 600;
color: #fff;
color: $navy-900;
margin-top: 3px;
letter-spacing: .2px;
}
@@ -35,15 +35,39 @@
white-space: nowrap;
}
// Двухстрочный триггер — название сверху, мелкая серая подпись (url) снизу,
// как itemSub в списке. Отдельно от .value, у которого одна строка целиком
// участвует в ellipsis и не может параллельно нести подпись другим кеглем.
.valueStack {
display: flex;
flex-direction: column;
min-width: 0;
text-align: left;
}
.valueMain {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.valueSub {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
color: $gray-400;
margin-top: 1px;
}
.placeholder { color: $gray-400; }
.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>
);
};