From 4e589980316f24b40045621a206657de9221b2f5 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Fri, 15 May 2026 00:45:30 +0300 Subject: [PATCH] feat(site-finder): weight profile panel + analyze options (#114 sub-PR 4/4 FINAL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/app/site-finder/page.tsx | 89 +++- .../site-finder/WeightProfilePanel.tsx | 431 ++++++++++++++++++ frontend/src/hooks/useSiteAnalysis.ts | 38 +- frontend/src/lib/api/weightProfiles.ts | 172 +++++++ 4 files changed, 723 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/site-finder/WeightProfilePanel.tsx create mode 100644 frontend/src/lib/api/weightProfiles.ts diff --git a/frontend/src/app/site-finder/page.tsx b/frontend/src/app/site-finder/page.tsx index 591db00b..faeddd61 100644 --- a/frontend/src/app/site-finder/page.tsx +++ b/frontend/src/app/site-finder/page.tsx @@ -13,7 +13,12 @@ import { OverviewTab } from "@/components/site-finder/OverviewTab"; import { EnvironmentTab } from "@/components/site-finder/EnvironmentTab"; import { LandTab } from "@/components/site-finder/LandTab"; import { MarketTab } from "@/components/site-finder/MarketTab"; +import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel"; import { useSiteAnalysis } from "@/hooks/useSiteAnalysis"; +import { + POI_DEFAULT_WEIGHTS, + type PoiCategoryKey, +} from "@/lib/api/weightProfiles"; // SiteMap imports Leaflet which requires browser APIs — load without SSR const SiteMap = dynamic( @@ -91,6 +96,24 @@ function SiteFinderContent() { const [isochrones, setIsochrones] = useState( undefined, ); + + // Weight profile state — lifted here so it survives tab switches. + // userId + adminToken allow the panel to load/save named profiles. + const [currentWeights, setCurrentWeights] = useState< + Record + >(() => ({ ...POI_DEFAULT_WEIGHTS })); + // Profile selection for the analyze call (null = system defaults). + const [activeProfileId, setActiveProfileId] = useState(null); + const [profileUserId, setProfileUserId] = useState(() => + typeof window === "undefined" + ? "" + : (localStorage.getItem("admin_user_id") ?? ""), + ); + const [adminToken] = useState(() => + typeof window === "undefined" + ? "" + : (localStorage.getItem("admin_token") ?? ""), + ); // Lazy init: считаем initialTab один раз на mount (useState всё равно // игнорирует initializer после первого render — не тратим CPU). const [tab, setTabState] = useState(() => { @@ -124,7 +147,26 @@ function SiteFinderContent() { function handleAnalyze(cadNum: string) { setIsochrones(undefined); setTab("overview"); - mutate(cadNum); + mutate({ + cad: cadNum, + options: + activeProfileId != null + ? { profileId: activeProfileId } + : profileUserId + ? { profileUserId } + : undefined, + }); + } + + function handleWeightsChange( + weights: Record, + profileId: number | null, + ) { + setCurrentWeights(weights); + // Store the active profile id so we can pass it to the analyze call. + // If user edited weights without saving a named profile, profileId=null + // and backend will use system defaults (sub-PR 5 would enable inline weights). + setActiveProfileId(profileId); } // Derive KPI values from data @@ -205,6 +247,51 @@ function SiteFinderContent() { + {/* Weight profile panel — collapsible, below header */} +
+ {/* Optional user-id field for profile CRUD (shown only when adminToken present) */} + {!!adminToken && ( +
+ + { + setProfileUserId(e.target.value); + if (typeof window !== "undefined") { + localStorage.setItem("admin_user_id", e.target.value); + } + }} + /> +
+ )} + +
+ {/* Error state */} {error && (
; + /** + * Called when user clicks "Применить". + * @param weights - New weights. + * @param profileId - The saved profile id if a named profile is active; null for custom/system. + */ + onWeightsChange: ( + weights: Record, + profileId: number | null, + ) => void; + /** + * If provided, enables save/load from DB. + * Must be non-empty for CRUD functionality. + */ + userId?: string; + /** Admin token for CRUD API calls. */ + adminToken?: string; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function clamp(v: number): number { + return Math.max(POI_WEIGHT_MIN, Math.min(POI_WEIGHT_MAX, v)); +} + +function weightsFromProfile( + profile: WeightProfile, +): Record { + const result = { ...POI_DEFAULT_WEIGHTS }; + for (const cat of POI_CATEGORIES) { + const v = profile.weights[cat]; + if (typeof v === "number") { + result[cat] = clamp(v); + } + } + return result; +} + +function weightsEqual( + a: Record, + b: Record, +): boolean { + return POI_CATEGORIES.every((c) => a[c] === b[c]); +} + +// ── Component ───────────────────────────────────────────────────────────────── + +export function WeightProfilePanel({ + currentWeights, + onWeightsChange, + userId, + adminToken, +}: Props) { + const [open, setOpen] = useState(false); + + // Local draft weights — editable before "Применить" + const [draft, setDraft] = useState>(() => ({ + ...currentWeights, + })); + + // Selected profile id (null = system defaults) + const [selectedProfileId, setSelectedProfileId] = useState( + null, + ); + + // Save-dialog state + const [showSaveDialog, setShowSaveDialog] = useState(false); + const [saveName, setSaveName] = useState(""); + const [saveDefault, setSaveDefault] = useState(false); + + // Debounce timer ref + const debounceRef = useRef | null>(null); + + // Profiles query (only when userId + adminToken provided) + const canUseCrud = !!userId && !!adminToken; + const profilesQuery = useWeightProfiles(userId ?? "", adminToken ?? ""); + + const createMutation = useCreateProfile(adminToken ?? ""); + + // ── Handlers ──────────────────────────────────────────────────────────────── + + function handleSliderChange(cat: PoiCategoryKey, raw: string) { + const value = clamp(parseFloat(raw)); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + setDraft((prev) => ({ ...prev, [cat]: value })); + }, 300); + // Optimistic visual update (no debounce for the display value input itself) + setDraft((prev) => ({ ...prev, [cat]: value })); + } + + function handleReset() { + setDraft({ ...POI_DEFAULT_WEIGHTS }); + setSelectedProfileId(null); + } + + function handleLoadProfile(profile: WeightProfile) { + setDraft(weightsFromProfile(profile)); + setSelectedProfileId(profile.id); + } + + function handleApply() { + onWeightsChange({ ...draft }, selectedProfileId); + } + + const handleSaveProfile = useCallback(async () => { + if (!canUseCrud || !userId || !adminToken) { + setShowSaveDialog(false); + return; + } + const name = saveName.trim(); + if (!name) return; + try { + await createMutation.mutateAsync({ + user_id: userId, + profile_name: name, + weights: { ...draft }, + is_default: saveDefault, + }); + setShowSaveDialog(false); + setSaveName(""); + setSaveDefault(false); + } catch { + // Error visible through createMutation.error + } + }, [ + canUseCrud, + userId, + adminToken, + saveName, + draft, + saveDefault, + createMutation, + ]); + + // ── Derived ───────────────────────────────────────────────────────────────── + + const profiles: WeightProfile[] = profilesQuery.data ?? []; + const isDirty = !weightsEqual(draft, currentWeights); + const isDraftDifferentFromDefaults = !weightsEqual( + draft, + POI_DEFAULT_WEIGHTS, + ); + + // ── Styles ─────────────────────────────────────────────────────────────────── + + const panelStyle: React.CSSProperties = { + background: "#fff", + border: "1px solid #e5e7eb", + borderRadius: 10, + overflow: "hidden", + }; + + const headerStyle: React.CSSProperties = { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: "10px 14px", + cursor: "pointer", + userSelect: "none", + background: open ? "#f9fafb" : "#fff", + borderBottom: open ? "1px solid #e5e7eb" : "none", + }; + + const bodyStyle: React.CSSProperties = { + padding: "12px 14px", + display: "flex", + flexDirection: "column", + gap: 10, + }; + + const sliderRowStyle: React.CSSProperties = { + display: "grid", + gridTemplateColumns: "130px 1fr 52px", + alignItems: "center", + gap: 8, + }; + + const valueChipStyle = (v: number): React.CSSProperties => ({ + fontSize: 12, + fontWeight: 600, + textAlign: "right", + color: v < 0 ? "#ef4444" : v === 0 ? "#9ca3af" : "#16a34a", + fontVariantNumeric: "tabular-nums", + }); + + const btnStyle = ( + variant: "primary" | "secondary" | "danger", + ): React.CSSProperties => ({ + padding: "6px 12px", + fontSize: 12, + fontWeight: 600, + borderRadius: 6, + border: "none", + cursor: "pointer", + background: + variant === "primary" + ? "#1d4ed8" + : variant === "danger" + ? "#dc2626" + : "#f3f4f6", + color: variant === "secondary" ? "#374151" : "#fff", + }); + + // ── Render ────────────────────────────────────────────────────────────────── + + return ( +
+ {/* Header / toggle */} +
setOpen((o) => !o)} role="button"> + POI Веса + + ▼ + +
+ + {open && ( +
+ {/* Profile selector */} + {canUseCrud && ( +
+ + + {profilesQuery.isLoading && ( + + )} +
+ )} + + {/* Hint when no crud */} + {!canUseCrud && ( +

+ Укажите User ID и Admin Token для сохранения профилей. +

+ )} + + {/* Sliders */} +
+ {POI_CATEGORIES.map((cat) => { + const value = draft[cat]; + return ( +
+ + {POI_LABELS[cat]} + + handleSliderChange(cat, e.target.value)} + /> + {value.toFixed(1)} +
+ ); + })} +
+ + {/* Action buttons */} +
+ + + {canUseCrud && isDraftDifferentFromDefaults && ( + + )} + + +
+ + {/* Info: weights changed but not applied */} + {isDirty && ( +

+ Изменения не применены к анализу — нажмите «Применить» перед + запуском. +

+ )} + + {/* Save dialog */} + {showSaveDialog && ( +
+ Новый профиль + setSaveName(e.target.value)} + style={{ + padding: "6px 10px", + fontSize: 13, + border: "1px solid #d1d5db", + borderRadius: 6, + }} + onKeyDown={(e) => { + if (e.key === "Enter") void handleSaveProfile(); + if (e.key === "Escape") setShowSaveDialog(false); + }} + /> + +
+ + +
+ {createMutation.error && ( +

+ {createMutation.error.message} +

+ )} +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/hooks/useSiteAnalysis.ts b/frontend/src/hooks/useSiteAnalysis.ts index 7323f45c..7a2c064c 100644 --- a/frontend/src/hooks/useSiteAnalysis.ts +++ b/frontend/src/hooks/useSiteAnalysis.ts @@ -39,6 +39,13 @@ export type AnalyzeResult = 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. * @@ -59,14 +66,34 @@ export function useSiteAnalysis() { const cancelledRef = useRef(false); const mutation = useMutation({ - mutationFn: async (cad: string): Promise => { + mutationFn: async ({ + cad, + options, + }: { + cad: string; + options?: AnalyzeOptions; + }): Promise => { 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 - >(`/api/v1/parcels/${encodeURIComponent(cad)}/analyze`, { + >(analyzeUrl(cad), { method: "POST", }); @@ -98,10 +125,9 @@ export function useSiteAnalysis() { // если очистить до await, mutation isPending=true но // fetchingState=null → пустой экран ~1 RTT. После await // mutation сразу резолвится с data — render skipped. - const second = await apiFetch( - `/api/v1/parcels/${encodeURIComponent(cad)}/analyze`, - { method: "POST" }, - ); + const second = await apiFetch(analyzeUrl(cad), { + method: "POST", + }); setFetchingState(null); return second; } diff --git a/frontend/src/lib/api/weightProfiles.ts b/frontend/src/lib/api/weightProfiles.ts new file mode 100644 index 00000000..0e725c2d --- /dev/null +++ b/frontend/src/lib/api/weightProfiles.ts @@ -0,0 +1,172 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { apiFetch } from "@/lib/api"; + +// ── Types ───────────────────────────────────────────────────────────────────── +// Manually typed to match backend Pydantic schemas (sub-PR 3). +// After PR #138 merges run `npm run codegen` to sync with generated openapi types. + +export interface WeightProfile { + id: number; + user_id: string; + profile_name: string; + weights: Record; + is_default: boolean; + description: string | null; + created_at: string; + updated_at: string; +} + +export interface WeightProfileCreate { + user_id: string; + profile_name: string; + weights: Record; + is_default?: boolean; + description?: string | null; +} + +export interface WeightProfileUpdate { + profile_name?: string; + weights?: Record; + is_default?: boolean; + description?: string | null; +} + +// ── Constants ───────────────────────────────────────────────────────────────── +// ALLOWED_CATEGORIES — mirrors backend weight_profiles.py ALLOWED_CATEGORIES. +// Keep in sync with backend; source of truth is `_POI_WEIGHTS` in parcels.py. + +export const POI_CATEGORIES = [ + "school", + "kindergarten", + "pharmacy", + "hospital", + "shop_mall", + "shop_supermarket", + "shop_small", + "park", + "bus_stop", + "metro_stop", + "tram_stop", +] as const; + +export type PoiCategoryKey = (typeof POI_CATEGORIES)[number]; + +// System defaults — mirrors _POI_WEIGHTS in backend/app/api/v1/parcels.py. +export const POI_DEFAULT_WEIGHTS: Record = { + school: 1.5, + kindergarten: 1.5, + pharmacy: 0.8, + hospital: 0.6, + shop_mall: 1.2, + shop_supermarket: 1.0, + shop_small: 0.5, + park: 1.8, + bus_stop: 0.3, + metro_stop: 1.5, + tram_stop: -0.5, +}; + +export const POI_LABELS: Record = { + school: "Школы", + kindergarten: "Детсады", + pharmacy: "Аптеки", + hospital: "Больницы", + shop_mall: "ТРЦ / Молы", + shop_supermarket: "Супермаркеты", + shop_small: "Магазины у дома", + park: "Парки", + bus_stop: "Автобусные ост.", + metro_stop: "Метро", + tram_stop: "Трамвайные ост. (−)", +}; + +// Weight range bounds — mirrors MIN_WEIGHT / MAX_WEIGHT in backend. +export const POI_WEIGHT_MIN = -2; +export const POI_WEIGHT_MAX = 3; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const BASE_PATH = "/api/v1/admin/site-finder/weight-profiles"; + +function profilesHeaders(adminToken: string): HeadersInit { + return { "X-Admin-Token": adminToken }; +} + +// ── Hooks ───────────────────────────────────────────────────────────────────── + +/** List all weight profiles for a given user_id. */ +export function useWeightProfiles(userId: string, adminToken: string) { + return useQuery({ + queryKey: ["weight-profiles", userId, adminToken], + queryFn: () => + apiFetch( + `${BASE_PATH}?user_id=${encodeURIComponent(userId)}`, + { + headers: profilesHeaders(adminToken), + }, + ), + enabled: !!userId && !!adminToken, + }); +} + +/** Create a new weight profile. Invalidates the list query on success. */ +export function useCreateProfile(adminToken: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload) => + apiFetch(BASE_PATH, { + method: "POST", + headers: profilesHeaders(adminToken), + body: JSON.stringify(payload), + }), + onSuccess: (_, variables) => { + void qc.invalidateQueries({ + queryKey: ["weight-profiles", variables.user_id], + }); + }, + }); +} + +/** Update an existing weight profile by id. */ +export function useUpdateProfile( + userId: string, + profileId: number, + adminToken: string, +) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload) => + apiFetch( + `${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`, + { + method: "PUT", + headers: profilesHeaders(adminToken), + body: JSON.stringify(payload), + }, + ), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] }); + }, + }); +} + +/** Delete a weight profile by id. Returns true if deleted. */ +export function useDeleteProfile(userId: string, adminToken: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (profileId) => + apiFetch( + `${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`, + { + method: "DELETE", + headers: profilesHeaders(adminToken), + }, + ), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] }); + }, + }); +} -- 2.45.3