From 1829c1c4ad7587a49fe6129364920e9de18f0b5e Mon Sep 17 00:00:00 2001 From: Ida Dittrich Date: Sat, 14 Feb 2026 17:34:04 +0100 Subject: [PATCH] updated pek pages --- src/App.tsx | 2 - .../UiComponents/MapView/MapView.module.css | 25 + .../UiComponents/MapView/MapView.tsx | 13 +- .../UiComponents/MapView/MapViewLeaflet.tsx | 297 +++++++-- .../ParcelInfoPanel.module.css | 275 ++++++++ .../ParcelInfoPanel/ParcelInfoPanel.tsx | 612 +++++++++++++++--- src/contexts/PekContext.tsx | 8 +- .../data/pages/realestate/index.ts | 9 +- .../data/pages/realestate/parcels.ts | 147 ----- .../data/pages/realestate/projects.ts | 146 ----- src/hooks/usePek.ts | 325 ++++------ src/pages/FeatureView.module.css | 3 + src/pages/FeatureView.tsx | 4 +- src/pages/admin/AdminFeatureAccessPage.tsx | 5 + .../admin/AdminFeatureInstanceUsersPage.tsx | 5 + .../realestate/RealEstateParcelsView.tsx | 267 -------- .../views/realestate/RealEstatePekView.tsx | 88 +-- .../realestate/RealEstateProjectsView.tsx | 224 ------- src/pages/views/realestate/index.ts | 2 - .../realestate/pek/PekMapView.module.css | 30 + src/pages/views/realestate/pek/PekMapView.tsx | 69 +- .../views/trustee/TrusteeViews.module.css | 6 + src/types/mandate.ts | 4 +- 23 files changed, 1283 insertions(+), 1283 deletions(-) delete mode 100644 src/core/PageManager/data/pages/realestate/parcels.ts delete mode 100644 src/core/PageManager/data/pages/realestate/projects.ts delete mode 100644 src/pages/views/realestate/RealEstateParcelsView.tsx delete mode 100644 src/pages/views/realestate/RealEstateProjectsView.tsx create mode 100644 src/pages/views/realestate/pek/PekMapView.module.css diff --git a/src/App.tsx b/src/App.tsx index dc505aa..b0e18ef 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -145,8 +145,6 @@ function App() { } /> } /> } /> - } /> - } /> {/* Chat Playground Feature Views */} } /> diff --git a/src/components/UiComponents/MapView/MapView.module.css b/src/components/UiComponents/MapView/MapView.module.css index fc4eb44..1378e6a 100644 --- a/src/components/UiComponents/MapView/MapView.module.css +++ b/src/components/UiComponents/MapView/MapView.module.css @@ -47,3 +47,28 @@ 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); + } +} + diff --git a/src/components/UiComponents/MapView/MapView.tsx b/src/components/UiComponents/MapView/MapView.tsx index b76c662..0268c88 100644 --- a/src/components/UiComponents/MapView/MapView.tsx +++ b/src/components/UiComponents/MapView/MapView.tsx @@ -10,11 +10,18 @@ export interface ParcelGeometry { number?: string; coordinates: MapPoint[]; 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 { parcels?: ParcelGeometry[]; + /** Combined outline from backend when 2+ parcels selected */ + combinedOutline?: CombinedOutlineGeojson; center?: MapPoint; zoomBounds?: { min_x: number; @@ -27,6 +34,10 @@ export interface MapViewProps { height?: string; className?: 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 diff --git a/src/components/UiComponents/MapView/MapViewLeaflet.tsx b/src/components/UiComponents/MapView/MapViewLeaflet.tsx index 78d7bd6..d57f13b 100644 --- a/src/components/UiComponents/MapView/MapViewLeaflet.tsx +++ b/src/components/UiComponents/MapView/MapViewLeaflet.tsx @@ -1,9 +1,25 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import { lv95ToWGS84, wgs84ToLV95 } from './LV95Converter'; import type { MapViewProps } from './MapView'; 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 import icon from 'leaflet/dist/images/marker-icon.png'; @@ -20,42 +36,56 @@ const DefaultIcon = L.icon({ L.Marker.prototype.options.icon = DefaultIcon; +const SELECTED_STYLE = { color: '#3b82f6', weight: 3, fillColor: '#3b82f6', fillOpacity: 0.3 }; + const MapViewLeaflet: React.FC = ({ parcels = [], + combinedOutline, center, zoomBounds, onMapClick, onParcelClick, height = '600px', 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(null); const mapContainerRef = useRef(null); const layersRef = useRef([]); const centerMarkerRef = useRef(null); + const parcelWfsLayerRef = useRef(null); + const baseLayerRef = useRef(null); + const abortControllerRef = useRef(null); + const debounceTimerRef = useRef | null>(null); + const lastBboxRef = useRef(null); + const [isWfsLoading, setIsWfsLoading] = useState(false); // Initialize map useEffect(() => { if (!mapContainerRef.current || mapRef.current) return; - // Default center: Switzerland (converted from LV95) - const defaultCenterLV95 = center || { x: 2600000, y: 1200000 }; + // Default center: Zurich canton (converted from LV95) + const defaultCenterLV95 = center || { x: 2682500, y: 1248000 }; 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, { center: [defaultCenter.lat, defaultCenter.lon], - zoom: zoomBounds ? 15 : 8, // Zoom level based on whether we have bounds + zoom: zoomBounds ? 15 : 10, zoomControl: true, - attributionControl: true + attributionControl: true, + preferCanvas: true }); - // Add OpenStreetMap tiles - L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { - attribution: '© OpenStreetMap contributors', - maxZoom: 19 - }).addTo(map); + const initialLayer = TILE_LAYER_DETAIL; + initialLayer.addTo(map); + baseLayerRef.current = initialLayer; + + // Create pane for WFS parcels (background layer, below selected parcels) + const wfsPane = map.createPane('wfs-parcels'); + wfsPane.style.zIndex = '200'; mapRef.current = map; @@ -63,9 +93,27 @@ const MapViewLeaflet: React.FC = ({ return () => { map.remove(); mapRef.current = null; + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + abortControllerRef.current?.abort(); + abortControllerRef.current = null; }; }, []); // 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 useEffect(() => { if (!mapRef.current) return; @@ -107,81 +155,85 @@ const MapViewLeaflet: React.FC = ({ }).addTo(map); centerMarkerRef.current = marker; } else { - // Default center: Switzerland - const defaultCenter = lv95ToWGS84(2600000, 1200000); - map.setView([defaultCenter.lat, defaultCenter.lon], 8); + // Default: fit to canton Zurich + const sw = lv95ToWGS84(ZURICH_BOUNDS_LV95.minX, ZURICH_BOUNDS_LV95.minY); + 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]); - // Draw parcels + // Draw parcels (only selected - all blue) or combined outline when 2+ useEffect(() => { if (!mapRef.current) return; 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 layersRef.current.forEach((layer) => { map.removeLayer(layer); }); layersRef.current = []; - // Add parcels - parcels.forEach((parcel) => { - if (parcel.coordinates.length < 3) { - 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 - } + const toWgs84 = (x: number, y: number) => { + const w = lv95ToWGS84(x, y); + return [w.lat, w.lon] as [number, number]; + }; - // Convert LV95 coordinates to WGS84 - const latLngs = parcel.coordinates.map((coord) => { - const wgs84 = lv95ToWGS84(coord.x, coord.y); - return [wgs84.lat, wgs84.lon] as [number, number]; - }); - - // Create polygon - const polygon = L.polygon(latLngs, { - color: parcel.isSelected ? '#3b82f6' : parcel.isAdjacent ? '#9ca3af' : '#22c55e', - weight: parcel.isSelected ? 3 : parcel.isAdjacent ? 2 : 1, - fillColor: parcel.isSelected ? '#3b82f6' : parcel.isAdjacent ? '#9ca3af' : '#22c55e', - fillOpacity: parcel.isSelected ? 0.3 : parcel.isAdjacent ? 0.2 : 0.2 - }); - - // Add popup with parcel info - const popupContent = ` -
- Parzelle ${parcel.number || parcel.id}
- ${parcel.egrid ? `EGRID: ${parcel.egrid}
` : ''} - ${parcel.isSelected ? 'Ausgewählt' : parcel.isAdjacent ? 'Angrenzend' : ''} -
- `; - polygon.bindPopup(popupContent); - - // Add click handler - if (onParcelClick) { - polygon.on('click', () => { - onParcelClick(parcel.id); + // When 2+ parcels and we have combined outline from backend, draw that only + if (combinedOutline && parcels.length > 1 && combinedOutline.coordinates?.length) { + const geo = combinedOutline; + if (geo.type === 'MultiPolygon') { + const coords = geo.coordinates as number[][][][]; + coords.forEach((polyCoords) => { + const ring = polyCoords[0]; + if (ring && ring.length >= 3) { + const latLngs = ring.map(([x, y]) => toWgs84(x, y)); + const polygon = L.polygon(latLngs, SELECTED_STYLE); + polygon.bindPopup('
Ausgewählte Fläche
Zum Entfernen Parzelle im Panel nutzen
'); + if (onParcelClick) { + polygon.on('click', () => onParcelClick('combined')); + } + polygon.addTo(map); + layersRef.current.push(polygon); + } }); + } 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('
Ausgewählte Fläche
Zum Entfernen Parzelle im Panel nutzen
'); + if (onParcelClick) { + polygon.on('click', () => onParcelClick('combined')); + } + polygon.addTo(map); + layersRef.current.push(polygon); + } } - - polygon.addTo(map); - layersRef.current.push(polygon); - }); - }, [parcels, onParcelClick]); + } else { + // Single parcel or no combined outline: draw individual parcels (all selected) + parcels.forEach((parcel) => { + if (parcel.coordinates.length < 3) return; + const latLngs = parcel.coordinates.map((coord) => toWgs84(coord.x, coord.y)); + const polygon = L.polygon(latLngs, SELECTED_STYLE); + polygon.bindPopup(` +
+ Parzelle ${parcel.number || parcel.id}
+ ${parcel.egrid ? `EGRID: ${parcel.egrid}
` : ''} + Ausgewählt +
+ `); + if (onParcelClick) { + polygon.on('click', () => onParcelClick(parcel.id)); + } + polygon.addTo(map); + layersRef.current.push(polygon); + }); + } + }, [parcels, combinedOutline, onParcelClick]); // Handle map clicks useEffect(() => { @@ -202,6 +254,106 @@ const MapViewLeaflet: React.FC = ({ }; }, [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 (
@@ -210,6 +362,11 @@ const MapViewLeaflet: React.FC = ({

{emptyMessage}

)} + {showWfsParcels && isWfsLoading && ( +
+ +
+ )}
); }; diff --git a/src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.module.css b/src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.module.css index 3e7797d..81b04d8 100644 --- a/src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.module.css +++ b/src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.module.css @@ -106,6 +106,44 @@ 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 { display: flex; flex-direction: column; @@ -241,6 +279,243 @@ 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) { .panel { width: 100vw; diff --git a/src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx b/src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx index 5185dca..b353fc0 100644 --- a/src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx +++ b/src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx @@ -1,23 +1,121 @@ -import React from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; 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'; +export interface SelectionSummary { + total_area_m2?: number; + bauzonen?: Array<{ bauzone: string; parcels: any[]; area_m2: number }>; +} + export interface ParcelInfoPanelProps { isOpen: boolean; onClose: () => void; parcels: any[]; + selectionSummary?: SelectionSummary | null; 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>; + vorschlaege?: string[]; + }; + errors?: string[]; } const ParcelInfoPanel: React.FC = ({ isOpen, onClose, parcels, + selectionSummary, onRemoveParcel, - adjacentParcels = [] + instanceId }) => { + const [parcelDocs, setParcelDocs] = useState>({}); + const [docsLoading, setDocsLoading] = useState>({}); + const [docsError, setDocsError] = useState>({}); + const [extractResults, setExtractResults] = useState>({}); + const [extractLoading, setExtractLoading] = useState>({}); + const [extractError, setExtractError] = useState>({}); + 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 = { 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; return ( @@ -50,13 +148,42 @@ const ParcelInfoPanel: React.FC = ({
- {/* Selected Parcels List */} + {selectionSummary?.total_area_m2 != null && ( +
+

Gesamtfläche

+

+ {selectionSummary.total_area_m2.toFixed(2)} m² + {selectionSummary.total_area_m2 >= 10000 && ( + + {' '}({(selectionSummary.total_area_m2 / 10000).toFixed(2)} ha) + + )} +

+
+ )} + {/* Selected Parcels by Bauzone or flat list */}
- {parcels.map((parcelData, index) => ( -
+ {(selectionSummary?.bauzonen && selectionSummary.bauzonen.length > 0 + ? selectionSummary.bauzonen.flatMap((bz) => { + const parcelsInZone = parcels.filter((p) => + bz.parcels.some((bp: any) => (bp.id || bp.parcel?.id) === p.parcel.id) + ); + return [ +
+

+ Bauzone {bz.bauzone} + {bz.area_m2 != null && ( + — {bz.area_m2.toFixed(2)} m² + )} +

+
, + ...parcelsInZone.map((parcelData, idx) => { + const areaForMach = bz?.area_m2 ?? parcelData.parcel.area_m2 ?? selectionSummary?.total_area_m2; + return ( +

- Parzelle {index + 1}: {parcelData.parcel.number || parcelData.parcel.id || 'Unbekannt'} + Parzelle {idx + 1}: {parcelData.parcel.number || parcelData.parcel.id || 'Unbekannt'}

{onRemoveParcel && (
)} - {parcelData.parcel.number && ( -
- Nummer: - {parcelData.parcel.number} -
- )} - {parcelData.parcel.name && ( -
- Name: - {parcelData.parcel.name} -
- )} {parcelData.parcel.egrid && (
EGRID: @@ -148,6 +263,132 @@ const ParcelInfoPanel: React.FC = ({ {parcelData.parcel.bauzone}
)} + {instanceId && parcelData.parcel.municipality_name && parcelData.parcel.bauzone && (docsLoading[parcelData.parcel.id] || (parcelDocs[parcelData.parcel.id] || []).length > 0) && ( +
+

Bauzonenverordnung

+ {docsLoading[parcelData.parcel.id] && ( +

Dokumente werden geladen...

+ )} + {docsError[parcelData.parcel.id] && ( +

{docsError[parcelData.parcel.id]}

+ )} + {(parcelDocs[parcelData.parcel.id] || []).length > 0 && ( +
+ {(parcelDocs[parcelData.parcel.id] || []).map((doc) => ( +
+ + {doc.label} + +
+ ))} +
+ )} + {(parcelDocs[parcelData.parcel.id] || []).length > 0 && ( + + )} + {extractError[parcelData.parcel.id] && ( +

{extractError[parcelData.parcel.id]}

+ )} + {extractResults[parcelData.parcel.id] && ( +
+ {(() => { + const m = extractResults[parcelData.parcel.id]?.machbarkeitsstudie; + const fakten = m?.fakten ?? []; + const vorschlaege = m?.vorschlaege ?? []; + const zusatzinfo = m?.zusatzinformationen ?? []; + return ( + <> + {fakten.length > 0 && ( +
+ Fakten aus BZO +
    + {fakten.map((row: { item: string; value: string; source?: string }, i: number) => ( +
  • + {row.item}: {row.value} + {row.source && ( + + {' '}({row.source}) + + )} +
  • + ))} +
+
+ )} + {vorschlaege.length > 0 && ( +
+ Vorschläge +
    + {vorschlaege.map((row: { item: string; value: string; is_section?: boolean } | string, i: number) => { + if (typeof row === 'string') return
  • {row}
  • ; + const r = row as { item: string; value: string; is_section?: boolean }; + if (r.is_section) { + return
  • {r.item}
  • ; + } + return ( +
  • + {r.value ? `${r.item}: ${r.value}` : r.item} +
  • + ); + })} +
+
+ )} + {zusatzinfo.length > 0 && ( +
+ Weiterführende Bestimmungen +
+ {zusatzinfo.map((art: { article_label: string; article_title: string; text: string; source?: string }, i: number) => ( +
+ + {art.article_label} {art.article_title} + {art.source && ({art.source})} + +

{art.text}

+
+ ))} +
+
+ )} + + ); + })()} + {(extractResults[parcelData.parcel.id]?.errors?.length ?? 0) > 0 && ( +

+ {(extractResults[parcelData.parcel.id]?.errors || []).join('; ')} +

+ )} +
+ )} +
+ )} {parcelData.parcel.zone && Array.isArray(parcelData.parcel.zone) && parcelData.parcel.zone.length > 0 && (
Zone: @@ -182,11 +423,267 @@ const ParcelInfoPanel: React.FC = ({
)} - {parcelData.parcel.centroid && ( + {parcelData.parcel.geoportal_url && (
- Zentrum (LV95): + Geoportal: + + Link öffnen + +
+ )} +
+ + + ); + }) + ]; + }) + : parcels.map((parcelData, index) => { + const areaForMachFlat = selectionSummary?.total_area_m2 ?? parcelData.parcel.area_m2; + return ( +
+
+

+ Parzelle {index + 1}: {parcelData.parcel.number || parcelData.parcel.id || 'Unbekannt'} +

+ {onRemoveParcel && ( + + )} +
+
+ {parcelData.parcel.id && ( +
+ ID: + {parcelData.parcel.id} +
+ )} + {parcelData.parcel.egrid && ( +
+ EGRID: + {parcelData.parcel.egrid} +
+ )} + {parcelData.parcel.identnd && ( +
+ IdentND: + {parcelData.parcel.identnd} +
+ )} + {parcelData.parcel.address && ( +
+ Adresse: + {parcelData.parcel.address} +
+ )} + {parcelData.parcel.canton && ( +
+ Kanton: + {parcelData.parcel.canton} +
+ )} + {parcelData.parcel.municipality_name && ( +
+ Gemeinde: + {parcelData.parcel.municipality_name} +
+ )} + {parcelData.parcel.municipality_code && ( +
+ Gemeinde-Code: + {parcelData.parcel.municipality_code} +
+ )} + {parcelData.parcel.area_m2 !== undefined && ( +
+ Fläche: - {parcelData.parcel.centroid.x.toFixed(2)}, {parcelData.parcel.centroid.y.toFixed(2)} + {parcelData.parcel.area_m2.toFixed(2)} m² + {parcelData.parcel.area_m2 >= 10000 && ( + + {' '}({(parcelData.parcel.area_m2 / 10000).toFixed(2)} ha) + + )} + +
+ )} + {parcelData.parcel.realestate_type && ( +
+ Grundstückstyp: + {parcelData.parcel.realestate_type} +
+ )} + {parcelData.parcel.bauzone && ( +
+ Bauzone: + {parcelData.parcel.bauzone} +
+ )} + {instanceId && parcelData.parcel.municipality_name && parcelData.parcel.bauzone && (docsLoading[parcelData.parcel.id] || (parcelDocs[parcelData.parcel.id] || []).length > 0) && ( +
+

Bauzonenverordnung

+ {docsLoading[parcelData.parcel.id] && ( +

Dokumente werden geladen...

+ )} + {docsError[parcelData.parcel.id] && ( +

{docsError[parcelData.parcel.id]}

+ )} + {(parcelDocs[parcelData.parcel.id] || []).length > 0 && ( +
+ {(parcelDocs[parcelData.parcel.id] || []).map((doc) => ( +
+ + {doc.label} + +
+ ))} +
+ )} + {(parcelDocs[parcelData.parcel.id] || []).length > 0 && ( + + )} + {extractError[parcelData.parcel.id] && ( +

{extractError[parcelData.parcel.id]}

+ )} + {extractResults[parcelData.parcel.id] && ( +
+ {(() => { + const m = extractResults[parcelData.parcel.id]?.machbarkeitsstudie; + const fakten = m?.fakten ?? []; + const vorschlaege = m?.vorschlaege ?? []; + const zusatzinfo = m?.zusatzinformationen ?? []; + return ( + <> + {fakten.length > 0 && ( +
+ Fakten aus BZO +
    + {fakten.map((row: { item: string; value: string; source?: string }, i: number) => ( +
  • + {row.item}: {row.value} + {row.source && ( + + {' '}({row.source}) + + )} +
  • + ))} +
+
+ )} + {vorschlaege.length > 0 && ( +
+ Vorschläge +
    + {vorschlaege.map((row: { item: string; value: string; is_section?: boolean } | string, i: number) => { + if (typeof row === 'string') return
  • {row}
  • ; + const r = row as { item: string; value: string; is_section?: boolean }; + if (r.is_section) { + return
  • {r.item}
  • ; + } + return ( +
  • + {r.value ? `${r.item}: ${r.value}` : r.item} +
  • + ); + })} +
+
+ )} + {zusatzinfo.length > 0 && ( +
+ Weiterführende Bestimmungen +
+ {zusatzinfo.map((art: { article_label: string; article_title: string; text: string; source?: string }, i: number) => ( +
+ + {art.article_label} {art.article_title} + {art.source && ({art.source})} + +

{art.text}

+
+ ))} +
+
+ )} + + ); + })()} + {(extractResults[parcelData.parcel.id]?.errors?.length ?? 0) > 0 && ( +

+ {(extractResults[parcelData.parcel.id]?.errors || []).join('; ')} +

+ )} +
+ )} +
+ )} + {parcelData.parcel.zone && Array.isArray(parcelData.parcel.zone) && parcelData.parcel.zone.length > 0 && ( +
+ Zone: + + {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 ( + + {' '}({zoneTypes.join(', ')}) + + ); + } + return null; + })()} + {import.meta.env.DEV && ( +
+ Details anzeigen +
+                                  {JSON.stringify(parcelData.parcel.zone, null, 2)}
+                                
+
+ )}
)} @@ -204,67 +701,24 @@ const ParcelInfoPanel: React.FC = ({
)}
- - {/* Map View Info for this parcel */} - {parcelData.map_view && ( -
-

Kartenansicht

-
- {parcelData.map_view.center && ( -
- Zentrum: - - {parcelData.map_view.center.x.toFixed(2)}, {parcelData.map_view.center.y.toFixed(2)} - -
- )} - {parcelData.map_view.zoom_bounds && ( - <> -
- Bounds Min: - - {parcelData.map_view.zoom_bounds.min_x.toFixed(2)}, {parcelData.map_view.zoom_bounds.min_y.toFixed(2)} - -
-
- Bounds Max: - - {parcelData.map_view.zoom_bounds.max_x.toFixed(2)}, {parcelData.map_view.zoom_bounds.max_y.toFixed(2)} - -
- - )} -
-
- )} - ))} + ); + }) + )} - {/* Adjacent Parcels */} - {adjacentParcels.length > 0 && ( -
-

- Angrenzende Parzellen ({adjacentParcels.length}) -

-
- {adjacentParcels.map((adjacent, index) => ( -
-
- - {adjacent.number || adjacent.id} - - {adjacent.egrid && ( - {adjacent.egrid} - )} -
-
- ))} -
-
- )} + + {previewDoc && ( + setPreviewDoc(null)} + fileId={previewDoc.fileId} + fileName={previewDoc.fileName} + mimeType={previewDoc.mimeType} + /> + )} )} diff --git a/src/contexts/PekContext.tsx b/src/contexts/PekContext.tsx index fbd3e06..3c6fae1 100644 --- a/src/contexts/PekContext.tsx +++ b/src/contexts/PekContext.tsx @@ -2,6 +2,7 @@ import React, { createContext, useContext, ReactNode } from 'react'; import { usePek } from '../hooks/usePek'; interface PekContextType { + instanceId: string | undefined; // Location input - separate fields kanton: string; setKanton: (value: string) => void; @@ -30,8 +31,9 @@ interface PekContextType { mapCenter: any; mapZoomBounds: any; parcelGeometries: any[]; + selectionSummary: any; handleMapClick: (point: any) => Promise; - handleParcelClick: (parcelId: string) => Promise; + handleParcelClick: (parcelId: string) => void; // Command processing commandInput: string; @@ -56,8 +58,8 @@ interface PekContextType { const PekContext = createContext(undefined); -export const PekProvider: React.FC<{ children: ReactNode }> = ({ children }) => { - const pekData = usePek(); +export const PekProvider: React.FC<{ children: ReactNode; instanceId?: string }> = ({ children, instanceId }) => { + const pekData = usePek(instanceId); return ( diff --git a/src/core/PageManager/data/pages/realestate/index.ts b/src/core/PageManager/data/pages/realestate/index.ts index 8596282..70608a6 100644 --- a/src/core/PageManager/data/pages/realestate/index.ts +++ b/src/core/PageManager/data/pages/realestate/index.ts @@ -1,10 +1,3 @@ import { GenericPageData } from '../../../pageInterface'; -import { realEstateProjectsPageData } from './projects'; -import { realEstateParcelsPageData } from './parcels'; -export { realEstateProjectsPageData, realEstateParcelsPageData }; - -export const realEstatePages: GenericPageData[] = [ - realEstateProjectsPageData, - realEstateParcelsPageData, -]; +export const realEstatePages: GenericPageData[] = []; diff --git a/src/core/PageManager/data/pages/realestate/parcels.ts b/src/core/PageManager/data/pages/realestate/parcels.ts deleted file mode 100644 index 4ce2e8c..0000000 --- a/src/core/PageManager/data/pages/realestate/parcels.ts +++ /dev/null @@ -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'); }, -}; diff --git a/src/core/PageManager/data/pages/realestate/projects.ts b/src/core/PageManager/data/pages/realestate/projects.ts deleted file mode 100644 index 79a7669..0000000 --- a/src/core/PageManager/data/pages/realestate/projects.ts +++ /dev/null @@ -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'); }, -}; diff --git a/src/hooks/usePek.ts b/src/hooks/usePek.ts index 5330cae..b47031c 100644 --- a/src/hooks/usePek.ts +++ b/src/hooks/usePek.ts @@ -126,7 +126,7 @@ export interface AddParcelResponse { } // Main PEK hook -export function usePek() { +export function usePek(instanceId?: string) { // Location input state - separate fields const [kanton, setKanton] = useState(''); const [gemeinde, setGemeinde] = useState(''); @@ -162,6 +162,11 @@ export function usePek() { max_y: number; } | null>(null); const [parcelGeometries, setParcelGeometries] = useState([]); + 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 const [commandInput, setCommandInput] = useState(''); @@ -178,17 +183,50 @@ export function usePek() { // Panel state const [isPanelOpen, setIsPanelOpen] = useState(false); - // Update parcel geometries when selected parcels change - // Ensure all selected parcels are marked as selected and not as adjacent + // Derive parcelGeometries ONLY from selected parcels (deselected = not drawn) useEffect(() => { - const selectedParcelIds = new Set(selectedParcels.map(p => p.parcel.id)); - - setParcelGeometries(prev => prev.map(geo => { - const isSelected = selectedParcelIds.has(geo.id); - // If parcel is selected, it should not be marked as adjacent - const isAdjacent = isSelected ? false : geo.isAdjacent; - return { ...geo, isSelected, isAdjacent }; - })); + const geometries: ParcelGeometry[] = selectedParcels.map(p => { + let coords: MapPoint[] = []; + const geo = p.map_view?.geometry_geojson?.geometry; + if (geo?.coordinates?.[0]) { + coords = geo.coordinates[0].map((c: number[]) => ({ x: c[0], y: c[1] })); + } else if (p.parcel.perimeter?.punkte) { + 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]); /** @@ -216,7 +254,7 @@ export function usePek() { const locationString = `${Math.round(lv95.x)},${Math.round(lv95.y)}`; // Directly search for parcel without updating input fields - await searchParcel(locationString, true); + await searchParcel(locationString); resolve(); } catch (err: any) { setLocationError(err.message || 'Fehler beim Konvertieren der Koordinaten'); @@ -256,9 +294,8 @@ export function usePek() { /** * 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()) { setParcelSearchError('Bitte geben Sie einen Standort ein'); return; @@ -271,7 +308,7 @@ export function usePek() { const response = await api.get('/api/realestate/parcel/search', { params: { 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 - // Update geometries within the callback to have access to updated selectedParcels - setSelectedParcels(prev => { - const exists = prev.some(p => p.parcel.id === data.parcel.id); - if (exists) { - return prev; // Already selected, don't add again - } - - const updatedSelectedParcels = [...prev, data]; - const selectedParcelIds = new Set(updatedSelectedParcels.map(p => p.parcel.id)); - - // Update geometries - setParcelGeometries(currentGeometries => { - const geometryMap = new Map(); - - // 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; - }); + // First selection: replace with single parcel + setSelectedParcels([data]); + if (data.map_view) { + setMapCenter(data.map_view.center); + setMapZoomBounds(data.map_view.zoom_bounds); + } else if (data.parcel.centroid) { + setMapCenter(data.parcel.centroid); + } // Open panel when parcel is found 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( async (point: MapPoint) => { const locationString = `${point.x},${point.y}`; 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]); /** - * Remove a parcel from selection + * Remove a parcel from selection. parcelGeometries are derived from selectedParcels. */ const removeParcel = useCallback((parcelId: string) => { 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(() => { 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) => { - // Check if parcel is already selected - const isSelected = isParcelSelected(parcelId); - - if (isSelected) { - // Remove from selection + const handleParcelClick = useCallback((parcelId: string) => { + if (!isPanelOpen) { + setIsPanelOpen(true); + } else if (parcelId !== 'combined') { 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 @@ -950,6 +839,7 @@ export function usePek() { }, [kanton, gemeinde, adresse]); return { + instanceId, // Location input - separate fields kanton, setKanton, @@ -978,6 +868,7 @@ export function usePek() { mapCenter, mapZoomBounds, parcelGeometries, + selectionSummary, handleMapClick, handleParcelClick, diff --git a/src/pages/FeatureView.module.css b/src/pages/FeatureView.module.css index 035a081..754d093 100644 --- a/src/pages/FeatureView.module.css +++ b/src/pages/FeatureView.module.css @@ -25,6 +25,9 @@ .viewContent { flex: 1; + display: flex; + flex-direction: column; + min-height: 0; overflow: auto; padding: 1.5rem; } diff --git a/src/pages/FeatureView.tsx b/src/pages/FeatureView.tsx index 7f131e7..86e0fb4 100644 --- a/src/pages/FeatureView.tsx +++ b/src/pages/FeatureView.tsx @@ -23,7 +23,7 @@ import { TrusteeExpenseImportView } from './views/trustee/TrusteeExpenseImportVi import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsView'; // RealEstate Views -import { RealEstatePekView, RealEstateProjectsView, RealEstateParcelsView, RealEstateInstanceRolesPlaceholder } from './views/realestate'; +import { RealEstatePekView, RealEstateInstanceRolesPlaceholder } from './views/realestate'; // Chat Playground Views (reusing existing workflow pages) import { PlaygroundPage, WorkflowsPage } from './workflows'; @@ -110,8 +110,6 @@ const VIEW_COMPONENTS: Record> = { }, realestate: { dashboard: RealEstatePekView, - projects: RealEstateProjectsView, - parcels: RealEstateParcelsView, 'instance-roles': RealEstateInstanceRolesPlaceholder, }, chatplayground: { diff --git a/src/pages/admin/AdminFeatureAccessPage.tsx b/src/pages/admin/AdminFeatureAccessPage.tsx index fb0b92e..af99f07 100644 --- a/src/pages/admin/AdminFeatureAccessPage.tsx +++ b/src/pages/admin/AdminFeatureAccessPage.tsx @@ -8,6 +8,7 @@ import React, { useState, useEffect, useMemo, useRef } from 'react'; import { useFeatureAccess, type FeatureInstance } from '../../hooks/useFeatureAccess'; import { useUserMandates, type Mandate } from '../../hooks/useUserMandates'; +import { useFeatureStore } from '../../stores/featureStore'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; import { FaPlus, FaSync, FaCube, FaBuilding, FaCogs, FaEdit } from 'react-icons/fa'; @@ -35,6 +36,7 @@ export const AdminFeatureAccessPage: React.FC = () => { const { fetchMandates } = useUserMandates(); const { showSuccess, showError } = useToast(); + const { loadFeatures } = useFeatureStore(); // State const [mandates, setMandates] = useState([]); @@ -171,6 +173,7 @@ export const AdminFeatureAccessPage: React.FC = () => { setChatbotSystemPrompt(''); setChatbotEnableWebResearch(true); fetchInstances(selectedMandateId); + loadFeatures(); // Refresh global navigation cache showSuccess('Feature-Instanz erstellt', `Die Instanz "${createLabel}" wurde erfolgreich erstellt.`); } else { showError('Fehler', result.error || 'Fehler beim Erstellen der Feature-Instanz'); @@ -267,6 +270,7 @@ export const AdminFeatureAccessPage: React.FC = () => { setChatbotConnectors(['preprocessor']); setChatbotSystemPrompt(''); fetchInstances(selectedMandateId); + loadFeatures(); // Refresh global navigation cache showSuccess('Feature-Instanz aktualisiert', `Die Instanz "${data.label}" wurde erfolgreich aktualisiert.`); } else { showError('Fehler', result.error || 'Fehler beim Aktualisieren der Feature-Instanz'); @@ -281,6 +285,7 @@ export const AdminFeatureAccessPage: React.FC = () => { if (!selectedMandateId) return false; const result = await deleteInstance(selectedMandateId, instanceId); if (result.success) { + loadFeatures(); // Refresh global navigation cache showSuccess('Instanz gelöscht', 'Die Feature-Instanz wurde gelöscht.'); return true; } else { diff --git a/src/pages/admin/AdminFeatureInstanceUsersPage.tsx b/src/pages/admin/AdminFeatureInstanceUsersPage.tsx index c7250af..29b335a 100644 --- a/src/pages/admin/AdminFeatureInstanceUsersPage.tsx +++ b/src/pages/admin/AdminFeatureInstanceUsersPage.tsx @@ -12,6 +12,7 @@ import { FormGeneratorTable } from '../../components/FormGenerator/FormGenerator import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; import { FaPlus, FaSync, FaUsers, FaBuilding, FaCube } from 'react-icons/fa'; import { useToast } from '../../contexts/ToastContext'; +import { useFeatureStore } from '../../stores/featureStore'; import api from '../../api'; import styles from './Admin.module.css'; @@ -31,6 +32,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => { const { fetchMandates } = useUserMandates(); const { showSuccess, showError } = useToast(); + const { loadFeatures } = useFeatureStore(); // Combined instance option type interface CombinedInstanceOption { @@ -303,6 +305,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => { if (result.success) { setShowAddModal(false); refreshUsers(); + loadFeatures(); // Refresh global navigation cache showSuccess('Benutzer hinzugefügt', 'Der Benutzer wurde erfolgreich zur Feature-Instanz hinzugefügt.'); } else { showError('Fehler', result.error || 'Fehler beim Hinzufügen des Benutzers'); @@ -326,6 +329,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => { if (result.success) { setEditingUser(null); refreshUsers(); + loadFeatures(); // Refresh global navigation cache showSuccess('Eintrag aktualisiert', 'Rollen und Aktiv-Status wurden erfolgreich aktualisiert.'); } else { showError('Fehler', result.error || 'Fehler beim Aktualisieren'); @@ -341,6 +345,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => { const result = await removeUserFromInstance(selectedMandateId, selectedInstanceId, user.userId); if (result.success) { refreshUsers(); + loadFeatures(); // Refresh global navigation cache showSuccess('Benutzer entfernt', `"${user.username}" wurde aus der Feature-Instanz entfernt.`); } else { showError('Fehler', result.error || 'Fehler beim Entfernen des Benutzers'); diff --git a/src/pages/views/realestate/RealEstateParcelsView.tsx b/src/pages/views/realestate/RealEstateParcelsView.tsx deleted file mode 100644 index 065ad2d..0000000 --- a/src/pages/views/realestate/RealEstateParcelsView.tsx +++ /dev/null @@ -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(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) => { - 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, - row: RealEstateParcel - ) => { - updateOptimistically(itemId, updateData); - const result = await handleUpdate(itemId, { ...row, ...updateData }); - if (!result.success) { - refetch(); - } - }; - - if (error) { - return ( -
-
- ⚠️ -

Fehler beim Laden der Parzellen: {error}

- -
-
- ); - } - - return ( -
-
-
-

Parzellen verwalten

-
-
- - {canCreate && ( - - )} -
-
- -
- {loading && (!parcels || parcels.length === 0) ? ( -
-
- Lade Parzellen... -
- ) : !parcels || parcels.length === 0 ? ( -
- -

Keine Parzellen vorhanden

-

- Erstellen Sie eine neue Parzelle, um zu beginnen. -

- {canCreate && ( - - )} -
- ) : ( - deletingItems.has(row.id), - }, - ] - : []), - ]} - onDelete={handleDeleteParcel} - hookData={{ - refetch, - permissions, - pagination, - handleDelete, - handleInlineUpdate, - updateOptimistically, - }} - emptyMessage="Keine Parzellen gefunden" - /> - )} -
- - {(editingParcel || isCreateMode) && ( -
-
e.stopPropagation()}> -
-

- {isCreateMode ? 'Neue Parzelle' : 'Parzelle bearbeiten'} -

- -
-
- {formAttributes.length === 0 ? ( -
-
- Lade Formular... -
- ) : ( - - )} -
-
-
- )} -
- ); -}; - -export default RealEstateParcelsView; diff --git a/src/pages/views/realestate/RealEstatePekView.tsx b/src/pages/views/realestate/RealEstatePekView.tsx index 6eb244d..230b9fb 100644 --- a/src/pages/views/realestate/RealEstatePekView.tsx +++ b/src/pages/views/realestate/RealEstatePekView.tsx @@ -6,105 +6,27 @@ */ import React from 'react'; -import { IoMdSend } from 'react-icons/io'; -import { PekProvider, usePekContext } from '../../../contexts/PekContext'; -import { Button, TextField } from '../../../components/UiComponents'; +import { PekProvider } from '../../../contexts/PekContext'; +import { useInstanceId } from '../../../hooks/useCurrentInstance'; import PekLocationInput from './pek/PekLocationInput'; import PekMapView from './pek/PekMapView'; -import { useLanguage } from '../../../providers/language/LanguageContext'; import styles from '../trustee/TrusteeViews.module.css'; 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 ( -
-

- {t('projects.description_text')} -

- +
- - {/* Optional: Command input and results */} -
-
-
- -
-
- -
- -
-
-
- - {commandResults.length > 0 && ( -
-

Antworten

-
- {commandResults.map((msg: any) => ( -
- {msg.role === 'user' ? 'Sie' : 'Assistent'}:{' '} - {typeof msg.message === 'string' ? msg.message : JSON.stringify(msg.message)} -
- ))} -
-
- )} -
); } export const RealEstatePekView: React.FC = () => { + const instanceId = useInstanceId(); return ( - + ); diff --git a/src/pages/views/realestate/RealEstateProjectsView.tsx b/src/pages/views/realestate/RealEstateProjectsView.tsx deleted file mode 100644 index 0e66a53..0000000 --- a/src/pages/views/realestate/RealEstateProjectsView.tsx +++ /dev/null @@ -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(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) => { - 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, row: RealEstateProject) => { - updateOptimistically(itemId, updateData); - const result = await handleUpdate(itemId, { ...row, ...updateData }); - if (!result.success) refetch(); - }; - - if (error) { - return ( -
-
- ⚠️ -

Fehler beim Laden der Projekte: {error}

- -
-
- ); - } - - return ( -
-
-
-

Projekte verwalten

-
-
- - {canCreate && ( - - )} -
-
- -
- {loading && (!projects || projects.length === 0) ? ( -
-
- Lade Projekte... -
- ) : !projects || projects.length === 0 ? ( -
- -

Keine Projekte vorhanden

-

Erstellen Sie ein neues Projekt, um zu beginnen.

- {canCreate && ( - - )} -
- ) : ( - deletingItems.has(row.id) }] : []), - ]} - onDelete={handleDeleteProject} - hookData={{ refetch, permissions, pagination, handleDelete, handleInlineUpdate, updateOptimistically }} - emptyMessage="Keine Projekte gefunden" - /> - )} -
- - {(editingProject || isCreateMode) && ( -
-
e.stopPropagation()}> -
-

{isCreateMode ? 'Neues Projekt' : 'Projekt bearbeiten'}

- -
-
- {formAttributes.length === 0 ? ( -
-
- Lade Formular... -
- ) : ( - - )} -
-
-
- )} -
- ); -}; - -export default RealEstateProjectsView; diff --git a/src/pages/views/realestate/index.ts b/src/pages/views/realestate/index.ts index 105f1ae..8215c16 100644 --- a/src/pages/views/realestate/index.ts +++ b/src/pages/views/realestate/index.ts @@ -1,5 +1,3 @@ export { RealEstateDashboardView } from './RealEstateDashboardView'; export { RealEstatePekView } from './RealEstatePekView'; -export { RealEstateProjectsView } from './RealEstateProjectsView'; -export { RealEstateParcelsView } from './RealEstateParcelsView'; export { RealEstateInstanceRolesPlaceholder } from './RealEstateInstanceRolesPlaceholder'; \ No newline at end of file diff --git a/src/pages/views/realestate/pek/PekMapView.module.css b/src/pages/views/realestate/pek/PekMapView.module.css new file mode 100644 index 0000000..73946cf --- /dev/null +++ b/src/pages/views/realestate/pek/PekMapView.module.css @@ -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; +} diff --git a/src/pages/views/realestate/pek/PekMapView.tsx b/src/pages/views/realestate/pek/PekMapView.tsx index d11364c..e2413c9 100644 --- a/src/pages/views/realestate/pek/PekMapView.tsx +++ b/src/pages/views/realestate/pek/PekMapView.tsx @@ -1,55 +1,68 @@ -import React from 'react'; -import { MapView, ParcelInfoPanel } from '../../../../components/UiComponents'; +import React, { useState } from 'react'; +import { Button, MapView, ParcelInfoPanel } from '../../../../components/UiComponents'; import { usePekContext } from '../../../../contexts/PekContext'; +import styles from './PekMapView.module.css'; const PekMapView: React.FC = () => { + const [showWfsParcels, setShowWfsParcels] = useState(false); const { + instanceId, mapCenter, mapZoomBounds, parcelGeometries, + selectionSummary, handleMapClick, handleParcelClick, selectedParcels, removeParcel, + clearSelectedParcels, isPanelOpen, setIsPanelOpen } = usePekContext(); - // Aggregate all adjacent parcels from all selected parcels - const allAdjacentParcels = React.useMemo(() => { - const adjacentSet = new Map(); - 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 ( <> -
- +
+
+ + +
+
+ +
setIsPanelOpen(false)} parcels={selectedParcels} + selectionSummary={selectionSummary} onRemoveParcel={removeParcel} - adjacentParcels={allAdjacentParcels} + instanceId={instanceId} /> ); diff --git a/src/pages/views/trustee/TrusteeViews.module.css b/src/pages/views/trustee/TrusteeViews.module.css index 09963e8..2e14f4f 100644 --- a/src/pages/views/trustee/TrusteeViews.module.css +++ b/src/pages/views/trustee/TrusteeViews.module.css @@ -170,6 +170,12 @@ gap: 1.5rem; } +/* Fill available height (used by PEK map view) */ +.dashboardViewFill { + flex: 1; + min-height: 0; +} + .statsGrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); diff --git a/src/types/mandate.ts b/src/types/mandate.ts index 30c1903..3f63196 100644 --- a/src/types/mandate.ts +++ b/src/types/mandate.ts @@ -235,9 +235,7 @@ export const FEATURE_REGISTRY: Record = { label: { de: 'Immobilien', en: 'Real Estate' }, icon: 'home', views: [ - { code: 'dashboard', label: { de: 'Übersicht', en: 'Dashboard' }, path: 'dashboard' }, - { code: 'projects', label: { de: 'Projekte', en: 'Projects' }, path: 'projects' }, - { code: 'parcels', label: { de: 'Parzellen', en: 'Parcels' }, path: 'parcels' }, + { code: 'dashboard', label: { de: 'Karte', en: 'Map' }, path: 'dashboard' }, { code: 'instance-roles', label: { de: 'Rollen & Rechte', en: 'Roles & Permissions' }, path: 'instance-roles', adminOnly: true }, ] },