diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/Step1PositionSelection.tsx b/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/Step1PositionSelection.tsx index b52c8f3..f83f841 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/Step1PositionSelection.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/Step1PositionSelection.tsx @@ -5,10 +5,13 @@ import { Request, Position, FormStep } from '../types/domain'; import { CtService } from '../../services/CtService'; import { CTFormData } from '../hooks/useCtForm'; import { useRequests } from '../hooks/useRequests'; +import { useDebouncedValue } from '../hooks/useDebouncedValue'; import { RequestsTable } from './RequestsTable'; import { + KnownPosition, buildPinnedRequests, countSelectedInRequest, + mergeKnownPositions, planStep1Save, resolveProject, resolveSelectedPositions, @@ -27,6 +30,8 @@ interface Props { const DEFAULT_DAYS = 5; const PAGE_SIZE = 10; +// Пауза в наборе номера, после которой поиск уходит на сервер. +const SEARCH_DEBOUNCE_MS = 500; export const Step1PositionSelection: React.FC = ({ service, @@ -44,7 +49,7 @@ export const Step1PositionSelection: React.FC = ({ const [selectedPositionIds, setSelectedPositionIds] = useState>( new Set(formData.selectedPositions.map(selectionKey)) ); - const [positionMap, setPositionMap] = useState>(new Map()); + const [positionMap, setPositionMap] = useState>(new Map()); const [isSaving, setIsSaving] = useState(false); const [saveError, setSaveError] = useState(undefined); @@ -55,8 +60,11 @@ export const Step1PositionSelection: React.FC = ({ [formData.selectedPositions, ctProjectName], ); + // Поиск по номеру — на сервере (часть номера, в пределах периода): иначе ради одной старой + // потребности приходилось грузить весь период. + const serverSearch = useDebouncedValue(searchQuery.trim(), SEARCH_DEBOUNCE_MS); const { requests, isLoading, error: loadError, reload, loadPositions, loadingPositionIds } = - useRequests(service, days, isReadOnly, pinnedRequests); + useRequests(service, days, serverSearch, isReadOnly, pinnedRequests); const readOnlyRequests = useMemo(() => { if (!isReadOnly) return []; @@ -80,6 +88,8 @@ export const Step1PositionSelection: React.FC = ({ }, [isReadOnly, formData.selectedPositions, formData.selectedProject]); const filteredRequests = useMemo(() => { + // Фильтр в браузере — пока набирают номер (до ответа сервера) и для закреплённых потребностей СТ, + // которые добавляются к выдаче без учёта поиска. const q = searchQuery.trim().toLowerCase(); let filtered = q ? requests.filter(r => r.requestNumber.toLowerCase().includes(q)) : requests; // Режим «Только выбранные» — оставляем заявки, где есть хотя бы одна выбранная позиция, @@ -97,15 +107,10 @@ export const Step1PositionSelection: React.FC = ({ const safePage = Math.min(currentPage, totalPages); const pagedRequests = isReadOnly ? readOnlyRequests : filteredRequests.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE); - // Build position→request map for resolving selection metadata + // Позиция → потребность для выбора и «Сохранить». Копим, а не пересобираем: поиск и смена периода + // меняют список, а выбранные раньше позиции должны дойти до сохранения. useEffect(() => { - const map = new Map(); - requests.forEach(req => { - req.positions.forEach(pos => { - map.set(pos.id, { position: pos, request: req }); - }); - }); - setPositionMap(map); + setPositionMap(prev => mergeKnownPositions(prev, requests)); }, [requests]); const project = resolveProject(formData.selectedProject, ctProjectName, selectedPositionIds.size > 0); diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/step1Plan.test.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/step1Plan.test.ts index 07cff3b..e96a130 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/step1Plan.test.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/step1Plan.test.ts @@ -1,12 +1,14 @@ import { + KnownPosition, buildPinnedRequests, countSelectedInRequest, + mergeKnownPositions, planStep1Save, resolveProject, resolveSelectedPositions, selectionKey, } from './step1Plan'; -import { Position } from '../types/domain'; +import { Position, Request } from '../types/domain'; function makePosition(id: string, overrides: Partial = {}): Position { return { @@ -75,6 +77,46 @@ describe('countSelectedInRequest', () => { }); }); +describe('mergeKnownPositions', () => { + function makeRequest(id: string, positionIds: string[]): Request { + return { + id, requestNumber: `081/26-04/${id}`, project: { id: 'Проект', name: 'Проект', code: '' }, + discipline: '', workType: '', organization: '', createdAt: '', + positionsCount: positionIds.length, positions: positionIds.map(p => makePosition(p)), + }; + } + + // Выбрали позицию → нашли другую потребность поиском на сервере (первой в выдаче уже нет) → + // «Сохранить» должно сохранить обе. Раньше карта позиций пересобиралась из текущего списка, + // и выбор до поиска молча пропадал (так же — при смене периода). + it('позиции прежнего списка остаются после загрузки нового', () => { + const beforeSearch = mergeKnownPositions(new Map(), [makeRequest('req1', ['req1__0'])]); + + const afterSearch = mergeKnownPositions(beforeSearch, [makeRequest('req2', ['req2__0'])]); + + expect(Array.from(afterSearch.keys()).sort()).toEqual(['req1__0', 'req2__0']); + expect(afterSearch.get('req1__0')?.request.id).toBe('req1'); + }); + + it('повторная загрузка той же потребности обновляет позицию', () => { + const first = mergeKnownPositions(new Map(), [makeRequest('req1', ['req1__0'])]); + const reloaded = makeRequest('req1', ['req1__0']); + reloaded.positions[0].quantity = 5; + + const result = mergeKnownPositions(first, [reloaded]); + + expect(result.get('req1__0')?.position.quantity).toBe(5); + }); + + it('не меняет переданную карту', () => { + const known = new Map(); + + mergeKnownPositions(known, [makeRequest('req1', ['req1__0'])]); + + expect(known.size).toBe(0); + }); +}); + describe('resolveSelectedPositions', () => { // Вернулись на шаг 1: потребность не раскрыта — выбранные позиции берём из сохранённых, // иначе «Сохранить» неактивна, хотя выбор есть. diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/step1Plan.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/step1Plan.ts index 1693c5d..09fc7f7 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/step1Plan.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/step1Plan.ts @@ -49,6 +49,25 @@ export function countSelectedInRequest(selectedIds: Set, requestId: stri return count; } +export interface KnownPosition { + position: Position; + request: Request; +} + +// Все позиции, которые показывались на шаге 1, — копятся, а не заменяются при каждой загрузке. +// Список потребностей меняется (поиск по номеру на сервере, другой период), а выбор остаётся: +// позиция, выбранная до поиска, должна дойти до «Сохранить», хоть её потребности и нет в выдаче. +export function mergeKnownPositions( + known: Map, + requests: Request[], +): Map { + const next = new Map(known); + requests.forEach(request => { + request.positions.forEach(position => next.set(position.id, { position, request })); + }); + return next; +} + // Выбранные позиции: загруженная версия, а если потребность не раскрыта — сохранённая в форме. // Без этого после возврата на шаг 1 выбор есть, а «Сохранить» неактивна. export function resolveSelectedPositions( diff --git a/src/webparts/ctFormsPortal/forms/ct/components/hooks/useDebouncedValue.ts b/src/webparts/ctFormsPortal/forms/ct/components/hooks/useDebouncedValue.ts new file mode 100644 index 0000000..040aff3 --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/ct/components/hooks/useDebouncedValue.ts @@ -0,0 +1,14 @@ +import { useEffect, useState } from 'react'; + +// Значение, которое обновляется только после паузы delayMs в изменениях — чтобы поиск с запросом +// к серверу уходил, когда пользователь перестал печатать, а не на каждую букву. +export function useDebouncedValue(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(timer); + }, [value, delayMs]); + + return debounced; +} diff --git a/src/webparts/ctFormsPortal/forms/ct/components/hooks/useRequests.ts b/src/webparts/ctFormsPortal/forms/ct/components/hooks/useRequests.ts index 51dd1ea..22962b7 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/hooks/useRequests.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/hooks/useRequests.ts @@ -27,9 +27,11 @@ async function loadMissingPinned(service: CtService, pinned: Request[], loaded: ); } +// requestNumber — поиск по номеру на сервере (часть номера, в пределах периода); пусто — весь период. export function useRequests( service: CtService, days: number, + requestNumber: string, skip: boolean, pinnedRequests: Request[], ): UseRequestsResult { @@ -51,7 +53,7 @@ export function useRequests( setError(undefined); service - .fetchRequests(days) + .fetchRequests(days, requestNumber || undefined) .then(async data => { const pinned = await loadMissingPinned(service, pinnedRequests, data); if (!cancelled) { @@ -69,7 +71,7 @@ export function useRequests( return () => { cancelled = true; }; - }, [service, days, reloadToken, skip, pinnedRequests]); + }, [service, days, requestNumber, reloadToken, skip, pinnedRequests]); const loadPositions = useCallback( async (requestId: string): Promise => { diff --git a/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts b/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts index 7c2f0d6..e0ecaec 100644 --- a/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts +++ b/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts @@ -179,9 +179,12 @@ export class CtService { return tokenProvider.getToken(AAD_RESOURCE); } - async fetchRequests(days: number): Promise { + // requestNumber — поиск на сервере: по части номера, в пределах периода days. Без него — + // все потребности периода. + async fetchRequests(days: number, requestNumber?: string): Promise { const token = await this._getApiToken(); - const res = await fetch(`${EXTERNAL_API}/Requests/${days}`, { + const query = requestNumber ? `?requestNumber=${encodeURIComponent(requestNumber)}` : ""; + const res = await fetch(`${EXTERNAL_API}/Requests/${days}${query}`, { headers: { Authorization: `Bearer ${token}` }, });