Merge pull request 'feat(#254,#114): custom POI UI (Leaflet click-to-add) + score breakdown stacked-bar' (#258) from feat/254-custom-pois-ui into main
This commit is contained in:
commit
e3eb8c873f
11 changed files with 1552 additions and 2 deletions
|
|
@ -17,6 +17,7 @@ import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel"
|
||||||
import { useSiteAnalysis } from "@/hooks/useSiteAnalysis";
|
import { useSiteAnalysis } from "@/hooks/useSiteAnalysis";
|
||||||
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
|
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
|
||||||
import { useConnectionPoints } from "@/hooks/useConnectionPoints";
|
import { useConnectionPoints } from "@/hooks/useConnectionPoints";
|
||||||
|
import { useCustomPois } from "@/hooks/useCustomPois";
|
||||||
import {
|
import {
|
||||||
POI_DEFAULT_WEIGHTS,
|
POI_DEFAULT_WEIGHTS,
|
||||||
type PoiCategoryKey,
|
type PoiCategoryKey,
|
||||||
|
|
@ -101,6 +102,8 @@ function SiteFinderContent() {
|
||||||
|
|
||||||
// Fetch connection points whenever a parcel is loaded
|
// Fetch connection points whenever a parcel is loaded
|
||||||
const { data: connectionPoints } = useConnectionPoints(data?.cad_num);
|
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.
|
// Weight profile state — lifted here so it survives tab switches.
|
||||||
// userId + adminToken allow the panel to load/save named profiles.
|
// userId + adminToken allow the panel to load/save named profiles.
|
||||||
|
|
@ -545,6 +548,8 @@ function SiteFinderContent() {
|
||||||
data={data}
|
data={data}
|
||||||
isochrones={isochrones}
|
isochrones={isochrones}
|
||||||
connectionPoints={connectionPoints}
|
connectionPoints={connectionPoints}
|
||||||
|
customPois={customPois}
|
||||||
|
parcelCad={data.cad_num}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
332
frontend/src/components/site-finder/CustomPoiAddModal.tsx
Normal file
332
frontend/src/components/site-finder/CustomPoiAddModal.tsx
Normal file
|
|
@ -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<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
267
frontend/src/components/site-finder/CustomPoiEditModal.tsx
Normal file
267
frontend/src/components/site-finder/CustomPoiEditModal.tsx
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
frontend/src/components/site-finder/CustomPoiLayer.tsx
Normal file
125
frontend/src/components/site-finder/CustomPoiLayer.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CircleMarker, LayerGroup, Popup } from "react-leaflet";
|
||||||
|
import type { CustomPoi } from "@/types/customPoi";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Props
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
pois: CustomPoi[];
|
||||||
|
onEdit: (poi: CustomPoi) => void;
|
||||||
|
onDelete: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Color helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function markerColor(weight: number): string {
|
||||||
|
if (weight > 0) return "#16a34a"; // green — positive
|
||||||
|
if (weight < 0) return "#dc2626"; // red — negative
|
||||||
|
return "#6b7280"; // gray — neutral
|
||||||
|
}
|
||||||
|
|
||||||
|
function weightLabel(weight: number): string {
|
||||||
|
if (weight > 0) return `+${weight}`;
|
||||||
|
return String(weight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function CustomPoiLayer({ pois, onEdit, onDelete }: Props) {
|
||||||
|
if (!pois.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LayerGroup>
|
||||||
|
{pois.map((poi) => {
|
||||||
|
const color = markerColor(poi.weight);
|
||||||
|
return (
|
||||||
|
<CircleMarker
|
||||||
|
key={poi.id}
|
||||||
|
center={[poi.lat, poi.lon]}
|
||||||
|
radius={9}
|
||||||
|
pathOptions={{
|
||||||
|
color,
|
||||||
|
fillColor: color,
|
||||||
|
fillOpacity: 0.85,
|
||||||
|
weight: 2.5,
|
||||||
|
dashArray: "4 2",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Popup>
|
||||||
|
<div style={{ fontSize: 13, lineHeight: 1.6, minWidth: 160 }}>
|
||||||
|
<div
|
||||||
|
style={{ fontWeight: 700, marginBottom: 2, color: "#111827" }}
|
||||||
|
>
|
||||||
|
{poi.name}
|
||||||
|
</div>
|
||||||
|
{poi.category && (
|
||||||
|
<div
|
||||||
|
style={{ fontSize: 11, color: "#6b7280", marginBottom: 4 }}
|
||||||
|
>
|
||||||
|
{poi.category}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 700,
|
||||||
|
color,
|
||||||
|
marginBottom: 6,
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Вес: {weightLabel(poi.weight)}
|
||||||
|
</div>
|
||||||
|
{poi.notes && (
|
||||||
|
<div
|
||||||
|
style={{ fontSize: 12, color: "#374151", marginBottom: 8 }}
|
||||||
|
>
|
||||||
|
{poi.notes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onEdit(poi)}
|
||||||
|
style={{
|
||||||
|
padding: "4px 10px",
|
||||||
|
fontSize: 12,
|
||||||
|
borderRadius: 5,
|
||||||
|
border: "1px solid #d1d5db",
|
||||||
|
background: "#f9fafb",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "#374151",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Изменить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDelete(poi.id)}
|
||||||
|
style={{
|
||||||
|
padding: "4px 10px",
|
||||||
|
fontSize: 12,
|
||||||
|
borderRadius: 5,
|
||||||
|
border: "none",
|
||||||
|
background: "#fef2f2",
|
||||||
|
color: "#dc2626",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</CircleMarker>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</LayerGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Floating button shown below the map to toggle "add custom POI" click mode.
|
||||||
|
* When active, cursor becomes crosshair and next map click opens the add modal.
|
||||||
|
*/
|
||||||
|
export function CustomPoiToggleButton({ active, onClick }: Props) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
padding: "7px 14px",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
borderRadius: 8,
|
||||||
|
border: active ? "2px solid #1d4ed8" : "1px solid #d1d5db",
|
||||||
|
background: active ? "#dbeafe" : "#fff",
|
||||||
|
color: active ? "#1d4ed8" : "#374151",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 0.15s",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
active
|
||||||
|
? "Кликните на карте для добавления точки. Нажмите снова для отмены"
|
||||||
|
: "Кликните для добавления пользовательской точки на карте"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{active ? "Отмена (нажмите Esc или эту кнопку)" : "+ Добавить точку"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import { ConfidenceBadge } from "./ConfidenceBadge";
|
||||||
import { GateVerdictBanner } from "./GateVerdictBanner";
|
import { GateVerdictBanner } from "./GateVerdictBanner";
|
||||||
import { IsochronesPanel } from "./IsochronesPanel";
|
import { IsochronesPanel } from "./IsochronesPanel";
|
||||||
import { ScoreBreakdownPanel } from "./ScoreBreakdownPanel";
|
import { ScoreBreakdownPanel } from "./ScoreBreakdownPanel";
|
||||||
|
import { ScoreBreakdownStackedBar } from "./ScoreBreakdownStackedBar";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: ParcelAnalysis;
|
data: ParcelAnalysis;
|
||||||
|
|
@ -161,6 +162,12 @@ export function OverviewTab({ data, onIsochronesResult }: Props) {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* #114: score breakdown stacked bar by category */}
|
||||||
|
{data.score_breakdown_detailed &&
|
||||||
|
data.score_breakdown_detailed.length > 0 && (
|
||||||
|
<ScoreBreakdownStackedBar breakdown={data.score_breakdown_detailed} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* POI breakdown */}
|
{/* POI breakdown */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
463
frontend/src/components/site-finder/ScoreBreakdownStackedBar.tsx
Normal file
463
frontend/src/components/site-finder/ScoreBreakdownStackedBar.tsx
Normal file
|
|
@ -0,0 +1,463 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { FactorContribution } from "@/types/site-finder";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Color mapping per category
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
|
school: "#1d4ed8",
|
||||||
|
kindergarten: "#1d4ed8",
|
||||||
|
pharmacy: "#10b981",
|
||||||
|
hospital: "#f59e0b",
|
||||||
|
shop_mall: "#a855f7",
|
||||||
|
shop_supermarket: "#a855f7",
|
||||||
|
shop_small: "#c084fc",
|
||||||
|
park: "#16a34a",
|
||||||
|
tram_stop: "#dc2626",
|
||||||
|
bus_stop: "#6b7280",
|
||||||
|
metro_stop: "#1e40af",
|
||||||
|
custom: "#f97316", // custom POIs — orange with border
|
||||||
|
};
|
||||||
|
|
||||||
|
function categoryColor(category: string): string {
|
||||||
|
return CATEGORY_COLORS[category] ?? "#94a3b8";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Props
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Full factor breakdown from ParcelAnalysis.score_breakdown_detailed */
|
||||||
|
breakdown: FactorContribution[];
|
||||||
|
/** Factor names that come from custom POIs (to highlight visually) */
|
||||||
|
customPoiFactors?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Aggregation helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface CategoryAgg {
|
||||||
|
category: string;
|
||||||
|
category_ru: string;
|
||||||
|
contribution: number;
|
||||||
|
count: number;
|
||||||
|
isCustom: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateByCategory(
|
||||||
|
items: FactorContribution[],
|
||||||
|
customFactors: Set<string>,
|
||||||
|
): CategoryAgg[] {
|
||||||
|
const map = new Map<string, CategoryAgg>();
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const key = item.category;
|
||||||
|
const existing = map.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.contribution += item.contribution;
|
||||||
|
existing.count += 1;
|
||||||
|
if (customFactors.has(item.factor)) existing.isCustom = true;
|
||||||
|
} else {
|
||||||
|
map.set(key, {
|
||||||
|
category: key,
|
||||||
|
category_ru: item.category_ru,
|
||||||
|
contribution: item.contribution,
|
||||||
|
count: 1,
|
||||||
|
isCustom: customFactors.has(item.factor),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...map.values()].sort((a, b) => b.contribution - a.contribution);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function ScoreBreakdownStackedBar({
|
||||||
|
breakdown,
|
||||||
|
customPoiFactors = [],
|
||||||
|
}: Props) {
|
||||||
|
const [groupBy, setGroupBy] = useState<"category" | "source">("category");
|
||||||
|
const [hoveredCat, setHoveredCat] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const customSet = new Set(customPoiFactors);
|
||||||
|
|
||||||
|
// Aggregate by category (or source as fallback label when groupBy=source)
|
||||||
|
const aggregated = aggregateByCategory(breakdown, customSet);
|
||||||
|
|
||||||
|
// Split positive and negative for stacked bar
|
||||||
|
const positives = aggregated.filter((a) => a.contribution > 0);
|
||||||
|
const negatives = aggregated.filter((a) => a.contribution < 0);
|
||||||
|
|
||||||
|
const totalPositive = positives.reduce((s, a) => s + a.contribution, 0) || 1;
|
||||||
|
const totalNegative =
|
||||||
|
Math.abs(negatives.reduce((s, a) => s + a.contribution, 0)) || 1;
|
||||||
|
|
||||||
|
if (aggregated.length === 0) return null;
|
||||||
|
|
||||||
|
const fmtV = (v: number) => `${v >= 0 ? "+" : ""}${v.toFixed(2)}`;
|
||||||
|
|
||||||
|
// Group-by toggle (source mode uses factor field as key — simpler visual)
|
||||||
|
const sourceAgg =
|
||||||
|
groupBy === "source"
|
||||||
|
? (() => {
|
||||||
|
const sm = new Map<string, { contribution: number; count: number }>();
|
||||||
|
for (const item of breakdown) {
|
||||||
|
const key = item.factor;
|
||||||
|
const ex = sm.get(key);
|
||||||
|
if (ex) {
|
||||||
|
ex.contribution += item.contribution;
|
||||||
|
ex.count += 1;
|
||||||
|
} else {
|
||||||
|
sm.set(key, { contribution: item.contribution, count: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...sm.entries()]
|
||||||
|
.map(([k, v]) => ({ factor: k, ...v }))
|
||||||
|
.sort((a, b) => b.contribution - a.contribution)
|
||||||
|
.slice(0, 10);
|
||||||
|
})()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 18px",
|
||||||
|
background: "#fff",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header + toggle */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#6b7280",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Вклад по категориям
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 6, fontSize: 12 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setGroupBy("category")}
|
||||||
|
style={{
|
||||||
|
padding: "3px 10px",
|
||||||
|
borderRadius: 5,
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: groupBy === "category" ? "#1d4ed8" : "#d1d5db",
|
||||||
|
background: groupBy === "category" ? "#dbeafe" : "#f9fafb",
|
||||||
|
color: groupBy === "category" ? "#1d4ed8" : "#6b7280",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: groupBy === "category" ? 600 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
По категории
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setGroupBy("source")}
|
||||||
|
style={{
|
||||||
|
padding: "3px 10px",
|
||||||
|
borderRadius: 5,
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: groupBy === "source" ? "#1d4ed8" : "#d1d5db",
|
||||||
|
background: groupBy === "source" ? "#dbeafe" : "#f9fafb",
|
||||||
|
color: groupBy === "source" ? "#1d4ed8" : "#6b7280",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: groupBy === "source" ? 600 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
По фактору
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stacked horizontal bar — positive contributions */}
|
||||||
|
{groupBy === "category" && positives.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 11, color: "#6b7280", marginBottom: 4 }}>
|
||||||
|
Положительный вклад
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 5,
|
||||||
|
overflow: "hidden",
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
}}
|
||||||
|
role="img"
|
||||||
|
aria-label="Stacked bar: положительный вклад по категориям"
|
||||||
|
>
|
||||||
|
{positives.map((agg) => {
|
||||||
|
const widthPct = (agg.contribution / totalPositive) * 100;
|
||||||
|
const color = agg.isCustom
|
||||||
|
? CATEGORY_COLORS.custom
|
||||||
|
: categoryColor(agg.category);
|
||||||
|
const isHovered = hoveredCat === agg.category;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={agg.category}
|
||||||
|
style={{
|
||||||
|
width: `${widthPct}%`,
|
||||||
|
background: color,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: 11,
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: 500,
|
||||||
|
overflow: "hidden",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
cursor: "default",
|
||||||
|
outline: agg.isCustom ? "2px dashed #ea580c" : "none",
|
||||||
|
outlineOffset: -2,
|
||||||
|
opacity: hoveredCat && !isHovered ? 0.6 : 1,
|
||||||
|
transition: "opacity 0.15s",
|
||||||
|
}}
|
||||||
|
title={`${agg.category_ru}${agg.isCustom ? " (custom)" : ""}: ${fmtV(agg.contribution)}`}
|
||||||
|
onMouseEnter={() => setHoveredCat(agg.category)}
|
||||||
|
onMouseLeave={() => setHoveredCat(null)}
|
||||||
|
>
|
||||||
|
{widthPct >= 12 ? `${Math.round(widthPct)}%` : ""}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Negative bar */}
|
||||||
|
{negatives.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: "#6b7280",
|
||||||
|
marginTop: 10,
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Снижают балл
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 5,
|
||||||
|
overflow: "hidden",
|
||||||
|
border: "1px solid #fca5a5",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{negatives.map((agg) => {
|
||||||
|
const widthPct =
|
||||||
|
(Math.abs(agg.contribution) / totalNegative) * 100;
|
||||||
|
const color = categoryColor(agg.category);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={agg.category}
|
||||||
|
style={{
|
||||||
|
width: `${widthPct}%`,
|
||||||
|
background: color,
|
||||||
|
opacity: 0.6,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: 10,
|
||||||
|
color: "#fff",
|
||||||
|
overflow: "hidden",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
cursor: "default",
|
||||||
|
}}
|
||||||
|
title={`${agg.category_ru}: ${fmtV(agg.contribution)}`}
|
||||||
|
>
|
||||||
|
{widthPct >= 15 ? `${Math.round(widthPct)}%` : ""}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 10,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#6b7280",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{aggregated.map((agg) => {
|
||||||
|
const color = agg.isCustom
|
||||||
|
? CATEGORY_COLORS.custom
|
||||||
|
: categoryColor(agg.category);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={agg.category}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
cursor: "default",
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHoveredCat(agg.category)}
|
||||||
|
onMouseLeave={() => setHoveredCat(null)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 2,
|
||||||
|
background: color,
|
||||||
|
outline: agg.isCustom ? "1.5px dashed #ea580c" : "none",
|
||||||
|
outlineOffset: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{agg.category_ru}
|
||||||
|
{agg.isCustom && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: 3,
|
||||||
|
fontSize: 10,
|
||||||
|
color: "#ea580c",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
(custom)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
:{" "}
|
||||||
|
<strong
|
||||||
|
style={{
|
||||||
|
color: agg.contribution >= 0 ? "#16a34a" : "#dc2626",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fmtV(agg.contribution)}
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Source (factor) view — top 10 table */}
|
||||||
|
{groupBy === "source" && sourceAgg && (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
||||||
|
{sourceAgg.map(({ factor, contribution }) => {
|
||||||
|
const isCustom = customSet.has(factor);
|
||||||
|
const barWidth = Math.min(
|
||||||
|
100,
|
||||||
|
(Math.abs(contribution) /
|
||||||
|
(Math.abs(sourceAgg[0]?.contribution) || 1)) *
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
const color =
|
||||||
|
contribution >= 0
|
||||||
|
? isCustom
|
||||||
|
? CATEGORY_COLORS.custom
|
||||||
|
: "#16a34a"
|
||||||
|
: "#dc2626";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={factor}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 10 }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#374151",
|
||||||
|
width: 180,
|
||||||
|
flexShrink: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
title={factor}
|
||||||
|
>
|
||||||
|
{isCustom && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
color: "#ea580c",
|
||||||
|
fontWeight: 600,
|
||||||
|
marginRight: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
[custom]
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{factor}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
height: 14,
|
||||||
|
background: "#f3f4f6",
|
||||||
|
borderRadius: 3,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${barWidth}%`,
|
||||||
|
height: "100%",
|
||||||
|
background: color,
|
||||||
|
borderRadius: 3,
|
||||||
|
outline: isCustom ? "1.5px dashed #ea580c" : "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color,
|
||||||
|
width: 50,
|
||||||
|
textAlign: "right",
|
||||||
|
flexShrink: 0,
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fmtV(contribution)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{breakdown.length > 10 && (
|
||||||
|
<div style={{ fontSize: 11, color: "#9ca3af", marginTop: 4 }}>
|
||||||
|
Показаны топ-10 из {breakdown.length} факторов
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
MapContainer,
|
MapContainer,
|
||||||
TileLayer,
|
TileLayer,
|
||||||
GeoJSON,
|
GeoJSON,
|
||||||
CircleMarker,
|
CircleMarker,
|
||||||
Popup,
|
Popup,
|
||||||
|
useMapEvents,
|
||||||
} from "react-leaflet";
|
} from "react-leaflet";
|
||||||
import type { Feature, FeatureCollection, Geometry, Position } from "geojson";
|
import type { Feature, FeatureCollection, Geometry, Position } from "geojson";
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
|
|
@ -16,6 +17,7 @@ import type {
|
||||||
ConnectionPointsResponse,
|
ConnectionPointsResponse,
|
||||||
EngineeringStructure,
|
EngineeringStructure,
|
||||||
} from "@/types/nspd";
|
} from "@/types/nspd";
|
||||||
|
import type { CustomPoi } from "@/types/customPoi";
|
||||||
import {
|
import {
|
||||||
ConnectionPointsLayer,
|
ConnectionPointsLayer,
|
||||||
CP_ALL_CATEGORIES,
|
CP_ALL_CATEGORIES,
|
||||||
|
|
@ -23,6 +25,15 @@ import {
|
||||||
type CpCategory,
|
type CpCategory,
|
||||||
} from "@/components/site-finder/ConnectionPointsLayer";
|
} from "@/components/site-finder/ConnectionPointsLayer";
|
||||||
import { CpLayerControlPanel } from "@/components/site-finder/CpLayerControlPanel";
|
import { CpLayerControlPanel } from "@/components/site-finder/CpLayerControlPanel";
|
||||||
|
import { CustomPoiLayer } from "@/components/site-finder/CustomPoiLayer";
|
||||||
|
import { CustomPoiToggleButton } from "@/components/site-finder/CustomPoiToggleButton";
|
||||||
|
import { CustomPoiAddModal } from "@/components/site-finder/CustomPoiAddModal";
|
||||||
|
import { CustomPoiEditModal } from "@/components/site-finder/CustomPoiEditModal";
|
||||||
|
import {
|
||||||
|
useAddCustomPoi,
|
||||||
|
useUpdateCustomPoi,
|
||||||
|
useDeleteCustomPoi,
|
||||||
|
} from "@/hooks/useCustomPois";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// POI legend config (for the legend row below the map)
|
// POI legend config (for the legend row below the map)
|
||||||
|
|
@ -95,9 +106,37 @@ interface Props {
|
||||||
data: ParcelAnalysis;
|
data: ParcelAnalysis;
|
||||||
isochrones?: FeatureCollection;
|
isochrones?: FeatureCollection;
|
||||||
connectionPoints?: ConnectionPointsResponse;
|
connectionPoints?: ConnectionPointsResponse;
|
||||||
|
customPois?: CustomPoi[];
|
||||||
|
parcelCad?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SiteMap({ data, isochrones, connectionPoints }: Props) {
|
// ---------------------------------------------------------------------------
|
||||||
|
// Map click handler sub-component (must be inside MapContainer)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface MapClickHandlerProps {
|
||||||
|
editMode: boolean;
|
||||||
|
onMapClick: (lat: number, lon: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapClickHandler({ editMode, onMapClick }: MapClickHandlerProps) {
|
||||||
|
useMapEvents({
|
||||||
|
click(e) {
|
||||||
|
if (editMode) {
|
||||||
|
onMapClick(e.latlng.lat, e.latlng.lng);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SiteMap({
|
||||||
|
data,
|
||||||
|
isochrones,
|
||||||
|
connectionPoints,
|
||||||
|
customPois,
|
||||||
|
parcelCad,
|
||||||
|
}: Props) {
|
||||||
// Fix Leaflet default icon paths broken by webpack bundler
|
// Fix Leaflet default icon paths broken by webpack bundler
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void import("leaflet").then((L) => {
|
void import("leaflet").then((L) => {
|
||||||
|
|
@ -118,6 +157,33 @@ export function SiteMap({ data, isochrones, connectionPoints }: Props) {
|
||||||
new Set(CP_ALL_CATEGORIES),
|
new Set(CP_ALL_CATEGORIES),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Custom POI add mode state
|
||||||
|
const [addMode, setAddMode] = useState(false);
|
||||||
|
const [pendingCoords, setPendingCoords] = useState<{
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [editingPoi, setEditingPoi] = useState<CustomPoi | null>(null);
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const addMutation = useAddCustomPoi(parcelCad);
|
||||||
|
const updateMutation = useUpdateCustomPoi(parcelCad);
|
||||||
|
const deleteMutation = useDeleteCustomPoi(parcelCad);
|
||||||
|
|
||||||
|
// Keyboard ESC cancels add mode
|
||||||
|
const addModeRef = useRef(addMode);
|
||||||
|
addModeRef.current = addMode;
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape" && addModeRef.current) {
|
||||||
|
setAddMode(false);
|
||||||
|
setPendingCoords(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
function toggleCategory(cat: CpCategory) {
|
function toggleCategory(cat: CpCategory) {
|
||||||
setVisibleCategories((prev) => {
|
setVisibleCategories((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
|
|
@ -138,6 +204,11 @@ export function SiteMap({ data, isochrones, connectionPoints }: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleMapClick(lat: number, lon: number) {
|
||||||
|
setPendingCoords({ lat, lon });
|
||||||
|
setAddMode(false); // toggle off click mode; modal opens
|
||||||
|
}
|
||||||
|
|
||||||
const center: [number, number] = data.geom_geojson
|
const center: [number, number] = data.geom_geojson
|
||||||
? geomCenter(data.geom_geojson)
|
? geomCenter(data.geom_geojson)
|
||||||
: [56.838, 60.6];
|
: [56.838, 60.6];
|
||||||
|
|
@ -159,6 +230,7 @@ export function SiteMap({ data, isochrones, connectionPoints }: Props) {
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
height: 420,
|
height: 420,
|
||||||
|
cursor: addMode ? "crosshair" : "grab",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MapContainer
|
<MapContainer
|
||||||
|
|
@ -167,6 +239,7 @@ export function SiteMap({ data, isochrones, connectionPoints }: Props) {
|
||||||
style={{ height: "100%", width: "100%" }}
|
style={{ height: "100%", width: "100%" }}
|
||||||
scrollWheelZoom
|
scrollWheelZoom
|
||||||
>
|
>
|
||||||
|
<MapClickHandler editMode={addMode} onMapClick={handleMapClick} />
|
||||||
<TileLayer
|
<TileLayer
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OSM</a>'
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OSM</a>'
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
|
@ -272,9 +345,77 @@ export function SiteMap({ data, isochrones, connectionPoints }: Props) {
|
||||||
visibleCategories={visibleCategories}
|
visibleCategories={visibleCategories}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Custom POI markers — topmost layer */}
|
||||||
|
{customPois && customPois.length > 0 && (
|
||||||
|
<CustomPoiLayer
|
||||||
|
pois={customPois}
|
||||||
|
onEdit={(poi) => setEditingPoi(poi)}
|
||||||
|
onDelete={(id) => deleteMutation.mutate(id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Custom POI add-mode toggle button */}
|
||||||
|
<div
|
||||||
|
style={{ marginTop: 8, display: "flex", gap: 10, alignItems: "center" }}
|
||||||
|
>
|
||||||
|
<CustomPoiToggleButton
|
||||||
|
active={addMode}
|
||||||
|
onClick={() => {
|
||||||
|
setAddMode((v) => !v);
|
||||||
|
setPendingCoords(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{addMode && (
|
||||||
|
<span style={{ fontSize: 12, color: "#6b7280" }}>
|
||||||
|
Кликните на карте для добавления точки
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{customPois && customPois.length > 0 && (
|
||||||
|
<span style={{ fontSize: 12, color: "#374151", marginLeft: "auto" }}>
|
||||||
|
{customPois.length} польз.{" "}
|
||||||
|
{customPois.length === 1
|
||||||
|
? "точка"
|
||||||
|
: customPois.length < 5
|
||||||
|
? "точки"
|
||||||
|
: "точек"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add modal */}
|
||||||
|
{pendingCoords && (
|
||||||
|
<CustomPoiAddModal
|
||||||
|
lat={pendingCoords.lat}
|
||||||
|
lon={pendingCoords.lon}
|
||||||
|
parcelCad={parcelCad ?? null}
|
||||||
|
isLoading={addMutation.isPending}
|
||||||
|
onSubmit={(payload) => {
|
||||||
|
addMutation.mutate(payload, {
|
||||||
|
onSuccess: () => setPendingCoords(null),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onCancel={() => setPendingCoords(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit modal */}
|
||||||
|
{editingPoi && (
|
||||||
|
<CustomPoiEditModal
|
||||||
|
poi={editingPoi}
|
||||||
|
isLoading={updateMutation.isPending}
|
||||||
|
onSubmit={(updateData) => {
|
||||||
|
updateMutation.mutate(
|
||||||
|
{ id: editingPoi.id, data: updateData },
|
||||||
|
{ onSuccess: () => setEditingPoi(null) },
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onCancel={() => setEditingPoi(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* POI category legend */}
|
{/* POI category legend */}
|
||||||
{presentCategories.length > 0 && (
|
{presentCategories.length > 0 && (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
128
frontend/src/hooks/useCustomPois.ts
Normal file
128
frontend/src/hooks/useCustomPois.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import { getOrCreateSessionId } from "@/lib/sessionId";
|
||||||
|
import type {
|
||||||
|
CustomPoi,
|
||||||
|
CustomPoiCreate,
|
||||||
|
CustomPoiUpdate,
|
||||||
|
} from "@/types/customPoi";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function sessionHeaders(): Record<string, string> {
|
||||||
|
const id = getOrCreateSessionId();
|
||||||
|
return id ? { "X-Session-Id": id } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Query key factory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function customPoisKey(parcelCad?: string | null): unknown[] {
|
||||||
|
return ["custom-pois", parcelCad ?? null];
|
||||||
|
}
|
||||||
|
|
||||||
|
function analyzeKey(parcelCad: string): unknown[] {
|
||||||
|
return ["analyze", parcelCad];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hooks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List custom POIs, optionally filtered by parcel cad number.
|
||||||
|
*/
|
||||||
|
export function useCustomPois(parcelCad?: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: customPoisKey(parcelCad),
|
||||||
|
queryFn: () => {
|
||||||
|
const qs = parcelCad
|
||||||
|
? `?parcel_cad=${encodeURIComponent(parcelCad)}`
|
||||||
|
: "";
|
||||||
|
return apiFetch<CustomPoi[]>(`/api/v1/custom-pois${qs}`, {
|
||||||
|
headers: sessionHeaders(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// Don't auto-fetch until session available (SSR guard).
|
||||||
|
enabled: typeof window !== "undefined",
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a custom POI. Invalidates list + analyze query on success.
|
||||||
|
*/
|
||||||
|
export function useAddCustomPoi(parcelCad?: string | null) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: CustomPoiCreate) =>
|
||||||
|
apiFetch<CustomPoi>("/api/v1/custom-pois", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
headers: { ...sessionHeaders() },
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: customPoisKey(parcelCad),
|
||||||
|
});
|
||||||
|
// Invalidate global list too
|
||||||
|
void queryClient.invalidateQueries({ queryKey: customPoisKey(null) });
|
||||||
|
if (parcelCad) {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: analyzeKey(parcelCad) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a custom POI by id.
|
||||||
|
*/
|
||||||
|
export function useUpdateCustomPoi(parcelCad?: string | null) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: CustomPoiUpdate }) =>
|
||||||
|
apiFetch<CustomPoi>(`/api/v1/custom-pois/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
headers: { ...sessionHeaders() },
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: customPoisKey(parcelCad),
|
||||||
|
});
|
||||||
|
void queryClient.invalidateQueries({ queryKey: customPoisKey(null) });
|
||||||
|
if (parcelCad) {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: analyzeKey(parcelCad) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a custom POI by id.
|
||||||
|
*/
|
||||||
|
export function useDeleteCustomPoi(parcelCad?: string | null) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) =>
|
||||||
|
apiFetch<unknown>(`/api/v1/custom-pois/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: sessionHeaders(),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: customPoisKey(parcelCad),
|
||||||
|
});
|
||||||
|
void queryClient.invalidateQueries({ queryKey: customPoisKey(null) });
|
||||||
|
if (parcelCad) {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: analyzeKey(parcelCad) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
14
frontend/src/lib/sessionId.ts
Normal file
14
frontend/src/lib/sessionId.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
/**
|
||||||
|
* Session ID utility for custom POI auth.
|
||||||
|
* Stored in localStorage under "session_id" key.
|
||||||
|
* Returns empty string during SSR (window not available).
|
||||||
|
*/
|
||||||
|
export function getOrCreateSessionId(): string {
|
||||||
|
if (typeof window === "undefined") return "";
|
||||||
|
let id = localStorage.getItem("session_id");
|
||||||
|
if (!id) {
|
||||||
|
id = crypto.randomUUID();
|
||||||
|
localStorage.setItem("session_id", id);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
30
frontend/src/types/customPoi.ts
Normal file
30
frontend/src/types/customPoi.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
export interface CustomPoi {
|
||||||
|
id: number;
|
||||||
|
user_id: string;
|
||||||
|
parcel_cad: string | null;
|
||||||
|
name: string;
|
||||||
|
category: string | null;
|
||||||
|
weight: number;
|
||||||
|
lon: number;
|
||||||
|
lat: number;
|
||||||
|
notes: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomPoiCreate {
|
||||||
|
name: string;
|
||||||
|
category?: string | null;
|
||||||
|
weight: number;
|
||||||
|
lon: number;
|
||||||
|
lat: number;
|
||||||
|
parcel_cad?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomPoiUpdate {
|
||||||
|
name?: string;
|
||||||
|
category?: string | null;
|
||||||
|
weight?: number;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue