updated pek pages

This commit is contained in:
Ida Dittrich 2026-02-14 17:34:04 +01:00
parent 7e3bc59581
commit 1829c1c4ad
23 changed files with 1283 additions and 1283 deletions

View file

@ -145,8 +145,6 @@ function App() {
<Route path="position-documents" element={<FeatureViewPage view="position-documents" />} />
<Route path="expense-import" element={<FeatureViewPage view="expense-import" />} />
<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 */}
<Route path="playground" element={<FeatureViewPage view="playground" />} />

View file

@ -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);
}
}

View file

@ -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

View file

@ -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<MapViewProps> = ({
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<L.Map | null>(null);
const mapContainerRef = useRef<HTMLDivElement>(null);
const layersRef = useRef<L.Layer[]>([]);
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
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<MapViewProps> = ({
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<MapViewProps> = ({
}).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 = `
<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);
// 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('<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 {
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);
}
}
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(`
<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
useEffect(() => {
@ -202,6 +254,106 @@ const MapViewLeaflet: React.FC<MapViewProps> = ({
};
}, [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 (
<div className={`${styles.mapViewContainer} ${className}`} style={{ height, position: 'relative' }}>
<div ref={mapContainerRef} style={{ width: '100%', height: '100%' }} />
@ -210,6 +362,11 @@ const MapViewLeaflet: React.FC<MapViewProps> = ({
<p>{emptyMessage}</p>
</div>
)}
{showWfsParcels && isWfsLoading && (
<div className={styles.wfsLoadingIndicator} aria-hidden>
<span className={styles.wfsLoadingSpinner} />
</div>
)}
</div>
);
};

View file

@ -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;

View file

@ -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<string, Array<{ item: string; value: string }>>;
vorschlaege?: string[];
};
errors?: string[];
}
const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
isOpen,
onClose,
parcels,
selectionSummary,
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;
return (
@ -50,13 +148,42 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
</div>
<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}>
{parcels.map((parcelData, index) => (
<section key={parcelData.parcel.id || index} className={styles.section}>
{(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 [
<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}>
<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>
{onRemoveParcel && (
<button
@ -75,18 +202,6 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
<span className={styles.value}>{parcelData.parcel.id}</span>
</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 && (
<div className={styles.infoItem}>
<span className={styles.label}>EGRID:</span>
@ -148,6 +263,132 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
<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,
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 && (
<div className={styles.infoItem}>
<span className={styles.label}>Zone:</span>
@ -182,11 +423,267 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
</span>
</div>
)}
{parcelData.parcel.centroid && (
{parcelData.parcel.geoportal_url && (
<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}>
{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>
</div>
)}
@ -204,67 +701,24 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
</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>
))}
);
})
)}
</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>
</motion.div>
{previewDoc && (
<ContentPreview
isOpen={!!previewDoc}
onClose={() => setPreviewDoc(null)}
fileId={previewDoc.fileId}
fileName={previewDoc.fileName}
mimeType={previewDoc.mimeType}
/>
)}
</>
)}
</AnimatePresence>

View file

@ -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<void>;
handleParcelClick: (parcelId: string) => Promise<void>;
handleParcelClick: (parcelId: string) => void;
// Command processing
commandInput: string;
@ -56,8 +58,8 @@ interface PekContextType {
const PekContext = createContext<PekContextType | undefined>(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 (
<PekContext.Provider value={pekData}>

View file

@ -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[] = [];

View file

@ -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'); },
};

View file

@ -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'); },
};

View file

@ -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<string>('');
const [gemeinde, setGemeinde] = useState<string>('');
@ -162,6 +162,11 @@ export function usePek() {
max_y: number;
} | null>(null);
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
const [commandInput, setCommandInput] = useState<string>('');
@ -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<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;
});
// 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,

View file

@ -25,6 +25,9 @@
.viewContent {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: auto;
padding: 1.5rem;
}

View file

@ -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<string, Record<string, ViewComponent>> = {
},
realestate: {
dashboard: RealEstatePekView,
projects: RealEstateProjectsView,
parcels: RealEstateParcelsView,
'instance-roles': RealEstateInstanceRolesPlaceholder,
},
chatplayground: {

View file

@ -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<Mandate[]>([]);
@ -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 {

View file

@ -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');

View file

@ -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;

View file

@ -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 (
<div className={styles.dashboardView}>
<p className={styles.muted} style={{ marginBottom: '1rem' }}>
{t('projects.description_text')}
</p>
<div className={`${styles.dashboardView} ${styles.dashboardViewFill}`}>
<PekLocationInput />
<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>
);
}
export const RealEstatePekView: React.FC = () => {
const instanceId = useInstanceId();
return (
<PekProvider>
<PekProvider instanceId={instanceId}>
<RealEstatePekViewContent />
</PekProvider>
);

View file

@ -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;

View file

@ -1,5 +1,3 @@
export { RealEstateDashboardView } from './RealEstateDashboardView';
export { RealEstatePekView } from './RealEstatePekView';
export { RealEstateProjectsView } from './RealEstateProjectsView';
export { RealEstateParcelsView } from './RealEstateParcelsView';
export { RealEstateInstanceRolesPlaceholder } from './RealEstateInstanceRolesPlaceholder';

View 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;
}

View file

@ -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<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 (
<>
<div style={{ marginBottom: '1.5rem' }}>
<MapView
parcels={parcelGeometries}
center={mapCenter || undefined}
zoomBounds={mapZoomBounds || undefined}
onMapClick={handleMapClick}
onParcelClick={handleParcelClick}
height="600px"
emptyMessage="Klicken Sie auf die Karte, um einen Standort auszuwählen, oder suchen Sie nach einer Adresse oben."
/>
<div className={styles.pekMapWrapper}>
<div className={styles.checkboxRow}>
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={showWfsParcels}
onChange={(e) => setShowWfsParcels(e.target.checked)}
/>
<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>
<ParcelInfoPanel
isOpen={isPanelOpen}
onClose={() => setIsPanelOpen(false)}
parcels={selectedParcels}
selectionSummary={selectionSummary}
onRemoveParcel={removeParcel}
adjacentParcels={allAdjacentParcels}
instanceId={instanceId}
/>
</>
);

View file

@ -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));

View file

@ -235,9 +235,7 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
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 },
]
},