gendesign/frontend/src/hooks/useSiteAnalysis.ts
lekss361 4e58998031 feat(site-finder): weight profile panel + analyze options (#114 sub-PR 4/4 FINAL)
Closing FINAL sub-PR for #114 Custom POI weights — UI complete.

- weightProfiles.ts: TS types + 4 TanStack Query hooks + 11 categories
- WeightProfilePanel.tsx: collapsible panel, sliders, save dialog
- useSiteAnalysis.ts: accepts AnalyzeOptions, query string for backend
- page.tsx: lifts weights state, integrates panel

Approach: 'save first' для ephemeral (no temp profile creation).

tsc 0 errors, ESLint 0 warnings.

Vault: Module_Weight_Profiles_Frontend.md NEW.

Closes #114
2026-05-15 00:45:30 +03:00

182 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useMutation } from "@tanstack/react-query";
import { useCallback, useRef, useState } from "react";
import { HTTPError, apiFetch, apiFetchWithStatus } from "@/lib/api";
import type { ParcelAnalysis } from "@/types/site-finder";
// #93 — on-demand cadastre fetch flow.
// Backend returns 202 Accepted when geometry needs fetching; frontend polls
// /fetch-status every 2s, re-triggers analyze когда status=ready.
export type FetchStatus =
| "ready"
| "fetching"
| "failed"
| "not_in_nspd"
| "invalid_format";
export interface FetchStatusResponse {
status: FetchStatus;
job_id: number | null;
error_msg: string | null;
eta_seconds: number | null;
}
export interface AnalyzeAcceptedResponse {
status: "fetching";
cad_num: string;
job_id: number;
eta_seconds: number;
message: string;
}
export type AnalyzeResult =
| { kind: "ready"; data: ParcelAnalysis }
| { kind: "fetching"; jobId: number; etaSeconds: number };
const POLL_INTERVAL_MS = 2000;
const POLL_MAX_ITERATIONS = 60; // 60 × 2s = 2 min hard cap
export interface AnalyzeOptions {
/** If set, backend uses this saved profile for weights. */
profileId?: number;
/** If set together with no profileId, backend uses user's default profile. */
profileUserId?: string;
}
/**
* Custom hook для analyze flow с graceful on-demand fetch fallback.
*
* Состояния:
* - idle: ничего не делали
* - pending (mutation): первичный POST /analyze в полёте
* - fetching: получили 202, идёт polling /fetch-status
* - data set: ParcelAnalysis отрисовываем
* - error: not_in_nspd / invalid_format / failed
*/
export function useSiteAnalysis() {
const [fetchingState, setFetchingState] = useState<{
cadNum: string;
jobId: number;
etaSeconds: number;
} | null>(null);
// ref для cancel polling — invalidates in-flight iteration
const cancelledRef = useRef(false);
const mutation = useMutation({
mutationFn: async ({
cad,
options,
}: {
cad: string;
options?: AnalyzeOptions;
}): Promise<ParcelAnalysis> => {
cancelledRef.current = false;
setFetchingState(null);
// Build optional query string for weight profile params.
const qs = new URLSearchParams();
if (options?.profileId != null) {
qs.set("profile_id", String(options.profileId));
}
if (options?.profileUserId) {
qs.set("profile_user_id", options.profileUserId);
}
const qsStr = qs.toString();
const analyzeUrl = (cadNum: string) => {
const base = `/api/v1/parcels/${encodeURIComponent(cadNum)}/analyze`;
return qsStr ? `${base}?${qsStr}` : base;
};
// First request — POST /analyze
const first = await apiFetchWithStatus<
ParcelAnalysis | AnalyzeAcceptedResponse
>(analyzeUrl(cad), {
method: "POST",
});
if (first.status === 200) {
return first.body as ParcelAnalysis;
}
// 202 Accepted — entering polling mode
if (first.status === 202) {
const accepted = first.body as AnalyzeAcceptedResponse;
setFetchingState({
cadNum: cad,
jobId: accepted.job_id,
etaSeconds: accepted.eta_seconds,
});
// Poll loop
for (let i = 0; i < POLL_MAX_ITERATIONS; i++) {
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
if (cancelledRef.current) {
throw new Error("Загрузка отменена пользователем");
}
const status = await apiFetch<FetchStatusResponse>(
`/api/v1/parcels/${encodeURIComponent(cad)}/fetch-status`,
);
if (status.status === "ready") {
// Re-trigger analyze — should be 200 now.
// setFetchingState(null) ПОСЛЕ await чтобы не было flicker:
// если очистить до await, mutation isPending=true но
// fetchingState=null → пустой экран ~1 RTT. После await
// mutation сразу резолвится с data — render skipped.
const second = await apiFetch<ParcelAnalysis>(analyzeUrl(cad), {
method: "POST",
});
setFetchingState(null);
return second;
}
if (status.status === "not_in_nspd") {
setFetchingState(null);
throw new HTTPError(
404,
status,
status.error_msg ?? "Кадастровый номер не найден в НСПД",
);
}
if (status.status === "failed") {
setFetchingState(null);
throw new HTTPError(
503,
status,
status.error_msg ?? "НСПД временно недоступен",
);
}
if (status.status === "invalid_format") {
setFetchingState(null);
throw new HTTPError(
400,
status,
status.error_msg ?? "Неверный формат кадастрового номера",
);
}
// status === "fetching" → continue polling
}
// Polling exhausted (2 min)
setFetchingState(null);
throw new Error(
"Загрузка длится слишком долго (>2 мин). Попробуйте позже.",
);
}
throw new Error(`Unexpected response status: ${first.status}`);
},
});
const cancel = useCallback(() => {
cancelledRef.current = true;
setFetchingState(null);
mutation.reset();
}, [mutation]);
return {
...mutation,
fetchingState,
cancel,
};
}