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.
332 lines
8.7 KiB
TypeScript
332 lines
8.7 KiB
TypeScript
"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>
|
||
);
|
||
}
|