gendesign/frontend/src/components/site-finder/CustomPoiEditModal.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

267 lines
6.6 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 { CustomPoi, CustomPoiUpdate } from "@/types/customPoi";
interface Props {
poi: CustomPoi;
onSubmit: (data: CustomPoiUpdate) => void;
onCancel: () => void;
isLoading?: boolean;
}
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;
export function CustomPoiEditModal({
poi,
onSubmit,
onCancel,
isLoading = false,
}: Props) {
const [name, setName] = useState(poi.name);
const [category, setCategory] = useState<string>(poi.category ?? "Другое");
const [weight, setWeight] = useState(poi.weight);
const [notes, setNotes] = useState(poi.notes ?? "");
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
onSubmit({
name: name.trim() || poi.name,
category: category === "Другое" ? null : category,
weight,
notes: notes.trim() || null,
});
}
const weightColor =
weight > 0 ? "#16a34a" : weight < 0 ? "#dc2626" : "#6b7280";
return (
<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 }}
>
<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)}
style={{
width: "100%",
padding: "8px 10px",
fontSize: 13,
border: "1px solid #d1d5db",
borderRadius: 7,
boxSizing: "border-box",
}}
autoFocus
/>
</div>
<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>
<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>
<div>
<label
style={{
display: "block",
fontSize: 12,
fontWeight: 600,
color: "#374151",
marginBottom: 4,
}}
>
Заметки
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
style={{
width: "100%",
padding: "8px 10px",
fontSize: 13,
border: "1px solid #d1d5db",
borderRadius: 7,
resize: "vertical",
boxSizing: "border-box",
}}
/>
</div>
<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>
);
}