updated pek pages
This commit is contained in:
parent
7e3bc59581
commit
1829c1c4ad
23 changed files with 1283 additions and 1283 deletions
|
|
@ -145,8 +145,6 @@ function App() {
|
|||
<Route path="position-documents" element={<FeatureViewPage view="position-documents" />} />
|
||||
<Route path="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" />} />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -1,147 +0,0 @@
|
|||
import { useCallback } from 'react';
|
||||
import { GenericPageData } from '../../../pageInterface';
|
||||
import { FaMapMarkerAlt, FaPlus } from 'react-icons/fa';
|
||||
import { useRealEstateParcels, useRealEstateParcelOperations } from '../../../../../hooks/useRealEstate';
|
||||
|
||||
const attributesToColumns = (attributes: any[]) => {
|
||||
return attributes.map(attr => {
|
||||
const isDateField = attr.type === 'date' || attr.type === 'timestamp' ||
|
||||
/(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name);
|
||||
return {
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type || 'string',
|
||||
width: attr.width || 200,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: isDateField ? false : (attr.filterable !== false),
|
||||
searchable: attr.searchable !== false,
|
||||
filterOptions: attr.filterOptions,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const createParcelsHook = () => {
|
||||
return () => {
|
||||
const {
|
||||
items: parcels,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
removeOptimistically,
|
||||
updateOptimistically,
|
||||
attributes,
|
||||
permissions,
|
||||
fetchById,
|
||||
generateEditFieldsFromAttributes,
|
||||
generateCreateFieldsFromAttributes,
|
||||
ensureAttributesLoaded,
|
||||
} = useRealEstateParcels();
|
||||
const {
|
||||
handleDelete,
|
||||
handleCreate,
|
||||
handleUpdate,
|
||||
deletingItems,
|
||||
creatingItem,
|
||||
deleteError,
|
||||
createError,
|
||||
updateError,
|
||||
} = useRealEstateParcelOperations();
|
||||
|
||||
const generatedColumns = attributes && attributes.length > 0
|
||||
? attributesToColumns(attributes)
|
||||
: undefined;
|
||||
|
||||
const wrappedHandleCreate = useCallback(async (formData: any) => {
|
||||
return await handleCreate(formData);
|
||||
}, [handleCreate]);
|
||||
|
||||
const handleDeleteSingle = useCallback(async (item: any) => {
|
||||
const success = await handleDelete(item.id);
|
||||
if (success) refetch();
|
||||
}, [handleDelete, refetch]);
|
||||
|
||||
const handleDeleteMultiple = useCallback(async (selectedItems: any[]) => {
|
||||
const ids = selectedItems.map(item => item.id);
|
||||
const results = await Promise.all(ids.map(id => handleDelete(id)));
|
||||
if (results.every(Boolean)) refetch();
|
||||
}, [handleDelete, refetch]);
|
||||
|
||||
return {
|
||||
data: parcels,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
removeOptimistically,
|
||||
updateOptimistically,
|
||||
handleDelete,
|
||||
handleDeleteMultiple,
|
||||
handleCreate: wrappedHandleCreate,
|
||||
handleUpdate,
|
||||
onDelete: handleDeleteSingle,
|
||||
onDeleteMultiple: handleDeleteMultiple,
|
||||
deletingItems,
|
||||
creatingItem,
|
||||
deleteError,
|
||||
createError,
|
||||
updateError,
|
||||
attributes,
|
||||
permissions,
|
||||
columns: generatedColumns,
|
||||
fetchById,
|
||||
generateEditFieldsFromAttributes,
|
||||
generateCreateFieldsFromAttributes,
|
||||
ensureAttributesLoaded,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export const realEstateParcelsPageData: GenericPageData = {
|
||||
id: 'realestate-parcels',
|
||||
path: 'realestate/parcels',
|
||||
name: 'realestate.parcels.title',
|
||||
description: 'realestate.parcels.description',
|
||||
parentPath: 'start.realestate',
|
||||
icon: FaMapMarkerAlt,
|
||||
title: 'realestate.parcels.title',
|
||||
subtitle: 'realestate.parcels.subtitle',
|
||||
headerButtons: [
|
||||
{
|
||||
id: 'new-parcel',
|
||||
label: 'realestate.parcels.new_button',
|
||||
icon: FaPlus,
|
||||
variant: 'primary',
|
||||
formConfig: {
|
||||
fields: [
|
||||
{ key: 'label', label: 'realestate.parcels.field.label', type: 'string', required: true },
|
||||
{ key: 'strasseNr', label: 'realestate.parcels.field.strasseNr', type: 'string' },
|
||||
{ key: 'plz', label: 'realestate.parcels.field.plz', type: 'string' },
|
||||
],
|
||||
popupTitle: 'realestate.parcels.modal.create.title',
|
||||
popupSize: 'medium',
|
||||
createOperationName: 'handleCreate',
|
||||
successMessage: 'realestate.parcels.create.success',
|
||||
errorMessage: 'realestate.parcels.create.error',
|
||||
},
|
||||
},
|
||||
],
|
||||
content: [
|
||||
{
|
||||
id: 'parcels-table',
|
||||
type: 'table',
|
||||
tableConfig: {
|
||||
hookFactory: createParcelsHook,
|
||||
actionButtons: [
|
||||
{ type: 'edit', operationName: 'handleUpdate', fetchItemFunctionName: 'fetchById' },
|
||||
{ type: 'delete', operationName: 'handleDelete' },
|
||||
],
|
||||
className: 'realestate-parcels-table',
|
||||
},
|
||||
},
|
||||
],
|
||||
moduleEnabled: true,
|
||||
onActivate: () => { if (import.meta.env.DEV) console.log('RealEstate Parcels activated'); },
|
||||
onLoad: () => { if (import.meta.env.DEV) console.log('RealEstate Parcels loaded'); },
|
||||
onUnload: () => { if (import.meta.env.DEV) console.log('RealEstate Parcels unloaded'); },
|
||||
};
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
import { useCallback } from 'react';
|
||||
import { GenericPageData } from '../../../pageInterface';
|
||||
import { FaBuilding, FaPlus } from 'react-icons/fa';
|
||||
import { useRealEstateProjects, useRealEstateProjectOperations } from '../../../../../hooks/useRealEstate';
|
||||
|
||||
const attributesToColumns = (attributes: any[]) => {
|
||||
return attributes.map(attr => {
|
||||
const isDateField = attr.type === 'date' || attr.type === 'timestamp' ||
|
||||
/(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name);
|
||||
return {
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type || 'string',
|
||||
width: attr.width || 200,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: isDateField ? false : (attr.filterable !== false),
|
||||
searchable: attr.searchable !== false,
|
||||
filterOptions: attr.filterOptions,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const createProjectsHook = () => {
|
||||
return () => {
|
||||
const {
|
||||
items: projects,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
removeOptimistically,
|
||||
updateOptimistically,
|
||||
attributes,
|
||||
permissions,
|
||||
fetchById,
|
||||
generateEditFieldsFromAttributes,
|
||||
generateCreateFieldsFromAttributes,
|
||||
ensureAttributesLoaded,
|
||||
} = useRealEstateProjects();
|
||||
const {
|
||||
handleDelete,
|
||||
handleCreate,
|
||||
handleUpdate,
|
||||
deletingItems,
|
||||
creatingItem,
|
||||
deleteError,
|
||||
createError,
|
||||
updateError,
|
||||
} = useRealEstateProjectOperations();
|
||||
|
||||
const generatedColumns = attributes && attributes.length > 0
|
||||
? attributesToColumns(attributes)
|
||||
: undefined;
|
||||
|
||||
const wrappedHandleCreate = useCallback(async (formData: any) => {
|
||||
return await handleCreate(formData);
|
||||
}, [handleCreate]);
|
||||
|
||||
const handleDeleteSingle = useCallback(async (item: any) => {
|
||||
const success = await handleDelete(item.id);
|
||||
if (success) refetch();
|
||||
}, [handleDelete, refetch]);
|
||||
|
||||
const handleDeleteMultiple = useCallback(async (selectedItems: any[]) => {
|
||||
const ids = selectedItems.map(item => item.id);
|
||||
const results = await Promise.all(ids.map(id => handleDelete(id)));
|
||||
if (results.every(Boolean)) refetch();
|
||||
}, [handleDelete, refetch]);
|
||||
|
||||
return {
|
||||
data: projects,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
removeOptimistically,
|
||||
updateOptimistically,
|
||||
handleDelete,
|
||||
handleDeleteMultiple,
|
||||
handleCreate: wrappedHandleCreate,
|
||||
handleUpdate,
|
||||
onDelete: handleDeleteSingle,
|
||||
onDeleteMultiple: handleDeleteMultiple,
|
||||
deletingItems,
|
||||
creatingItem,
|
||||
deleteError,
|
||||
createError,
|
||||
updateError,
|
||||
attributes,
|
||||
permissions,
|
||||
columns: generatedColumns,
|
||||
fetchById,
|
||||
generateEditFieldsFromAttributes,
|
||||
generateCreateFieldsFromAttributes,
|
||||
ensureAttributesLoaded,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export const realEstateProjectsPageData: GenericPageData = {
|
||||
id: 'realestate-projects',
|
||||
path: 'realestate/projects',
|
||||
name: 'realestate.projects.title',
|
||||
description: 'realestate.projects.description',
|
||||
parentPath: 'start.realestate',
|
||||
icon: FaBuilding,
|
||||
title: 'realestate.projects.title',
|
||||
subtitle: 'realestate.projects.subtitle',
|
||||
headerButtons: [
|
||||
{
|
||||
id: 'new-project',
|
||||
label: 'realestate.projects.new_button',
|
||||
icon: FaPlus,
|
||||
variant: 'primary',
|
||||
formConfig: {
|
||||
fields: [
|
||||
{ key: 'label', label: 'realestate.projects.field.label', type: 'string', required: true },
|
||||
{ key: 'statusProzess', label: 'realestate.projects.field.statusProzess', type: 'string' },
|
||||
],
|
||||
popupTitle: 'realestate.projects.modal.create.title',
|
||||
popupSize: 'medium',
|
||||
createOperationName: 'handleCreate',
|
||||
successMessage: 'realestate.projects.create.success',
|
||||
errorMessage: 'realestate.projects.create.error',
|
||||
},
|
||||
},
|
||||
],
|
||||
content: [
|
||||
{
|
||||
id: 'projects-table',
|
||||
type: 'table',
|
||||
tableConfig: {
|
||||
hookFactory: createProjectsHook,
|
||||
actionButtons: [
|
||||
{ type: 'edit', operationName: 'handleUpdate', fetchItemFunctionName: 'fetchById' },
|
||||
{ type: 'delete', operationName: 'handleDelete' },
|
||||
],
|
||||
className: 'realestate-projects-table',
|
||||
},
|
||||
},
|
||||
],
|
||||
moduleEnabled: true,
|
||||
onActivate: () => { if (import.meta.env.DEV) console.log('RealEstate Projects activated'); },
|
||||
onLoad: () => { if (import.meta.env.DEV) console.log('RealEstate Projects loaded'); },
|
||||
onUnload: () => { if (import.meta.env.DEV) console.log('RealEstate Projects unloaded'); },
|
||||
};
|
||||
|
|
@ -126,7 +126,7 @@ export interface AddParcelResponse {
|
|||
}
|
||||
|
||||
// Main PEK hook
|
||||
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,
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@
|
|||
|
||||
.viewContent {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -1,267 +0,0 @@
|
|||
/**
|
||||
* RealEstateParcelsView
|
||||
*
|
||||
* Parzellen-Verwaltung für eine Real Estate/PEK-Instanz.
|
||||
* Verwendet FormGeneratorTable analog zu TrusteeDocumentsView.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
useRealEstateParcels,
|
||||
useRealEstateParcelOperations,
|
||||
type RealEstateParcel,
|
||||
} from '../../../hooks/useRealEstate';
|
||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||
import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable';
|
||||
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FaSync, FaMapMarkerAlt } from 'react-icons/fa';
|
||||
import styles from '../../admin/Admin.module.css';
|
||||
|
||||
export const RealEstateParcelsView: React.FC = () => {
|
||||
const instanceId = useInstanceId();
|
||||
|
||||
const {
|
||||
items: parcels,
|
||||
attributes,
|
||||
permissions,
|
||||
pagination,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
fetchById,
|
||||
updateOptimistically,
|
||||
removeOptimistically,
|
||||
} = useRealEstateParcels();
|
||||
|
||||
const {
|
||||
handleDelete,
|
||||
handleCreate,
|
||||
handleUpdate,
|
||||
deletingItems,
|
||||
} = useRealEstateParcelOperations();
|
||||
|
||||
const [editingParcel, setEditingParcel] = useState<RealEstateParcel | null>(null);
|
||||
const [isCreateMode, setIsCreateMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (instanceId) {
|
||||
refetch();
|
||||
}
|
||||
}, [instanceId, refetch]);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return (attributes || []).map(attr => ({
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type as 'string' | 'number' | 'date' | 'boolean',
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
width: attr.width || 150,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
}));
|
||||
}, [attributes]);
|
||||
|
||||
const canCreate = permissions?.create !== 'n';
|
||||
const canUpdate = permissions?.update !== 'n';
|
||||
const canDelete = permissions?.delete !== 'n';
|
||||
|
||||
const handleEditClick = async (parcel: RealEstateParcel) => {
|
||||
const full = await fetchById(parcel.id);
|
||||
if (full) {
|
||||
setEditingParcel(full);
|
||||
setIsCreateMode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateClick = () => {
|
||||
setEditingParcel(null);
|
||||
setIsCreateMode(true);
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (data: Partial<RealEstateParcel>) => {
|
||||
if (isCreateMode) {
|
||||
const result = await handleCreate(data);
|
||||
if (result.success) {
|
||||
setIsCreateMode(false);
|
||||
refetch();
|
||||
}
|
||||
} else if (editingParcel) {
|
||||
const result = await handleUpdate(editingParcel.id, data);
|
||||
if (result.success) {
|
||||
setEditingParcel(null);
|
||||
refetch();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteParcel = async (parcel: RealEstateParcel) => {
|
||||
removeOptimistically(parcel.id);
|
||||
const success = await handleDelete(parcel.id);
|
||||
if (!success) {
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setEditingParcel(null);
|
||||
setIsCreateMode(false);
|
||||
};
|
||||
|
||||
const formAttributes = useMemo(() => {
|
||||
const excluded = ['id', 'mandateId', 'instanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy'];
|
||||
return (attributes || []).filter(attr => !excluded.includes(attr.name));
|
||||
}, [attributes]);
|
||||
|
||||
const handleInlineUpdate = async (
|
||||
itemId: string,
|
||||
updateData: Partial<RealEstateParcel>,
|
||||
row: RealEstateParcel
|
||||
) => {
|
||||
updateOptimistically(itemId, updateData);
|
||||
const result = await handleUpdate(itemId, { ...row, ...updateData });
|
||||
if (!result.success) {
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={styles.errorContainer}>
|
||||
<span className={styles.errorIcon}>⚠️</span>
|
||||
<p className={styles.errorMessage}>Fehler beim Laden der Parzellen: {error}</p>
|
||||
<button className={styles.secondaryButton} onClick={() => refetch()}>
|
||||
<FaSync /> Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<p className={styles.pageSubtitle}>Parzellen verwalten</p>
|
||||
</div>
|
||||
<div className={styles.headerActions}>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={() => refetch()}
|
||||
disabled={loading}
|
||||
>
|
||||
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
|
||||
</button>
|
||||
{canCreate && (
|
||||
<button className={styles.primaryButton} onClick={handleCreateClick}>
|
||||
+ Neue Parzelle
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.tableContainer}>
|
||||
{loading && (!parcels || parcels.length === 0) ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Parzellen...</span>
|
||||
</div>
|
||||
) : !parcels || parcels.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<FaMapMarkerAlt className={styles.emptyIcon} />
|
||||
<h3 className={styles.emptyTitle}>Keine Parzellen vorhanden</h3>
|
||||
<p className={styles.emptyDescription}>
|
||||
Erstellen Sie eine neue Parzelle, um zu beginnen.
|
||||
</p>
|
||||
{canCreate && (
|
||||
<button className={styles.primaryButton} onClick={handleCreateClick}>
|
||||
+ Neue Parzelle
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorTable
|
||||
data={parcels}
|
||||
columns={columns}
|
||||
apiEndpoint={instanceId ? `/api/realestate/${instanceId}/parcels` : undefined}
|
||||
loading={loading}
|
||||
pagination={true}
|
||||
pageSize={25}
|
||||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={false}
|
||||
actionButtons={[
|
||||
...(canUpdate
|
||||
? [
|
||||
{
|
||||
type: 'edit' as const,
|
||||
onAction: handleEditClick,
|
||||
title: 'Bearbeiten',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canDelete
|
||||
? [
|
||||
{
|
||||
type: 'delete' as const,
|
||||
title: 'Löschen',
|
||||
loading: (row: RealEstateParcel) => deletingItems.has(row.id),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
onDelete={handleDeleteParcel}
|
||||
hookData={{
|
||||
refetch,
|
||||
permissions,
|
||||
pagination,
|
||||
handleDelete,
|
||||
handleInlineUpdate,
|
||||
updateOptimistically,
|
||||
}}
|
||||
emptyMessage="Keine Parzellen gefunden"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(editingParcel || isCreateMode) && (
|
||||
<div className={styles.modalOverlay} onClick={handleCloseModal}>
|
||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h2 className={styles.modalTitle}>
|
||||
{isCreateMode ? 'Neue Parzelle' : 'Parzelle bearbeiten'}
|
||||
</h2>
|
||||
<button className={styles.modalClose} onClick={handleCloseModal}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.modalContent}>
|
||||
{formAttributes.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Formular...</span>
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorForm
|
||||
attributes={formAttributes}
|
||||
data={editingParcel || {}}
|
||||
mode={isCreateMode ? 'create' : 'edit'}
|
||||
onSubmit={handleFormSubmit}
|
||||
onCancel={handleCloseModal}
|
||||
submitButtonText={isCreateMode ? 'Erstellen' : 'Speichern'}
|
||||
cancelButtonText="Abbrechen"
|
||||
instanceId={instanceId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RealEstateParcelsView;
|
||||
|
|
@ -6,105 +6,27 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { 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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,224 +0,0 @@
|
|||
/**
|
||||
* RealEstateProjectsView
|
||||
*
|
||||
* Projekt-Verwaltung für eine Real Estate/PEK-Instanz.
|
||||
* Verwendet FormGeneratorTable analog zu TrusteeDocumentsView.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
useRealEstateProjects,
|
||||
useRealEstateProjectOperations,
|
||||
type RealEstateProject,
|
||||
} from '../../../hooks/useRealEstate';
|
||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||
import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable';
|
||||
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FaSync, FaBuilding } from 'react-icons/fa';
|
||||
import styles from '../../admin/Admin.module.css';
|
||||
|
||||
export const RealEstateProjectsView: React.FC = () => {
|
||||
const instanceId = useInstanceId();
|
||||
|
||||
const {
|
||||
items: projects,
|
||||
attributes,
|
||||
permissions,
|
||||
pagination,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
fetchById,
|
||||
updateOptimistically,
|
||||
removeOptimistically,
|
||||
} = useRealEstateProjects();
|
||||
|
||||
const {
|
||||
handleDelete,
|
||||
handleCreate,
|
||||
handleUpdate,
|
||||
deletingItems,
|
||||
} = useRealEstateProjectOperations();
|
||||
|
||||
const [editingProject, setEditingProject] = useState<RealEstateProject | null>(null);
|
||||
const [isCreateMode, setIsCreateMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (instanceId) refetch();
|
||||
}, [instanceId, refetch]);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return (attributes || []).map(attr => ({
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: (attr.type || 'string') as 'string' | 'number' | 'date' | 'boolean',
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
width: attr.width || 150,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
}));
|
||||
}, [attributes]);
|
||||
|
||||
const canCreate = permissions?.create !== 'n';
|
||||
const canUpdate = permissions?.update !== 'n';
|
||||
const canDelete = permissions?.delete !== 'n';
|
||||
|
||||
const handleEditClick = async (project: RealEstateProject) => {
|
||||
const full = await fetchById(project.id);
|
||||
if (full) {
|
||||
setEditingProject(full);
|
||||
setIsCreateMode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateClick = () => {
|
||||
setEditingProject(null);
|
||||
setIsCreateMode(true);
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (data: Partial<RealEstateProject>) => {
|
||||
if (isCreateMode) {
|
||||
const result = await handleCreate(data);
|
||||
if (result.success) {
|
||||
setIsCreateMode(false);
|
||||
refetch();
|
||||
}
|
||||
} else if (editingProject) {
|
||||
const result = await handleUpdate(editingProject.id, data);
|
||||
if (result.success) {
|
||||
setEditingProject(null);
|
||||
refetch();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProject = async (project: RealEstateProject) => {
|
||||
removeOptimistically(project.id);
|
||||
const success = await handleDelete(project.id);
|
||||
if (!success) refetch();
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setEditingProject(null);
|
||||
setIsCreateMode(false);
|
||||
};
|
||||
|
||||
const formAttributes = useMemo(() => {
|
||||
const excluded = ['id', 'mandateId', 'instanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy'];
|
||||
return (attributes || []).filter(attr => !excluded.includes(attr.name));
|
||||
}, [attributes]);
|
||||
|
||||
const handleInlineUpdate = async (itemId: string, updateData: Partial<RealEstateProject>, row: RealEstateProject) => {
|
||||
updateOptimistically(itemId, updateData);
|
||||
const result = await handleUpdate(itemId, { ...row, ...updateData });
|
||||
if (!result.success) refetch();
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={styles.errorContainer}>
|
||||
<span className={styles.errorIcon}>⚠️</span>
|
||||
<p className={styles.errorMessage}>Fehler beim Laden der Projekte: {error}</p>
|
||||
<button className={styles.secondaryButton} onClick={() => refetch()}>
|
||||
<FaSync /> Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<p className={styles.pageSubtitle}>Projekte verwalten</p>
|
||||
</div>
|
||||
<div className={styles.headerActions}>
|
||||
<button className={styles.secondaryButton} onClick={() => refetch()} disabled={loading}>
|
||||
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
|
||||
</button>
|
||||
{canCreate && (
|
||||
<button className={styles.primaryButton} onClick={handleCreateClick}>
|
||||
+ Neues Projekt
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.tableContainer}>
|
||||
{loading && (!projects || projects.length === 0) ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Projekte...</span>
|
||||
</div>
|
||||
) : !projects || projects.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<FaBuilding className={styles.emptyIcon} />
|
||||
<h3 className={styles.emptyTitle}>Keine Projekte vorhanden</h3>
|
||||
<p className={styles.emptyDescription}>Erstellen Sie ein neues Projekt, um zu beginnen.</p>
|
||||
{canCreate && (
|
||||
<button className={styles.primaryButton} onClick={handleCreateClick}>
|
||||
+ Neues Projekt
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorTable
|
||||
data={projects}
|
||||
columns={columns}
|
||||
apiEndpoint={instanceId ? `/api/realestate/${instanceId}/projects` : undefined}
|
||||
loading={loading}
|
||||
pagination={true}
|
||||
pageSize={25}
|
||||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={false}
|
||||
actionButtons={[
|
||||
...(canUpdate ? [{ type: 'edit' as const, onAction: handleEditClick, title: 'Bearbeiten' }] : []),
|
||||
...(canDelete ? [{ type: 'delete' as const, title: 'Löschen', loading: (row: RealEstateProject) => deletingItems.has(row.id) }] : []),
|
||||
]}
|
||||
onDelete={handleDeleteProject}
|
||||
hookData={{ refetch, permissions, pagination, handleDelete, handleInlineUpdate, updateOptimistically }}
|
||||
emptyMessage="Keine Projekte gefunden"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(editingProject || isCreateMode) && (
|
||||
<div className={styles.modalOverlay} onClick={handleCloseModal}>
|
||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h2 className={styles.modalTitle}>{isCreateMode ? 'Neues Projekt' : 'Projekt bearbeiten'}</h2>
|
||||
<button className={styles.modalClose} onClick={handleCloseModal}>✕</button>
|
||||
</div>
|
||||
<div className={styles.modalContent}>
|
||||
{formAttributes.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Formular...</span>
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorForm
|
||||
attributes={formAttributes}
|
||||
data={editingProject || {}}
|
||||
mode={isCreateMode ? 'create' : 'edit'}
|
||||
onSubmit={handleFormSubmit}
|
||||
onCancel={handleCloseModal}
|
||||
submitButtonText={isCreateMode ? 'Erstellen' : 'Speichern'}
|
||||
cancelButtonText="Abbrechen"
|
||||
instanceId={instanceId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RealEstateProjectsView;
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
export { RealEstateDashboardView } from './RealEstateDashboardView';
|
||||
export { RealEstatePekView } from './RealEstatePekView';
|
||||
export { RealEstateProjectsView } from './RealEstateProjectsView';
|
||||
export { RealEstateParcelsView } from './RealEstateParcelsView';
|
||||
export { RealEstateInstanceRolesPlaceholder } from './RealEstateInstanceRolesPlaceholder';
|
||||
30
src/pages/views/realestate/pek/PekMapView.module.css
Normal file
30
src/pages/views/realestate/pek/PekMapView.module.css
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* PEK Map View - map fills available space
|
||||
*/
|
||||
|
||||
.pekMapWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.checkboxRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checkboxLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.mapContainer {
|
||||
flex: 1;
|
||||
min-height: 400px;
|
||||
position: relative;
|
||||
}
|
||||
|
|
@ -1,55 +1,68 @@
|
|||
import React from 'react';
|
||||
import { 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
]
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue