feat(ct): поиск по номеру потребности на шаге 1 — на сервере (#5)

Раньше поиск фильтровал в браузере всё, что загружено за период: чтобы найти
старую потребность, приходилось поднимать период и ждать загрузки сотен
потребностей ради одной. Теперь номер уходит в API (?requestNumber=, часть
номера, в пределах периода) после паузы в наборе — приходят только совпадения.

Попутно: позиции, выбранные до смены списка (поиск, другой период), больше не
пропадают при «Сохранить» — карта позиций копится, а не пересобирается из
текущего списка.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-27 14:48:01 +05:00
parent efbe732995
commit 3a6aaa4722
6 changed files with 100 additions and 15 deletions
@@ -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<Props> = ({
service,
@@ -44,7 +49,7 @@ export const Step1PositionSelection: React.FC<Props> = ({
const [selectedPositionIds, setSelectedPositionIds] = useState<Set<string>>(
new Set(formData.selectedPositions.map(selectionKey))
);
const [positionMap, setPositionMap] = useState<Map<string, { position: Position; request: Request }>>(new Map());
const [positionMap, setPositionMap] = useState<Map<string, KnownPosition>>(new Map());
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | undefined>(undefined);
@@ -55,8 +60,11 @@ export const Step1PositionSelection: React.FC<Props> = ({
[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<Props> = ({
}, [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<Props> = ({
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<string, { position: Position; request: Request }>();
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);
@@ -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> = {}): 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<string, KnownPosition>();
mergeKnownPositions(known, [makeRequest('req1', ['req1__0'])]);
expect(known.size).toBe(0);
});
});
describe('resolveSelectedPositions', () => {
// Вернулись на шаг 1: потребность не раскрыта — выбранные позиции берём из сохранённых,
// иначе «Сохранить» неактивна, хотя выбор есть.
@@ -49,6 +49,25 @@ export function countSelectedInRequest(selectedIds: Set<string>, requestId: stri
return count;
}
export interface KnownPosition {
position: Position;
request: Request;
}
// Все позиции, которые показывались на шаге 1, — копятся, а не заменяются при каждой загрузке.
// Список потребностей меняется (поиск по номеру на сервере, другой период), а выбор остаётся:
// позиция, выбранная до поиска, должна дойти до «Сохранить», хоть её потребности и нет в выдаче.
export function mergeKnownPositions(
known: Map<string, KnownPosition>,
requests: Request[],
): Map<string, KnownPosition> {
const next = new Map(known);
requests.forEach(request => {
request.positions.forEach(position => next.set(position.id, { position, request }));
});
return next;
}
// Выбранные позиции: загруженная версия, а если потребность не раскрыта — сохранённая в форме.
// Без этого после возврата на шаг 1 выбор есть, а «Сохранить» неактивна.
export function resolveSelectedPositions(
@@ -0,0 +1,14 @@
import { useEffect, useState } from 'react';
// Значение, которое обновляется только после паузы delayMs в изменениях — чтобы поиск с запросом
// к серверу уходил, когда пользователь перестал печатать, а не на каждую букву.
export function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}
@@ -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<Position[]> => {
@@ -179,9 +179,12 @@ export class CtService {
return tokenProvider.getToken(AAD_RESOURCE);
}
async fetchRequests(days: number): Promise<Request[]> {
// requestNumber — поиск на сервере: по части номера, в пределах периода days. Без него —
// все потребности периода.
async fetchRequests(days: number, requestNumber?: string): Promise<Request[]> {
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}` },
});