gendesign/frontend/src/components/site-finder/WeightProfilePanel.tsx
Light1YT 86e9ea2937 fix(week-review): автофиксы код-ревью — 169 issue (label «week ревью 1»)
Многоагентный аудит + имплементация: один воркер на файл, точечные правки.
Верификация: py_compile (47/47 .py) + tsc --noEmit (0 ошибок). Unit-тесты
не прогонялись (окружение не поднято: rollup native dep / нет pytest-venv).

Полностью исправлено (169): #1336, #1337, #1339, #1340, #1341, #1342, #1343, #1345, #1346, #1348, #1349, #1350, #1351, #1354, #1356, #1358, #1359, #1360, #1362, #1364, #1365, #1366, #1367, #1368, #1369, #1370, #1371, #1372, #1373, #1374, #1375, #1376, #1377, #1378, #1379, #1380, #1381, #1382, #1384, #1385, #1386, #1387, #1388, #1389, #1390, #1391, #1392, #1394, #1395, #1396, #1397, #1399, #1400, #1401, #1402, #1403, #1404, #1408, #1409, #1410, #1411, #1412, #1413, #1414, #1415, #1416, #1417, #1418, #1420, #1423, #1425, #1426, #1427, #1428, #1429, #1430, #1431, #1432, #1433, #1434, #1435, #1437, #1438, #1439, #1440, #1441, #1442, #1443, #1444, #1445, #1446, #1447, #1448, #1449, #1450, #1451, #1452, #1453, #1454, #1455, #1456, #1457, #1458, #1459, #1460, #1461, #1462, #1463, #1464, #1465, #1466, #1467, #1468, #1469, #1471, #1472, #1473, #1474, #1476, #1478, #1479, #1481, #1482, #1483, #1484, #1485, #1487, #1488, #1489, #1490, #1491, #1492, #1493, #1494, #1495, #1496, #1497, #1499, #1500, #1501, #1502, #1504, #1505, #1506, #1507, #1510, #1514, #1515, #1516, #1517, #1518, #1519, #1521, #1522, #1523, #1524, #1525, #1526, #1527, #1528, #1529, #1531, #1532, #1533, #1534, #1535, #1536, #1537, #1538

Частично (9, in-file часть, остаток cross-file): #1361, #1419, #1422, #1424, #1470, #1475, #1477, #1480, #1498
Требуют cross-file (3, не тронуты): #1338, #1363, #1421
Пропущено (1): #1539

Не входило в партию: 22 needs-Leha issue (нужны решения владельца).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:21:11 +05:00

423 lines
13 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, 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);
// 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));
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>
);
}