updated pek pages
This commit is contained in:
parent
7e3bc59581
commit
1829c1c4ad
23 changed files with 1283 additions and 1283 deletions
|
|
@ -145,8 +145,6 @@ function App() {
|
||||||
<Route path="position-documents" element={<FeatureViewPage view="position-documents" />} />
|
<Route path="position-documents" element={<FeatureViewPage view="position-documents" />} />
|
||||||
<Route path="expense-import" element={<FeatureViewPage view="expense-import" />} />
|
<Route path="expense-import" element={<FeatureViewPage view="expense-import" />} />
|
||||||
<Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} />
|
<Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} />
|
||||||
<Route path="projects" element={<FeatureViewPage view="projects" />} />
|
|
||||||
<Route path="parcels" element={<FeatureViewPage view="parcels" />} />
|
|
||||||
|
|
||||||
{/* Chat Playground Feature Views */}
|
{/* Chat Playground Feature Views */}
|
||||||
<Route path="playground" element={<FeatureViewPage view="playground" />} />
|
<Route path="playground" element={<FeatureViewPage view="playground" />} />
|
||||||
|
|
|
||||||
|
|
@ -47,3 +47,28 @@
|
||||||
max-width: 80%;
|
max-width: 80%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Subtle loading indicator for WFS parcel layer */
|
||||||
|
.wfsLoadingIndicator {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 2rem;
|
||||||
|
right: 2rem;
|
||||||
|
z-index: 500;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wfsLoadingSpinner {
|
||||||
|
display: block;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 2px solid rgba(107, 114, 128, 0.2);
|
||||||
|
border-top-color: #6b7280;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: wfsSpin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes wfsSpin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,18 @@ export interface ParcelGeometry {
|
||||||
number?: string;
|
number?: string;
|
||||||
coordinates: MapPoint[];
|
coordinates: MapPoint[];
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
isAdjacent?: boolean;
|
}
|
||||||
|
|
||||||
|
/** GeoJSON geometry for combined multi-parcel outline (LV95 coordinates) */
|
||||||
|
export interface CombinedOutlineGeojson {
|
||||||
|
type: 'Polygon' | 'MultiPolygon';
|
||||||
|
coordinates: number[][][] | number[][][][];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MapViewProps {
|
export interface MapViewProps {
|
||||||
parcels?: ParcelGeometry[];
|
parcels?: ParcelGeometry[];
|
||||||
|
/** Combined outline from backend when 2+ parcels selected */
|
||||||
|
combinedOutline?: CombinedOutlineGeojson;
|
||||||
center?: MapPoint;
|
center?: MapPoint;
|
||||||
zoomBounds?: {
|
zoomBounds?: {
|
||||||
min_x: number;
|
min_x: number;
|
||||||
|
|
@ -27,6 +34,10 @@ export interface MapViewProps {
|
||||||
height?: string;
|
height?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
|
/** Enable dynamic WFS parcel layer (loads parcels from viewport) */
|
||||||
|
showWfsParcels?: boolean;
|
||||||
|
/** Base URL for parcels API (default: "", uses same origin) */
|
||||||
|
parcelsApiBaseUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export the Leaflet implementation
|
// Re-export the Leaflet implementation
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,25 @@
|
||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { lv95ToWGS84, wgs84ToLV95 } from './LV95Converter';
|
import { lv95ToWGS84, wgs84ToLV95 } from './LV95Converter';
|
||||||
import type { MapViewProps } from './MapView';
|
import type { MapViewProps } from './MapView';
|
||||||
import styles from './MapView.module.css';
|
import styles from './MapView.module.css';
|
||||||
|
import api from '../../../api';
|
||||||
|
|
||||||
|
// Zurich canton bounds (LV95) for default initial view
|
||||||
|
const ZURICH_BOUNDS_LV95 = { minX: 2669500, minY: 1240500, maxX: 2695500, maxY: 1295500 };
|
||||||
|
const WFS_DEBOUNCE_MS = 150;
|
||||||
|
const WFS_MIN_ZOOM = 14; // Don't load parcels when zoomed out – they render as gray blobs
|
||||||
|
|
||||||
|
// Base layers: detail (OSM) vs minimal (for parcel view)
|
||||||
|
const TILE_LAYER_DETAIL = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© OpenStreetMap contributors',
|
||||||
|
maxZoom: 19
|
||||||
|
});
|
||||||
|
const TILE_LAYER_MINIMAL = L.tileLayer(
|
||||||
|
'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png',
|
||||||
|
{ attribution: '© OpenStreetMap © CARTO', maxZoom: 19 }
|
||||||
|
);
|
||||||
|
|
||||||
// Fix for default marker icons in Leaflet
|
// Fix for default marker icons in Leaflet
|
||||||
import icon from 'leaflet/dist/images/marker-icon.png';
|
import icon from 'leaflet/dist/images/marker-icon.png';
|
||||||
|
|
@ -20,42 +36,56 @@ const DefaultIcon = L.icon({
|
||||||
|
|
||||||
L.Marker.prototype.options.icon = DefaultIcon;
|
L.Marker.prototype.options.icon = DefaultIcon;
|
||||||
|
|
||||||
|
const SELECTED_STYLE = { color: '#3b82f6', weight: 3, fillColor: '#3b82f6', fillOpacity: 0.3 };
|
||||||
|
|
||||||
const MapViewLeaflet: React.FC<MapViewProps> = ({
|
const MapViewLeaflet: React.FC<MapViewProps> = ({
|
||||||
parcels = [],
|
parcels = [],
|
||||||
|
combinedOutline,
|
||||||
center,
|
center,
|
||||||
zoomBounds,
|
zoomBounds,
|
||||||
onMapClick,
|
onMapClick,
|
||||||
onParcelClick,
|
onParcelClick,
|
||||||
height = '600px',
|
height = '600px',
|
||||||
className = '',
|
className = '',
|
||||||
emptyMessage = 'Klicken Sie auf die Karte, um einen Standort auszuwählen'
|
emptyMessage = 'Klicken Sie auf die Karte, um einen Standort auszuwählen',
|
||||||
|
showWfsParcels = false,
|
||||||
|
parcelsApiBaseUrl = ''
|
||||||
}) => {
|
}) => {
|
||||||
const mapRef = useRef<L.Map | null>(null);
|
const mapRef = useRef<L.Map | null>(null);
|
||||||
const mapContainerRef = useRef<HTMLDivElement>(null);
|
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const layersRef = useRef<L.Layer[]>([]);
|
const layersRef = useRef<L.Layer[]>([]);
|
||||||
const centerMarkerRef = useRef<L.Marker | null>(null);
|
const centerMarkerRef = useRef<L.Marker | null>(null);
|
||||||
|
const parcelWfsLayerRef = useRef<L.GeoJSON | null>(null);
|
||||||
|
const baseLayerRef = useRef<L.TileLayer | null>(null);
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const lastBboxRef = useRef<string | null>(null);
|
||||||
|
const [isWfsLoading, setIsWfsLoading] = useState(false);
|
||||||
|
|
||||||
// Initialize map
|
// Initialize map
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapContainerRef.current || mapRef.current) return;
|
if (!mapContainerRef.current || mapRef.current) return;
|
||||||
|
|
||||||
// Default center: Switzerland (converted from LV95)
|
// Default center: Zurich canton (converted from LV95)
|
||||||
const defaultCenterLV95 = center || { x: 2600000, y: 1200000 };
|
const defaultCenterLV95 = center || { x: 2682500, y: 1248000 };
|
||||||
const defaultCenter = lv95ToWGS84(defaultCenterLV95.x, defaultCenterLV95.y);
|
const defaultCenter = lv95ToWGS84(defaultCenterLV95.x, defaultCenterLV95.y);
|
||||||
|
|
||||||
// Create map
|
// Create map (preferCanvas for smoother rendering of many parcel polygons)
|
||||||
const map = L.map(mapContainerRef.current, {
|
const map = L.map(mapContainerRef.current, {
|
||||||
center: [defaultCenter.lat, defaultCenter.lon],
|
center: [defaultCenter.lat, defaultCenter.lon],
|
||||||
zoom: zoomBounds ? 15 : 8, // Zoom level based on whether we have bounds
|
zoom: zoomBounds ? 15 : 10,
|
||||||
zoomControl: true,
|
zoomControl: true,
|
||||||
attributionControl: true
|
attributionControl: true,
|
||||||
|
preferCanvas: true
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add OpenStreetMap tiles
|
const initialLayer = TILE_LAYER_DETAIL;
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
initialLayer.addTo(map);
|
||||||
attribution: '© OpenStreetMap contributors',
|
baseLayerRef.current = initialLayer;
|
||||||
maxZoom: 19
|
|
||||||
}).addTo(map);
|
// Create pane for WFS parcels (background layer, below selected parcels)
|
||||||
|
const wfsPane = map.createPane('wfs-parcels');
|
||||||
|
wfsPane.style.zIndex = '200';
|
||||||
|
|
||||||
mapRef.current = map;
|
mapRef.current = map;
|
||||||
|
|
||||||
|
|
@ -63,9 +93,27 @@ const MapViewLeaflet: React.FC<MapViewProps> = ({
|
||||||
return () => {
|
return () => {
|
||||||
map.remove();
|
map.remove();
|
||||||
mapRef.current = null;
|
mapRef.current = null;
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
debounceTimerRef.current = null;
|
||||||
|
}
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
|
abortControllerRef.current = null;
|
||||||
};
|
};
|
||||||
}, []); // Only run once on mount
|
}, []); // Only run once on mount
|
||||||
|
|
||||||
|
// Swap base layer when parcel view toggled: minimal (no shops/labels) vs full detail
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapRef.current || !baseLayerRef.current) return;
|
||||||
|
const map = mapRef.current;
|
||||||
|
const current = baseLayerRef.current;
|
||||||
|
const next = showWfsParcels ? TILE_LAYER_MINIMAL : TILE_LAYER_DETAIL;
|
||||||
|
if (current === next) return;
|
||||||
|
map.removeLayer(current);
|
||||||
|
next.addTo(map);
|
||||||
|
baseLayerRef.current = next;
|
||||||
|
}, [showWfsParcels]);
|
||||||
|
|
||||||
// Update map center and zoom when center or zoomBounds change
|
// Update map center and zoom when center or zoomBounds change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapRef.current) return;
|
if (!mapRef.current) return;
|
||||||
|
|
@ -107,81 +155,85 @@ const MapViewLeaflet: React.FC<MapViewProps> = ({
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
centerMarkerRef.current = marker;
|
centerMarkerRef.current = marker;
|
||||||
} else {
|
} else {
|
||||||
// Default center: Switzerland
|
// Default: fit to canton Zurich
|
||||||
const defaultCenter = lv95ToWGS84(2600000, 1200000);
|
const sw = lv95ToWGS84(ZURICH_BOUNDS_LV95.minX, ZURICH_BOUNDS_LV95.minY);
|
||||||
map.setView([defaultCenter.lat, defaultCenter.lon], 8);
|
const ne = lv95ToWGS84(ZURICH_BOUNDS_LV95.maxX, ZURICH_BOUNDS_LV95.maxY);
|
||||||
|
map.fitBounds([[sw.lat, sw.lon], [ne.lat, ne.lon]], {
|
||||||
|
padding: [20, 20],
|
||||||
|
maxZoom: 12
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [center, zoomBounds, parcels.length]);
|
}, [center, zoomBounds, parcels.length]);
|
||||||
|
|
||||||
// Draw parcels
|
// Draw parcels (only selected - all blue) or combined outline when 2+
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapRef.current) return;
|
if (!mapRef.current) return;
|
||||||
|
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
|
|
||||||
// Debug logging
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('🗺️ MapView: Drawing parcels', {
|
|
||||||
parcelCount: parcels.length,
|
|
||||||
parcels: parcels.map(p => ({
|
|
||||||
id: p.id,
|
|
||||||
coordCount: p.coordinates.length,
|
|
||||||
isSelected: p.isSelected,
|
|
||||||
isAdjacent: p.isAdjacent
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove existing parcel layers
|
// Remove existing parcel layers
|
||||||
layersRef.current.forEach((layer) => {
|
layersRef.current.forEach((layer) => {
|
||||||
map.removeLayer(layer);
|
map.removeLayer(layer);
|
||||||
});
|
});
|
||||||
layersRef.current = [];
|
layersRef.current = [];
|
||||||
|
|
||||||
// Add parcels
|
const toWgs84 = (x: number, y: number) => {
|
||||||
parcels.forEach((parcel) => {
|
const w = lv95ToWGS84(x, y);
|
||||||
if (parcel.coordinates.length < 3) {
|
return [w.lat, w.lon] as [number, number];
|
||||||
if (import.meta.env.DEV) {
|
};
|
||||||
console.warn(`⚠️ Parcel ${parcel.id} has insufficient coordinates: ${parcel.coordinates.length}`);
|
|
||||||
}
|
|
||||||
return; // Need at least 3 points for a polygon
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert LV95 coordinates to WGS84
|
// When 2+ parcels and we have combined outline from backend, draw that only
|
||||||
const latLngs = parcel.coordinates.map((coord) => {
|
if (combinedOutline && parcels.length > 1 && combinedOutline.coordinates?.length) {
|
||||||
const wgs84 = lv95ToWGS84(coord.x, coord.y);
|
const geo = combinedOutline;
|
||||||
return [wgs84.lat, wgs84.lon] as [number, number];
|
if (geo.type === 'MultiPolygon') {
|
||||||
});
|
const coords = geo.coordinates as number[][][][];
|
||||||
|
coords.forEach((polyCoords) => {
|
||||||
// Create polygon
|
const ring = polyCoords[0];
|
||||||
const polygon = L.polygon(latLngs, {
|
if (ring && ring.length >= 3) {
|
||||||
color: parcel.isSelected ? '#3b82f6' : parcel.isAdjacent ? '#9ca3af' : '#22c55e',
|
const latLngs = ring.map(([x, y]) => toWgs84(x, y));
|
||||||
weight: parcel.isSelected ? 3 : parcel.isAdjacent ? 2 : 1,
|
const polygon = L.polygon(latLngs, SELECTED_STYLE);
|
||||||
fillColor: parcel.isSelected ? '#3b82f6' : parcel.isAdjacent ? '#9ca3af' : '#22c55e',
|
polygon.bindPopup('<div><strong>Ausgewählte Fläche</strong><br/><em>Zum Entfernen Parzelle im Panel nutzen</em></div>');
|
||||||
fillOpacity: parcel.isSelected ? 0.3 : parcel.isAdjacent ? 0.2 : 0.2
|
if (onParcelClick) {
|
||||||
});
|
polygon.on('click', () => onParcelClick('combined'));
|
||||||
|
}
|
||||||
// Add popup with parcel info
|
polygon.addTo(map);
|
||||||
const popupContent = `
|
layersRef.current.push(polygon);
|
||||||
<div>
|
}
|
||||||
<strong>Parzelle ${parcel.number || parcel.id}</strong><br/>
|
|
||||||
${parcel.egrid ? `EGRID: ${parcel.egrid}<br/>` : ''}
|
|
||||||
${parcel.isSelected ? '<em>Ausgewählt</em>' : parcel.isAdjacent ? '<em>Angrenzend</em>' : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
polygon.bindPopup(popupContent);
|
|
||||||
|
|
||||||
// Add click handler
|
|
||||||
if (onParcelClick) {
|
|
||||||
polygon.on('click', () => {
|
|
||||||
onParcelClick(parcel.id);
|
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
const ring = (geo.coordinates as number[][][])[0];
|
||||||
|
if (ring && ring.length >= 3) {
|
||||||
|
const latLngs = ring.map(([x, y]) => toWgs84(x, y));
|
||||||
|
const polygon = L.polygon(latLngs, SELECTED_STYLE);
|
||||||
|
polygon.bindPopup('<div><strong>Ausgewählte Fläche</strong><br/><em>Zum Entfernen Parzelle im Panel nutzen</em></div>');
|
||||||
|
if (onParcelClick) {
|
||||||
|
polygon.on('click', () => onParcelClick('combined'));
|
||||||
|
}
|
||||||
|
polygon.addTo(map);
|
||||||
|
layersRef.current.push(polygon);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
polygon.addTo(map);
|
// Single parcel or no combined outline: draw individual parcels (all selected)
|
||||||
layersRef.current.push(polygon);
|
parcels.forEach((parcel) => {
|
||||||
});
|
if (parcel.coordinates.length < 3) return;
|
||||||
}, [parcels, onParcelClick]);
|
const latLngs = parcel.coordinates.map((coord) => toWgs84(coord.x, coord.y));
|
||||||
|
const polygon = L.polygon(latLngs, SELECTED_STYLE);
|
||||||
|
polygon.bindPopup(`
|
||||||
|
<div>
|
||||||
|
<strong>Parzelle ${parcel.number || parcel.id}</strong><br/>
|
||||||
|
${parcel.egrid ? `EGRID: ${parcel.egrid}<br/>` : ''}
|
||||||
|
<em>Ausgewählt</em>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
if (onParcelClick) {
|
||||||
|
polygon.on('click', () => onParcelClick(parcel.id));
|
||||||
|
}
|
||||||
|
polygon.addTo(map);
|
||||||
|
layersRef.current.push(polygon);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [parcels, combinedOutline, onParcelClick]);
|
||||||
|
|
||||||
// Handle map clicks
|
// Handle map clicks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -202,6 +254,106 @@ const MapViewLeaflet: React.FC<MapViewProps> = ({
|
||||||
};
|
};
|
||||||
}, [onMapClick]);
|
}, [onMapClick]);
|
||||||
|
|
||||||
|
// Dynamic WFS parcel layer: load on moveend/zoomend when showWfsParcels
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapRef.current || !showWfsParcels) {
|
||||||
|
if (mapRef.current && parcelWfsLayerRef.current) {
|
||||||
|
mapRef.current.removeLayer(parcelWfsLayerRef.current);
|
||||||
|
parcelWfsLayerRef.current = null;
|
||||||
|
}
|
||||||
|
lastBboxRef.current = null;
|
||||||
|
setIsWfsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const map = mapRef.current;
|
||||||
|
|
||||||
|
const fetchWfsParcels = () => {
|
||||||
|
const zoom = map.getZoom();
|
||||||
|
if (zoom < WFS_MIN_ZOOM) {
|
||||||
|
if (parcelWfsLayerRef.current) {
|
||||||
|
map.removeLayer(parcelWfsLayerRef.current);
|
||||||
|
parcelWfsLayerRef.current = null;
|
||||||
|
}
|
||||||
|
lastBboxRef.current = null;
|
||||||
|
setIsWfsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bounds = map.getBounds();
|
||||||
|
const sw = bounds.getSouthWest();
|
||||||
|
const ne = bounds.getNorthEast();
|
||||||
|
const swLv95 = wgs84ToLV95(sw.lat, sw.lng);
|
||||||
|
const neLv95 = wgs84ToLV95(ne.lat, ne.lng);
|
||||||
|
const minX = Math.min(swLv95.x, neLv95.x);
|
||||||
|
const minY = Math.min(swLv95.y, neLv95.y);
|
||||||
|
const maxX = Math.max(swLv95.x, neLv95.x);
|
||||||
|
const maxY = Math.max(swLv95.y, neLv95.y);
|
||||||
|
const bbox = `${minX},${minY},${maxX},${maxY}`;
|
||||||
|
|
||||||
|
if (bbox === lastBboxRef.current) return;
|
||||||
|
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
const signal = abortControllerRef.current.signal;
|
||||||
|
setIsWfsLoading(true);
|
||||||
|
|
||||||
|
const url = `${parcelsApiBaseUrl}/api/realestate/parcel/wfs?bbox=${encodeURIComponent(bbox)}`;
|
||||||
|
api
|
||||||
|
.get(url, { signal })
|
||||||
|
.then((res) => {
|
||||||
|
const data = res.data as GeoJSON.FeatureCollection;
|
||||||
|
if (!data || data.type !== 'FeatureCollection') {
|
||||||
|
setIsWfsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const geoJsonLayer = L.geoJSON(data as GeoJSON.GeoJsonObject, {
|
||||||
|
style: { color: '#666', weight: 1, fillOpacity: 0.05 },
|
||||||
|
interactive: false,
|
||||||
|
pane: 'wfs-parcels'
|
||||||
|
});
|
||||||
|
geoJsonLayer.addTo(map);
|
||||||
|
const prevLayer = parcelWfsLayerRef.current;
|
||||||
|
if (prevLayer) map.removeLayer(prevLayer);
|
||||||
|
parcelWfsLayerRef.current = geoJsonLayer;
|
||||||
|
lastBboxRef.current = bbox;
|
||||||
|
setIsWfsLoading(false);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (err?.name !== 'CanceledError' && err?.message !== 'canceled') {
|
||||||
|
if (import.meta.env.DEV) console.warn('WFS parcels fetch failed:', err);
|
||||||
|
}
|
||||||
|
setIsWfsLoading(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleFetch = () => {
|
||||||
|
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
|
||||||
|
debounceTimerRef.current = setTimeout(fetchWfsParcels, WFS_DEBOUNCE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
scheduleFetch();
|
||||||
|
|
||||||
|
map.on('moveend', scheduleFetch);
|
||||||
|
map.on('zoomend', scheduleFetch);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.off('moveend', scheduleFetch);
|
||||||
|
map.off('zoomend', scheduleFetch);
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
debounceTimerRef.current = null;
|
||||||
|
}
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
|
if (parcelWfsLayerRef.current) {
|
||||||
|
map.removeLayer(parcelWfsLayerRef.current);
|
||||||
|
parcelWfsLayerRef.current = null;
|
||||||
|
}
|
||||||
|
setIsWfsLoading(false);
|
||||||
|
};
|
||||||
|
}, [showWfsParcels, parcelsApiBaseUrl]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.mapViewContainer} ${className}`} style={{ height, position: 'relative' }}>
|
<div className={`${styles.mapViewContainer} ${className}`} style={{ height, position: 'relative' }}>
|
||||||
<div ref={mapContainerRef} style={{ width: '100%', height: '100%' }} />
|
<div ref={mapContainerRef} style={{ width: '100%', height: '100%' }} />
|
||||||
|
|
@ -210,6 +362,11 @@ const MapViewLeaflet: React.FC<MapViewProps> = ({
|
||||||
<p>{emptyMessage}</p>
|
<p>{emptyMessage}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{showWfsParcels && isWfsLoading && (
|
||||||
|
<div className={styles.wfsLoadingIndicator} aria-hidden>
|
||||||
|
<span className={styles.wfsLoadingSpinner} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,44 @@
|
||||||
color: var(--color-error-dark, #dc2626);
|
color: var(--color-error-dark, #dc2626);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.aggregatedSection {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: var(--color-bg-secondary, #f9fafb);
|
||||||
|
border: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aggregatedTitle {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aggregatedValue {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bauzoneSection {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bauzoneTitle {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary, #3b82f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bauzoneArea {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
.parcelsList {
|
.parcelsList {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -241,6 +279,243 @@
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.setupSection {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
background-color: var(--color-bg-secondary, #f9fafb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setupHint {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setupButtons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bzoButton {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background-color: var(--color-primary, #3b82f6);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s, opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bzoButton:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-primary-dark, #2563eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bzoButton:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.setupMessage {
|
||||||
|
margin: 0.5rem 0 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bzoSection {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: var(--color-bg-secondary, #f9fafb);
|
||||||
|
border: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bzoHint {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: var(--color-bg, #fff);
|
||||||
|
border: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docIcon {
|
||||||
|
color: var(--color-primary, #3b82f6);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docLabel {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docOpenBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background-color: var(--color-primary, #3b82f6);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docOpenBtn:hover {
|
||||||
|
background-color: var(--color-primary-dark, #2563eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bzoSection .bzoButton {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bzoError {
|
||||||
|
margin: 0.5rem 0 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-error, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bzoResult {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bzoSummary {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bzoRules {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bzoFakten {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bzoSuggestions {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bzoMachbarkeit {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.machbarkeitSection {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.machbarkeitSection h5 {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.machbarkeitSection ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rulesList {
|
||||||
|
margin: 0.25rem 0 0 0;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceHint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflowSection {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
color: var(--color-primary, #3b82f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bzoZusatzinfo {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zusatzinfoList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zusatzinfoItem {
|
||||||
|
background-color: var(--color-bg-secondary, #f9fafb);
|
||||||
|
border: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zusatzinfoItem summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zusatzinfoItem summary:hover {
|
||||||
|
color: var(--color-primary, #3b82f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zusatzinfoText {
|
||||||
|
margin: 0.75rem 0 0 0;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.panel {
|
.panel {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,121 @@
|
||||||
import React from 'react';
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { FaTimes, FaTrash } from 'react-icons/fa';
|
import { FaTimes, FaTrash, FaFileAlt, FaSync, FaEye } from 'react-icons/fa';
|
||||||
|
import api from '../../../api';
|
||||||
|
import { ContentPreview } from '../../ContentPreview';
|
||||||
import styles from './ParcelInfoPanel.module.css';
|
import styles from './ParcelInfoPanel.module.css';
|
||||||
|
|
||||||
|
export interface SelectionSummary {
|
||||||
|
total_area_m2?: number;
|
||||||
|
bauzonen?: Array<{ bauzone: string; parcels: any[]; area_m2: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ParcelInfoPanelProps {
|
export interface ParcelInfoPanelProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
parcels: any[];
|
parcels: any[];
|
||||||
|
selectionSummary?: SelectionSummary | null;
|
||||||
onRemoveParcel?: (parcelId: string) => void;
|
onRemoveParcel?: (parcelId: string) => void;
|
||||||
adjacentParcels?: any[];
|
instanceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParcelDocument {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
fileId: string;
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BzoResult {
|
||||||
|
ai_summary?: string;
|
||||||
|
relevant_rules?: any[];
|
||||||
|
machbarkeitsstudie?: {
|
||||||
|
bauzone_rules?: Array<{ parameter: string; value: string; unit: string }>;
|
||||||
|
sonderregeln?: { apply: boolean; details: string };
|
||||||
|
machbarkeitsstudie?: Record<string, Array<{ item: string; value: string }>>;
|
||||||
|
vorschlaege?: string[];
|
||||||
|
};
|
||||||
|
errors?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
|
const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
parcels,
|
parcels,
|
||||||
|
selectionSummary,
|
||||||
onRemoveParcel,
|
onRemoveParcel,
|
||||||
adjacentParcels = []
|
instanceId
|
||||||
}) => {
|
}) => {
|
||||||
|
const [parcelDocs, setParcelDocs] = useState<Record<string, ParcelDocument[]>>({});
|
||||||
|
const [docsLoading, setDocsLoading] = useState<Record<string, boolean>>({});
|
||||||
|
const [docsError, setDocsError] = useState<Record<string, string>>({});
|
||||||
|
const [extractResults, setExtractResults] = useState<Record<string, BzoResult | null>>({});
|
||||||
|
const [extractLoading, setExtractLoading] = useState<Record<string, boolean>>({});
|
||||||
|
const [extractError, setExtractError] = useState<Record<string, string>>({});
|
||||||
|
const [previewDoc, setPreviewDoc] = useState<{ fileId: string; fileName: string; mimeType: string } | null>(null);
|
||||||
|
|
||||||
|
const fetchParcelDocuments = useCallback(async (parcelId: string, gemeinde: string, bauzone: string) => {
|
||||||
|
if (!instanceId || !gemeinde || !bauzone) return;
|
||||||
|
setDocsLoading(prev => ({ ...prev, [parcelId]: true }));
|
||||||
|
setDocsError(prev => ({ ...prev, [parcelId]: '' }));
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/api/realestate/${instanceId}/parcel-documents`, {
|
||||||
|
params: { gemeinde, bauzone }
|
||||||
|
});
|
||||||
|
setParcelDocs(prev => ({ ...prev, [parcelId]: res.data?.documents || [] }));
|
||||||
|
if (res.data?.error) {
|
||||||
|
setDocsError(prev => ({ ...prev, [parcelId]: res.data.error }));
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setDocsError(prev => ({
|
||||||
|
...prev,
|
||||||
|
[parcelId]: e?.response?.data?.detail || e?.message || 'Fehler beim Laden'
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
setDocsLoading(prev => ({ ...prev, [parcelId]: false }));
|
||||||
|
}
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
const runExtraction = useCallback(async (
|
||||||
|
parcelId: string,
|
||||||
|
gemeinde: string,
|
||||||
|
bauzone: string,
|
||||||
|
totalAreaM2?: number
|
||||||
|
) => {
|
||||||
|
if (!instanceId || !gemeinde || !bauzone) return;
|
||||||
|
setExtractLoading(prev => ({ ...prev, [parcelId]: true }));
|
||||||
|
setExtractError(prev => ({ ...prev, [parcelId]: '' }));
|
||||||
|
setExtractResults(prev => ({ ...prev, [parcelId]: null }));
|
||||||
|
try {
|
||||||
|
const params: Record<string, string | number> = { gemeinde, bauzone };
|
||||||
|
if (totalAreaM2 != null && totalAreaM2 > 0) {
|
||||||
|
params.total_area_m2 = totalAreaM2;
|
||||||
|
}
|
||||||
|
const res = await api.get(`/api/realestate/${instanceId}/bzo-information`, { params });
|
||||||
|
setExtractResults(prev => ({ ...prev, [parcelId]: res.data }));
|
||||||
|
} catch (e: any) {
|
||||||
|
setExtractError(prev => ({
|
||||||
|
...prev,
|
||||||
|
[parcelId]: e?.response?.data?.detail || e?.message || 'Fehler bei der Extraktion'
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
setExtractLoading(prev => ({ ...prev, [parcelId]: false }));
|
||||||
|
}
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !instanceId) return;
|
||||||
|
parcels.forEach((p) => {
|
||||||
|
const g = p?.parcel?.municipality_name;
|
||||||
|
const b = p?.parcel?.bauzone;
|
||||||
|
const id = p?.parcel?.id;
|
||||||
|
if (id && g && b && !parcelDocs[id]?.length && !docsLoading[id]) {
|
||||||
|
fetchParcelDocuments(id, g, b);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [isOpen, instanceId, parcels, fetchParcelDocuments]);
|
||||||
|
|
||||||
if (!parcels || parcels.length === 0) return null;
|
if (!parcels || parcels.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -50,13 +148,42 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
{/* Selected Parcels List */}
|
{selectionSummary?.total_area_m2 != null && (
|
||||||
|
<div className={styles.aggregatedSection}>
|
||||||
|
<h3 className={styles.aggregatedTitle}>Gesamtfläche</h3>
|
||||||
|
<p className={styles.aggregatedValue}>
|
||||||
|
{selectionSummary.total_area_m2.toFixed(2)} m²
|
||||||
|
{selectionSummary.total_area_m2 >= 10000 && (
|
||||||
|
<span className={styles.subValue}>
|
||||||
|
{' '}({(selectionSummary.total_area_m2 / 10000).toFixed(2)} ha)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Selected Parcels by Bauzone or flat list */}
|
||||||
<div className={styles.parcelsList}>
|
<div className={styles.parcelsList}>
|
||||||
{parcels.map((parcelData, index) => (
|
{(selectionSummary?.bauzonen && selectionSummary.bauzonen.length > 0
|
||||||
<section key={parcelData.parcel.id || index} className={styles.section}>
|
? selectionSummary.bauzonen.flatMap((bz) => {
|
||||||
|
const parcelsInZone = parcels.filter((p) =>
|
||||||
|
bz.parcels.some((bp: any) => (bp.id || bp.parcel?.id) === p.parcel.id)
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
<section key={`h-${bz.bauzone}`} className={styles.bauzoneSection}>
|
||||||
|
<h4 className={styles.bauzoneTitle}>
|
||||||
|
Bauzone {bz.bauzone}
|
||||||
|
{bz.area_m2 != null && (
|
||||||
|
<span className={styles.bauzoneArea}> — {bz.area_m2.toFixed(2)} m²</span>
|
||||||
|
)}
|
||||||
|
</h4>
|
||||||
|
</section>,
|
||||||
|
...parcelsInZone.map((parcelData, idx) => {
|
||||||
|
const areaForMach = bz?.area_m2 ?? parcelData.parcel.area_m2 ?? selectionSummary?.total_area_m2;
|
||||||
|
return (
|
||||||
|
<section key={parcelData.parcel.id} className={styles.section}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<h3 className={styles.sectionTitle}>
|
<h3 className={styles.sectionTitle}>
|
||||||
Parzelle {index + 1}: {parcelData.parcel.number || parcelData.parcel.id || 'Unbekannt'}
|
Parzelle {idx + 1}: {parcelData.parcel.number || parcelData.parcel.id || 'Unbekannt'}
|
||||||
</h3>
|
</h3>
|
||||||
{onRemoveParcel && (
|
{onRemoveParcel && (
|
||||||
<button
|
<button
|
||||||
|
|
@ -75,18 +202,6 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
|
||||||
<span className={styles.value}>{parcelData.parcel.id}</span>
|
<span className={styles.value}>{parcelData.parcel.id}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{parcelData.parcel.number && (
|
|
||||||
<div className={styles.infoItem}>
|
|
||||||
<span className={styles.label}>Nummer:</span>
|
|
||||||
<span className={styles.value}>{parcelData.parcel.number}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{parcelData.parcel.name && (
|
|
||||||
<div className={styles.infoItem}>
|
|
||||||
<span className={styles.label}>Name:</span>
|
|
||||||
<span className={styles.value}>{parcelData.parcel.name}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{parcelData.parcel.egrid && (
|
{parcelData.parcel.egrid && (
|
||||||
<div className={styles.infoItem}>
|
<div className={styles.infoItem}>
|
||||||
<span className={styles.label}>EGRID:</span>
|
<span className={styles.label}>EGRID:</span>
|
||||||
|
|
@ -148,6 +263,132 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
|
||||||
<span className={styles.value}>{parcelData.parcel.bauzone}</span>
|
<span className={styles.value}>{parcelData.parcel.bauzone}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{instanceId && parcelData.parcel.municipality_name && parcelData.parcel.bauzone && (docsLoading[parcelData.parcel.id] || (parcelDocs[parcelData.parcel.id] || []).length > 0) && (
|
||||||
|
<div className={styles.bzoSection}>
|
||||||
|
<h4 className={styles.subSectionTitle}>Bauzonenverordnung</h4>
|
||||||
|
{docsLoading[parcelData.parcel.id] && (
|
||||||
|
<p className={styles.bzoHint}>Dokumente werden geladen...</p>
|
||||||
|
)}
|
||||||
|
{docsError[parcelData.parcel.id] && (
|
||||||
|
<p className={styles.bzoError}>{docsError[parcelData.parcel.id]}</p>
|
||||||
|
)}
|
||||||
|
{(parcelDocs[parcelData.parcel.id] || []).length > 0 && (
|
||||||
|
<div className={styles.docList}>
|
||||||
|
{(parcelDocs[parcelData.parcel.id] || []).map((doc) => (
|
||||||
|
<div key={doc.id} className={styles.docItem}>
|
||||||
|
<FaFileAlt className={styles.docIcon} />
|
||||||
|
<span className={styles.docLabel}>{doc.label}</span>
|
||||||
|
<button
|
||||||
|
className={styles.docOpenBtn}
|
||||||
|
onClick={() => setPreviewDoc({
|
||||||
|
fileId: doc.fileId,
|
||||||
|
fileName: doc.fileName,
|
||||||
|
mimeType: doc.mimeType
|
||||||
|
})}
|
||||||
|
title="Dokument öffnen"
|
||||||
|
>
|
||||||
|
<FaEye /> Öffnen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(parcelDocs[parcelData.parcel.id] || []).length > 0 && (
|
||||||
|
<button
|
||||||
|
className={styles.bzoButton}
|
||||||
|
onClick={() => runExtraction(
|
||||||
|
parcelData.parcel.id,
|
||||||
|
parcelData.parcel.municipality_name,
|
||||||
|
parcelData.parcel.bauzone,
|
||||||
|
areaForMach
|
||||||
|
)}
|
||||||
|
disabled={extractLoading[parcelData.parcel.id]}
|
||||||
|
title="Inhalt mit LangGraph extrahieren (inkl. Machbarkeitsstudie)"
|
||||||
|
>
|
||||||
|
{extractLoading[parcelData.parcel.id] ? (
|
||||||
|
<FaSync className={styles.spin} />
|
||||||
|
) : (
|
||||||
|
<FaFileAlt />
|
||||||
|
)}
|
||||||
|
Inhalt extrahieren (LangGraph)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{extractError[parcelData.parcel.id] && (
|
||||||
|
<p className={styles.bzoError}>{extractError[parcelData.parcel.id]}</p>
|
||||||
|
)}
|
||||||
|
{extractResults[parcelData.parcel.id] && (
|
||||||
|
<div className={styles.bzoResult}>
|
||||||
|
{(() => {
|
||||||
|
const m = extractResults[parcelData.parcel.id]?.machbarkeitsstudie;
|
||||||
|
const fakten = m?.fakten ?? [];
|
||||||
|
const vorschlaege = m?.vorschlaege ?? [];
|
||||||
|
const zusatzinfo = m?.zusatzinformationen ?? [];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{fakten.length > 0 && (
|
||||||
|
<div className={styles.bzoFakten}>
|
||||||
|
<span className={styles.label}>Fakten aus BZO</span>
|
||||||
|
<ul className={styles.rulesList}>
|
||||||
|
{fakten.map((row: { item: string; value: string; source?: string }, i: number) => (
|
||||||
|
<li key={i}>
|
||||||
|
{row.item}: {row.value}
|
||||||
|
{row.source && (
|
||||||
|
<span className={styles.sourceHint} title={row.source}>
|
||||||
|
{' '}({row.source})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{vorschlaege.length > 0 && (
|
||||||
|
<div className={styles.bzoSuggestions}>
|
||||||
|
<span className={styles.label}>Vorschläge</span>
|
||||||
|
<ul className={styles.rulesList}>
|
||||||
|
{vorschlaege.map((row: { item: string; value: string; is_section?: boolean } | string, i: number) => {
|
||||||
|
if (typeof row === 'string') return <li key={i}>{row}</li>;
|
||||||
|
const r = row as { item: string; value: string; is_section?: boolean };
|
||||||
|
if (r.is_section) {
|
||||||
|
return <li key={i} className={styles.workflowSection}>{r.item}</li>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<li key={i}>
|
||||||
|
{r.value ? `${r.item}: ${r.value}` : r.item}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{zusatzinfo.length > 0 && (
|
||||||
|
<div className={styles.bzoZusatzinfo}>
|
||||||
|
<span className={styles.label}>Weiterführende Bestimmungen</span>
|
||||||
|
<div className={styles.zusatzinfoList}>
|
||||||
|
{zusatzinfo.map((art: { article_label: string; article_title: string; text: string; source?: string }, i: number) => (
|
||||||
|
<details key={i} className={styles.zusatzinfoItem}>
|
||||||
|
<summary>
|
||||||
|
{art.article_label} {art.article_title}
|
||||||
|
{art.source && <span className={styles.sourceHint}> ({art.source})</span>}
|
||||||
|
</summary>
|
||||||
|
<p className={styles.zusatzinfoText}>{art.text}</p>
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{(extractResults[parcelData.parcel.id]?.errors?.length ?? 0) > 0 && (
|
||||||
|
<p className={styles.bzoError}>
|
||||||
|
{(extractResults[parcelData.parcel.id]?.errors || []).join('; ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{parcelData.parcel.zone && Array.isArray(parcelData.parcel.zone) && parcelData.parcel.zone.length > 0 && (
|
{parcelData.parcel.zone && Array.isArray(parcelData.parcel.zone) && parcelData.parcel.zone.length > 0 && (
|
||||||
<div className={styles.infoItem}>
|
<div className={styles.infoItem}>
|
||||||
<span className={styles.label}>Zone:</span>
|
<span className={styles.label}>Zone:</span>
|
||||||
|
|
@ -182,11 +423,267 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{parcelData.parcel.centroid && (
|
{parcelData.parcel.geoportal_url && (
|
||||||
<div className={styles.infoItem}>
|
<div className={styles.infoItem}>
|
||||||
<span className={styles.label}>Zentrum (LV95):</span>
|
<span className={styles.label}>Geoportal:</span>
|
||||||
|
<a
|
||||||
|
href={parcelData.parcel.geoportal_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={styles.link}
|
||||||
|
>
|
||||||
|
Link öffnen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
];
|
||||||
|
})
|
||||||
|
: parcels.map((parcelData, index) => {
|
||||||
|
const areaForMachFlat = selectionSummary?.total_area_m2 ?? parcelData.parcel.area_m2;
|
||||||
|
return (
|
||||||
|
<section key={parcelData.parcel.id || index} className={styles.section}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<h3 className={styles.sectionTitle}>
|
||||||
|
Parzelle {index + 1}: {parcelData.parcel.number || parcelData.parcel.id || 'Unbekannt'}
|
||||||
|
</h3>
|
||||||
|
{onRemoveParcel && (
|
||||||
|
<button
|
||||||
|
className={styles.removeButton}
|
||||||
|
onClick={() => onRemoveParcel(parcelData.parcel.id)}
|
||||||
|
title="Parzelle entfernen"
|
||||||
|
>
|
||||||
|
<FaTrash />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.infoGrid}>
|
||||||
|
{parcelData.parcel.id && (
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<span className={styles.label}>ID:</span>
|
||||||
|
<span className={styles.value}>{parcelData.parcel.id}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{parcelData.parcel.egrid && (
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<span className={styles.label}>EGRID:</span>
|
||||||
|
<span className={styles.value}>{parcelData.parcel.egrid}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{parcelData.parcel.identnd && (
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<span className={styles.label}>IdentND:</span>
|
||||||
|
<span className={styles.value}>{parcelData.parcel.identnd}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{parcelData.parcel.address && (
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<span className={styles.label}>Adresse:</span>
|
||||||
|
<span className={styles.value}>{parcelData.parcel.address}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{parcelData.parcel.canton && (
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<span className={styles.label}>Kanton:</span>
|
||||||
|
<span className={styles.value}>{parcelData.parcel.canton}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{parcelData.parcel.municipality_name && (
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<span className={styles.label}>Gemeinde:</span>
|
||||||
|
<span className={styles.value}>{parcelData.parcel.municipality_name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{parcelData.parcel.municipality_code && (
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<span className={styles.label}>Gemeinde-Code:</span>
|
||||||
|
<span className={styles.value}>{parcelData.parcel.municipality_code}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{parcelData.parcel.area_m2 !== undefined && (
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<span className={styles.label}>Fläche:</span>
|
||||||
<span className={styles.value}>
|
<span className={styles.value}>
|
||||||
{parcelData.parcel.centroid.x.toFixed(2)}, {parcelData.parcel.centroid.y.toFixed(2)}
|
{parcelData.parcel.area_m2.toFixed(2)} m²
|
||||||
|
{parcelData.parcel.area_m2 >= 10000 && (
|
||||||
|
<span className={styles.subValue}>
|
||||||
|
{' '}({(parcelData.parcel.area_m2 / 10000).toFixed(2)} ha)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{parcelData.parcel.realestate_type && (
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<span className={styles.label}>Grundstückstyp:</span>
|
||||||
|
<span className={styles.value}>{parcelData.parcel.realestate_type}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{parcelData.parcel.bauzone && (
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<span className={styles.label}>Bauzone:</span>
|
||||||
|
<span className={styles.value}>{parcelData.parcel.bauzone}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{instanceId && parcelData.parcel.municipality_name && parcelData.parcel.bauzone && (docsLoading[parcelData.parcel.id] || (parcelDocs[parcelData.parcel.id] || []).length > 0) && (
|
||||||
|
<div className={styles.bzoSection}>
|
||||||
|
<h4 className={styles.subSectionTitle}>Bauzonenverordnung</h4>
|
||||||
|
{docsLoading[parcelData.parcel.id] && (
|
||||||
|
<p className={styles.bzoHint}>Dokumente werden geladen...</p>
|
||||||
|
)}
|
||||||
|
{docsError[parcelData.parcel.id] && (
|
||||||
|
<p className={styles.bzoError}>{docsError[parcelData.parcel.id]}</p>
|
||||||
|
)}
|
||||||
|
{(parcelDocs[parcelData.parcel.id] || []).length > 0 && (
|
||||||
|
<div className={styles.docList}>
|
||||||
|
{(parcelDocs[parcelData.parcel.id] || []).map((doc) => (
|
||||||
|
<div key={doc.id} className={styles.docItem}>
|
||||||
|
<FaFileAlt className={styles.docIcon} />
|
||||||
|
<span className={styles.docLabel}>{doc.label}</span>
|
||||||
|
<button
|
||||||
|
className={styles.docOpenBtn}
|
||||||
|
onClick={() => setPreviewDoc({
|
||||||
|
fileId: doc.fileId,
|
||||||
|
fileName: doc.fileName,
|
||||||
|
mimeType: doc.mimeType
|
||||||
|
})}
|
||||||
|
title="Dokument öffnen"
|
||||||
|
>
|
||||||
|
<FaEye /> Öffnen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(parcelDocs[parcelData.parcel.id] || []).length > 0 && (
|
||||||
|
<button
|
||||||
|
className={styles.bzoButton}
|
||||||
|
onClick={() => runExtraction(
|
||||||
|
parcelData.parcel.id,
|
||||||
|
parcelData.parcel.municipality_name,
|
||||||
|
parcelData.parcel.bauzone,
|
||||||
|
areaForMachFlat
|
||||||
|
)}
|
||||||
|
disabled={extractLoading[parcelData.parcel.id]}
|
||||||
|
title="Inhalt mit LangGraph extrahieren (inkl. Machbarkeitsstudie)"
|
||||||
|
>
|
||||||
|
{extractLoading[parcelData.parcel.id] ? (
|
||||||
|
<FaSync className={styles.spin} />
|
||||||
|
) : (
|
||||||
|
<FaFileAlt />
|
||||||
|
)}
|
||||||
|
Inhalt extrahieren (LangGraph)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{extractError[parcelData.parcel.id] && (
|
||||||
|
<p className={styles.bzoError}>{extractError[parcelData.parcel.id]}</p>
|
||||||
|
)}
|
||||||
|
{extractResults[parcelData.parcel.id] && (
|
||||||
|
<div className={styles.bzoResult}>
|
||||||
|
{(() => {
|
||||||
|
const m = extractResults[parcelData.parcel.id]?.machbarkeitsstudie;
|
||||||
|
const fakten = m?.fakten ?? [];
|
||||||
|
const vorschlaege = m?.vorschlaege ?? [];
|
||||||
|
const zusatzinfo = m?.zusatzinformationen ?? [];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{fakten.length > 0 && (
|
||||||
|
<div className={styles.bzoFakten}>
|
||||||
|
<span className={styles.label}>Fakten aus BZO</span>
|
||||||
|
<ul className={styles.rulesList}>
|
||||||
|
{fakten.map((row: { item: string; value: string; source?: string }, i: number) => (
|
||||||
|
<li key={i}>
|
||||||
|
{row.item}: {row.value}
|
||||||
|
{row.source && (
|
||||||
|
<span className={styles.sourceHint} title={row.source}>
|
||||||
|
{' '}({row.source})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{vorschlaege.length > 0 && (
|
||||||
|
<div className={styles.bzoSuggestions}>
|
||||||
|
<span className={styles.label}>Vorschläge</span>
|
||||||
|
<ul className={styles.rulesList}>
|
||||||
|
{vorschlaege.map((row: { item: string; value: string; is_section?: boolean } | string, i: number) => {
|
||||||
|
if (typeof row === 'string') return <li key={i}>{row}</li>;
|
||||||
|
const r = row as { item: string; value: string; is_section?: boolean };
|
||||||
|
if (r.is_section) {
|
||||||
|
return <li key={i} className={styles.workflowSection}>{r.item}</li>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<li key={i}>
|
||||||
|
{r.value ? `${r.item}: ${r.value}` : r.item}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{zusatzinfo.length > 0 && (
|
||||||
|
<div className={styles.bzoZusatzinfo}>
|
||||||
|
<span className={styles.label}>Weiterführende Bestimmungen</span>
|
||||||
|
<div className={styles.zusatzinfoList}>
|
||||||
|
{zusatzinfo.map((art: { article_label: string; article_title: string; text: string; source?: string }, i: number) => (
|
||||||
|
<details key={i} className={styles.zusatzinfoItem}>
|
||||||
|
<summary>
|
||||||
|
{art.article_label} {art.article_title}
|
||||||
|
{art.source && <span className={styles.sourceHint}> ({art.source})</span>}
|
||||||
|
</summary>
|
||||||
|
<p className={styles.zusatzinfoText}>{art.text}</p>
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{(extractResults[parcelData.parcel.id]?.errors?.length ?? 0) > 0 && (
|
||||||
|
<p className={styles.bzoError}>
|
||||||
|
{(extractResults[parcelData.parcel.id]?.errors || []).join('; ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{parcelData.parcel.zone && Array.isArray(parcelData.parcel.zone) && parcelData.parcel.zone.length > 0 && (
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<span className={styles.label}>Zone:</span>
|
||||||
|
<span className={styles.value}>
|
||||||
|
{parcelData.parcel.zone.length} Zone{parcelData.parcel.zone.length !== 1 ? 'n' : ''} gefunden
|
||||||
|
{(() => {
|
||||||
|
const zoneTypes = parcelData.parcel.zone
|
||||||
|
.map((z: any) => {
|
||||||
|
const attrs = z.attributes || {};
|
||||||
|
return attrs.typ || attrs.zone_typ || attrs.bauzone || attrs.zone || attrs.label || null;
|
||||||
|
})
|
||||||
|
.filter((t: string | null) => t !== null);
|
||||||
|
if (zoneTypes.length > 0) {
|
||||||
|
return (
|
||||||
|
<span className={styles.subValue}>
|
||||||
|
{' '}({zoneTypes.join(', ')})
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
|
{import.meta.env.DEV && (
|
||||||
|
<details className={styles.zoneDetails}>
|
||||||
|
<summary className={styles.zoneSummary}>Details anzeigen</summary>
|
||||||
|
<pre className={styles.zoneData}>
|
||||||
|
{JSON.stringify(parcelData.parcel.zone, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -204,67 +701,24 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Map View Info for this parcel */}
|
|
||||||
{parcelData.map_view && (
|
|
||||||
<div className={styles.mapViewSection}>
|
|
||||||
<h4 className={styles.subSectionTitle}>Kartenansicht</h4>
|
|
||||||
<div className={styles.infoGrid}>
|
|
||||||
{parcelData.map_view.center && (
|
|
||||||
<div className={styles.infoItem}>
|
|
||||||
<span className={styles.label}>Zentrum:</span>
|
|
||||||
<span className={styles.value}>
|
|
||||||
{parcelData.map_view.center.x.toFixed(2)}, {parcelData.map_view.center.y.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{parcelData.map_view.zoom_bounds && (
|
|
||||||
<>
|
|
||||||
<div className={styles.infoItem}>
|
|
||||||
<span className={styles.label}>Bounds Min:</span>
|
|
||||||
<span className={styles.value}>
|
|
||||||
{parcelData.map_view.zoom_bounds.min_x.toFixed(2)}, {parcelData.map_view.zoom_bounds.min_y.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.infoItem}>
|
|
||||||
<span className={styles.label}>Bounds Max:</span>
|
|
||||||
<span className={styles.value}>
|
|
||||||
{parcelData.map_view.zoom_bounds.max_x.toFixed(2)}, {parcelData.map_view.zoom_bounds.max_y.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
))}
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Adjacent Parcels */}
|
|
||||||
{adjacentParcels.length > 0 && (
|
|
||||||
<section className={styles.section}>
|
|
||||||
<h3 className={styles.sectionTitle}>
|
|
||||||
Angrenzende Parzellen ({adjacentParcels.length})
|
|
||||||
</h3>
|
|
||||||
<div className={styles.adjacentList}>
|
|
||||||
{adjacentParcels.map((adjacent, index) => (
|
|
||||||
<div key={adjacent.id || index} className={styles.adjacentItem}>
|
|
||||||
<div className={styles.adjacentHeader}>
|
|
||||||
<span className={styles.adjacentNumber}>
|
|
||||||
{adjacent.number || adjacent.id}
|
|
||||||
</span>
|
|
||||||
{adjacent.egrid && (
|
|
||||||
<span className={styles.adjacentEgrid}>{adjacent.egrid}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{previewDoc && (
|
||||||
|
<ContentPreview
|
||||||
|
isOpen={!!previewDoc}
|
||||||
|
onClose={() => setPreviewDoc(null)}
|
||||||
|
fileId={previewDoc.fileId}
|
||||||
|
fileName={previewDoc.fileName}
|
||||||
|
mimeType={previewDoc.mimeType}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React, { createContext, useContext, ReactNode } from 'react';
|
||||||
import { usePek } from '../hooks/usePek';
|
import { usePek } from '../hooks/usePek';
|
||||||
|
|
||||||
interface PekContextType {
|
interface PekContextType {
|
||||||
|
instanceId: string | undefined;
|
||||||
// Location input - separate fields
|
// Location input - separate fields
|
||||||
kanton: string;
|
kanton: string;
|
||||||
setKanton: (value: string) => void;
|
setKanton: (value: string) => void;
|
||||||
|
|
@ -30,8 +31,9 @@ interface PekContextType {
|
||||||
mapCenter: any;
|
mapCenter: any;
|
||||||
mapZoomBounds: any;
|
mapZoomBounds: any;
|
||||||
parcelGeometries: any[];
|
parcelGeometries: any[];
|
||||||
|
selectionSummary: any;
|
||||||
handleMapClick: (point: any) => Promise<void>;
|
handleMapClick: (point: any) => Promise<void>;
|
||||||
handleParcelClick: (parcelId: string) => Promise<void>;
|
handleParcelClick: (parcelId: string) => void;
|
||||||
|
|
||||||
// Command processing
|
// Command processing
|
||||||
commandInput: string;
|
commandInput: string;
|
||||||
|
|
@ -56,8 +58,8 @@ interface PekContextType {
|
||||||
|
|
||||||
const PekContext = createContext<PekContextType | undefined>(undefined);
|
const PekContext = createContext<PekContextType | undefined>(undefined);
|
||||||
|
|
||||||
export const PekProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
export const PekProvider: React.FC<{ children: ReactNode; instanceId?: string }> = ({ children, instanceId }) => {
|
||||||
const pekData = usePek();
|
const pekData = usePek(instanceId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PekContext.Provider value={pekData}>
|
<PekContext.Provider value={pekData}>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,3 @@
|
||||||
import { GenericPageData } from '../../../pageInterface';
|
import { GenericPageData } from '../../../pageInterface';
|
||||||
import { realEstateProjectsPageData } from './projects';
|
|
||||||
import { realEstateParcelsPageData } from './parcels';
|
|
||||||
|
|
||||||
export { realEstateProjectsPageData, realEstateParcelsPageData };
|
export const realEstatePages: GenericPageData[] = [];
|
||||||
|
|
||||||
export const realEstatePages: GenericPageData[] = [
|
|
||||||
realEstateProjectsPageData,
|
|
||||||
realEstateParcelsPageData,
|
|
||||||
];
|
|
||||||
|
|
|
||||||
|
|
@ -1,147 +0,0 @@
|
||||||
import { useCallback } from 'react';
|
|
||||||
import { GenericPageData } from '../../../pageInterface';
|
|
||||||
import { FaMapMarkerAlt, FaPlus } from 'react-icons/fa';
|
|
||||||
import { useRealEstateParcels, useRealEstateParcelOperations } from '../../../../../hooks/useRealEstate';
|
|
||||||
|
|
||||||
const attributesToColumns = (attributes: any[]) => {
|
|
||||||
return attributes.map(attr => {
|
|
||||||
const isDateField = attr.type === 'date' || attr.type === 'timestamp' ||
|
|
||||||
/(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name);
|
|
||||||
return {
|
|
||||||
key: attr.name,
|
|
||||||
label: attr.label || attr.name,
|
|
||||||
type: attr.type || 'string',
|
|
||||||
width: attr.width || 200,
|
|
||||||
minWidth: attr.minWidth || 100,
|
|
||||||
maxWidth: attr.maxWidth || 400,
|
|
||||||
sortable: attr.sortable !== false,
|
|
||||||
filterable: isDateField ? false : (attr.filterable !== false),
|
|
||||||
searchable: attr.searchable !== false,
|
|
||||||
filterOptions: attr.filterOptions,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const createParcelsHook = () => {
|
|
||||||
return () => {
|
|
||||||
const {
|
|
||||||
items: parcels,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
refetch,
|
|
||||||
removeOptimistically,
|
|
||||||
updateOptimistically,
|
|
||||||
attributes,
|
|
||||||
permissions,
|
|
||||||
fetchById,
|
|
||||||
generateEditFieldsFromAttributes,
|
|
||||||
generateCreateFieldsFromAttributes,
|
|
||||||
ensureAttributesLoaded,
|
|
||||||
} = useRealEstateParcels();
|
|
||||||
const {
|
|
||||||
handleDelete,
|
|
||||||
handleCreate,
|
|
||||||
handleUpdate,
|
|
||||||
deletingItems,
|
|
||||||
creatingItem,
|
|
||||||
deleteError,
|
|
||||||
createError,
|
|
||||||
updateError,
|
|
||||||
} = useRealEstateParcelOperations();
|
|
||||||
|
|
||||||
const generatedColumns = attributes && attributes.length > 0
|
|
||||||
? attributesToColumns(attributes)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const wrappedHandleCreate = useCallback(async (formData: any) => {
|
|
||||||
return await handleCreate(formData);
|
|
||||||
}, [handleCreate]);
|
|
||||||
|
|
||||||
const handleDeleteSingle = useCallback(async (item: any) => {
|
|
||||||
const success = await handleDelete(item.id);
|
|
||||||
if (success) refetch();
|
|
||||||
}, [handleDelete, refetch]);
|
|
||||||
|
|
||||||
const handleDeleteMultiple = useCallback(async (selectedItems: any[]) => {
|
|
||||||
const ids = selectedItems.map(item => item.id);
|
|
||||||
const results = await Promise.all(ids.map(id => handleDelete(id)));
|
|
||||||
if (results.every(Boolean)) refetch();
|
|
||||||
}, [handleDelete, refetch]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: parcels,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
refetch,
|
|
||||||
removeOptimistically,
|
|
||||||
updateOptimistically,
|
|
||||||
handleDelete,
|
|
||||||
handleDeleteMultiple,
|
|
||||||
handleCreate: wrappedHandleCreate,
|
|
||||||
handleUpdate,
|
|
||||||
onDelete: handleDeleteSingle,
|
|
||||||
onDeleteMultiple: handleDeleteMultiple,
|
|
||||||
deletingItems,
|
|
||||||
creatingItem,
|
|
||||||
deleteError,
|
|
||||||
createError,
|
|
||||||
updateError,
|
|
||||||
attributes,
|
|
||||||
permissions,
|
|
||||||
columns: generatedColumns,
|
|
||||||
fetchById,
|
|
||||||
generateEditFieldsFromAttributes,
|
|
||||||
generateCreateFieldsFromAttributes,
|
|
||||||
ensureAttributesLoaded,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const realEstateParcelsPageData: GenericPageData = {
|
|
||||||
id: 'realestate-parcels',
|
|
||||||
path: 'realestate/parcels',
|
|
||||||
name: 'realestate.parcels.title',
|
|
||||||
description: 'realestate.parcels.description',
|
|
||||||
parentPath: 'start.realestate',
|
|
||||||
icon: FaMapMarkerAlt,
|
|
||||||
title: 'realestate.parcels.title',
|
|
||||||
subtitle: 'realestate.parcels.subtitle',
|
|
||||||
headerButtons: [
|
|
||||||
{
|
|
||||||
id: 'new-parcel',
|
|
||||||
label: 'realestate.parcels.new_button',
|
|
||||||
icon: FaPlus,
|
|
||||||
variant: 'primary',
|
|
||||||
formConfig: {
|
|
||||||
fields: [
|
|
||||||
{ key: 'label', label: 'realestate.parcels.field.label', type: 'string', required: true },
|
|
||||||
{ key: 'strasseNr', label: 'realestate.parcels.field.strasseNr', type: 'string' },
|
|
||||||
{ key: 'plz', label: 'realestate.parcels.field.plz', type: 'string' },
|
|
||||||
],
|
|
||||||
popupTitle: 'realestate.parcels.modal.create.title',
|
|
||||||
popupSize: 'medium',
|
|
||||||
createOperationName: 'handleCreate',
|
|
||||||
successMessage: 'realestate.parcels.create.success',
|
|
||||||
errorMessage: 'realestate.parcels.create.error',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
id: 'parcels-table',
|
|
||||||
type: 'table',
|
|
||||||
tableConfig: {
|
|
||||||
hookFactory: createParcelsHook,
|
|
||||||
actionButtons: [
|
|
||||||
{ type: 'edit', operationName: 'handleUpdate', fetchItemFunctionName: 'fetchById' },
|
|
||||||
{ type: 'delete', operationName: 'handleDelete' },
|
|
||||||
],
|
|
||||||
className: 'realestate-parcels-table',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
moduleEnabled: true,
|
|
||||||
onActivate: () => { if (import.meta.env.DEV) console.log('RealEstate Parcels activated'); },
|
|
||||||
onLoad: () => { if (import.meta.env.DEV) console.log('RealEstate Parcels loaded'); },
|
|
||||||
onUnload: () => { if (import.meta.env.DEV) console.log('RealEstate Parcels unloaded'); },
|
|
||||||
};
|
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
import { useCallback } from 'react';
|
|
||||||
import { GenericPageData } from '../../../pageInterface';
|
|
||||||
import { FaBuilding, FaPlus } from 'react-icons/fa';
|
|
||||||
import { useRealEstateProjects, useRealEstateProjectOperations } from '../../../../../hooks/useRealEstate';
|
|
||||||
|
|
||||||
const attributesToColumns = (attributes: any[]) => {
|
|
||||||
return attributes.map(attr => {
|
|
||||||
const isDateField = attr.type === 'date' || attr.type === 'timestamp' ||
|
|
||||||
/(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name);
|
|
||||||
return {
|
|
||||||
key: attr.name,
|
|
||||||
label: attr.label || attr.name,
|
|
||||||
type: attr.type || 'string',
|
|
||||||
width: attr.width || 200,
|
|
||||||
minWidth: attr.minWidth || 100,
|
|
||||||
maxWidth: attr.maxWidth || 400,
|
|
||||||
sortable: attr.sortable !== false,
|
|
||||||
filterable: isDateField ? false : (attr.filterable !== false),
|
|
||||||
searchable: attr.searchable !== false,
|
|
||||||
filterOptions: attr.filterOptions,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const createProjectsHook = () => {
|
|
||||||
return () => {
|
|
||||||
const {
|
|
||||||
items: projects,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
refetch,
|
|
||||||
removeOptimistically,
|
|
||||||
updateOptimistically,
|
|
||||||
attributes,
|
|
||||||
permissions,
|
|
||||||
fetchById,
|
|
||||||
generateEditFieldsFromAttributes,
|
|
||||||
generateCreateFieldsFromAttributes,
|
|
||||||
ensureAttributesLoaded,
|
|
||||||
} = useRealEstateProjects();
|
|
||||||
const {
|
|
||||||
handleDelete,
|
|
||||||
handleCreate,
|
|
||||||
handleUpdate,
|
|
||||||
deletingItems,
|
|
||||||
creatingItem,
|
|
||||||
deleteError,
|
|
||||||
createError,
|
|
||||||
updateError,
|
|
||||||
} = useRealEstateProjectOperations();
|
|
||||||
|
|
||||||
const generatedColumns = attributes && attributes.length > 0
|
|
||||||
? attributesToColumns(attributes)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const wrappedHandleCreate = useCallback(async (formData: any) => {
|
|
||||||
return await handleCreate(formData);
|
|
||||||
}, [handleCreate]);
|
|
||||||
|
|
||||||
const handleDeleteSingle = useCallback(async (item: any) => {
|
|
||||||
const success = await handleDelete(item.id);
|
|
||||||
if (success) refetch();
|
|
||||||
}, [handleDelete, refetch]);
|
|
||||||
|
|
||||||
const handleDeleteMultiple = useCallback(async (selectedItems: any[]) => {
|
|
||||||
const ids = selectedItems.map(item => item.id);
|
|
||||||
const results = await Promise.all(ids.map(id => handleDelete(id)));
|
|
||||||
if (results.every(Boolean)) refetch();
|
|
||||||
}, [handleDelete, refetch]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: projects,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
refetch,
|
|
||||||
removeOptimistically,
|
|
||||||
updateOptimistically,
|
|
||||||
handleDelete,
|
|
||||||
handleDeleteMultiple,
|
|
||||||
handleCreate: wrappedHandleCreate,
|
|
||||||
handleUpdate,
|
|
||||||
onDelete: handleDeleteSingle,
|
|
||||||
onDeleteMultiple: handleDeleteMultiple,
|
|
||||||
deletingItems,
|
|
||||||
creatingItem,
|
|
||||||
deleteError,
|
|
||||||
createError,
|
|
||||||
updateError,
|
|
||||||
attributes,
|
|
||||||
permissions,
|
|
||||||
columns: generatedColumns,
|
|
||||||
fetchById,
|
|
||||||
generateEditFieldsFromAttributes,
|
|
||||||
generateCreateFieldsFromAttributes,
|
|
||||||
ensureAttributesLoaded,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const realEstateProjectsPageData: GenericPageData = {
|
|
||||||
id: 'realestate-projects',
|
|
||||||
path: 'realestate/projects',
|
|
||||||
name: 'realestate.projects.title',
|
|
||||||
description: 'realestate.projects.description',
|
|
||||||
parentPath: 'start.realestate',
|
|
||||||
icon: FaBuilding,
|
|
||||||
title: 'realestate.projects.title',
|
|
||||||
subtitle: 'realestate.projects.subtitle',
|
|
||||||
headerButtons: [
|
|
||||||
{
|
|
||||||
id: 'new-project',
|
|
||||||
label: 'realestate.projects.new_button',
|
|
||||||
icon: FaPlus,
|
|
||||||
variant: 'primary',
|
|
||||||
formConfig: {
|
|
||||||
fields: [
|
|
||||||
{ key: 'label', label: 'realestate.projects.field.label', type: 'string', required: true },
|
|
||||||
{ key: 'statusProzess', label: 'realestate.projects.field.statusProzess', type: 'string' },
|
|
||||||
],
|
|
||||||
popupTitle: 'realestate.projects.modal.create.title',
|
|
||||||
popupSize: 'medium',
|
|
||||||
createOperationName: 'handleCreate',
|
|
||||||
successMessage: 'realestate.projects.create.success',
|
|
||||||
errorMessage: 'realestate.projects.create.error',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
id: 'projects-table',
|
|
||||||
type: 'table',
|
|
||||||
tableConfig: {
|
|
||||||
hookFactory: createProjectsHook,
|
|
||||||
actionButtons: [
|
|
||||||
{ type: 'edit', operationName: 'handleUpdate', fetchItemFunctionName: 'fetchById' },
|
|
||||||
{ type: 'delete', operationName: 'handleDelete' },
|
|
||||||
],
|
|
||||||
className: 'realestate-projects-table',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
moduleEnabled: true,
|
|
||||||
onActivate: () => { if (import.meta.env.DEV) console.log('RealEstate Projects activated'); },
|
|
||||||
onLoad: () => { if (import.meta.env.DEV) console.log('RealEstate Projects loaded'); },
|
|
||||||
onUnload: () => { if (import.meta.env.DEV) console.log('RealEstate Projects unloaded'); },
|
|
||||||
};
|
|
||||||
|
|
@ -126,7 +126,7 @@ export interface AddParcelResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main PEK hook
|
// Main PEK hook
|
||||||
export function usePek() {
|
export function usePek(instanceId?: string) {
|
||||||
// Location input state - separate fields
|
// Location input state - separate fields
|
||||||
const [kanton, setKanton] = useState<string>('');
|
const [kanton, setKanton] = useState<string>('');
|
||||||
const [gemeinde, setGemeinde] = useState<string>('');
|
const [gemeinde, setGemeinde] = useState<string>('');
|
||||||
|
|
@ -162,6 +162,11 @@ export function usePek() {
|
||||||
max_y: number;
|
max_y: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [parcelGeometries, setParcelGeometries] = useState<ParcelGeometry[]>([]);
|
const [parcelGeometries, setParcelGeometries] = useState<ParcelGeometry[]>([]);
|
||||||
|
const [selectionSummary, setSelectionSummary] = useState<{
|
||||||
|
combinedOutline?: { type: 'Polygon' | 'MultiPolygon'; coordinates: number[][][] | number[][][][] };
|
||||||
|
total_area_m2?: number;
|
||||||
|
bauzonen?: Array<{ bauzone: string; parcels: any[]; area_m2: number }>;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// Command processing state
|
// Command processing state
|
||||||
const [commandInput, setCommandInput] = useState<string>('');
|
const [commandInput, setCommandInput] = useState<string>('');
|
||||||
|
|
@ -178,17 +183,50 @@ export function usePek() {
|
||||||
// Panel state
|
// Panel state
|
||||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||||
|
|
||||||
// Update parcel geometries when selected parcels change
|
// Derive parcelGeometries ONLY from selected parcels (deselected = not drawn)
|
||||||
// Ensure all selected parcels are marked as selected and not as adjacent
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const selectedParcelIds = new Set(selectedParcels.map(p => p.parcel.id));
|
const geometries: ParcelGeometry[] = selectedParcels.map(p => {
|
||||||
|
let coords: MapPoint[] = [];
|
||||||
setParcelGeometries(prev => prev.map(geo => {
|
const geo = p.map_view?.geometry_geojson?.geometry;
|
||||||
const isSelected = selectedParcelIds.has(geo.id);
|
if (geo?.coordinates?.[0]) {
|
||||||
// If parcel is selected, it should not be marked as adjacent
|
coords = geo.coordinates[0].map((c: number[]) => ({ x: c[0], y: c[1] }));
|
||||||
const isAdjacent = isSelected ? false : geo.isAdjacent;
|
} else if (p.parcel.perimeter?.punkte) {
|
||||||
return { ...geo, isSelected, isAdjacent };
|
coords = p.parcel.perimeter.punkte.map((pt: { x: number; y: number }) => ({ x: pt.x, y: pt.y }));
|
||||||
}));
|
}
|
||||||
|
return {
|
||||||
|
id: p.parcel.id,
|
||||||
|
egrid: p.parcel.egrid,
|
||||||
|
number: p.parcel.number,
|
||||||
|
coordinates: coords,
|
||||||
|
isSelected: true,
|
||||||
|
};
|
||||||
|
}).filter(g => g.coordinates.length >= 3);
|
||||||
|
setParcelGeometries(geometries);
|
||||||
|
}, [selectedParcels]);
|
||||||
|
|
||||||
|
// Fetch selection summary when selected parcels change
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedParcels.length === 0) {
|
||||||
|
setSelectionSummary(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
api.post('/api/realestate/parcel/selection-summary', { parcels: selectedParcels })
|
||||||
|
.then((res) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setSelectionSummary({
|
||||||
|
combinedOutline: res.data.combined_outline_geojson,
|
||||||
|
total_area_m2: res.data.total_area_m2,
|
||||||
|
bauzonen: res.data.bauzonen,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled && import.meta.env.DEV) {
|
||||||
|
console.warn('Selection summary fetch failed:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
}, [selectedParcels]);
|
}, [selectedParcels]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -216,7 +254,7 @@ export function usePek() {
|
||||||
const locationString = `${Math.round(lv95.x)},${Math.round(lv95.y)}`;
|
const locationString = `${Math.round(lv95.x)},${Math.round(lv95.y)}`;
|
||||||
|
|
||||||
// Directly search for parcel without updating input fields
|
// Directly search for parcel without updating input fields
|
||||||
await searchParcel(locationString, true);
|
await searchParcel(locationString);
|
||||||
resolve();
|
resolve();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setLocationError(err.message || 'Fehler beim Konvertieren der Koordinaten');
|
setLocationError(err.message || 'Fehler beim Konvertieren der Koordinaten');
|
||||||
|
|
@ -256,9 +294,8 @@ export function usePek() {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search for parcel by location (address or coordinates)
|
* Search for parcel by location (address or coordinates)
|
||||||
* Always includes adjacent parcels by default
|
|
||||||
*/
|
*/
|
||||||
const searchParcel = useCallback(async (location: string, includeAdjacent: boolean = true) => {
|
const searchParcel = useCallback(async (location: string) => {
|
||||||
if (!location.trim()) {
|
if (!location.trim()) {
|
||||||
setParcelSearchError('Bitte geben Sie einen Standort ein');
|
setParcelSearchError('Bitte geben Sie einen Standort ein');
|
||||||
return;
|
return;
|
||||||
|
|
@ -271,7 +308,7 @@ export function usePek() {
|
||||||
const response = await api.get('/api/realestate/parcel/search', {
|
const response = await api.get('/api/realestate/parcel/search', {
|
||||||
params: {
|
params: {
|
||||||
location: location.trim(),
|
location: location.trim(),
|
||||||
include_adjacent: includeAdjacent
|
include_adjacent: false
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -287,157 +324,14 @@ export function usePek() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add parcel to selected parcels array if not already selected
|
// First selection: replace with single parcel
|
||||||
// Update geometries within the callback to have access to updated selectedParcels
|
setSelectedParcels([data]);
|
||||||
setSelectedParcels(prev => {
|
if (data.map_view) {
|
||||||
const exists = prev.some(p => p.parcel.id === data.parcel.id);
|
setMapCenter(data.map_view.center);
|
||||||
if (exists) {
|
setMapZoomBounds(data.map_view.zoom_bounds);
|
||||||
return prev; // Already selected, don't add again
|
} else if (data.parcel.centroid) {
|
||||||
}
|
setMapCenter(data.parcel.centroid);
|
||||||
|
}
|
||||||
const updatedSelectedParcels = [...prev, data];
|
|
||||||
const selectedParcelIds = new Set(updatedSelectedParcels.map(p => p.parcel.id));
|
|
||||||
|
|
||||||
// Update geometries
|
|
||||||
setParcelGeometries(currentGeometries => {
|
|
||||||
const geometryMap = new Map<string, ParcelGeometry>();
|
|
||||||
|
|
||||||
// Keep existing geometries
|
|
||||||
currentGeometries.forEach(geo => {
|
|
||||||
geometryMap.set(geo.id, geo);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update map center and zoom bounds
|
|
||||||
if (data.map_view) {
|
|
||||||
setMapCenter(data.map_view.center);
|
|
||||||
setMapZoomBounds(data.map_view.zoom_bounds);
|
|
||||||
|
|
||||||
// Main parcel - use geometry_geojson if available, otherwise use perimeter.punkte
|
|
||||||
let mainParcelCoordinates: MapPoint[] = [];
|
|
||||||
|
|
||||||
if (data.map_view.geometry_geojson?.geometry?.coordinates) {
|
|
||||||
const coords = data.map_view.geometry_geojson.geometry.coordinates[0];
|
|
||||||
if (Array.isArray(coords)) {
|
|
||||||
mainParcelCoordinates = coords.map((coord: number[]) => ({
|
|
||||||
x: coord[0],
|
|
||||||
y: coord[1]
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} else if (data.parcel.perimeter?.punkte) {
|
|
||||||
mainParcelCoordinates = data.parcel.perimeter.punkte.map((p) => ({
|
|
||||||
x: p.x,
|
|
||||||
y: p.y
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mainParcelCoordinates.length > 0) {
|
|
||||||
geometryMap.set(data.parcel.id, {
|
|
||||||
id: data.parcel.id,
|
|
||||||
egrid: data.parcel.egrid,
|
|
||||||
number: data.parcel.number,
|
|
||||||
coordinates: mainParcelCoordinates,
|
|
||||||
isSelected: true,
|
|
||||||
isAdjacent: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add adjacent parcels, but skip if already selected
|
|
||||||
if (data.adjacent_parcels && includeAdjacent && data.adjacent_parcels.length > 0) {
|
|
||||||
data.adjacent_parcels.forEach((adjacent) => {
|
|
||||||
// Skip if this adjacent parcel is already selected
|
|
||||||
if (selectedParcelIds.has(adjacent.id)) {
|
|
||||||
// If it exists, mark as selected, not adjacent
|
|
||||||
const existingGeo = geometryMap.get(adjacent.id);
|
|
||||||
if (existingGeo) {
|
|
||||||
geometryMap.set(adjacent.id, {
|
|
||||||
...existingGeo,
|
|
||||||
isSelected: true,
|
|
||||||
isAdjacent: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log(`⏭️ Skipping adjacent parcel ${adjacent.id} - already selected`);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only add if not already in map
|
|
||||||
if (!geometryMap.has(adjacent.id)) {
|
|
||||||
let adjCoordinates: MapPoint[] = [];
|
|
||||||
|
|
||||||
if (adjacent.geometry_geojson?.geometry?.coordinates) {
|
|
||||||
const coords = adjacent.geometry_geojson.geometry.coordinates[0];
|
|
||||||
if (Array.isArray(coords) && coords.length > 0) {
|
|
||||||
adjCoordinates = coords.map((coord: number[]) => ({
|
|
||||||
x: coord[0],
|
|
||||||
y: coord[1]
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} else if (adjacent.perimeter?.punkte) {
|
|
||||||
adjCoordinates = adjacent.perimeter.punkte.map((p) => ({
|
|
||||||
x: p.x,
|
|
||||||
y: p.y
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (adjCoordinates.length >= 3) {
|
|
||||||
geometryMap.set(adjacent.id, {
|
|
||||||
id: adjacent.id,
|
|
||||||
egrid: adjacent.egrid,
|
|
||||||
number: adjacent.number,
|
|
||||||
coordinates: adjCoordinates,
|
|
||||||
isSelected: false,
|
|
||||||
isAdjacent: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// If no map_view, still try to use parcel data
|
|
||||||
if (data.parcel.perimeter?.punkte) {
|
|
||||||
const coordinates = data.parcel.perimeter.punkte.map((p) => ({
|
|
||||||
x: p.x,
|
|
||||||
y: p.y
|
|
||||||
}));
|
|
||||||
|
|
||||||
geometryMap.set(data.parcel.id, {
|
|
||||||
id: data.parcel.id,
|
|
||||||
egrid: data.parcel.egrid,
|
|
||||||
number: data.parcel.number,
|
|
||||||
coordinates,
|
|
||||||
isSelected: true,
|
|
||||||
isAdjacent: false
|
|
||||||
});
|
|
||||||
|
|
||||||
if (data.parcel.centroid) {
|
|
||||||
setMapCenter(data.parcel.centroid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update all geometries: mark selected ones and unmark adjacent for selected ones
|
|
||||||
const updatedGeometries = Array.from(geometryMap.values()).map(geo => {
|
|
||||||
const isSelected = selectedParcelIds.has(geo.id);
|
|
||||||
return {
|
|
||||||
...geo,
|
|
||||||
isSelected,
|
|
||||||
isAdjacent: isSelected ? false : geo.isAdjacent
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log(`🗺️ Total geometries to display: ${updatedGeometries.length}`, {
|
|
||||||
selected: updatedGeometries.filter(g => g.isSelected).length,
|
|
||||||
adjacent: updatedGeometries.filter(g => g.isAdjacent).length
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return updatedGeometries;
|
|
||||||
});
|
|
||||||
|
|
||||||
return updatedSelectedParcels;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Open panel when parcel is found
|
// Open panel when parcel is found
|
||||||
setIsPanelOpen(true);
|
setIsPanelOpen(true);
|
||||||
|
|
@ -454,15 +348,50 @@ export function usePek() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle map click - search for parcel at clicked coordinates
|
* Add an adjacent parcel to the selection. Only adjacent parcels can be added.
|
||||||
|
*/
|
||||||
|
const addAdjacentParcel = useCallback(async (location: { x: number; y: number }) => {
|
||||||
|
if (selectedParcels.length === 0) return { success: false, error: 'No selection' };
|
||||||
|
setIsSearchingParcel(true);
|
||||||
|
setParcelSearchError(null);
|
||||||
|
try {
|
||||||
|
const res = await api.post('/api/realestate/parcel/add-adjacent', {
|
||||||
|
location: { x: location.x, y: location.y },
|
||||||
|
selected_parcels: selectedParcels,
|
||||||
|
});
|
||||||
|
const data: ParcelSearchResponse = res.data;
|
||||||
|
const exists = selectedParcels.some(p => p.parcel.id === data.parcel.id);
|
||||||
|
if (!exists) {
|
||||||
|
setSelectedParcels(prev => [...prev, data]);
|
||||||
|
if (data.map_view?.zoom_bounds) {
|
||||||
|
setMapZoomBounds(data.map_view.zoom_bounds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsPanelOpen(true);
|
||||||
|
return { success: true, data };
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err.response?.data?.detail || err.message || 'Fehler beim Hinzufügen';
|
||||||
|
setParcelSearchError(msg);
|
||||||
|
return { success: false, error: msg };
|
||||||
|
} finally {
|
||||||
|
setIsSearchingParcel(false);
|
||||||
|
}
|
||||||
|
}, [selectedParcels]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle map click - search (first selection) or add adjacent (when selection exists)
|
||||||
*/
|
*/
|
||||||
const handleMapClick = useCallback(
|
const handleMapClick = useCallback(
|
||||||
async (point: MapPoint) => {
|
async (point: MapPoint) => {
|
||||||
const locationString = `${point.x},${point.y}`;
|
const locationString = `${point.x},${point.y}`;
|
||||||
setLocationInput(locationString);
|
setLocationInput(locationString);
|
||||||
await searchParcel(locationString, true); // Always include adjacent parcels
|
if (selectedParcels.length === 0) {
|
||||||
|
await searchParcel(locationString);
|
||||||
|
} else {
|
||||||
|
await addAdjacentParcel({ x: point.x, y: point.y });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[searchParcel]
|
[searchParcel, addAdjacentParcel, selectedParcels.length]
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -473,14 +402,10 @@ export function usePek() {
|
||||||
}, [selectedParcels]);
|
}, [selectedParcels]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a parcel from selection
|
* Remove a parcel from selection. parcelGeometries are derived from selectedParcels.
|
||||||
*/
|
*/
|
||||||
const removeParcel = useCallback((parcelId: string) => {
|
const removeParcel = useCallback((parcelId: string) => {
|
||||||
setSelectedParcels(prev => prev.filter(p => p.parcel.id !== parcelId));
|
setSelectedParcels(prev => prev.filter(p => p.parcel.id !== parcelId));
|
||||||
// Update geometries to reflect deselection
|
|
||||||
setParcelGeometries(prev => prev.map(geo =>
|
|
||||||
geo.id === parcelId ? { ...geo, isSelected: false } : geo
|
|
||||||
));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -488,55 +413,19 @@ export function usePek() {
|
||||||
*/
|
*/
|
||||||
const clearSelectedParcels = useCallback(() => {
|
const clearSelectedParcels = useCallback(() => {
|
||||||
setSelectedParcels([]);
|
setSelectedParcels([]);
|
||||||
// Update geometries to reflect deselection
|
|
||||||
setParcelGeometries(prev => prev.map(geo => ({ ...geo, isSelected: false })));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle parcel click on map - toggle parcel selection
|
* Handle parcel click on map: if panel is hidden, open it; if visible, remove parcel.
|
||||||
|
* For combined outline (parcelId === 'combined'), only opens panel, never removes.
|
||||||
*/
|
*/
|
||||||
const handleParcelClick = useCallback(async (parcelId: string) => {
|
const handleParcelClick = useCallback((parcelId: string) => {
|
||||||
// Check if parcel is already selected
|
if (!isPanelOpen) {
|
||||||
const isSelected = isParcelSelected(parcelId);
|
setIsPanelOpen(true);
|
||||||
|
} else if (parcelId !== 'combined') {
|
||||||
if (isSelected) {
|
|
||||||
// Remove from selection
|
|
||||||
removeParcel(parcelId);
|
removeParcel(parcelId);
|
||||||
} else {
|
|
||||||
// Find the clicked parcel in the geometries
|
|
||||||
const clickedParcel = parcelGeometries.find(p => p.id === parcelId);
|
|
||||||
|
|
||||||
if (clickedParcel && clickedParcel.coordinates.length > 0) {
|
|
||||||
// Use a point inside the parcel (first coordinate is always on the boundary, which is inside)
|
|
||||||
const firstCoord = clickedParcel.coordinates[0];
|
|
||||||
|
|
||||||
// Use first coordinate (guaranteed to be on/in the parcel) for search
|
|
||||||
const locationString = `${firstCoord.x},${firstCoord.y}`;
|
|
||||||
await searchParcel(locationString, true); // Always include adjacent parcels
|
|
||||||
} else {
|
|
||||||
// Fallback: try to search by parcel ID/EGRID if available
|
|
||||||
// Check all selected parcels for adjacent parcels
|
|
||||||
for (const selectedParcel of selectedParcels) {
|
|
||||||
if (selectedParcel.adjacent_parcels) {
|
|
||||||
const adjacentParcel = selectedParcel.adjacent_parcels.find(p => p.id === parcelId);
|
|
||||||
if (adjacentParcel?.egrid) {
|
|
||||||
// Search by EGRID
|
|
||||||
await searchParcel(adjacentParcel.egrid, true);
|
|
||||||
break;
|
|
||||||
} else if (adjacentParcel?.number) {
|
|
||||||
// Try searching by number (might need address context)
|
|
||||||
await searchParcel(adjacentParcel.number, true);
|
|
||||||
break;
|
|
||||||
} else if (adjacentParcel?.id) {
|
|
||||||
// Last resort: try searching by ID
|
|
||||||
await searchParcel(adjacentParcel.id, true);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [parcelGeometries, selectedParcels, isParcelSelected, removeParcel, searchParcel]);
|
}, [isPanelOpen, setIsPanelOpen, removeParcel]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process natural language command
|
* Process natural language command
|
||||||
|
|
@ -950,6 +839,7 @@ export function usePek() {
|
||||||
}, [kanton, gemeinde, adresse]);
|
}, [kanton, gemeinde, adresse]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
instanceId,
|
||||||
// Location input - separate fields
|
// Location input - separate fields
|
||||||
kanton,
|
kanton,
|
||||||
setKanton,
|
setKanton,
|
||||||
|
|
@ -978,6 +868,7 @@ export function usePek() {
|
||||||
mapCenter,
|
mapCenter,
|
||||||
mapZoomBounds,
|
mapZoomBounds,
|
||||||
parcelGeometries,
|
parcelGeometries,
|
||||||
|
selectionSummary,
|
||||||
handleMapClick,
|
handleMapClick,
|
||||||
handleParcelClick,
|
handleParcelClick,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,9 @@
|
||||||
|
|
||||||
.viewContent {
|
.viewContent {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import { TrusteeExpenseImportView } from './views/trustee/TrusteeExpenseImportVi
|
||||||
import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsView';
|
import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsView';
|
||||||
|
|
||||||
// RealEstate Views
|
// RealEstate Views
|
||||||
import { RealEstatePekView, RealEstateProjectsView, RealEstateParcelsView, RealEstateInstanceRolesPlaceholder } from './views/realestate';
|
import { RealEstatePekView, RealEstateInstanceRolesPlaceholder } from './views/realestate';
|
||||||
|
|
||||||
// Chat Playground Views (reusing existing workflow pages)
|
// Chat Playground Views (reusing existing workflow pages)
|
||||||
import { PlaygroundPage, WorkflowsPage } from './workflows';
|
import { PlaygroundPage, WorkflowsPage } from './workflows';
|
||||||
|
|
@ -110,8 +110,6 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
||||||
},
|
},
|
||||||
realestate: {
|
realestate: {
|
||||||
dashboard: RealEstatePekView,
|
dashboard: RealEstatePekView,
|
||||||
projects: RealEstateProjectsView,
|
|
||||||
parcels: RealEstateParcelsView,
|
|
||||||
'instance-roles': RealEstateInstanceRolesPlaceholder,
|
'instance-roles': RealEstateInstanceRolesPlaceholder,
|
||||||
},
|
},
|
||||||
chatplayground: {
|
chatplayground: {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import { useFeatureAccess, type FeatureInstance } from '../../hooks/useFeatureAccess';
|
import { useFeatureAccess, type FeatureInstance } from '../../hooks/useFeatureAccess';
|
||||||
import { useUserMandates, type Mandate } from '../../hooks/useUserMandates';
|
import { useUserMandates, type Mandate } from '../../hooks/useUserMandates';
|
||||||
|
import { useFeatureStore } from '../../stores/featureStore';
|
||||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { FaPlus, FaSync, FaCube, FaBuilding, FaCogs, FaEdit } from 'react-icons/fa';
|
import { FaPlus, FaSync, FaCube, FaBuilding, FaCogs, FaEdit } from 'react-icons/fa';
|
||||||
|
|
@ -35,6 +36,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
|
|
||||||
const { fetchMandates } = useUserMandates();
|
const { fetchMandates } = useUserMandates();
|
||||||
const { showSuccess, showError } = useToast();
|
const { showSuccess, showError } = useToast();
|
||||||
|
const { loadFeatures } = useFeatureStore();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [mandates, setMandates] = useState<Mandate[]>([]);
|
const [mandates, setMandates] = useState<Mandate[]>([]);
|
||||||
|
|
@ -171,6 +173,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
setChatbotSystemPrompt('');
|
setChatbotSystemPrompt('');
|
||||||
setChatbotEnableWebResearch(true);
|
setChatbotEnableWebResearch(true);
|
||||||
fetchInstances(selectedMandateId);
|
fetchInstances(selectedMandateId);
|
||||||
|
loadFeatures(); // Refresh global navigation cache
|
||||||
showSuccess('Feature-Instanz erstellt', `Die Instanz "${createLabel}" wurde erfolgreich erstellt.`);
|
showSuccess('Feature-Instanz erstellt', `Die Instanz "${createLabel}" wurde erfolgreich erstellt.`);
|
||||||
} else {
|
} else {
|
||||||
showError('Fehler', result.error || 'Fehler beim Erstellen der Feature-Instanz');
|
showError('Fehler', result.error || 'Fehler beim Erstellen der Feature-Instanz');
|
||||||
|
|
@ -267,6 +270,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
setChatbotConnectors(['preprocessor']);
|
setChatbotConnectors(['preprocessor']);
|
||||||
setChatbotSystemPrompt('');
|
setChatbotSystemPrompt('');
|
||||||
fetchInstances(selectedMandateId);
|
fetchInstances(selectedMandateId);
|
||||||
|
loadFeatures(); // Refresh global navigation cache
|
||||||
showSuccess('Feature-Instanz aktualisiert', `Die Instanz "${data.label}" wurde erfolgreich aktualisiert.`);
|
showSuccess('Feature-Instanz aktualisiert', `Die Instanz "${data.label}" wurde erfolgreich aktualisiert.`);
|
||||||
} else {
|
} else {
|
||||||
showError('Fehler', result.error || 'Fehler beim Aktualisieren der Feature-Instanz');
|
showError('Fehler', result.error || 'Fehler beim Aktualisieren der Feature-Instanz');
|
||||||
|
|
@ -281,6 +285,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
if (!selectedMandateId) return false;
|
if (!selectedMandateId) return false;
|
||||||
const result = await deleteInstance(selectedMandateId, instanceId);
|
const result = await deleteInstance(selectedMandateId, instanceId);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
loadFeatures(); // Refresh global navigation cache
|
||||||
showSuccess('Instanz gelöscht', 'Die Feature-Instanz wurde gelöscht.');
|
showSuccess('Instanz gelöscht', 'Die Feature-Instanz wurde gelöscht.');
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { FormGeneratorTable } from '../../components/FormGenerator/FormGenerator
|
||||||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { FaPlus, FaSync, FaUsers, FaBuilding, FaCube } from 'react-icons/fa';
|
import { FaPlus, FaSync, FaUsers, FaBuilding, FaCube } from 'react-icons/fa';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
|
import { useFeatureStore } from '../../stores/featureStore';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
|
||||||
|
|
@ -31,6 +32,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
|
|
||||||
const { fetchMandates } = useUserMandates();
|
const { fetchMandates } = useUserMandates();
|
||||||
const { showSuccess, showError } = useToast();
|
const { showSuccess, showError } = useToast();
|
||||||
|
const { loadFeatures } = useFeatureStore();
|
||||||
|
|
||||||
// Combined instance option type
|
// Combined instance option type
|
||||||
interface CombinedInstanceOption {
|
interface CombinedInstanceOption {
|
||||||
|
|
@ -303,6 +305,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setShowAddModal(false);
|
setShowAddModal(false);
|
||||||
refreshUsers();
|
refreshUsers();
|
||||||
|
loadFeatures(); // Refresh global navigation cache
|
||||||
showSuccess('Benutzer hinzugefügt', 'Der Benutzer wurde erfolgreich zur Feature-Instanz hinzugefügt.');
|
showSuccess('Benutzer hinzugefügt', 'Der Benutzer wurde erfolgreich zur Feature-Instanz hinzugefügt.');
|
||||||
} else {
|
} else {
|
||||||
showError('Fehler', result.error || 'Fehler beim Hinzufügen des Benutzers');
|
showError('Fehler', result.error || 'Fehler beim Hinzufügen des Benutzers');
|
||||||
|
|
@ -326,6 +329,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setEditingUser(null);
|
setEditingUser(null);
|
||||||
refreshUsers();
|
refreshUsers();
|
||||||
|
loadFeatures(); // Refresh global navigation cache
|
||||||
showSuccess('Eintrag aktualisiert', 'Rollen und Aktiv-Status wurden erfolgreich aktualisiert.');
|
showSuccess('Eintrag aktualisiert', 'Rollen und Aktiv-Status wurden erfolgreich aktualisiert.');
|
||||||
} else {
|
} else {
|
||||||
showError('Fehler', result.error || 'Fehler beim Aktualisieren');
|
showError('Fehler', result.error || 'Fehler beim Aktualisieren');
|
||||||
|
|
@ -341,6 +345,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
const result = await removeUserFromInstance(selectedMandateId, selectedInstanceId, user.userId);
|
const result = await removeUserFromInstance(selectedMandateId, selectedInstanceId, user.userId);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
refreshUsers();
|
refreshUsers();
|
||||||
|
loadFeatures(); // Refresh global navigation cache
|
||||||
showSuccess('Benutzer entfernt', `"${user.username}" wurde aus der Feature-Instanz entfernt.`);
|
showSuccess('Benutzer entfernt', `"${user.username}" wurde aus der Feature-Instanz entfernt.`);
|
||||||
} else {
|
} else {
|
||||||
showError('Fehler', result.error || 'Fehler beim Entfernen des Benutzers');
|
showError('Fehler', result.error || 'Fehler beim Entfernen des Benutzers');
|
||||||
|
|
|
||||||
|
|
@ -1,267 +0,0 @@
|
||||||
/**
|
|
||||||
* RealEstateParcelsView
|
|
||||||
*
|
|
||||||
* Parzellen-Verwaltung für eine Real Estate/PEK-Instanz.
|
|
||||||
* Verwendet FormGeneratorTable analog zu TrusteeDocumentsView.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useMemo, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
useRealEstateParcels,
|
|
||||||
useRealEstateParcelOperations,
|
|
||||||
type RealEstateParcel,
|
|
||||||
} from '../../../hooks/useRealEstate';
|
|
||||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
|
||||||
import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable';
|
|
||||||
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
|
|
||||||
import { FaSync, FaMapMarkerAlt } from 'react-icons/fa';
|
|
||||||
import styles from '../../admin/Admin.module.css';
|
|
||||||
|
|
||||||
export const RealEstateParcelsView: React.FC = () => {
|
|
||||||
const instanceId = useInstanceId();
|
|
||||||
|
|
||||||
const {
|
|
||||||
items: parcels,
|
|
||||||
attributes,
|
|
||||||
permissions,
|
|
||||||
pagination,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
refetch,
|
|
||||||
fetchById,
|
|
||||||
updateOptimistically,
|
|
||||||
removeOptimistically,
|
|
||||||
} = useRealEstateParcels();
|
|
||||||
|
|
||||||
const {
|
|
||||||
handleDelete,
|
|
||||||
handleCreate,
|
|
||||||
handleUpdate,
|
|
||||||
deletingItems,
|
|
||||||
} = useRealEstateParcelOperations();
|
|
||||||
|
|
||||||
const [editingParcel, setEditingParcel] = useState<RealEstateParcel | null>(null);
|
|
||||||
const [isCreateMode, setIsCreateMode] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (instanceId) {
|
|
||||||
refetch();
|
|
||||||
}
|
|
||||||
}, [instanceId, refetch]);
|
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
|
||||||
return (attributes || []).map(attr => ({
|
|
||||||
key: attr.name,
|
|
||||||
label: attr.label || attr.name,
|
|
||||||
type: attr.type as 'string' | 'number' | 'date' | 'boolean',
|
|
||||||
sortable: attr.sortable !== false,
|
|
||||||
filterable: attr.filterable !== false,
|
|
||||||
searchable: attr.searchable !== false,
|
|
||||||
width: attr.width || 150,
|
|
||||||
minWidth: attr.minWidth || 100,
|
|
||||||
maxWidth: attr.maxWidth || 400,
|
|
||||||
}));
|
|
||||||
}, [attributes]);
|
|
||||||
|
|
||||||
const canCreate = permissions?.create !== 'n';
|
|
||||||
const canUpdate = permissions?.update !== 'n';
|
|
||||||
const canDelete = permissions?.delete !== 'n';
|
|
||||||
|
|
||||||
const handleEditClick = async (parcel: RealEstateParcel) => {
|
|
||||||
const full = await fetchById(parcel.id);
|
|
||||||
if (full) {
|
|
||||||
setEditingParcel(full);
|
|
||||||
setIsCreateMode(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateClick = () => {
|
|
||||||
setEditingParcel(null);
|
|
||||||
setIsCreateMode(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFormSubmit = async (data: Partial<RealEstateParcel>) => {
|
|
||||||
if (isCreateMode) {
|
|
||||||
const result = await handleCreate(data);
|
|
||||||
if (result.success) {
|
|
||||||
setIsCreateMode(false);
|
|
||||||
refetch();
|
|
||||||
}
|
|
||||||
} else if (editingParcel) {
|
|
||||||
const result = await handleUpdate(editingParcel.id, data);
|
|
||||||
if (result.success) {
|
|
||||||
setEditingParcel(null);
|
|
||||||
refetch();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteParcel = async (parcel: RealEstateParcel) => {
|
|
||||||
removeOptimistically(parcel.id);
|
|
||||||
const success = await handleDelete(parcel.id);
|
|
||||||
if (!success) {
|
|
||||||
refetch();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseModal = () => {
|
|
||||||
setEditingParcel(null);
|
|
||||||
setIsCreateMode(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formAttributes = useMemo(() => {
|
|
||||||
const excluded = ['id', 'mandateId', 'instanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy'];
|
|
||||||
return (attributes || []).filter(attr => !excluded.includes(attr.name));
|
|
||||||
}, [attributes]);
|
|
||||||
|
|
||||||
const handleInlineUpdate = async (
|
|
||||||
itemId: string,
|
|
||||||
updateData: Partial<RealEstateParcel>,
|
|
||||||
row: RealEstateParcel
|
|
||||||
) => {
|
|
||||||
updateOptimistically(itemId, updateData);
|
|
||||||
const result = await handleUpdate(itemId, { ...row, ...updateData });
|
|
||||||
if (!result.success) {
|
|
||||||
refetch();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className={styles.adminPage}>
|
|
||||||
<div className={styles.errorContainer}>
|
|
||||||
<span className={styles.errorIcon}>⚠️</span>
|
|
||||||
<p className={styles.errorMessage}>Fehler beim Laden der Parzellen: {error}</p>
|
|
||||||
<button className={styles.secondaryButton} onClick={() => refetch()}>
|
|
||||||
<FaSync /> Erneut versuchen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.adminPage}>
|
|
||||||
<div className={styles.pageHeader}>
|
|
||||||
<div>
|
|
||||||
<p className={styles.pageSubtitle}>Parzellen verwalten</p>
|
|
||||||
</div>
|
|
||||||
<div className={styles.headerActions}>
|
|
||||||
<button
|
|
||||||
className={styles.secondaryButton}
|
|
||||||
onClick={() => refetch()}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
|
|
||||||
</button>
|
|
||||||
{canCreate && (
|
|
||||||
<button className={styles.primaryButton} onClick={handleCreateClick}>
|
|
||||||
+ Neue Parzelle
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.tableContainer}>
|
|
||||||
{loading && (!parcels || parcels.length === 0) ? (
|
|
||||||
<div className={styles.loadingContainer}>
|
|
||||||
<div className={styles.spinner} />
|
|
||||||
<span>Lade Parzellen...</span>
|
|
||||||
</div>
|
|
||||||
) : !parcels || parcels.length === 0 ? (
|
|
||||||
<div className={styles.emptyState}>
|
|
||||||
<FaMapMarkerAlt className={styles.emptyIcon} />
|
|
||||||
<h3 className={styles.emptyTitle}>Keine Parzellen vorhanden</h3>
|
|
||||||
<p className={styles.emptyDescription}>
|
|
||||||
Erstellen Sie eine neue Parzelle, um zu beginnen.
|
|
||||||
</p>
|
|
||||||
{canCreate && (
|
|
||||||
<button className={styles.primaryButton} onClick={handleCreateClick}>
|
|
||||||
+ Neue Parzelle
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<FormGeneratorTable
|
|
||||||
data={parcels}
|
|
||||||
columns={columns}
|
|
||||||
apiEndpoint={instanceId ? `/api/realestate/${instanceId}/parcels` : undefined}
|
|
||||||
loading={loading}
|
|
||||||
pagination={true}
|
|
||||||
pageSize={25}
|
|
||||||
searchable={true}
|
|
||||||
filterable={true}
|
|
||||||
sortable={true}
|
|
||||||
selectable={false}
|
|
||||||
actionButtons={[
|
|
||||||
...(canUpdate
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
type: 'edit' as const,
|
|
||||||
onAction: handleEditClick,
|
|
||||||
title: 'Bearbeiten',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(canDelete
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
type: 'delete' as const,
|
|
||||||
title: 'Löschen',
|
|
||||||
loading: (row: RealEstateParcel) => deletingItems.has(row.id),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
]}
|
|
||||||
onDelete={handleDeleteParcel}
|
|
||||||
hookData={{
|
|
||||||
refetch,
|
|
||||||
permissions,
|
|
||||||
pagination,
|
|
||||||
handleDelete,
|
|
||||||
handleInlineUpdate,
|
|
||||||
updateOptimistically,
|
|
||||||
}}
|
|
||||||
emptyMessage="Keine Parzellen gefunden"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(editingParcel || isCreateMode) && (
|
|
||||||
<div className={styles.modalOverlay} onClick={handleCloseModal}>
|
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
|
||||||
<div className={styles.modalHeader}>
|
|
||||||
<h2 className={styles.modalTitle}>
|
|
||||||
{isCreateMode ? 'Neue Parzelle' : 'Parzelle bearbeiten'}
|
|
||||||
</h2>
|
|
||||||
<button className={styles.modalClose} onClick={handleCloseModal}>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className={styles.modalContent}>
|
|
||||||
{formAttributes.length === 0 ? (
|
|
||||||
<div className={styles.loadingContainer}>
|
|
||||||
<div className={styles.spinner} />
|
|
||||||
<span>Lade Formular...</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<FormGeneratorForm
|
|
||||||
attributes={formAttributes}
|
|
||||||
data={editingParcel || {}}
|
|
||||||
mode={isCreateMode ? 'create' : 'edit'}
|
|
||||||
onSubmit={handleFormSubmit}
|
|
||||||
onCancel={handleCloseModal}
|
|
||||||
submitButtonText={isCreateMode ? 'Erstellen' : 'Speichern'}
|
|
||||||
cancelButtonText="Abbrechen"
|
|
||||||
instanceId={instanceId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RealEstateParcelsView;
|
|
||||||
|
|
@ -6,105 +6,27 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { IoMdSend } from 'react-icons/io';
|
import { PekProvider } from '../../../contexts/PekContext';
|
||||||
import { PekProvider, usePekContext } from '../../../contexts/PekContext';
|
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
import { Button, TextField } from '../../../components/UiComponents';
|
|
||||||
import PekLocationInput from './pek/PekLocationInput';
|
import PekLocationInput from './pek/PekLocationInput';
|
||||||
import PekMapView from './pek/PekMapView';
|
import PekMapView from './pek/PekMapView';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
|
||||||
import styles from '../trustee/TrusteeViews.module.css';
|
import styles from '../trustee/TrusteeViews.module.css';
|
||||||
|
|
||||||
function RealEstatePekViewContent() {
|
function RealEstatePekViewContent() {
|
||||||
const {
|
|
||||||
commandInput,
|
|
||||||
setCommandInput,
|
|
||||||
processCommand,
|
|
||||||
isProcessingCommand,
|
|
||||||
commandResults
|
|
||||||
} = usePekContext();
|
|
||||||
const { t } = useLanguage();
|
|
||||||
|
|
||||||
const onSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (commandInput.trim()) {
|
|
||||||
processCommand(commandInput.trim());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.dashboardView}>
|
<div className={`${styles.dashboardView} ${styles.dashboardViewFill}`}>
|
||||||
<p className={styles.muted} style={{ marginBottom: '1rem' }}>
|
|
||||||
{t('projects.description_text')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<PekLocationInput />
|
<PekLocationInput />
|
||||||
<PekMapView />
|
<PekMapView />
|
||||||
|
|
||||||
{/* Optional: Command input and results */}
|
|
||||||
<section style={{ marginTop: '2rem' }}>
|
|
||||||
<form onSubmit={onSubmit} className={styles.form} style={{ maxWidth: 600 }}>
|
|
||||||
<div className={styles.formField}>
|
|
||||||
<label htmlFor="pek-command">
|
|
||||||
{t('projects.command.placeholder')}
|
|
||||||
</label>
|
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-end' }}>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<TextField
|
|
||||||
id="pek-command"
|
|
||||||
value={commandInput}
|
|
||||||
onChange={setCommandInput}
|
|
||||||
placeholder={t('projects.command.placeholder')}
|
|
||||||
disabled={isProcessingCommand}
|
|
||||||
size="md"
|
|
||||||
type="text"
|
|
||||||
name="command"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="primary"
|
|
||||||
size="md"
|
|
||||||
icon={IoMdSend}
|
|
||||||
disabled={!commandInput.trim() || isProcessingCommand}
|
|
||||||
loading={isProcessingCommand}
|
|
||||||
>
|
|
||||||
Senden
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{commandResults.length > 0 && (
|
|
||||||
<div style={{ marginTop: '1rem', maxWidth: 800 }}>
|
|
||||||
<h3 style={{ fontSize: '1rem', marginBottom: '0.5rem' }}>Antworten</h3>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
|
||||||
{commandResults.map((msg: any) => (
|
|
||||||
<div
|
|
||||||
key={msg.id}
|
|
||||||
style={{
|
|
||||||
padding: '0.75rem 1rem',
|
|
||||||
background: 'var(--surface-color, #f8f9fa)',
|
|
||||||
border: '1px solid var(--border-color, #e0e0e0)',
|
|
||||||
borderRadius: 8,
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
whiteSpace: 'pre-wrap'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong>{msg.role === 'user' ? 'Sie' : 'Assistent'}:</strong>{' '}
|
|
||||||
{typeof msg.message === 'string' ? msg.message : JSON.stringify(msg.message)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RealEstatePekView: React.FC = () => {
|
export const RealEstatePekView: React.FC = () => {
|
||||||
|
const instanceId = useInstanceId();
|
||||||
return (
|
return (
|
||||||
<PekProvider>
|
<PekProvider instanceId={instanceId}>
|
||||||
<RealEstatePekViewContent />
|
<RealEstatePekViewContent />
|
||||||
</PekProvider>
|
</PekProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,224 +0,0 @@
|
||||||
/**
|
|
||||||
* RealEstateProjectsView
|
|
||||||
*
|
|
||||||
* Projekt-Verwaltung für eine Real Estate/PEK-Instanz.
|
|
||||||
* Verwendet FormGeneratorTable analog zu TrusteeDocumentsView.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useMemo, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
useRealEstateProjects,
|
|
||||||
useRealEstateProjectOperations,
|
|
||||||
type RealEstateProject,
|
|
||||||
} from '../../../hooks/useRealEstate';
|
|
||||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
|
||||||
import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable';
|
|
||||||
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
|
|
||||||
import { FaSync, FaBuilding } from 'react-icons/fa';
|
|
||||||
import styles from '../../admin/Admin.module.css';
|
|
||||||
|
|
||||||
export const RealEstateProjectsView: React.FC = () => {
|
|
||||||
const instanceId = useInstanceId();
|
|
||||||
|
|
||||||
const {
|
|
||||||
items: projects,
|
|
||||||
attributes,
|
|
||||||
permissions,
|
|
||||||
pagination,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
refetch,
|
|
||||||
fetchById,
|
|
||||||
updateOptimistically,
|
|
||||||
removeOptimistically,
|
|
||||||
} = useRealEstateProjects();
|
|
||||||
|
|
||||||
const {
|
|
||||||
handleDelete,
|
|
||||||
handleCreate,
|
|
||||||
handleUpdate,
|
|
||||||
deletingItems,
|
|
||||||
} = useRealEstateProjectOperations();
|
|
||||||
|
|
||||||
const [editingProject, setEditingProject] = useState<RealEstateProject | null>(null);
|
|
||||||
const [isCreateMode, setIsCreateMode] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (instanceId) refetch();
|
|
||||||
}, [instanceId, refetch]);
|
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
|
||||||
return (attributes || []).map(attr => ({
|
|
||||||
key: attr.name,
|
|
||||||
label: attr.label || attr.name,
|
|
||||||
type: (attr.type || 'string') as 'string' | 'number' | 'date' | 'boolean',
|
|
||||||
sortable: attr.sortable !== false,
|
|
||||||
filterable: attr.filterable !== false,
|
|
||||||
searchable: attr.searchable !== false,
|
|
||||||
width: attr.width || 150,
|
|
||||||
minWidth: attr.minWidth || 100,
|
|
||||||
maxWidth: attr.maxWidth || 400,
|
|
||||||
}));
|
|
||||||
}, [attributes]);
|
|
||||||
|
|
||||||
const canCreate = permissions?.create !== 'n';
|
|
||||||
const canUpdate = permissions?.update !== 'n';
|
|
||||||
const canDelete = permissions?.delete !== 'n';
|
|
||||||
|
|
||||||
const handleEditClick = async (project: RealEstateProject) => {
|
|
||||||
const full = await fetchById(project.id);
|
|
||||||
if (full) {
|
|
||||||
setEditingProject(full);
|
|
||||||
setIsCreateMode(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateClick = () => {
|
|
||||||
setEditingProject(null);
|
|
||||||
setIsCreateMode(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFormSubmit = async (data: Partial<RealEstateProject>) => {
|
|
||||||
if (isCreateMode) {
|
|
||||||
const result = await handleCreate(data);
|
|
||||||
if (result.success) {
|
|
||||||
setIsCreateMode(false);
|
|
||||||
refetch();
|
|
||||||
}
|
|
||||||
} else if (editingProject) {
|
|
||||||
const result = await handleUpdate(editingProject.id, data);
|
|
||||||
if (result.success) {
|
|
||||||
setEditingProject(null);
|
|
||||||
refetch();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteProject = async (project: RealEstateProject) => {
|
|
||||||
removeOptimistically(project.id);
|
|
||||||
const success = await handleDelete(project.id);
|
|
||||||
if (!success) refetch();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseModal = () => {
|
|
||||||
setEditingProject(null);
|
|
||||||
setIsCreateMode(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formAttributes = useMemo(() => {
|
|
||||||
const excluded = ['id', 'mandateId', 'instanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy'];
|
|
||||||
return (attributes || []).filter(attr => !excluded.includes(attr.name));
|
|
||||||
}, [attributes]);
|
|
||||||
|
|
||||||
const handleInlineUpdate = async (itemId: string, updateData: Partial<RealEstateProject>, row: RealEstateProject) => {
|
|
||||||
updateOptimistically(itemId, updateData);
|
|
||||||
const result = await handleUpdate(itemId, { ...row, ...updateData });
|
|
||||||
if (!result.success) refetch();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className={styles.adminPage}>
|
|
||||||
<div className={styles.errorContainer}>
|
|
||||||
<span className={styles.errorIcon}>⚠️</span>
|
|
||||||
<p className={styles.errorMessage}>Fehler beim Laden der Projekte: {error}</p>
|
|
||||||
<button className={styles.secondaryButton} onClick={() => refetch()}>
|
|
||||||
<FaSync /> Erneut versuchen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.adminPage}>
|
|
||||||
<div className={styles.pageHeader}>
|
|
||||||
<div>
|
|
||||||
<p className={styles.pageSubtitle}>Projekte verwalten</p>
|
|
||||||
</div>
|
|
||||||
<div className={styles.headerActions}>
|
|
||||||
<button className={styles.secondaryButton} onClick={() => refetch()} disabled={loading}>
|
|
||||||
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
|
|
||||||
</button>
|
|
||||||
{canCreate && (
|
|
||||||
<button className={styles.primaryButton} onClick={handleCreateClick}>
|
|
||||||
+ Neues Projekt
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.tableContainer}>
|
|
||||||
{loading && (!projects || projects.length === 0) ? (
|
|
||||||
<div className={styles.loadingContainer}>
|
|
||||||
<div className={styles.spinner} />
|
|
||||||
<span>Lade Projekte...</span>
|
|
||||||
</div>
|
|
||||||
) : !projects || projects.length === 0 ? (
|
|
||||||
<div className={styles.emptyState}>
|
|
||||||
<FaBuilding className={styles.emptyIcon} />
|
|
||||||
<h3 className={styles.emptyTitle}>Keine Projekte vorhanden</h3>
|
|
||||||
<p className={styles.emptyDescription}>Erstellen Sie ein neues Projekt, um zu beginnen.</p>
|
|
||||||
{canCreate && (
|
|
||||||
<button className={styles.primaryButton} onClick={handleCreateClick}>
|
|
||||||
+ Neues Projekt
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<FormGeneratorTable
|
|
||||||
data={projects}
|
|
||||||
columns={columns}
|
|
||||||
apiEndpoint={instanceId ? `/api/realestate/${instanceId}/projects` : undefined}
|
|
||||||
loading={loading}
|
|
||||||
pagination={true}
|
|
||||||
pageSize={25}
|
|
||||||
searchable={true}
|
|
||||||
filterable={true}
|
|
||||||
sortable={true}
|
|
||||||
selectable={false}
|
|
||||||
actionButtons={[
|
|
||||||
...(canUpdate ? [{ type: 'edit' as const, onAction: handleEditClick, title: 'Bearbeiten' }] : []),
|
|
||||||
...(canDelete ? [{ type: 'delete' as const, title: 'Löschen', loading: (row: RealEstateProject) => deletingItems.has(row.id) }] : []),
|
|
||||||
]}
|
|
||||||
onDelete={handleDeleteProject}
|
|
||||||
hookData={{ refetch, permissions, pagination, handleDelete, handleInlineUpdate, updateOptimistically }}
|
|
||||||
emptyMessage="Keine Projekte gefunden"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(editingProject || isCreateMode) && (
|
|
||||||
<div className={styles.modalOverlay} onClick={handleCloseModal}>
|
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
|
||||||
<div className={styles.modalHeader}>
|
|
||||||
<h2 className={styles.modalTitle}>{isCreateMode ? 'Neues Projekt' : 'Projekt bearbeiten'}</h2>
|
|
||||||
<button className={styles.modalClose} onClick={handleCloseModal}>✕</button>
|
|
||||||
</div>
|
|
||||||
<div className={styles.modalContent}>
|
|
||||||
{formAttributes.length === 0 ? (
|
|
||||||
<div className={styles.loadingContainer}>
|
|
||||||
<div className={styles.spinner} />
|
|
||||||
<span>Lade Formular...</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<FormGeneratorForm
|
|
||||||
attributes={formAttributes}
|
|
||||||
data={editingProject || {}}
|
|
||||||
mode={isCreateMode ? 'create' : 'edit'}
|
|
||||||
onSubmit={handleFormSubmit}
|
|
||||||
onCancel={handleCloseModal}
|
|
||||||
submitButtonText={isCreateMode ? 'Erstellen' : 'Speichern'}
|
|
||||||
cancelButtonText="Abbrechen"
|
|
||||||
instanceId={instanceId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RealEstateProjectsView;
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
export { RealEstateDashboardView } from './RealEstateDashboardView';
|
export { RealEstateDashboardView } from './RealEstateDashboardView';
|
||||||
export { RealEstatePekView } from './RealEstatePekView';
|
export { RealEstatePekView } from './RealEstatePekView';
|
||||||
export { RealEstateProjectsView } from './RealEstateProjectsView';
|
|
||||||
export { RealEstateParcelsView } from './RealEstateParcelsView';
|
|
||||||
export { RealEstateInstanceRolesPlaceholder } from './RealEstateInstanceRolesPlaceholder';
|
export { RealEstateInstanceRolesPlaceholder } from './RealEstateInstanceRolesPlaceholder';
|
||||||
30
src/pages/views/realestate/pek/PekMapView.module.css
Normal file
30
src/pages/views/realestate/pek/PekMapView.module.css
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
/**
|
||||||
|
* PEK Map View - map fills available space
|
||||||
|
*/
|
||||||
|
|
||||||
|
.pekMapWrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxLabel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapContainer {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 400px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
@ -1,55 +1,68 @@
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { MapView, ParcelInfoPanel } from '../../../../components/UiComponents';
|
import { Button, MapView, ParcelInfoPanel } from '../../../../components/UiComponents';
|
||||||
import { usePekContext } from '../../../../contexts/PekContext';
|
import { usePekContext } from '../../../../contexts/PekContext';
|
||||||
|
import styles from './PekMapView.module.css';
|
||||||
|
|
||||||
const PekMapView: React.FC = () => {
|
const PekMapView: React.FC = () => {
|
||||||
|
const [showWfsParcels, setShowWfsParcels] = useState(false);
|
||||||
const {
|
const {
|
||||||
|
instanceId,
|
||||||
mapCenter,
|
mapCenter,
|
||||||
mapZoomBounds,
|
mapZoomBounds,
|
||||||
parcelGeometries,
|
parcelGeometries,
|
||||||
|
selectionSummary,
|
||||||
handleMapClick,
|
handleMapClick,
|
||||||
handleParcelClick,
|
handleParcelClick,
|
||||||
selectedParcels,
|
selectedParcels,
|
||||||
removeParcel,
|
removeParcel,
|
||||||
|
clearSelectedParcels,
|
||||||
isPanelOpen,
|
isPanelOpen,
|
||||||
setIsPanelOpen
|
setIsPanelOpen
|
||||||
} = usePekContext();
|
} = usePekContext();
|
||||||
|
|
||||||
// Aggregate all adjacent parcels from all selected parcels
|
|
||||||
const allAdjacentParcels = React.useMemo(() => {
|
|
||||||
const adjacentSet = new Map<string, any>();
|
|
||||||
selectedParcels.forEach((parcel) => {
|
|
||||||
if (parcel.adjacent_parcels) {
|
|
||||||
parcel.adjacent_parcels.forEach((adj: { id: string }) => {
|
|
||||||
if (!adjacentSet.has(adj.id)) {
|
|
||||||
adjacentSet.set(adj.id, adj);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return Array.from(adjacentSet.values());
|
|
||||||
}, [selectedParcels]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{ marginBottom: '1.5rem' }}>
|
<div className={styles.pekMapWrapper}>
|
||||||
<MapView
|
<div className={styles.checkboxRow}>
|
||||||
parcels={parcelGeometries}
|
<label className={styles.checkboxLabel}>
|
||||||
center={mapCenter || undefined}
|
<input
|
||||||
zoomBounds={mapZoomBounds || undefined}
|
type="checkbox"
|
||||||
onMapClick={handleMapClick}
|
checked={showWfsParcels}
|
||||||
onParcelClick={handleParcelClick}
|
onChange={(e) => setShowWfsParcels(e.target.checked)}
|
||||||
height="600px"
|
/>
|
||||||
emptyMessage="Klicken Sie auf die Karte, um einen Standort auszuwählen, oder suchen Sie nach einer Adresse oben."
|
<span>Alle Parzellen anzeigen</span>
|
||||||
/>
|
</label>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearSelectedParcels}
|
||||||
|
disabled={selectedParcels.length === 0}
|
||||||
|
>
|
||||||
|
Alle Parzellen abwählen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.mapContainer}>
|
||||||
|
<MapView
|
||||||
|
parcels={parcelGeometries}
|
||||||
|
combinedOutline={selectionSummary?.combinedOutline}
|
||||||
|
center={mapCenter || undefined}
|
||||||
|
zoomBounds={mapZoomBounds || undefined}
|
||||||
|
onMapClick={handleMapClick}
|
||||||
|
onParcelClick={handleParcelClick}
|
||||||
|
height="100%"
|
||||||
|
emptyMessage="Klicken Sie auf die Karte, um einen Standort auszuwählen, oder suchen Sie nach einer Adresse oben."
|
||||||
|
showWfsParcels={showWfsParcels}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ParcelInfoPanel
|
<ParcelInfoPanel
|
||||||
isOpen={isPanelOpen}
|
isOpen={isPanelOpen}
|
||||||
onClose={() => setIsPanelOpen(false)}
|
onClose={() => setIsPanelOpen(false)}
|
||||||
parcels={selectedParcels}
|
parcels={selectedParcels}
|
||||||
|
selectionSummary={selectionSummary}
|
||||||
onRemoveParcel={removeParcel}
|
onRemoveParcel={removeParcel}
|
||||||
adjacentParcels={allAdjacentParcels}
|
instanceId={instanceId}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,12 @@
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Fill available height (used by PEK map view) */
|
||||||
|
.dashboardViewFill {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.statsGrid {
|
.statsGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
|
|
||||||
|
|
@ -235,9 +235,7 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
|
||||||
label: { de: 'Immobilien', en: 'Real Estate' },
|
label: { de: 'Immobilien', en: 'Real Estate' },
|
||||||
icon: 'home',
|
icon: 'home',
|
||||||
views: [
|
views: [
|
||||||
{ code: 'dashboard', label: { de: 'Übersicht', en: 'Dashboard' }, path: 'dashboard' },
|
{ code: 'dashboard', label: { de: 'Karte', en: 'Map' }, path: 'dashboard' },
|
||||||
{ code: 'projects', label: { de: 'Projekte', en: 'Projects' }, path: 'projects' },
|
|
||||||
{ code: 'parcels', label: { de: 'Parzellen', en: 'Parcels' }, path: 'parcels' },
|
|
||||||
{ code: 'instance-roles', label: { de: 'Rollen & Rechte', en: 'Roles & Permissions' }, path: 'instance-roles', adminOnly: true },
|
{ code: 'instance-roles', label: { de: 'Rollen & Rechte', en: 'Roles & Permissions' }, path: 'instance-roles', adminOnly: true },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue