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>
431 lines
14 KiB
TypeScript
431 lines
14 KiB
TypeScript
"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>
|
||
);
|
||
}
|