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:
parent
7250aa6187
commit
b42c9dd8f5
4 changed files with 723 additions and 7 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
431
frontend/src/components/site-finder/WeightProfilePanel.tsx
Normal file
431
frontend/src/components/site-finder/WeightProfilePanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
172
frontend/src/lib/api/weightProfiles.ts
Normal file
172
frontend/src/lib/api/weightProfiles.ts
Normal 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] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue