feat(site-finder): weight profile panel + analyze options (#114 sub-PR 4/4 FINAL) #139
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 { 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<FeatureCollection | 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 всё равно
|
||||
// игнорирует initializer после первого render — не тратим CPU).
|
||||
const [tab, setTabState] = useState<TabId>(() => {
|
||||
|
|
@ -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<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
|
||||
|
|
@ -205,6 +247,51 @@ function SiteFinderContent() {
|
|||
</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 && (
|
||||
<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_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<ParcelAnalysis> => {
|
||||
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
|
||||
>(`/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<ParcelAnalysis>(
|
||||
`/api/v1/parcels/${encodeURIComponent(cad)}/analyze`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
const second = await apiFetch<ParcelAnalysis>(analyzeUrl(cad), {
|
||||
method: "POST",
|
||||
});
|
||||
setFetchingState(null);
|
||||
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