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 { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm'; import { CTFormData } from '../hooks/useCtForm';
import { useRequests } from '../hooks/useRequests'; import { useRequests } from '../hooks/useRequests';
import { useDebouncedValue } from '../hooks/useDebouncedValue';
import { RequestsTable } from './RequestsTable'; import { RequestsTable } from './RequestsTable';
import { import {
KnownPosition,
buildPinnedRequests, buildPinnedRequests,
countSelectedInRequest, countSelectedInRequest,
mergeKnownPositions,
planStep1Save, planStep1Save,
resolveProject, resolveProject,
resolveSelectedPositions, resolveSelectedPositions,
@@ -27,6 +30,8 @@ interface Props {
const DEFAULT_DAYS = 5; const DEFAULT_DAYS = 5;
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
// Пауза в наборе номера, после которой поиск уходит на сервер.
const SEARCH_DEBOUNCE_MS = 500;
export const Step1PositionSelection: React.FC<Props> = ({ export const Step1PositionSelection: React.FC<Props> = ({
service, service,
@@ -44,7 +49,7 @@ export const Step1PositionSelection: React.FC<Props> = ({
const [selectedPositionIds, setSelectedPositionIds] = useState<Set<string>>( const [selectedPositionIds, setSelectedPositionIds] = useState<Set<string>>(
new Set(formData.selectedPositions.map(selectionKey)) 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 [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | undefined>(undefined); const [saveError, setSaveError] = useState<string | undefined>(undefined);
@@ -55,8 +60,11 @@ export const Step1PositionSelection: React.FC<Props> = ({
[formData.selectedPositions, ctProjectName], [formData.selectedPositions, ctProjectName],
); );
// Поиск по номеру — на сервере (часть номера, в пределах периода): иначе ради одной старой
// потребности приходилось грузить весь период.
const serverSearch = useDebouncedValue(searchQuery.trim(), SEARCH_DEBOUNCE_MS);
const { requests, isLoading, error: loadError, reload, loadPositions, loadingPositionIds } = const { requests, isLoading, error: loadError, reload, loadPositions, loadingPositionIds } =
useRequests(service, days, isReadOnly, pinnedRequests); useRequests(service, days, serverSearch, isReadOnly, pinnedRequests);
const readOnlyRequests = useMemo(() => { const readOnlyRequests = useMemo(() => {
if (!isReadOnly) return []; if (!isReadOnly) return [];
@@ -80,6 +88,8 @@ export const Step1PositionSelection: React.FC<Props> = ({
}, [isReadOnly, formData.selectedPositions, formData.selectedProject]); }, [isReadOnly, formData.selectedPositions, formData.selectedProject]);
const filteredRequests = useMemo(() => { const filteredRequests = useMemo(() => {
// Фильтр в браузере — пока набирают номер (до ответа сервера) и для закреплённых потребностей СТ,
// которые добавляются к выдаче без учёта поиска.
const q = searchQuery.trim().toLowerCase(); const q = searchQuery.trim().toLowerCase();
let filtered = q ? requests.filter(r => r.requestNumber.toLowerCase().includes(q)) : requests; 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 safePage = Math.min(currentPage, totalPages);
const pagedRequests = isReadOnly ? readOnlyRequests : filteredRequests.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE); const pagedRequests = isReadOnly ? readOnlyRequests : filteredRequests.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
// Build position→request map for resolving selection metadata // Позиция → потребность для выбора и «Сохранить». Копим, а не пересобираем: поиск и смена периода
// меняют список, а выбранные раньше позиции должны дойти до сохранения.
useEffect(() => { useEffect(() => {
const map = new Map<string, { position: Position; request: Request }>(); setPositionMap(prev => mergeKnownPositions(prev, requests));
requests.forEach(req => {
req.positions.forEach(pos => {
map.set(pos.id, { position: pos, request: req });
});
});
setPositionMap(map);
}, [requests]); }, [requests]);
const project = resolveProject(formData.selectedProject, ctProjectName, selectedPositionIds.size > 0); const project = resolveProject(formData.selectedProject, ctProjectName, selectedPositionIds.size > 0);
@@ -1,12 +1,14 @@
import { import {
KnownPosition,
buildPinnedRequests, buildPinnedRequests,
countSelectedInRequest, countSelectedInRequest,
mergeKnownPositions,
planStep1Save, planStep1Save,
resolveProject, resolveProject,
resolveSelectedPositions, resolveSelectedPositions,
selectionKey, selectionKey,
} from './step1Plan'; } from './step1Plan';
import { Position } from '../types/domain'; import { Position, Request } from '../types/domain';
function makePosition(id: string, overrides: Partial<Position> = {}): Position { function makePosition(id: string, overrides: Partial<Position> = {}): Position {
return { 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', () => { describe('resolveSelectedPositions', () => {
// Вернулись на шаг 1: потребность не раскрыта — выбранные позиции берём из сохранённых, // Вернулись на шаг 1: потребность не раскрыта — выбранные позиции берём из сохранённых,
// иначе «Сохранить» неактивна, хотя выбор есть. // иначе «Сохранить» неактивна, хотя выбор есть.
@@ -49,6 +49,25 @@ export function countSelectedInRequest(selectedIds: Set<string>, requestId: stri
return count; 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 выбор есть, а «Сохранить» неактивна. // Без этого после возврата на шаг 1 выбор есть, а «Сохранить» неактивна.
export function resolveSelectedPositions( 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( export function useRequests(
service: CtService, service: CtService,
days: number, days: number,
requestNumber: string,
skip: boolean, skip: boolean,
pinnedRequests: Request[], pinnedRequests: Request[],
): UseRequestsResult { ): UseRequestsResult {
@@ -51,7 +53,7 @@ export function useRequests(
setError(undefined); setError(undefined);
service service
.fetchRequests(days) .fetchRequests(days, requestNumber || undefined)
.then(async data => { .then(async data => {
const pinned = await loadMissingPinned(service, pinnedRequests, data); const pinned = await loadMissingPinned(service, pinnedRequests, data);
if (!cancelled) { if (!cancelled) {
@@ -69,7 +71,7 @@ export function useRequests(
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [service, days, reloadToken, skip, pinnedRequests]); }, [service, days, requestNumber, reloadToken, skip, pinnedRequests]);
const loadPositions = useCallback( const loadPositions = useCallback(
async (requestId: string): Promise<Position[]> => { async (requestId: string): Promise<Position[]> => {
@@ -179,9 +179,12 @@ export class CtService {
return tokenProvider.getToken(AAD_RESOURCE); 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 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}` }, headers: { Authorization: `Bearer ${token}` },
}); });