feat(ct): поиск по номеру потребности на шаге 1 — на сервере (#5)
Раньше поиск фильтровал в браузере всё, что загружено за период: чтобы найти старую потребность, приходилось поднимать период и ждать загрузки сотен потребностей ради одной. Теперь номер уходит в API (?requestNumber=, часть номера, в пределах периода) после паузы в наборе — приходят только совпадения. Попутно: позиции, выбранные до смены списка (поиск, другой период), больше не пропадают при «Сохранить» — карта позиций копится, а не пересобирается из текущего списка. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+15
-10
@@ -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);
|
||||||
|
|||||||
+43
-1
@@ -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}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user