переработка выбора

This commit is contained in:
gorbenko.pavel
2026-08-19 14:52:34 +05:00
parent 82512eb1db
commit 30fb3fa2b1
7 changed files with 224 additions and 77 deletions
@@ -15,6 +15,7 @@ import {
} from '../../models';
import { texts } from '../../texts';
import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useRequestTypeListPartitions } from '../../hooks/useRequestTypeListPartitions';
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
import { findEmployeeByEmail } from '../../utils/employee';
@@ -47,6 +48,14 @@ export interface IManageRequestsScreenProps {
route?: IManageRequestRouteParams;
}
// Подпись заявки в комбобоксе — уникальный код (RequestUniqueCode, тот же, что
// видит заявитель на портале), а не служебный числовой Id списка SharePoint.
// Запасной вариант на #id — для заявок без заполненного кода (старые заявки,
// либо список данных этой колонки не заводит).
function requestLabel(r: Pick<ManagedRequestSummary, 'id' | 'title' | 'uniqueCode'>): string {
return `${r.uniqueCode || `#${r.id}`} ${r.title}`;
}
// Цвет плашки статуса — те же группы смысла, что и в request-portal
// (StatusBadge.tsx): решено положительно/отрицательно/ожидает/черновик.
function statusVariant(status: string): BadgeVariant {
@@ -158,7 +167,19 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
if (!route) return;
setSelection({
requestTypeId: route.requestTypeId,
selectedRequest: { id: Number(route.requestId), title: '', status: '', created: '', authorName: '' }
// listId из ссылки — какая партиция, если ссылку скопировали уже после
// этого фикса; пусто у старых ссылок — тогда reloadMatrix ниже
// подставит текущую партицию (тот же эффект, что был раньше, но теперь
// явный, а не единственно возможный).
selectedRequest: {
id: Number(route.requestId),
title: '',
status: '',
created: '',
authorName: '',
uniqueCode: null,
listId: route.listId
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -167,11 +188,15 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
// прислать коллеге. replaceRoute, а не navigate: смена заявки — это правка
// состояния текущего экрана, а не переход на новую страницу с отдельным
// шагом в истории браузера. title/status заявки в URL не пишем — их выводит
// сам requestId, хранить производные данные в ссылке незачем.
// сам requestId, хранить производные данные в ссылке незачем. listId —
// пишем: без него по ссылке нельзя однозначно понять, в какой партиции
// искать requestId (см. IManageRequestRouteParams).
React.useEffect(() => {
replaceRoute({
tab: 'manageRequests',
manage: selectedRequest ? { requestTypeId, requestId: String(selectedRequest.id) } : undefined
manage: selectedRequest
? { requestTypeId, requestId: String(selectedRequest.id), listId: selectedRequest.listId }
: undefined
});
}, [requestTypeId, selectedRequest]);
const [matrix, setMatrix] = React.useState<ManagedRequestMatrix | undefined>();
@@ -192,6 +217,11 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
// и читать заявки этого типа отсюда просто негде.
const manageableRequestTypes = requestTypes.items.filter(rt => rt.listId && rt.siteUrl);
// Вся серия партиций выбранного типа (архив + текущая) — заявки этого типа
// физически лежат в любой из них (см. ТЗ по архивации), искать и открывать
// нужно по всем сразу, не только по текущей.
const partitions = useRequestTypeListPartitions(api, requestType?.id);
const selectRequestType = (id: string): void => {
// Заявка выбрана для ДРУГОГО типа — сбрасываем, иначе комбобокс продолжит
// показывать заявку не того типа, что выбран сейчас.
@@ -205,10 +235,12 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
const searchRequests = React.useCallback(
(query: string, page: number) => {
if (!requestType) return Promise.resolve({ items: [], hasMore: false });
return requestManage.searchRequests(requestType, query, page);
if (!requestType || !requestType.siteUrl || partitions.items.length === 0) {
return Promise.resolve({ items: [], hasMore: false });
}
return requestManage.searchRequests(partitions.items, requestType.siteUrl, query, page);
},
[requestManage, requestType]
[requestManage, requestType, partitions.items]
);
// silent=true — фоновое обновление (после своего же действия, автообновление
@@ -219,18 +251,38 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
const reloadMatrix = React.useCallback(
(silent = false) => {
if (!requestType || !selectedRequest) return;
// Партиции ещё не подтянулись (первый рендер после смены типа/захода по
// ссылке) — без них нельзя даже подставить запасной вариант (текущая
// партиция) для ссылок без listId, ждём следующего рендера.
if (partitions.isLoading) return;
if (!requestType.siteUrl) {
setMatrixError('У типа заявки не указан список SharePoint.');
return;
}
// listId заявки уже известен (выбрана из комбобокса, либо ссылка после
// этого фикса) — используем его. Иначе (старая ссылка без listId) —
// единственный разумный запасной вариант: текущая партиция, тот же
// список, что искался бы и раньше.
const listId = selectedRequest.listId || partitions.items.filter(p => p.isCurrent)[0]?.listId;
if (!listId) {
setMatrixError('Для типа заявки не найдено ни одной партиции списка SharePoint.');
return;
}
if (!silent) setMatrixLoading(true);
setMatrixError(undefined);
requestManage
.getMatrix(requestType, selectedRequest.id)
.getMatrix(requestType.siteUrl, listId, selectedRequest.id)
.then(m => {
setMatrix(m);
// title/status в комбобоксе (а значит, и в localStorage) держим
// title/status/listId в комбобоксе (а значит, и в localStorage) держим
// синхронными с сервером — особенно важно после захода по глубокой
// ссылке, где изначально они пустые (см. эффект-затравку выше).
// ссылке, где изначально они пустые (см. эффект-затравку выше), и
// после подстановки запасной (текущей) партиции выше: как только
// реальная партиция заявки известна, оседает в выборе — следующий
// reloadMatrix пойдёт уже по ней напрямую.
//
// ВАЖНО: возвращаем ТОТ ЖЕ prev (без изменений), если title/status не
// отличаются — m.request это каждый раз НОВЫЙ объект (свежий парсинг
// ВАЖНО: возвращаем ТОТ ЖЕ prev (без изменений), если ничего не
// отличается — m.request это каждый раз НОВЫЙ объект (свежий парсинг
// ответа), и если писать его в состояние безусловно, selectedRequest
// получает новую ссылку на КАЖДОЙ загрузке, даже когда содержимое не
// поменялось. Это меняет зависимости reloadMatrix → пересоздаёт саму
@@ -240,7 +292,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
setSelection(prev => {
const sel = prev.selectedRequest;
if (!sel || sel.id !== m.request.id) return prev;
const same = sel.title === m.request.title && sel.status === m.request.status;
const same = sel.title === m.request.title && sel.status === m.request.status && sel.listId === m.request.listId;
return same ? prev : { ...prev, selectedRequest: m.request };
});
})
@@ -249,7 +301,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
if (!silent) setMatrixLoading(false);
});
},
[requestManage, requestType, selectedRequest]
[requestManage, requestType, selectedRequest, partitions.items, partitions.isLoading]
);
React.useEffect(() => {
@@ -533,14 +585,14 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
<AsyncSearchableSelect<ManagedRequestSummary>
key={requestTypeId || 'none'}
value={selectedRequest ? String(selectedRequest.id) : ''}
selectedLabel={selectedRequest ? `#${selectedRequest.id} ${selectedRequest.title}` : undefined}
selectedLabel={selectedRequest ? requestLabel(selectedRequest) : undefined}
selectedSublabel={selectedRequest?.status}
disabled={!requestType}
placeholder={texts.manageRequests.requestPlaceholder}
searchPlaceholder={texts.manageRequests.requestSearchPlaceholder}
search={searchRequests}
getValue={r => String(r.id)}
getLabel={r => `#${r.id} ${r.title}`}
getLabel={requestLabel}
getSublabel={r => r.status}
onChange={(_, item) => selectRequest(item)}
/>
@@ -0,0 +1,20 @@
import * as React from 'react';
import { RequestListPartition } from '../models';
import { ApprovalRouteApi } from '../services/api';
import { AsyncList, useAsyncList } from './useAsyncList';
/**
* Серия партиций (архив + текущая) ОДНОГО типа заявки — нужна экрану
* редактирования заявок: заявка может лежать в любой из них, не только в
* текущей (см. requestManageService.ts).
*/
export function useRequestTypeListPartitions(
api: ApprovalRouteApi,
requestTypeId: number | undefined
): AsyncList<RequestListPartition> {
const load = React.useCallback(
() => (requestTypeId ? api.requestTypes.lists(requestTypeId) : Promise.resolve([])),
[api, requestTypeId]
);
return useAsyncList(load);
}
@@ -78,6 +78,21 @@ export interface ManagedRequestSummary {
status: string;
created: string; // ISO
authorName: string;
/**
* Уникальный код заявки (колонка RequestUniqueCode списка данных) — то, что
* реально видит заявитель, в отличие от служебного числового Id списка
* SharePoint. null — у заявки его нет (старая заявка, колонка не заполнена)
* или список данных этой колонки не заводит; тогда в UI показывается id.
*/
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит SharePoint: колонки может не быть
uniqueCode: string | null;
/**
* Список-партиция, в котором физически лежит эта заявка. У разных партиций
* независимая нумерация Id (см. ТЗ по архивации), поэтому один и тот же
* requestId в разных партициях — это РАЗНЫЕ заявки: без listId открыть
* заявку однозначно нельзя.
*/
listId: string;
}
export interface ManagedRequestPage {
@@ -19,7 +19,12 @@ describe('parseRoute', () => {
it('manageRequests с aamtype+aamreq — даёт заполненный manage', () => {
const route = parseRoute('?aatab=manageRequests&aamtype=5&aamreq=42');
expect(route.tab).toBe('manageRequests');
expect(route.manage).toEqual({ requestTypeId: '5', requestId: '42' });
expect(route.manage).toEqual({ requestTypeId: '5', requestId: '42', listId: '' });
});
it('manageRequests с aamlist — партиция заявки читается в manage.listId', () => {
const route = parseRoute('?aatab=manageRequests&aamtype=5&aamreq=42&aamlist=%2Fsites%2Fx%2FLists%2FForm2');
expect(route.manage).toEqual({ requestTypeId: '5', requestId: '42', listId: '/sites/x/Lists/Form2' });
});
it('manageRequests без aamreq — manage не собирается (id заявки обязателен)', () => {
@@ -78,14 +83,14 @@ describe('parseRoute', () => {
describe('navigate', () => {
it('после navigate() параметры читаются обратно тем же parseRoute (round-trip)', () => {
navigate({ tab: 'manageRequests', manage: { requestTypeId: '3', requestId: '10' } });
navigate({ tab: 'manageRequests', manage: { requestTypeId: '3', requestId: '10', listId: '/sites/x/Lists/Form' } });
const roundTripped = parseRoute(window.location.search);
expect(roundTripped.tab).toBe('manageRequests');
expect(roundTripped.manage).toEqual({ requestTypeId: '3', requestId: '10' });
expect(roundTripped.manage).toEqual({ requestTypeId: '3', requestId: '10', listId: '/sites/x/Lists/Form' });
});
it('navigate на dashboard полностью убирает параметры вкладки из адреса', () => {
navigate({ tab: 'manageRequests', manage: { requestTypeId: '3', requestId: '10' } });
navigate({ tab: 'manageRequests', manage: { requestTypeId: '3', requestId: '10', listId: '/sites/x/Lists/Form' } });
navigate({ tab: 'dashboard' });
expect(window.location.search).toBe('');
});
+11 -2
View File
@@ -36,6 +36,14 @@ export interface IResolveRouteParams {
export interface IManageRequestRouteParams {
requestTypeId: string;
requestId: string;
/**
* Партиция (список-архив данных), где физически лежит заявка — у разных
* партиций независимая нумерация Id (см. ТЗ по архивации), поэтому без
* listId requestId неоднозначен. Пусто — старая ссылка (до архивации) или
* ссылка ещё не резолвнута; тогда экран считает, что заявка в текущей
* партиции (см. ManageRequestsScreen).
*/
listId: string;
}
export interface IAdminRoute {
@@ -64,7 +72,7 @@ const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'ma
const PARAM_KEYS = [
'aatab', 'aaroute', 'aarfilter',
'aartype', 'aauser', 'aaproject', 'aaprojectname', 'aastatus', 'aafields', 'aadept',
'aamtype', 'aamreq'
'aamtype', 'aamreq', 'aamlist'
];
// raw — результат URLSearchParams.get() (DOM API, возвращает string | null).
@@ -114,7 +122,7 @@ export function parseRoute(search: string = window.location.search): IAdminRoute
const manageReqId = params.get('aamreq');
const manage: IManageRequestRouteParams | undefined =
tab === 'manageRequests' && manageTypeId && manageReqId
? { requestTypeId: manageTypeId, requestId: manageReqId }
? { requestTypeId: manageTypeId, requestId: manageReqId, listId: params.get('aamlist') || '' }
: undefined;
return {
@@ -152,6 +160,7 @@ function buildQuery(route: IAdminRoute): string {
if (route.tab === 'manageRequests' && route.manage && route.manage.requestTypeId && route.manage.requestId) {
params.set('aamtype', route.manage.requestTypeId);
params.set('aamreq', route.manage.requestId);
if (route.manage.listId) params.set('aamlist', route.manage.listId);
}
const query = params.toString();
@@ -6,6 +6,7 @@ import {
PatchRequestTypeInput,
PatchTemplateRouteInput,
PatchTemplateRouteStepInput,
RequestListPartition,
RequestType,
RequestTypeListSeries,
ResolveTemplateRouteQuery,
@@ -47,7 +48,13 @@ export class ApprovalRouteApi {
// портала, которому нужен весь список адресов разом, а не по одному запросу
// на тип.
listsAll: (): Promise<RequestTypeListSeries[]> =>
this.http.get<RequestTypeListSeries[]>('/api/request-types/lists')
this.http.get<RequestTypeListSeries[]>('/api/request-types/lists'),
// Вся серия партиций ОДНОГО типа заявки (архив + текущая) — нужна экрану
// редактирования заявок: заявка может лежать в любой из них, не только в
// текущей (см. requestManageService.ts).
lists: (requestTypeId: number): Promise<RequestListPartition[]> =>
this.http.get<RequestListPartition[]>(`/api/request-types/${requestTypeId}/lists`)
};
public readonly templateRoutes = {
@@ -12,7 +12,7 @@ import {
ManagedRequestMatrix,
ManagedRequestPage,
ManagedRequestSummary,
RequestType
RequestListPartition
} from '../models';
// Список данных «<Форма> — данные» → список согласования «<Форма> — согласование»
@@ -86,15 +86,19 @@ interface IRawDataRow {
RequestStatus?: string;
Created?: string;
Author?: { Title?: string };
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит SharePoint: колонки может не быть в старых списках/строках
RequestUniqueCode?: string | null;
}
function toSummary(r: IRawDataRow): ManagedRequestSummary {
function toSummary(r: IRawDataRow, listId: string): ManagedRequestSummary {
return {
id: r.Id,
title: r.Title || `#${r.Id}`,
status: r.RequestStatus || '',
created: r.Created || '',
authorName: r.Author?.Title || ''
authorName: r.Author?.Title || '',
uniqueCode: r.RequestUniqueCode || null,
listId
};
}
@@ -124,24 +128,41 @@ export class RequestManageService {
return { dataListTitle: info.Title, approvalListTitle };
}
// К какой партиции относится хит Search — по префиксу его Path. Партиции
// определяются вызывающей стороной (ListPartitionService.ListAsync), их
// список короткий (единицы), линейный поиск тут не проблема.
private partitionForPath(
siteUrl: string,
partitions: RequestListPartition[],
path: string
): RequestListPartition | undefined {
return partitions.filter(p => path.indexOf(listAbsUrl(siteUrl, p.listId)) === 0)[0];
}
/**
* Заявки типа requestType постранично, для комбобокса выбора заявки. Сначала
* Search по названию (свободный терм, пустой query — просто последние без
* фильтра), затем дочитывание полей REST'ом по найденным Id — Search не
* отдаёт произвольные поля списка надёжно (та же двухшаговая схема, что в
* request-portal).
* Заявки по ВСЕМ партициям списка данных этого типа (архив + текущая)
* постранично, для комбобокса выбора заявки. Сначала Search по названию
* (свободный терм, пустой query — просто последние без фильтра), затем
* дочитывание полей REST'ом по найденным Id — Search не отдаёт произвольные
* поля списка надёжно (та же двухшаговая схема, что в request-portal).
*
* Заявки, набранные ростом одного типа, физически «переезжают» на новый
* список-партицию (см. ТЗ по архивации) — искать нужно сразу по всем, иначе
* заявки из архивных партиций в комбобоксе просто не найти.
*/
public async searchRequests(
requestType: RequestType,
partitions: RequestListPartition[],
siteUrl: string,
query: string,
page: number,
pageSize = 20
): Promise<ManagedRequestPage> {
if (!requestType.listId || !requestType.siteUrl) return { items: [], hasMore: false };
if (partitions.length === 0) return { items: [], hasMore: false };
const sp = this.clientFor(requestType.siteUrl);
const absUrl = listAbsUrl(requestType.siteUrl, requestType.listId);
const clauses = [`Path:"${absUrl}*"`];
const sp = this.clientFor(siteUrl);
const pathClauses = partitions.map(p => `Path:"${listAbsUrl(siteUrl, p.listId)}*"`);
const pathClause = pathClauses.length > 1 ? `(${pathClauses.join(' OR ')})` : pathClauses[0];
const clauses = [pathClause];
const text = freeTextClause(query);
if (text) clauses.push(text);
@@ -154,65 +175,83 @@ export class RequestManageService {
SortList: [{ Property: 'Created', Direction: 1 }]
});
const rawRows = (result.PrimarySearchResults ?? []) as Array<{ ListItemID?: string }>;
// Дедуп по Id обязателен: TrimDuplicates: false выше выключает штатную
// дедупликацию Search (она иногда схлопывает нужные нам заявки), и тогда
// индекс может отдать один и тот же элемент несколькими хитами
// (переиндексация, несколько «версий» в crawl). Без этого фильтра хиты
// превращались в пункты комбобокса один-в-один, и заявка показывалась в
// списке дважды — с одинаковым React-ключом. Та же причина и то же лечение,
// что в request-portal (features/myRequests/myRequests.service.ts/hitsToEntries).
const seen = new Set<number>();
const ids = rawRows
.map(r => Number(r.ListItemID))
.filter(id => !isNaN(id) && id > 0)
.filter(id => {
if (seen.has(id)) return false;
seen.add(id);
return true;
const rawRows = (result.PrimarySearchResults ?? []) as Array<{ ListItemID?: string; Path?: string }>;
// Дедуп обязателен по паре (listId, Id), а НЕ по одному Id: у разных
// партиций независимая нумерация элементов (см. ТЗ по архивации), поэтому
// одинаковый номер в разных партициях — это две РАЗНЫЕ заявки. TrimDuplicates:
// false выше выключает штатную дедупликацию Search (она иногда схлопывает
// нужные нам заявки), и тогда индекс может отдать один и тот же элемент
// несколькими хитами (переиндексация, несколько «версий» в crawl) — та же
// причина и то же лечение, что в request-portal
// (features/myRequests/myRequests.service.ts/hitsToEntries).
const seen = new Set<string>();
const hits: Array<{ id: number; partition: RequestListPartition }> = [];
rawRows.forEach(r => {
const id = Number(r.ListItemID);
if (isNaN(id) || id <= 0 || !r.Path) return;
const partition = this.partitionForPath(siteUrl, partitions, r.Path);
if (!partition) return;
const key = `${partition.listId}|${id}`;
if (seen.has(key)) return;
seen.add(key);
hits.push({ id, partition });
});
if (ids.length === 0) return { items: [], hasMore: false };
if (hits.length === 0) return { items: [], hasMore: false };
const list = sp.web.getList(requestType.listId);
const idsByListId = new Map<string, number[]>();
hits.forEach(h => idsByListId.set(h.partition.listId, [...(idsByListId.get(h.partition.listId) || []), h.id]));
const rowByKey = new Map<string, IRawDataRow>();
await Promise.all(
Array.from(idsByListId.entries()).map(async ([listId, ids]) => {
const list = sp.web.getList(listId);
const dataRows = (await list.items
.filter(ids.map(id => `Id eq ${id}`).join(' or '))
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/Title')
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/Title', 'RequestUniqueCode')
.expand('Author')
.top(ids.length)()) as IRawDataRow[];
dataRows.forEach(r => rowByKey.set(`${listId}|${r.Id}`, r));
})
);
const byId = new Map(dataRows.map(r => [r.Id, r]));
// Порядок задаёт Search (SortList по дате), не порядок ответа REST.
const items = ids
.map(id => byId.get(id))
.filter((r): r is IRawDataRow => !!r)
.map(toSummary);
const items = hits
.map(h => {
const row = rowByKey.get(`${h.partition.listId}|${h.id}`);
return row ? toSummary(row, h.partition.listId) : undefined;
})
.filter((r): r is ManagedRequestSummary => !!r);
const totalRows = (result as unknown as { TotalRows?: number }).TotalRows ?? items.length;
return { items, hasMore: page * pageSize < totalRows };
}
/** Заявка + полный маршрут (обе фазы, по StepOrder) — для экрана редактирования. */
public async getMatrix(requestType: RequestType, requestId: number): Promise<ManagedRequestMatrix> {
if (!requestType.listId || !requestType.siteUrl) {
throw new Error('У типа заявки не указан список SharePoint.');
}
const sp = this.clientFor(requestType.siteUrl);
const dataList = sp.web.getList(requestType.listId);
/**
* Заявка + полный маршрут (обе фазы, по StepOrder) — для экрана редактирования.
*
* listId — КОНКРЕТНАЯ партиция, где физически лежит заявка (из
* ManagedRequestSummary.listId, отданного searchRequests/предыдущей
* getMatrix) — не requestType.listId: та партиция может быть уже не той, где
* лежит эта заявка (см. ТЗ по архивации).
*/
public async getMatrix(siteUrl: string, listId: string, requestId: number): Promise<ManagedRequestMatrix> {
const sp = this.clientFor(siteUrl);
const dataList = sp.web.getList(listId);
const [requestRow, { dataListTitle, approvalListTitle }] = await Promise.all([
dataList.items
.getById(requestId)
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/Title')
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/Title', 'RequestUniqueCode')
.expand('Author')() as Promise<IRawDataRow>,
this.listTitles(sp, requestType.listId)
this.listTitles(sp, listId)
]);
const dataListUrl = listAbsUrl(requestType.siteUrl, requestType.listId);
const dataListUrl = listAbsUrl(siteUrl, listId);
if (!approvalListTitle) {
return {
request: toSummary(requestRow),
siteUrl: requestType.siteUrl,
request: toSummary(requestRow, listId),
siteUrl,
dataListTitle,
dataListUrl,
approvalListTitle: '',
@@ -264,7 +303,7 @@ export class RequestManageService {
};
});
return { request: toSummary(requestRow), siteUrl: requestType.siteUrl, dataListTitle, dataListUrl, approvalListTitle, rows };
return { request: toSummary(requestRow, listId), siteUrl, dataListTitle, dataListUrl, approvalListTitle, rows };
}
/** email → числовой Id пользователя сайта (Person-поле требует Id, не email). */