gendesign/frontend/src/components/site-finder/WeightProfilePanel.tsx
lekss361 b42c9dd8f5
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>
2026-05-15 00:50:02 +03:00

431 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

"use client";
import { 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>
);
}