всякие фиксы

This commit is contained in:
gorbenko.pavel
2026-07-31 14:20:13 +05:00
parent 06b52c5186
commit d38962754d
6 changed files with 136 additions and 12 deletions
@@ -1,6 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { ApprovalRouteApi } from '../../services/api'; import { ApprovalRouteApi } from '../../services/api';
import { SharePointListsClient } from '../../services/sharePointLists'; import { SharePointListsClient, SharePointSiteSuggestion } from '../../services/sharePointLists';
import { RequestType } from '../../models'; import { RequestType } from '../../models';
import { texts } from '../../texts'; import { texts } from '../../texts';
import { useRequestTypes } from '../../hooks/useRequestTypes'; import { useRequestTypes } from '../../hooks/useRequestTypes';
@@ -10,6 +10,7 @@ import { useDeleteTarget } from '../../hooks/useDeleteTarget';
import { import {
ActionsCell, ActionsCell,
Alert, Alert,
AsyncSearchableSelect,
Breadcrumbs, Breadcrumbs,
Button, Button,
ConfirmDialog, ConfirmDialog,
@@ -34,10 +35,12 @@ interface IFormState {
pageUrl: string; pageUrl: string;
section: string; section: string;
siteUrl: string; siteUrl: string;
/** Заголовок сайта — только для отображения в триггере поиска, на бэкенд не уходит. */
siteTitle: string;
listName: string; listName: string;
} }
const EMPTY_FORM: IFormState = { name: '', pageUrl: '', section: '', siteUrl: '', listName: '' }; const EMPTY_FORM: IFormState = { name: '', pageUrl: '', section: '', siteUrl: '', siteTitle: '', listName: '' };
export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sharePoint }) => { export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sharePoint }) => {
const list = useRequestTypes(api); const list = useRequestTypes(api);
@@ -47,6 +50,10 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
const [form, setForm] = React.useState<IFormState>(EMPTY_FORM); const [form, setForm] = React.useState<IFormState>(EMPTY_FORM);
const submit = useAsyncSubmit(); const submit = useAsyncSubmit();
const sharePointLists = useSharePointLists(sharePoint, form.siteUrl); const sharePointLists = useSharePointLists(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); const deleteTarget = useDeleteTarget<RequestType>(item => api.requestTypes.remove(item.id), list.reload);
@@ -63,6 +70,7 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
pageUrl: item.pageUrl, pageUrl: item.pageUrl,
section: item.section, section: item.section,
siteUrl: item.siteUrl ?? '', siteUrl: item.siteUrl ?? '',
siteTitle: item.siteUrl ?? '',
listName: item.listName ?? '' listName: item.listName ?? ''
}); });
setShowForm(true); setShowForm(true);
@@ -117,6 +125,8 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
{ key: 'name', header: texts.requestTypes.columnName, render: r => <PrimaryCell>{r.name}</PrimaryCell> }, { key: 'name', header: texts.requestTypes.columnName, render: r => <PrimaryCell>{r.name}</PrimaryCell> },
{ key: 'url', header: texts.requestTypes.columnPageUrl, render: r => r.pageUrl }, { key: 'url', header: texts.requestTypes.columnPageUrl, render: r => r.pageUrl },
{ key: 'section', header: texts.requestTypes.columnSection, render: r => r.section }, { 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 => r.siteUrl ?? texts.common.empty },
{ {
key: 'actions', key: 'actions',
header: '', header: '',
@@ -167,10 +177,18 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
/> />
</Field> </Field>
<Field label={texts.requestTypes.siteUrlLabel} full hint={texts.requestTypes.siteUrlHint}> <Field label={texts.requestTypes.siteUrlLabel} full hint={texts.requestTypes.siteUrlHint}>
<TextInput <AsyncSearchableSelect<SharePointSiteSuggestion>
value={form.siteUrl} value={form.siteUrl}
onChange={e => setForm({ ...form, siteUrl: e.target.value, listName: '' })} selectedLabel={form.siteTitle || form.siteUrl}
allowClear
clearLabel={texts.select.notSelected}
placeholder={texts.requestTypes.siteUrlPlaceholder} 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) => setForm({ ...form, siteUrl, siteTitle: item?.title ?? siteUrl, listName: '' })}
/> />
</Field> </Field>
<Field <Field
@@ -16,7 +16,7 @@ import { toEmployeeOption } from '../../utils/employee';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { useSharePointFields } from '../../hooks/useSharePointFields'; import { useSharePointFields } from '../../hooks/useSharePointFields';
import { ApprovalRouteApi } from '../../services/api'; import { ApprovalRouteApi } from '../../services/api';
import { SharePointListsClient } from '../../services/sharePointLists'; import { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists';
import { Checkbox, Field, FormGrid, FormModal, RadioGroup, SearchableSelect, Select, TextInput } from '../ui'; import { Checkbox, Field, FormGrid, FormModal, RadioGroup, SearchableSelect, Select, TextInput } from '../ui';
export interface IRouteStepFormModalProps { export interface IRouteStepFormModalProps {
@@ -123,6 +123,25 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
return options; return options;
}, [sharePointFields.items, form.fieldName]); }, [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 submitForm = (): void => {
const orderNo = Number(form.orderNo); const orderNo = Number(form.orderNo);
if (!orderNo || orderNo < 1) { if (!orderNo || orderNo < 1) {
@@ -141,6 +160,10 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
submit.fail(texts.routeSteps.validationCondition); submit.fail(texts.routeSteps.validationCondition);
return; return;
} }
if (form.hasCondition && isNumericField && !Number.isFinite(Number(form.value.trim()))) {
submit.fail(texts.routeSteps.validationConditionNumber);
return;
}
const payload = { const payload = {
orderNo, orderNo,
@@ -293,13 +316,14 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
</Field> </Field>
<Field label={texts.routeSteps.conditionOperationLabel} required> <Field label={texts.routeSteps.conditionOperationLabel} required>
<Select <Select
options={OPERATION_OPTIONS} options={operationOptions}
value={form.operation} value={form.operation}
onChange={e => setForm({ ...form, operation: e.target.value as ComparisonOperator })} onChange={e => setForm({ ...form, operation: e.target.value as ComparisonOperator })}
/> />
</Field> </Field>
<Field label={texts.routeSteps.conditionValueLabel} required> <Field label={texts.routeSteps.conditionValueLabel} required>
<TextInput <TextInput
type={isNumericField ? 'number' : 'text'}
value={form.value} value={form.value}
onChange={e => setForm({ ...form, value: e.target.value })} onChange={e => setForm({ ...form, value: e.target.value })}
placeholder={texts.routeSteps.conditionValuePlaceholder} placeholder={texts.routeSteps.conditionValuePlaceholder}
@@ -5,7 +5,7 @@ import { PortalDictionaryClient } from '../../services/portalDictionary';
import { SharePointListsClient } from '../../services/sharePointLists'; import { SharePointListsClient } from '../../services/sharePointLists';
import { RequestType, RouteStepStatus, TemplateRoute, TemplateRouteStep } from '../../models'; import { RequestType, RouteStepStatus, TemplateRoute, TemplateRouteStep } from '../../models';
import { routeStepStatusLabels, routeTypeLabels, texts } from '../../texts'; import { routeStepStatusLabels, routeTypeLabels, texts } from '../../texts';
import { describeStepExecutor } from '../../utils/stepExecutor'; import { describeStepCondition, describeStepExecutor } from '../../utils/stepExecutor';
import { useRouteSteps } from '../../hooks/useRouteSteps'; import { useRouteSteps } from '../../hooks/useRouteSteps';
import { useDeleteTarget } from '../../hooks/useDeleteTarget'; import { useDeleteTarget } from '../../hooks/useDeleteTarget';
import { usePortalEmployees } from '../../hooks/usePortalEmployees'; import { usePortalEmployees } from '../../hooks/usePortalEmployees';
@@ -121,6 +121,11 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
align: 'center', align: 'center',
render: r => (r.allowReassignToOtherApprovers ? <Badge variant="ok">{texts.routeSteps.reassignAllowed}</Badge> : <Badge variant="neutral">{texts.common.no}</Badge>) 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', key: 'actions',
header: '', header: '',
@@ -2,6 +2,7 @@ import { spfi, SPFI, SPFx } from '@pnp/sp';
import '@pnp/sp/webs'; import '@pnp/sp/webs';
import '@pnp/sp/lists'; import '@pnp/sp/lists';
import '@pnp/sp/fields'; import '@pnp/sp/fields';
import '@pnp/sp/search';
import { WebPartContext } from '@microsoft/sp-webpart-base'; import { WebPartContext } from '@microsoft/sp-webpart-base';
// Списки, из которых типы заявок берут list_name/field_name, живут на произвольном // Списки, из которых типы заявок берут list_name/field_name, живут на произвольном
@@ -9,6 +10,10 @@ import { WebPartContext } from '@microsoft/sp-webpart-base';
// размещён сам веб-парт. Поэтому клиент всегда создаётся под конкретный site_url // размещён сам веб-парт. Поэтому клиент всегда создаётся под конкретный site_url
// (spfi(siteUrl).using(SPFx(context))), а не через общий getSP() под текущий сайт. // (spfi(siteUrl).using(SPFx(context))), а не через общий getSP() под текущий сайт.
// См. приложенный референс (sp-lists-frontend.md), раздел 8 «Другой сайт». // См. приложенный референс (sp-lists-frontend.md), раздел 8 «Другой сайт».
//
// Поиск сайтов (searchSites), наоборот, идёт через поисковый индекс SharePoint —
// он общий на весь тенант независимо от того, с какого сайта пришёл запрос,
// поэтому там клиент строится под текущий сайт веб-части (SPFx(context) без url).
export interface SharePointList { export interface SharePointList {
id: string; id: string;
@@ -18,6 +23,26 @@ export interface SharePointList {
export interface SharePointListField { export interface SharePointListField {
internalName: string; internalName: string;
title: string; title: string;
/** SharePoint TypeAsString (Text, Number, Currency, DateTime, Choice, ...). */
typeAsString: string;
}
export interface SharePointSiteSuggestion {
title: string;
url: string;
}
export interface SharePointSiteSearchResult {
items: SharePointSiteSuggestion[];
hasMore: boolean;
}
// SharePoint TypeAsString для колонок с числовыми значениями — только для них
// имеют смысл операторы «больше»/«меньше» (см. RouteStepFormModal).
const NUMERIC_FIELD_TYPES = new Set(['Number', 'Currency']);
export function isNumericFieldType(typeAsString: string): boolean {
return NUMERIC_FIELD_TYPES.has(typeAsString);
} }
interface IListItem { interface IListItem {
@@ -28,6 +53,12 @@ interface IListItem {
interface IFieldItem { interface IFieldItem {
InternalName: string; InternalName: string;
Title: string; Title: string;
TypeAsString: string;
}
interface ISearchResultRow {
Title?: string;
Path?: string;
} }
function clientFor(context: WebPartContext, siteUrl: string): SPFI { function clientFor(context: WebPartContext, siteUrl: string): SPFI {
@@ -48,14 +79,42 @@ export class SharePointListsClient {
return items.map(x => ({ id: x.Id, title: x.Title })); return items.map(x => ({ id: x.Id, title: x.Title }));
} }
/** Заголовки столбцов (внутреннее + отображаемое имя) списка — исключая служебные поля. */ /** Заголовки столбцов (внутреннее + отображаемое имя + тип) списка — исключая служебные поля. */
public async listFields(siteUrl: string, listTitle: string): Promise<SharePointListField[]> { public async listFields(siteUrl: string, listTitle: string): Promise<SharePointListField[]> {
const sp = clientFor(this.context, siteUrl); const sp = clientFor(this.context, siteUrl);
const items = await sp.web.lists.getByTitle(listTitle).fields const items = await sp.web.lists.getByTitle(listTitle).fields
.select('InternalName', 'Title') .select('InternalName', 'Title', 'TypeAsString')
.filter('Hidden eq false and ReadOnlyField eq false') .filter('Hidden eq false and ReadOnlyField eq false')
.orderBy('Title')() as IFieldItem[]; .orderBy('Title')() as IFieldItem[];
return items.map(x => ({ internalName: x.InternalName, title: x.Title })); return items.map(x => ({ internalName: x.InternalName, title: x.Title, typeAsString: x.TypeAsString }));
}
/**
* Поиск сайтов (site collections) по всему тенанту через поисковый индекс
* SharePoint (contentclass:STS_Site) — не требует заранее знать site_url.
* Результаты обрезаны правами доступа текущего пользователя, как и любой
* SharePoint Search запрос.
*/
public async searchSites(query: string, page: number, pageSize = 20): Promise<SharePointSiteSearchResult> {
const sp = spfi().using(SPFx(this.context));
const escaped = query.trim().replace(/["*()]/g, '');
const queryText = escaped ? `contentclass:STS_Site ${escaped}*` : 'contentclass:STS_Site';
const result = await sp.search({
Querytext: queryText,
SelectProperties: ['Title', 'Path'],
RowLimit: pageSize,
StartRow: (page - 1) * pageSize,
TrimDuplicates: true
});
const rows = (result.PrimarySearchResults ?? []) as ISearchResultRow[];
const items = rows
.filter(r => Boolean(r.Path))
.map(r => ({ title: r.Title || r.Path as string, url: r.Path as string }));
const totalRows = (result as unknown as { TotalRows?: number }).TotalRows ?? items.length;
return { items, hasMore: page * pageSize < totalRows };
} }
} }
@@ -45,6 +45,13 @@ export const comparisonOperatorLabels: Record<ComparisonOperator, string> = {
[ComparisonOperator.Equal]: 'Равно (=)' [ComparisonOperator.Equal]: 'Равно (=)'
}; };
export const comparisonOperatorSymbols: Record<ComparisonOperator, string> = {
[ComparisonOperator.None]: '',
[ComparisonOperator.GreaterThan]: '>',
[ComparisonOperator.LessThan]: '<',
[ComparisonOperator.Equal]: '='
};
export const texts = { export const texts = {
common: { common: {
home: 'Главная', home: 'Главная',
@@ -104,6 +111,8 @@ export const texts = {
columnName: 'Название', columnName: 'Название',
columnPageUrl: 'Url страницы', columnPageUrl: 'Url страницы',
columnSection: 'Раздел', columnSection: 'Раздел',
columnListName: 'Список SharePoint',
columnSiteUrl: 'Сайт SharePoint',
createTitle: 'Новый тип заявки', createTitle: 'Новый тип заявки',
editTitle: 'Редактировать тип заявки', editTitle: 'Редактировать тип заявки',
nameLabel: 'Название', nameLabel: 'Название',
@@ -116,6 +125,7 @@ export const texts = {
siteUrlLabel: 'Сайт SharePoint', siteUrlLabel: 'Сайт SharePoint',
siteUrlHint: 'Сайт, где живёт список — например, https://tnsukz.sharepoint.com/sites/hrm.', siteUrlHint: 'Сайт, где живёт список — например, https://tnsukz.sharepoint.com/sites/hrm.',
siteUrlPlaceholder: 'https://tnsukz.sharepoint.com/sites/hrm', siteUrlPlaceholder: 'https://tnsukz.sharepoint.com/sites/hrm',
siteUrlSearchPlaceholder: 'Поиск сайта по названию...',
listNameLabel: 'Список SharePoint', listNameLabel: 'Список SharePoint',
listNameHint: 'Источник полей (field_name) для условий в шагах маршрута.', listNameHint: 'Источник полей (field_name) для условий в шагах маршрута.',
listNamePlaceholder: 'Сначала укажите сайт SharePoint', listNamePlaceholder: 'Сначала укажите сайт SharePoint',
@@ -184,6 +194,7 @@ export const texts = {
columnExecutor: 'Исполнитель', columnExecutor: 'Исполнитель',
columnStatus: 'Статус', columnStatus: 'Статус',
columnReassign: 'Переназначение', columnReassign: 'Переназначение',
columnCondition: 'Условие',
reassignAllowed: 'Разрешено', reassignAllowed: 'Разрешено',
executorRole: (roleLabel: string): string => `Роль: ${roleLabel}`, executorRole: (roleLabel: string): string => `Роль: ${roleLabel}`,
executorHead: (headLabel: string): string => `Руководитель: ${headLabel}`, executorHead: (headLabel: string): string => `Руководитель: ${headLabel}`,
@@ -222,6 +233,7 @@ export const texts = {
validationExecutor: 'Выберите сотрудника-исполнителя.', validationExecutor: 'Выберите сотрудника-исполнителя.',
validationPosition: 'Укажите должность.', validationPosition: 'Укажите должность.',
validationCondition: 'Заполните поле списка, оператор и значение условия.', validationCondition: 'Заполните поле списка, оператор и значение условия.',
validationConditionNumber: 'Выбранное поле числовое — значение условия должно быть числом.',
deleteTitle: 'Удалить шаг?', deleteTitle: 'Удалить шаг?',
deleteText: (orderNo: number): string => `Шаг #${orderNo} будет удалён безвозвратно.` deleteText: (orderNo: number): string => `Шаг #${orderNo} будет удалён безвозвратно.`
}, },
@@ -1,5 +1,5 @@
import { Employee, HeadType, ProjectRole, StepExecutorKind, TemplateRouteStep } from '../models'; import { ComparisonOperator, Employee, HeadType, ProjectRole, StepExecutorKind, TemplateRouteStep } from '../models';
import { headTypeLabels, projectRoleLabels, texts } from '../texts'; import { comparisonOperatorSymbols, headTypeLabels, projectRoleLabels, texts } from '../texts';
import { findEmployeeByEmail } from './employee'; import { findEmployeeByEmail } from './employee';
/** Каким из четырёх взаимоисключающих способов у шага задан исполнитель. */ /** Каким из четырёх взаимоисключающих способов у шага задан исполнитель. */
@@ -36,3 +36,9 @@ export function describeStepExecutor(step: TemplateRouteStep, employees: Employe
} }
return { primary: texts.common.empty }; return { primary: texts.common.empty };
} }
/** Колонка «Условие» в таблице шагов — undefined, если у шага условия нет. */
export function describeStepCondition(step: TemplateRouteStep): string | undefined {
if (!step.fieldName || !step.operation || step.operation === ComparisonOperator.None || !step.value) return undefined;
return `${step.fieldName} ${comparisonOperatorSymbols[step.operation]} ${step.value}`;
}