gendesign/frontend/src/components/site-finder/CustomPoiAddModal.tsx
lekss361 6ecd7a9269 feat(#254,#114): custom POI UI + score breakdown stacked-bar viz
Task A (#254): Leaflet click-to-add custom POIs
- sessionId.ts: getOrCreateSessionId() from localStorage
- types/customPoi.ts: CustomPoi, CustomPoiCreate, CustomPoiUpdate
- hooks/useCustomPois.ts: useCustomPois / useAddCustomPoi / useUpdateCustomPoi / useDeleteCustomPoi (TanStack Query, X-Session-Id header)
- CustomPoiLayer.tsx: LayerGroup with CircleMarker (green/red/gray by weight), popup with edit/delete
- CustomPoiToggleButton.tsx: crosshair mode toggle button
- CustomPoiAddModal.tsx: modal form (name, 26 categories, weight -5..+5, notes)
- CustomPoiEditModal.tsx: same form for editing existing POI
- SiteMap.tsx: MapClickHandler (useMapEvents), integrated all custom POI components
- page.tsx: useCustomPois hook + passes customPois + parcelCad to SiteMap

Task B (#114): ScoreBreakdownStackedBar
- ScoreBreakdownStackedBar.tsx: horizontal stacked bar by category with positive/negative split, toggle between category and factor views, custom POIs highlighted orange with dashed outline
- OverviewTab.tsx: renders ScoreBreakdownStackedBar below ScoreBreakdownPanel

Requires backend PR with /api/v1/custom-pois endpoints merged before POI CRUD works.
2026-05-17 09:35:35 +03:00

332 lines
8.7 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 { useState } from "react";
import type { CustomPoiCreate } from "@/types/customPoi";
// ---------------------------------------------------------------------------
// OSM POI category options (25 common + "Другое")
// ---------------------------------------------------------------------------
const CATEGORY_OPTIONS = [
"school",
"kindergarten",
"pharmacy",
"hospital",
"clinic",
"shop_mall",
"shop_supermarket",
"shop_small",
"park",
"tram_stop",
"bus_stop",
"metro_stop",
"cafe",
"restaurant",
"bank",
"atm",
"post_office",
"library",
"sports_centre",
"gym",
"cinema",
"theatre",
"hotel",
"fuel",
"parking",
"Другое",
] as const;
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
interface Props {
lon: number;
lat: number;
parcelCad?: string | null;
onSubmit: (data: CustomPoiCreate) => void;
onCancel: () => void;
isLoading?: boolean;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function CustomPoiAddModal({
lon,
lat,
parcelCad,
onSubmit,
onCancel,
isLoading = false,
}: Props) {
const [name, setName] = useState("");
const [category, setCategory] = useState<string>("Другое");
const [weight, setWeight] = useState(0);
const [notes, setNotes] = useState("");
const [nameError, setNameError] = useState<string | null>(null);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!name.trim()) {
setNameError("Название обязательно");
return;
}
setNameError(null);
onSubmit({
name: name.trim(),
category: category === "Другое" ? null : category,
weight,
lon,
lat,
parcel_cad: parcelCad ?? null,
notes: notes.trim() || null,
});
}
// Derived color for weight indicator
const weightColor =
weight > 0 ? "#16a34a" : weight < 0 ? "#dc2626" : "#6b7280";
return (
/* Backdrop */
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.35)",
zIndex: 9999,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={(e) => {
if (e.target === e.currentTarget) onCancel();
}}
>
<div
style={{
background: "#fff",
borderRadius: 14,
padding: "24px 28px",
width: 380,
maxWidth: "calc(100vw - 40px)",
boxShadow: "0 20px 40px rgba(0,0,0,0.18)",
}}
onClick={(e) => e.stopPropagation()}
>
<h3
style={{
margin: "0 0 18px",
fontSize: 17,
fontWeight: 700,
color: "#111827",
}}
>
Добавить точку интереса
</h3>
<form
onSubmit={handleSubmit}
style={{ display: "flex", flexDirection: "column", gap: 14 }}
>
{/* Coordinates (read-only) */}
<div style={{ fontSize: 12, color: "#9ca3af" }}>
{lat.toFixed(5)}, {lon.toFixed(5)}
{parcelCad && (
<span style={{ marginLeft: 8, color: "#6b7280" }}>
· {parcelCad}
</span>
)}
</div>
{/* Name */}
<div>
<label
style={{
display: "block",
fontSize: 12,
fontWeight: 600,
color: "#374151",
marginBottom: 4,
}}
>
Название *
</label>
<input
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
setNameError(null);
}}
placeholder="Например: Детская площадка"
style={{
width: "100%",
padding: "8px 10px",
fontSize: 13,
border: `1px solid ${nameError ? "#f87171" : "#d1d5db"}`,
borderRadius: 7,
boxSizing: "border-box",
}}
autoFocus
/>
{nameError && (
<p style={{ margin: "3px 0 0", fontSize: 11, color: "#dc2626" }}>
{nameError}
</p>
)}
</div>
{/* Category */}
<div>
<label
style={{
display: "block",
fontSize: 12,
fontWeight: 600,
color: "#374151",
marginBottom: 4,
}}
>
Категория
</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
style={{
width: "100%",
padding: "8px 10px",
fontSize: 13,
border: "1px solid #d1d5db",
borderRadius: 7,
background: "#fff",
boxSizing: "border-box",
}}
>
{CATEGORY_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</div>
{/* Weight slider */}
<div>
<label
style={{
display: "block",
fontSize: 12,
fontWeight: 600,
color: "#374151",
marginBottom: 4,
}}
>
Вес{" "}
<span style={{ fontWeight: 700, color: weightColor }}>
{weight > 0 ? `+${weight}` : weight}
</span>
</label>
<input
type="range"
min={-5}
max={5}
step={1}
value={weight}
onChange={(e) => setWeight(Number(e.target.value))}
style={{ width: "100%" }}
/>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 11,
color: "#9ca3af",
marginTop: 2,
}}
>
<span>-5 (отрицательный)</span>
<span>0</span>
<span>+5 (положительный)</span>
</div>
</div>
{/* Notes */}
<div>
<label
style={{
display: "block",
fontSize: 12,
fontWeight: 600,
color: "#374151",
marginBottom: 4,
}}
>
Заметки
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Необязательно"
rows={2}
style={{
width: "100%",
padding: "8px 10px",
fontSize: 13,
border: "1px solid #d1d5db",
borderRadius: 7,
resize: "vertical",
boxSizing: "border-box",
}}
/>
</div>
{/* Buttons */}
<div
style={{
display: "flex",
gap: 10,
justifyContent: "flex-end",
marginTop: 4,
}}
>
<button
type="button"
onClick={onCancel}
style={{
padding: "8px 18px",
fontSize: 13,
borderRadius: 7,
border: "1px solid #d1d5db",
background: "#f9fafb",
cursor: "pointer",
color: "#374151",
}}
disabled={isLoading}
>
Отмена
</button>
<button
type="submit"
style={{
padding: "8px 20px",
fontSize: 13,
borderRadius: 7,
border: "none",
background: isLoading ? "#93c5fd" : "#1d4ed8",
color: "#fff",
cursor: isLoading ? "not-allowed" : "pointer",
fontWeight: 600,
}}
disabled={isLoading}
>
{isLoading ? "Сохранение…" : "Добавить"}
</button>
</div>
</form>
</div>
</div>
);
}