feat(site-finder): weight profile panel + analyze options (#114 sub-PR 4/4 FINAL) (#139)

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

Co-authored-by: lekss361 <claudestars@proton.me>
This commit is contained in:
lekss361 2026-05-15 00:50:02 +03:00 committed by GitHub
parent 7250aa6187
commit b42c9dd8f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 723 additions and 7 deletions

View file

@ -13,7 +13,12 @@ import { OverviewTab } from "@/components/site-finder/OverviewTab";
import { EnvironmentTab } from "@/components/site-finder/EnvironmentTab"; import { EnvironmentTab } from "@/components/site-finder/EnvironmentTab";
import { LandTab } from "@/components/site-finder/LandTab"; import { LandTab } from "@/components/site-finder/LandTab";
import { MarketTab } from "@/components/site-finder/MarketTab"; import { MarketTab } from "@/components/site-finder/MarketTab";
import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel";
import { useSiteAnalysis } from "@/hooks/useSiteAnalysis"; 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 // SiteMap imports Leaflet which requires browser APIs — load without SSR
const SiteMap = dynamic( const SiteMap = dynamic(
@ -91,6 +96,24 @@ function SiteFinderContent() {
const [isochrones, setIsochrones] = useState<FeatureCollection | undefined>( const [isochrones, setIsochrones] = useState<FeatureCollection | undefined>(
undefined, 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<PoiCategoryKey, number>
>(() => ({ ...POI_DEFAULT_WEIGHTS }));
// Profile selection for the analyze call (null = system defaults).
const [activeProfileId, setActiveProfileId] = useState<number | null>(null);
const [profileUserId, setProfileUserId] = useState<string>(() =>
typeof window === "undefined"
? ""
: (localStorage.getItem("admin_user_id") ?? ""),
);
const [adminToken] = useState<string>(() =>
typeof window === "undefined"
? ""
: (localStorage.getItem("admin_token") ?? ""),
);
// Lazy init: считаем initialTab один раз на mount (useState всё равно // Lazy init: считаем initialTab один раз на mount (useState всё равно
// игнорирует initializer после первого render — не тратим CPU). // игнорирует initializer после первого render — не тратим CPU).
const [tab, setTabState] = useState<TabId>(() => { const [tab, setTabState] = useState<TabId>(() => {
@ -124,7 +147,26 @@ function SiteFinderContent() {
function handleAnalyze(cadNum: string) { function handleAnalyze(cadNum: string) {
setIsochrones(undefined); setIsochrones(undefined);
setTab("overview"); setTab("overview");
mutate(cadNum); mutate({
cad: cadNum,
options:
activeProfileId != null
? { profileId: activeProfileId }
: profileUserId
? { profileUserId }
: undefined,
});
}
function handleWeightsChange(
weights: Record<PoiCategoryKey, number>,
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 // Derive KPI values from data
@ -205,6 +247,51 @@ function SiteFinderContent() {
</div> </div>
</div> </div>
{/* Weight profile panel — collapsible, below header */}
<div style={{ marginBottom: 16 }}>
{/* Optional user-id field for profile CRUD (shown only when adminToken present) */}
{!!adminToken && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 6,
}}
>
<label
style={{ fontSize: 12, color: "#6b7280", whiteSpace: "nowrap" }}
>
User ID (для профилей):
</label>
<input
type="text"
value={profileUserId}
placeholder="user-abc"
style={{
padding: "4px 8px",
fontSize: 12,
border: "1px solid #d1d5db",
borderRadius: 6,
width: 180,
}}
onChange={(e) => {
setProfileUserId(e.target.value);
if (typeof window !== "undefined") {
localStorage.setItem("admin_user_id", e.target.value);
}
}}
/>
</div>
)}
<WeightProfilePanel
currentWeights={currentWeights}
onWeightsChange={handleWeightsChange}
userId={profileUserId || undefined}
adminToken={adminToken || undefined}
/>
</div>
{/* Error state */} {/* Error state */}
{error && ( {error && (
<div <div

View file

@ -0,0 +1,431 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { SectionLabel } from "@/components/ui/SectionLabel";
import {
POI_CATEGORIES,
POI_DEFAULT_WEIGHTS,
POI_LABELS,
POI_WEIGHT_MAX,
POI_WEIGHT_MIN,
useCreateProfile,
useWeightProfiles,
type PoiCategoryKey,
type WeightProfile,
} from "@/lib/api/weightProfiles";
// ── Props ─────────────────────────────────────────────────────────────────────
interface Props {
/** Current effective weights for the analyze call. */
currentWeights: Record<PoiCategoryKey, number>;
/**
* 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<PoiCategoryKey, number>,
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<PoiCategoryKey, number> {
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<PoiCategoryKey, number>,
b: Record<PoiCategoryKey, number>,
): 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<Record<PoiCategoryKey, number>>(() => ({
...currentWeights,
}));
// Selected profile id (null = system defaults)
const [selectedProfileId, setSelectedProfileId] = useState<number | null>(
null,
);
// Save-dialog state
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [saveName, setSaveName] = useState("");
const [saveDefault, setSaveDefault] = useState(false);
// Debounce timer ref
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div style={panelStyle}>
{/* Header / toggle */}
<div style={headerStyle} onClick={() => setOpen((o) => !o)} role="button">
<SectionLabel>POI Веса</SectionLabel>
<span
style={{
fontSize: 10,
color: "#9ca3af",
transform: open ? "rotate(180deg)" : "none",
display: "inline-block",
transition: "transform 0.15s",
}}
>
</span>
</div>
{open && (
<div style={bodyStyle}>
{/* Profile selector */}
{canUseCrud && (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<label
style={{ fontSize: 12, color: "#6b7280", whiteSpace: "nowrap" }}
>
Профиль:
</label>
<select
style={{
flex: 1,
padding: "4px 8px",
fontSize: 12,
border: "1px solid #d1d5db",
borderRadius: 6,
}}
value={selectedProfileId ?? ""}
onChange={(e) => {
const val = e.target.value;
if (val === "") {
handleReset();
} else {
const p = profiles.find((x) => x.id === parseInt(val, 10));
if (p) handleLoadProfile(p);
}
}}
>
<option value="">По умолчанию</option>
{profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.profile_name}
{p.is_default ? " ★" : ""}
</option>
))}
</select>
{profilesQuery.isLoading && (
<span style={{ fontSize: 11, color: "#9ca3af" }}></span>
)}
</div>
)}
{/* Hint when no crud */}
{!canUseCrud && (
<p style={{ fontSize: 11, color: "#9ca3af", margin: 0 }}>
Укажите User ID и Admin Token для сохранения профилей.
</p>
)}
{/* Sliders */}
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{POI_CATEGORIES.map((cat) => {
const value = draft[cat];
return (
<div key={cat} style={sliderRowStyle}>
<span style={{ fontSize: 12, color: "#374151" }}>
{POI_LABELS[cat]}
</span>
<input
type="range"
min={POI_WEIGHT_MIN}
max={POI_WEIGHT_MAX}
step={0.1}
value={value}
style={{ width: "100%", accentColor: "#1d4ed8" }}
onChange={(e) => handleSliderChange(cat, e.target.value)}
/>
<span style={valueChipStyle(value)}>{value.toFixed(1)}</span>
</div>
);
})}
</div>
{/* Action buttons */}
<div
style={{ display: "flex", gap: 8, flexWrap: "wrap", paddingTop: 4 }}
>
<button style={btnStyle("secondary")} onClick={handleReset}>
Сбросить
</button>
{canUseCrud && isDraftDifferentFromDefaults && (
<button
style={btnStyle("secondary")}
onClick={() => setShowSaveDialog(true)}
>
Сохранить как профиль
</button>
)}
<button
style={{
...btnStyle("primary"),
opacity: isDirty ? 1 : 0.5,
cursor: isDirty ? "pointer" : "default",
marginLeft: "auto",
}}
onClick={handleApply}
disabled={!isDirty}
>
Применить
</button>
</div>
{/* Info: weights changed but not applied */}
{isDirty && (
<p style={{ fontSize: 11, color: "#f59e0b", margin: 0 }}>
Изменения не применены к анализу нажмите «Применить» перед
запуском.
</p>
)}
{/* Save dialog */}
{showSaveDialog && (
<div
style={{
background: "#f9fafb",
border: "1px solid #e5e7eb",
borderRadius: 8,
padding: "12px 14px",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
<SectionLabel>Новый профиль</SectionLabel>
<input
autoFocus
type="text"
placeholder="Название профиля"
value={saveName}
onChange={(e) => 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);
}}
/>
<label
style={{
display: "flex",
alignItems: "center",
gap: 6,
fontSize: 12,
}}
>
<input
type="checkbox"
checked={saveDefault}
onChange={(e) => setSaveDefault(e.target.checked)}
/>
Установить по умолчанию
</label>
<div style={{ display: "flex", gap: 8 }}>
<button
style={btnStyle("primary")}
disabled={!saveName.trim() || createMutation.isPending}
onClick={() => void handleSaveProfile()}
>
{createMutation.isPending ? "Сохранение…" : "Сохранить"}
</button>
<button
style={btnStyle("secondary")}
onClick={() => setShowSaveDialog(false)}
>
Отмена
</button>
</div>
{createMutation.error && (
<p style={{ fontSize: 11, color: "#dc2626", margin: 0 }}>
{createMutation.error.message}
</p>
)}
</div>
)}
</div>
)}
</div>
);
}

View file

@ -39,6 +39,13 @@ export type AnalyzeResult =
const POLL_INTERVAL_MS = 2000; const POLL_INTERVAL_MS = 2000;
const POLL_MAX_ITERATIONS = 60; // 60 × 2s = 2 min hard cap 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. * Custom hook для analyze flow с graceful on-demand fetch fallback.
* *
@ -59,14 +66,34 @@ export function useSiteAnalysis() {
const cancelledRef = useRef(false); const cancelledRef = useRef(false);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async (cad: string): Promise<ParcelAnalysis> => { mutationFn: async ({
cad,
options,
}: {
cad: string;
options?: AnalyzeOptions;
}): Promise<ParcelAnalysis> => {
cancelledRef.current = false; cancelledRef.current = false;
setFetchingState(null); 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 // First request — POST /analyze
const first = await apiFetchWithStatus< const first = await apiFetchWithStatus<
ParcelAnalysis | AnalyzeAcceptedResponse ParcelAnalysis | AnalyzeAcceptedResponse
>(`/api/v1/parcels/${encodeURIComponent(cad)}/analyze`, { >(analyzeUrl(cad), {
method: "POST", method: "POST",
}); });
@ -98,10 +125,9 @@ export function useSiteAnalysis() {
// если очистить до await, mutation isPending=true но // если очистить до await, mutation isPending=true но
// fetchingState=null → пустой экран ~1 RTT. После await // fetchingState=null → пустой экран ~1 RTT. После await
// mutation сразу резолвится с data — render skipped. // mutation сразу резолвится с data — render skipped.
const second = await apiFetch<ParcelAnalysis>( const second = await apiFetch<ParcelAnalysis>(analyzeUrl(cad), {
`/api/v1/parcels/${encodeURIComponent(cad)}/analyze`, method: "POST",
{ method: "POST" }, });
);
setFetchingState(null); setFetchingState(null);
return second; return second;
} }

View file

@ -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<string, number>;
is_default: boolean;
description: string | null;
created_at: string;
updated_at: string;
}
export interface WeightProfileCreate {
user_id: string;
profile_name: string;
weights: Record<string, number>;
is_default?: boolean;
description?: string | null;
}
export interface WeightProfileUpdate {
profile_name?: string;
weights?: Record<string, number>;
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<PoiCategoryKey, number> = {
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<PoiCategoryKey, string> = {
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<WeightProfile[]>({
queryKey: ["weight-profiles", userId, adminToken],
queryFn: () =>
apiFetch<WeightProfile[]>(
`${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<WeightProfile, Error, WeightProfileCreate>({
mutationFn: (payload) =>
apiFetch<WeightProfile>(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<WeightProfile, Error, WeightProfileUpdate>({
mutationFn: (payload) =>
apiFetch<WeightProfile>(
`${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<void, Error, number>({
mutationFn: (profileId) =>
apiFetch<void>(
`${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`,
{
method: "DELETE",
headers: profilesHeaders(adminToken),
},
),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] });
},
});
}