From 6ecd7a9269cbf6b7bd2cb5b2dc46edd383b9f139 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 09:35:35 +0300 Subject: [PATCH] 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. --- frontend/src/app/site-finder/page.tsx | 5 + .../site-finder/CustomPoiAddModal.tsx | 332 +++++++++++++ .../site-finder/CustomPoiEditModal.tsx | 267 ++++++++++ .../components/site-finder/CustomPoiLayer.tsx | 125 +++++ .../site-finder/CustomPoiToggleButton.tsx | 38 ++ .../components/site-finder/OverviewTab.tsx | 7 + .../site-finder/ScoreBreakdownStackedBar.tsx | 463 ++++++++++++++++++ .../src/components/site-finder/SiteMap.tsx | 145 +++++- frontend/src/hooks/useCustomPois.ts | 128 +++++ frontend/src/lib/sessionId.ts | 14 + frontend/src/types/customPoi.ts | 30 ++ 11 files changed, 1552 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/site-finder/CustomPoiAddModal.tsx create mode 100644 frontend/src/components/site-finder/CustomPoiEditModal.tsx create mode 100644 frontend/src/components/site-finder/CustomPoiLayer.tsx create mode 100644 frontend/src/components/site-finder/CustomPoiToggleButton.tsx create mode 100644 frontend/src/components/site-finder/ScoreBreakdownStackedBar.tsx create mode 100644 frontend/src/hooks/useCustomPois.ts create mode 100644 frontend/src/lib/sessionId.ts create mode 100644 frontend/src/types/customPoi.ts diff --git a/frontend/src/app/site-finder/page.tsx b/frontend/src/app/site-finder/page.tsx index 460a9c00..568b146f 100644 --- a/frontend/src/app/site-finder/page.tsx +++ b/frontend/src/app/site-finder/page.tsx @@ -17,6 +17,7 @@ import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel" import { useSiteAnalysis } from "@/hooks/useSiteAnalysis"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { useConnectionPoints } from "@/hooks/useConnectionPoints"; +import { useCustomPois } from "@/hooks/useCustomPois"; import { POI_DEFAULT_WEIGHTS, type PoiCategoryKey, @@ -101,6 +102,8 @@ function SiteFinderContent() { // Fetch connection points whenever a parcel is loaded const { data: connectionPoints } = useConnectionPoints(data?.cad_num); + // Fetch custom POIs for current parcel + const { data: customPois } = useCustomPois(data?.cad_num); // Weight profile state — lifted here so it survives tab switches. // userId + adminToken allow the panel to load/save named profiles. @@ -545,6 +548,8 @@ function SiteFinderContent() { data={data} isochrones={isochrones} connectionPoints={connectionPoints} + customPois={customPois} + parcelCad={data.cad_num} /> diff --git a/frontend/src/components/site-finder/CustomPoiAddModal.tsx b/frontend/src/components/site-finder/CustomPoiAddModal.tsx new file mode 100644 index 00000000..1c9c8201 --- /dev/null +++ b/frontend/src/components/site-finder/CustomPoiAddModal.tsx @@ -0,0 +1,332 @@ +"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("Другое"); + const [weight, setWeight] = useState(0); + const [notes, setNotes] = useState(""); + const [nameError, setNameError] = useState(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 */ +
{ + if (e.target === e.currentTarget) onCancel(); + }} + > +
e.stopPropagation()} + > +

+ Добавить точку интереса +

+ +
+ {/* Coordinates (read-only) */} +
+ {lat.toFixed(5)}, {lon.toFixed(5)} + {parcelCad && ( + + · {parcelCad} + + )} +
+ + {/* Name */} +
+ + { + 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 && ( +

+ {nameError} +

+ )} +
+ + {/* Category */} +
+ + +
+ + {/* Weight slider */} +
+ + setWeight(Number(e.target.value))} + style={{ width: "100%" }} + /> +
+ -5 (отрицательный) + 0 + +5 (положительный) +
+
+ + {/* Notes */} +
+ +