diff --git a/src/App.tsx b/src/App.tsx index 17aa5ef..d69f5ca 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,8 @@ /** * App.tsx - * + * * Haupt-App-Komponente mit Multi-Tenant Router-Setup. - * + * * URL-Struktur: * - / → Dashboard/Übersicht * - /settings → Benutzer-Einstellungen @@ -30,25 +30,15 @@ import { ProtectedRoute } from './providers/auth/ProtectedRoute'; import { LanguageProvider } from './providers/language/LanguageContext'; import { ToastProvider } from './contexts/ToastContext'; import { WorkflowSelectionProvider } from './contexts/WorkflowSelectionContext'; -import { FileProvider } from './contexts/FileContext'; - -// Layouts import { MainLayout } from './layouts/MainLayout'; import { FeatureLayout } from './layouts/FeatureLayout'; - -// Pages import { DashboardPage } from './pages/Dashboard'; import { SettingsPage } from './pages/Settings'; import { GDPRPage } from './pages/GDPR'; import { FeatureViewPage } from './pages/FeatureView'; -import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminAutomationEventsPage } from './pages/admin'; - -// Basedata Pages (global) +import { AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolesPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage } from './pages/admin'; +import { PlaygroundPage, WorkflowsPage, AutomationsPage } from './pages/workflows'; import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata'; - -// Billing Pages -import { BillingDataView, BillingAdmin } from './pages/billing'; - function App() { // Load saved theme preference and set app name on app mount useEffect(() => { @@ -93,9 +83,7 @@ function App() { {/* ================================================== */} - - }> {/* Dashboard (Root) */} @@ -105,6 +93,15 @@ function App() { } /> } /> + {/* ============================================== */} + {/* WORKFLOWS ROUTES (global) */} + {/* ============================================== */} + + } /> + } /> + } /> + + {/* ============================================== */} {/* BASISDATEN ROUTES (global) */} {/* ============================================== */} @@ -114,13 +111,10 @@ function App() { } /> - {/* ============================================== */} - {/* BILLING ROUTES */} - {/* ============================================== */} - - } /> - } /> - + {/* Legacy top-level routes – redirect to dashboard (migrated to feature-instance routes) */} + } /> + } /> + } /> {/* ============================================== */} {/* FEATURE-INSTANZ ROUTES */} @@ -148,15 +142,6 @@ function App() { } /> } /> - {/* Chat Playground Feature Views */} - } /> - } /> - - {/* Automation Feature Views */} - } /> - } /> - } /> - {/* Catch-all für unbekannte Sub-Pfade */} } /> @@ -165,8 +150,6 @@ function App() { {/* ADMIN ROUTES (nur SysAdmin) */} {/* ============================================== */} - } /> - } /> } /> } /> } /> @@ -177,8 +160,6 @@ function App() { } /> } /> } /> - } /> - } /> diff --git a/src/components/ContentPreview/UrlContentPreview.tsx b/src/components/ContentPreview/UrlContentPreview.tsx new file mode 100644 index 0000000..5d41e68 --- /dev/null +++ b/src/components/ContentPreview/UrlContentPreview.tsx @@ -0,0 +1,348 @@ +import { useState, useEffect } from 'react'; +import { IoIosDownload } from 'react-icons/io'; +import { Popup, PopupAction } from '../UiComponents/Popup/Popup'; +import { useLanguage } from '../../providers/language/LanguageContext'; +import { PdfRenderer, PdfJsRenderer, LoadingRenderer, ErrorRenderer } from './renderers'; +import styles from './ContentPreview.module.css'; + +export interface UrlContentPreviewProps { + isOpen: boolean; + onClose: () => void; + url: string; + fileName: string; + mimeType?: string; +} + +export function UrlContentPreview({ + isOpen, + onClose, + url, + fileName, + mimeType = 'application/pdf' +}: UrlContentPreviewProps) { + const { t } = useLanguage(); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [hasLoaded, setHasLoaded] = useState(false); + const [warning, setWarning] = useState(null); + const [showPdfAnyway, setShowPdfAnyway] = useState(false); + const [usePdfJs, setUsePdfJs] = useState(false); + + // Reset state when modal opens/closes + useEffect(() => { + if (isOpen && url) { + setIsLoading(true); + setError(null); + setWarning(null); + setHasLoaded(false); + setShowPdfAnyway(false); + setUsePdfJs(false); // Start with iframe + } else { + setIsLoading(false); + setError(null); + setWarning(null); + setHasLoaded(false); + setShowPdfAnyway(false); + setUsePdfJs(false); + } + }, [isOpen, url]); + + const handleDownload = () => { + try { + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } catch (err) { + console.error('Failed to download file:', err); + // Fallback: open in new tab + window.open(url, '_blank', 'noopener,noreferrer'); + } + }; + + const handlePdfLoad = () => { + setIsLoading(false); + setHasLoaded(true); + setError(null); + }; + + const handlePdfError = () => { + // Try PDF.js as fallback instead of showing error immediately + if (!usePdfJs) { + console.log('Iframe failed, switching to PDF.js fallback'); + setUsePdfJs(true); + setIsLoading(true); // Restart loading with PDF.js + setError(null); + setWarning(null); + return; + } + // If PDF.js also fails, show error + setIsLoading(false); + setError('Failed to load PDF. This might be due to CORS restrictions. You can try downloading the file or opening it in a new tab.'); + setShowPdfAnyway(true); + }; + + const handleOpenInNewTab = () => { + window.open(url, '_blank', 'noopener,noreferrer'); + }; + + // Set up progressive timeout for loading (schnellerer Fallback) + useEffect(() => { + if (isOpen && isLoading && !hasLoaded) { + // Schnellerer Timeout für externe PDFs: Warning after 3s, Error after 5s + const QUICK_TIMEOUT = 5000; // 5 Sekunden + const WARNING_TIMEOUT = 3000; // 3 Sekunden Warnung + + const warningTimeout = setTimeout(() => { + if (isLoading && !hasLoaded) { + setWarning('PDF lädt langsam. Sie können es auch direkt herunterladen oder in einem neuen Tab öffnen.'); + // Don't set isLoading to false - let it continue + } + }, WARNING_TIMEOUT); + + const errorTimeout = setTimeout(() => { + if (isLoading && !hasLoaded && !usePdfJs) { + // Try PDF.js as fallback after 5 seconds + console.log('PDF loading timeout, switching to PDF.js fallback'); + setUsePdfJs(true); + setIsLoading(true); // Restart loading with PDF.js + setWarning('PDF lädt langsam. Versuche alternative Anzeigemethode...'); + } else if (isLoading && !hasLoaded && usePdfJs) { + // PDF.js also failed, show error + setShowPdfAnyway(true); + setError('PDF lädt langsam. Bitte verwenden Sie den Download-Button oder öffnen Sie es in einem neuen Tab.'); + setIsLoading(false); + } + }, QUICK_TIMEOUT); + + return () => { + clearTimeout(warningTimeout); + clearTimeout(errorTimeout); + }; + } + }, [isOpen, isLoading, hasLoaded, usePdfJs]); + + // Validate URL + useEffect(() => { + if (isOpen && url) { + try { + new URL(url); + } catch (e) { + setError('Invalid URL'); + setIsLoading(false); + } + } + }, [isOpen, url]); + + // Create action buttons for the popup header + const actions: PopupAction[] = [ + { + label: String(''), + icon: , + onClick: handleDownload, + disabled: false, + variant: 'success' as const + } + ]; + + const renderPreview = () => { + // Show warning but continue loading + const showWarning = warning && !error; + + // For PDF files, always try to show PDF (even if there's an error) + if (mimeType === 'application/pdf' && (hasLoaded || showPdfAnyway || !error)) { + return ( +
+ {showWarning && ( +
+ ⚠️ {warning} +
+ )} + {error && ( +
+ ⚠️ {error} +
+ + +
+
+ )} +
+ +
+
+ ); + } + + // Show error only if we're not showing PDF anyway + if (error && !showPdfAnyway) { + return ( +
+
⚠️
+

{error}

+
+ + + +
+
+ ); + } + + if (isLoading && !hasLoaded && !showPdfAnyway) { + return ( +
+ {warning && ( +
+ ⚠️ {warning} +
+ + +
+
+ )} +
+ +
+
+ ); + } + + // For other file types, show unsupported message + if (mimeType !== 'application/pdf') { + return ( +
+
📄
+
{fileName}
+

Preview not supported for this file type. Please download the file to view it.

+ +
+ ); + } + + return null; + }; + + return ( + +
+ {renderPreview()} +
+
+ ); +} + +export default UrlContentPreview; diff --git a/src/components/ContentPreview/renderers/PdfJsRenderer.tsx b/src/components/ContentPreview/renderers/PdfJsRenderer.tsx new file mode 100644 index 0000000..becf575 --- /dev/null +++ b/src/components/ContentPreview/renderers/PdfJsRenderer.tsx @@ -0,0 +1,238 @@ +import { useEffect, useRef, useState } from 'react'; +import * as pdfjsLib from 'pdfjs-dist'; +import styles from '../ContentPreview.module.css'; + +// Set worker source for PDF.js +if (typeof window !== 'undefined') { + // Try to use local worker first, fallback to CDN + try { + pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( + '../../../../node_modules/pdfjs-dist/build/pdf.worker.min.js', + import.meta.url + ).toString(); + } catch (e) { + // Fallback to CDN if local worker not available + pdfjsLib.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.js`; + } +} + +interface PdfJsRendererProps { + previewUrl: string; + fileName: string; + onError: () => void; + onLoad?: () => void; +} + +export function PdfJsRenderer({ previewUrl, fileName, onError, onLoad }: PdfJsRendererProps) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [numPages, setNumPages] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [scale, setScale] = useState(1.5); + + useEffect(() => { + let pdfDoc: pdfjsLib.PDFDocumentProxy | null = null; + let isMounted = true; + + const loadPdf = async () => { + try { + setIsLoading(true); + setError(null); + + // Load PDF using fetch (like download) + const response = await fetch(previewUrl); + if (!response.ok) { + throw new Error(`Failed to fetch PDF: ${response.statusText}`); + } + + const arrayBuffer = await response.arrayBuffer(); + const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer }); + pdfDoc = await loadingTask.promise; + + if (!isMounted) return; + + setNumPages(pdfDoc.numPages); + setIsLoading(false); + if (onLoad) { + onLoad(); + } + } catch (err) { + console.error('Error loading PDF with PDF.js:', err); + if (isMounted) { + setError(err instanceof Error ? err.message : 'Failed to load PDF'); + setIsLoading(false); + onError(); + } + } + }; + + loadPdf(); + + return () => { + isMounted = false; + }; + }, [previewUrl, onLoad, onError]); + + useEffect(() => { + if (!canvasRef.current || isLoading || error) return; + + let isMounted = true; + + const renderPage = async (pageNum: number) => { + try { + // Load PDF again for rendering (could be optimized with caching) + const response = await fetch(previewUrl); + const arrayBuffer = await response.arrayBuffer(); + const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer }); + const pdfDoc = await loadingTask.promise; + + if (!isMounted) return; + + const page = await pdfDoc.getPage(pageNum); + const viewport = page.getViewport({ scale }); + + const canvas = canvasRef.current; + if (!canvas) return; + + canvas.height = viewport.height; + canvas.width = viewport.width; + + const context = canvas.getContext('2d'); + if (!context) return; + + const renderContext = { + canvasContext: context, + viewport: viewport, + }; + + await page.render(renderContext).promise; + } catch (err) { + console.error('Error rendering PDF page:', err); + if (isMounted) { + setError(err instanceof Error ? err.message : 'Failed to render PDF page'); + } + } + }; + + renderPage(currentPage); + + return () => { + isMounted = false; + }; + }, [previewUrl, currentPage, scale, isLoading, error]); + + if (error) { + return ( +
+
⚠️
+

Fehler beim Laden der PDF: {error}

+
+ ); + } + + if (isLoading) { + return ( +
+
+

PDF wird geladen...

+
+ ); + } + + return ( +
+ {/* Navigation Controls */} + {numPages > 1 && ( +
+ + + Seite {currentPage} von {numPages} + + +
+ + + {Math.round(scale * 100)}% + + +
+
+ )} + + {/* PDF Canvas */} +
+ +
+
+ ); +} diff --git a/src/components/Navigation/MandateNavigation.tsx b/src/components/Navigation/MandateNavigation.tsx index dc9ffb5..7804c17 100644 --- a/src/components/Navigation/MandateNavigation.tsx +++ b/src/components/Navigation/MandateNavigation.tsx @@ -8,24 +8,26 @@ * Backend liefert Blocks-Struktur mit Static und Dynamic Blocks. * UI mappt uiComponent zu Icons via pageRegistry. * - * TREE STRUCTURE (alles collapsible): - * ▼ Meine Sicht - * - Übersicht, Einstellungen, Prompts, Dateien, Verbindungen, Billing - * ───────────── - * ▼ Mandant 1 - * - 🎯 Instanz 1 (Feature-Icon + Instanz-Name) - * - 💼 Instanz 2 (Feature-Icon + Instanz-Name) - * ───────────── - * ▶ Administration - * - Users, Mandates, Roles, ... + * Struktur (gemäss Navigation-API-Konzept): + * - SYSTEM (static block, order: 10) + * - MEINE FEATURES (dynamic block, order: 15) + * - Mandant 1 + * - Feature A + * - Instanz 1 (mit Views) + * - WORKFLOWS (static block, order: 20) + * - BASISDATEN (static block, order: 30) + * - MIGRATE TO FEATURES (static block, order: 40) + * - ADMINISTRATION (static block, order: 200) */ import React, { useMemo } from 'react'; import { useNavigation } from '../../hooks/useNavigation'; import type { + StaticBlock, DynamicBlock, NavigationItem, NavigationMandate, + MandateFeature, FeatureInstance, FeatureView } from '../../hooks/useNavigation'; @@ -51,20 +53,13 @@ function navigationItemToTreeNode(item: NavigationItem): TreeNodeItem { } /** - * Convert a list of NavigationItems into a collapsible TreeNodeItem container. - * Used for grouping static items under "Meine Sicht" and "Administration". + * Convert a StaticBlock to TreeItem (section) */ -function _staticItemsToTreeNode( - id: string, - label: string, - items: NavigationItem[], - defaultExpanded: boolean = true, -): TreeNodeItem { +function staticBlockToTreeItem(block: StaticBlock): TreeItem { return { - id, - label, - children: items.map(navigationItemToTreeNode), - defaultExpanded, + type: 'section', + title: block.title, + children: block.items.map(navigationItemToTreeNode), }; } @@ -80,52 +75,59 @@ function featureViewToTreeNode(view: FeatureView): TreeNodeItem { } /** - * Convert a FeatureInstance to TreeNodeItem (with feature icon) - * Instance node gets path to first view so clicking the instance name navigates to dashboard. - * Shows the feature icon next to the instance name for visual distinction. + * Convert a FeatureInstance to TreeNodeItem */ -function featureInstanceToTreeNode(instance: FeatureInstance, featureUiComponent: string): TreeNodeItem { - const children = instance.views.map(featureViewToTreeNode); +function featureInstanceToTreeNode(instance: FeatureInstance): TreeNodeItem { return { id: instance.id, label: instance.uiLabel, - icon: getPageIcon(featureUiComponent), // Use feature icon for instance path: instance.views.length > 0 ? instance.views[0].uiPath : undefined, - children, + children: instance.views.map(featureViewToTreeNode), + defaultExpanded: false, + }; +} + +/** + * Convert a MandateFeature to TreeNodeItem + */ +function mandateFeatureToTreeNode(feature: MandateFeature): TreeNodeItem | null { + if (feature.instances.length === 0) { + return null; + } + + return { + id: feature.uiComponent, + label: feature.uiLabel, + icon: getPageIcon(feature.uiComponent), + badge: feature.instances.length, + path: feature.instances.length > 0 && feature.instances[0].views.length > 0 + ? feature.instances[0].views[0].uiPath + : undefined, + children: feature.instances.map(featureInstanceToTreeNode), defaultExpanded: false, }; } /** * Convert a NavigationMandate to TreeNodeItem - * - * FLAT STRUCTURE: Instances are listed directly under mandate (no feature grouping). - * Each instance shows the feature's icon for visual distinction. - * - * Before: Mandate → Feature → Instance → Views - * Now: Mandate → Instance (with feature icon) → Views */ function navigationMandateToTreeNode(mandate: NavigationMandate): TreeNodeItem | null { if (mandate.features.length === 0) { return null; } - // Flatten: collect all instances from all features directly under mandate - const instanceNodes: TreeNodeItem[] = []; - for (const feature of mandate.features) { - for (const instance of feature.instances) { - instanceNodes.push(featureInstanceToTreeNode(instance, feature.uiComponent)); - } - } + const children = mandate.features + .map(mandateFeatureToTreeNode) + .filter((node): node is TreeNodeItem => node !== null); - if (instanceNodes.length === 0) { + if (children.length === 0) { return null; } return { id: mandate.id, label: mandate.uiLabel, - children: instanceNodes, + children, defaultExpanded: true, }; } @@ -172,49 +174,40 @@ export const MandateNavigation: React.FC = () => { const { blocks, loading } = useNavigation('de'); // Build navigation items from blocks - // Groups static items into collapsible containers: - // - "Meine Sicht": all non-admin static items (Übersicht, Einstellungen, Prompts, etc.) - // - "Administration": all admin static items - // - Dynamic block (mandates) renders between them const navigationItems: TreeItem[] = useMemo(() => { const items: TreeItem[] = []; - - // Collect static items by category - const meineSichtItems: NavigationItem[] = []; - let adminItems: NavigationItem[] = []; - + + // Process blocks in order (already sorted by backend) for (const block of blocks) { if (block.type === 'static') { - if (block.id === 'admin') { - adminItems = [...block.items]; - } else if (block.items.length > 0) { - meineSichtItems.push(...block.items); + // Static block: system, workflows, basedata, migrate, admin + if (block.items.length > 0) { + // Add separator before admin block + if (block.id === 'admin') { + items.push({ type: 'separator' }); + } + items.push(staticBlockToTreeItem(block)); } - } - } - - // "Meine Sicht" - collapsible container for user-facing pages - if (meineSichtItems.length > 0) { - items.push(_staticItemsToTreeNode('meine-sicht', 'Meine Sicht', meineSichtItems, true)); - } - - // Dynamic block: mandates with feature instances - for (const block of blocks) { - if (block.type === 'dynamic') { + } else if (block.type === 'dynamic') { + // Dynamic block: features/mandates + // Add separator before dynamic block + items.push({ type: 'separator' }); + const mandateNodes = dynamicBlockToTreeNodes(block); if (mandateNodes.length > 0) { - if (items.length > 0) items.push({ type: 'separator' }); items.push(...mandateNodes); } + + // Add separator after dynamic block (before next static blocks) + items.push({ type: 'separator' }); } } - - // "Administration" - collapsible container for admin pages - if (adminItems.length > 0) { - if (items.length > 0) items.push({ type: 'separator' }); - items.push(_staticItemsToTreeNode('administration', 'Administration', adminItems, false)); + + // Remove trailing separator if present + while (items.length > 0 && (items[items.length - 1] as TreeItem & { type?: string }).type === 'separator') { + items.pop(); } - + return items; }, [blocks]); diff --git a/src/components/UiComponents/AddressAutocomplete/AddressAutocomplete.module.css b/src/components/UiComponents/AddressAutocomplete/AddressAutocomplete.module.css new file mode 100644 index 0000000..35cbf15 --- /dev/null +++ b/src/components/UiComponents/AddressAutocomplete/AddressAutocomplete.module.css @@ -0,0 +1,211 @@ +.autocompleteContainer { + position: relative; + width: 100%; +} + +.suggestionsWrapper { + position: absolute; + top: 100%; + left: 0; + right: 0; + margin-top: 4px; + z-index: 1000; + max-height: 300px; + overflow: hidden; + border-radius: 12px; + + /* Glassmorphism effect */ + background: rgba(255, 255, 255, 0.85); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.3); + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.1), + 0 0 0 1px rgba(255, 255, 255, 0.2) inset; +} + +/* Dark theme support */ +[data-theme="dark"] .suggestionsWrapper { + background: rgba(30, 30, 30, 0.85); + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.3), + 0 0 0 1px rgba(255, 255, 255, 0.1) inset; +} + +.suggestionsList { + list-style: none; + margin: 0; + padding: 8px; + max-height: 300px; + overflow-y: auto; + overflow-x: hidden; +} + +.suggestionItem { + padding: 12px 16px; + cursor: pointer; + border-radius: 8px; + transition: all 0.2s ease; + margin-bottom: 4px; + position: relative; + + /* Subtle background for better visibility */ + background: rgba(255, 255, 255, 0.5); +} + +.suggestionItem:last-child { + margin-bottom: 0; +} + +.suggestionItem:hover { + background: rgba(59, 130, 246, 0.1); + transform: translateX(2px); +} + +.suggestionItemSelected { + background: rgba(59, 130, 246, 0.15) !important; + + /* Glow effect for selected item */ + box-shadow: + 0 0 12px rgba(59, 130, 246, 0.4), + 0 0 24px rgba(59, 130, 246, 0.2), + inset 0 0 8px rgba(59, 130, 246, 0.1); + border: 1px solid rgba(59, 130, 246, 0.3); + transform: translateX(4px); +} + +/* Dark theme adjustments */ +[data-theme="dark"] .suggestionItem { + background: rgba(255, 255, 255, 0.05); +} + +[data-theme="dark"] .suggestionItem:hover { + background: rgba(59, 130, 246, 0.2); +} + +[data-theme="dark"] .suggestionItemSelected { + background: rgba(59, 130, 246, 0.25) !important; + box-shadow: + 0 0 16px rgba(59, 130, 246, 0.5), + 0 0 32px rgba(59, 130, 246, 0.3), + inset 0 0 12px rgba(59, 130, 246, 0.15); + border: 1px solid rgba(59, 130, 246, 0.4); +} + +.suggestionText { + display: block; + color: var(--color-text, #111827); + font-size: 14px; + line-height: 1.5; + word-break: break-word; +} + +[data-theme="dark"] .suggestionText { + color: var(--color-text, #f9fafb); +} + +.highlight { + background: rgba(59, 130, 246, 0.2); + color: var(--color-primary, #3b82f6); + font-weight: 600; + padding: 0 2px; + border-radius: 2px; +} + +[data-theme="dark"] .highlight { + background: rgba(59, 130, 246, 0.3); + color: #60a5fa; +} + +.loadingText, +.noResultsText { + display: block; + padding: 12px 16px; + color: var(--color-text-secondary, #6b7280); + font-size: 14px; + text-align: center; + font-style: italic; +} + +.errorText { + display: block; + padding: 12px 16px; + color: #ef4444; + font-size: 14px; + text-align: center; + font-weight: 500; +} + +[data-theme="dark"] .loadingText, +[data-theme="dark"] .noResultsText { + color: var(--color-text-secondary, #9ca3af); +} + +[data-theme="dark"] .errorText { + color: #f87171; +} + +/* Scrollbar styling */ +.suggestionsList::-webkit-scrollbar { + width: 8px; +} + +.suggestionsList::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.05); + border-radius: 4px; +} + +.suggestionsList::-webkit-scrollbar-thumb { + background: rgba(59, 130, 246, 0.3); + border-radius: 4px; + transition: background 0.2s; +} + +.suggestionsList::-webkit-scrollbar-thumb:hover { + background: rgba(59, 130, 246, 0.5); +} + +[data-theme="dark"] .suggestionsList::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.05); +} + +[data-theme="dark"] .suggestionsList::-webkit-scrollbar-thumb { + background: rgba(59, 130, 246, 0.4); +} + +[data-theme="dark"] .suggestionsList::-webkit-scrollbar-thumb:hover { + background: rgba(59, 130, 246, 0.6); +} + +/* Responsive design */ +@media (max-width: 640px) { + .suggestionsWrapper { + border-radius: 8px; + max-height: 250px; + } + + .suggestionItem { + padding: 10px 12px; + } + + .suggestionText { + font-size: 13px; + } +} + +/* Animation for dropdown appearance */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.suggestionsWrapper { + animation: fadeIn 0.2s ease-out; +} diff --git a/src/components/UiComponents/AddressAutocomplete/AddressAutocomplete.tsx b/src/components/UiComponents/AddressAutocomplete/AddressAutocomplete.tsx new file mode 100644 index 0000000..df68338 --- /dev/null +++ b/src/components/UiComponents/AddressAutocomplete/AddressAutocomplete.tsx @@ -0,0 +1,309 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import TextField, { BaseTextFieldProps } from '../TextField/TextField'; +import { autocompleteAddress, AddressSuggestion } from '../../../api/realEstateApi'; +import styles from './AddressAutocomplete.module.css'; + +interface AddressAutocompleteProps extends BaseTextFieldProps { + onSelect?: (suggestion: AddressSuggestion) => void; + debounceMs?: number; + minQueryLength?: number; + maxSuggestions?: number; +} + +const AddressAutocomplete: React.FC = ({ + value = '', + onChange, + onSelect, + placeholder, + disabled = false, + required = false, + readonly = false, + size = 'md', + error, + helperText, + label, + className = '', + type = 'text', + name, + id, + onKeyDown, + debounceMs = 300, + minQueryLength = 2, + maxSuggestions = 10, + ...props +}) => { + const [suggestions, setSuggestions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + const [showSuggestions, setShowSuggestions] = useState(false); + const [query, setQuery] = useState(value); + const [autocompleteError, setAutocompleteError] = useState(null); + + const containerRef = useRef(null); + const suggestionsRef = useRef(null); + const debounceTimerRef = useRef(null); + + // Sync query with value prop + useEffect(() => { + setQuery(value); + }, [value]); + + // Debounced search function + const performSearch = useCallback(async (searchQuery: string) => { + if (searchQuery.length < minQueryLength) { + if (import.meta.env.DEV) { + console.log('🔍 [AddressAutocomplete] Query too short:', { + query: searchQuery, + length: searchQuery.length, + minLength: minQueryLength + }); + } + setSuggestions([]); + setShowSuggestions(false); + setIsLoading(false); + return; + } + + if (import.meta.env.DEV) { + console.log('🔍 [AddressAutocomplete] Starting search:', { + query: searchQuery, + length: searchQuery.length, + maxSuggestions: maxSuggestions + }); + } + + setIsLoading(true); + setAutocompleteError(null); // Clear previous errors + try { + const results = await autocompleteAddress(searchQuery, maxSuggestions); + + if (import.meta.env.DEV) { + console.log('✅ [AddressAutocomplete] Search completed:', { + query: searchQuery, + resultCount: results.length, + results: results.slice(0, 3) // Log first 3 + }); + } + + setSuggestions(results); + setShowSuggestions(results.length > 0 || true); // Show dropdown even if empty to show "no results" or error + setSelectedIndex(-1); + setAutocompleteError(null); // Clear any previous errors on success + } catch (err: any) { + console.error('❌ [AddressAutocomplete] Error in performSearch:', err); + const errorMessage = err?.response?.data?.detail || err?.message || 'Fehler beim Laden der Adressvorschläge'; + setAutocompleteError(errorMessage); + setSuggestions([]); + setShowSuggestions(true); // Show dropdown to display error + setSelectedIndex(-1); + } finally { + setIsLoading(false); + } + }, [minQueryLength, maxSuggestions]); + + // Handle input change with debouncing + const handleInputChange = useCallback((newValue: string) => { + if (import.meta.env.DEV) { + console.log('⌨️ [AddressAutocomplete] Input changed:', { + newValue: newValue, + length: newValue.length, + willSearch: newValue.length >= minQueryLength + }); + } + + setQuery(newValue); + setAutocompleteError(null); // Clear error on new input + + // Update parent component immediately + if (onChange) { + onChange(newValue); + } + + // Clear existing timer + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + // Set new timer for debounced search + debounceTimerRef.current = setTimeout(() => { + if (import.meta.env.DEV) { + console.log('⏱️ [AddressAutocomplete] Debounce timer fired, calling performSearch'); + } + performSearch(newValue); + }, debounceMs); + }, [onChange, debounceMs, performSearch, minQueryLength]); + + // Handle suggestion selection + const handleSelectSuggestion = useCallback((suggestion: AddressSuggestion) => { + setQuery(suggestion.value); + setShowSuggestions(false); + setSelectedIndex(-1); + + if (onChange) { + onChange(suggestion.value); + } + + if (onSelect) { + onSelect(suggestion); + } + }, [onChange, onSelect]); + + // Handle keyboard navigation + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (!showSuggestions || suggestions.length === 0) { + if (onKeyDown) { + onKeyDown(e); + } + return; + } + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setSelectedIndex(prev => + prev < suggestions.length - 1 ? prev + 1 : prev + ); + break; + case 'ArrowUp': + e.preventDefault(); + setSelectedIndex(prev => prev > 0 ? prev - 1 : -1); + break; + case 'Enter': + if (selectedIndex >= 0 && selectedIndex < suggestions.length) { + e.preventDefault(); + handleSelectSuggestion(suggestions[selectedIndex]); + } else if (onKeyDown) { + onKeyDown(e); + } + break; + case 'Escape': + e.preventDefault(); + setShowSuggestions(false); + setSelectedIndex(-1); + break; + default: + if (onKeyDown) { + onKeyDown(e); + } + } + }, [showSuggestions, suggestions, selectedIndex, handleSelectSuggestion, onKeyDown]); + + // Click outside handler + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setShowSuggestions(false); + setSelectedIndex(-1); + } + }; + + if (showSuggestions) { + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + } + }, [showSuggestions]); + + // Scroll selected item into view + useEffect(() => { + if (selectedIndex >= 0 && suggestionsRef.current) { + const selectedElement = suggestionsRef.current.children[selectedIndex] as HTMLElement; + if (selectedElement) { + selectedElement.scrollIntoView({ + block: 'nearest', + behavior: 'smooth' + }); + } + } + }, [selectedIndex]); + + // Highlight matching text in suggestion + const highlightText = (text: string, query: string): React.ReactNode => { + if (!query || query.length < minQueryLength) { + return text; + } + + const parts = text.split(new RegExp(`(${query})`, 'gi')); + return ( + <> + {parts.map((part, index) => + part.toLowerCase() === query.toLowerCase() ? ( + {part} + ) : ( + {part} + ) + )} + + ); + }; + + // Cleanup on unmount + useEffect(() => { + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, []); + + return ( +
+ + + {showSuggestions && ( +
+
    + {isLoading && ( +
  • + Suche Adressen... +
  • + )} + {!isLoading && autocompleteError && ( +
  • + {autocompleteError} +
  • + )} + {!isLoading && !autocompleteError && suggestions.length === 0 && query.length >= minQueryLength && ( +
  • + Keine Adressen gefunden +
  • + )} + {!isLoading && suggestions.map((suggestion, index) => ( +
  • handleSelectSuggestion(suggestion)} + onMouseEnter={() => setSelectedIndex(index)} + > + + {highlightText(suggestion.label, query)} + +
  • + ))} +
+
+ )} +
+ ); +}; + +export default AddressAutocomplete; diff --git a/src/components/UiComponents/AddressAutocomplete/index.ts b/src/components/UiComponents/AddressAutocomplete/index.ts new file mode 100644 index 0000000..604ed15 --- /dev/null +++ b/src/components/UiComponents/AddressAutocomplete/index.ts @@ -0,0 +1,2 @@ +export { default } from './AddressAutocomplete'; +export type { AddressSuggestion } from '../../../api/realEstateApi'; diff --git a/src/components/UiComponents/BauvorschriftenSection/BauvorschriftenSection.module.css b/src/components/UiComponents/BauvorschriftenSection/BauvorschriftenSection.module.css new file mode 100644 index 0000000..c425a44 --- /dev/null +++ b/src/components/UiComponents/BauvorschriftenSection/BauvorschriftenSection.module.css @@ -0,0 +1,127 @@ +/* Bauvorschriften Section Styles */ +.bauvorschriftenSection { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--color-border, #e5e7eb); +} + +.bauvorschriftenHeader { + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + padding: 0.5rem 0; + margin-bottom: 0.75rem; + transition: opacity 0.2s; +} + +.bauvorschriftenHeader:hover { + opacity: 0.8; +} + +.bauvorschriftenHeader .subSectionTitle { + margin: 0; + display: flex; + align-items: center; + color: var(--color-accent, #10b981); + font-size: 1rem; + font-weight: 600; +} + +.expandButton { + background: none; + border: none; + cursor: pointer; + padding: 0.25rem; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-secondary, #6b7280); + transition: color 0.2s; +} + +.expandButton:hover { + color: var(--color-text, #111827); +} + +.bauvorschriftenContent { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.bauvorschriftenGrid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem; + margin-top: 0.5rem; +} + +.bauvorschriftItem { + padding: 0.75rem; + background: linear-gradient(135deg, rgba(243, 244, 246, 0.8) 0%, rgba(249, 250, 251, 0.8) 100%); + backdrop-filter: blur(10px); + border-radius: 8px; + border-left: 3px solid var(--color-accent, #10b981); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + transition: transform 0.2s, box-shadow 0.2s; +} + +.bauvorschriftItem:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(16, 185, 129, 0.2); +} + +.bauvorschriftItem .label { + display: block; + font-size: 0.75rem; + font-weight: 500; + color: var(--color-text-secondary, #6b7280); + margin-bottom: 0.25rem; +} + +.bauvorschriftItem .value { + display: block; + font-size: 1rem; + font-weight: 600; + color: var(--color-text, #111827); +} + +.sourceLink { + margin-top: 0.5rem; +} + +.sourceLinkButton { + display: inline-flex; + align-items: center; + padding: 0.75rem 1rem; + background: linear-gradient(135deg, var(--color-accent, #10b981) 0%, #059669 100%); + color: white; + text-decoration: none; + border-radius: 8px; + font-weight: 500; + transition: all 0.2s; + box-shadow: 0 4px 6px rgba(16, 185, 129, 0.3); +} + +.sourceLinkButton:hover { + transform: translateY(-2px); + box-shadow: 0 6px 12px rgba(16, 185, 129, 0.4); +} + +.bauvorschriftenFooter { + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid var(--color-border, #e5e7eb); +} + +.lastUpdated { + font-size: 0.75rem; + color: var(--color-text-secondary, #6b7280); +} + +@media (max-width: 768px) { + .bauvorschriftenGrid { + grid-template-columns: 1fr; + } +} diff --git a/src/components/UiComponents/BauvorschriftenSection/BauvorschriftenSection.tsx b/src/components/UiComponents/BauvorschriftenSection/BauvorschriftenSection.tsx new file mode 100644 index 0000000..bca2a82 --- /dev/null +++ b/src/components/UiComponents/BauvorschriftenSection/BauvorschriftenSection.tsx @@ -0,0 +1,109 @@ +import React, { useState } from 'react'; +import { FaChevronDown, FaChevronUp, FaFilePdf, FaRuler } from 'react-icons/fa'; +import styles from './BauvorschriftenSection.module.css'; + +export interface BauvorschriftenZone { + zonenbezeichnung: string; + ausnuetzungsziffer?: number; + vollgeschosse?: number; + gebaeudelaengeMax?: number; + grenzabstand?: number; + mehrlaengenzuschlag?: string; + hoechstmassMax?: number; + fassadenhoehe?: string; + quelleUrl?: string; + extraktionsDatum?: string; +} + +export interface BauvorschriftenSectionProps { + bauvorschriften: BauvorschriftenZone; +} + +export const BauvorschriftenSection: React.FC = ({ bauvorschriften }) => { + const [isExpanded, setIsExpanded] = useState(true); + + return ( +
+
setIsExpanded(!isExpanded)}> +

+ + Bauvorschriften - {bauvorschriften.zonenbezeichnung} +

+ +
+ + {isExpanded && ( +
+
+ {bauvorschriften.ausnuetzungsziffer !== undefined && bauvorschriften.ausnuetzungsziffer !== null && ( +
+ Ausnützungsziffer: + {bauvorschriften.ausnuetzungsziffer}% +
+ )} + {bauvorschriften.vollgeschosse !== undefined && bauvorschriften.vollgeschosse !== null && ( +
+ Vollgeschosse: + {bauvorschriften.vollgeschosse} +
+ )} + {bauvorschriften.gebaeudelaengeMax !== undefined && bauvorschriften.gebaeudelaengeMax !== null && ( +
+ Gebäudelänge max: + {bauvorschriften.gebaeudelaengeMax} m +
+ )} + {bauvorschriften.grenzabstand !== undefined && bauvorschriften.grenzabstand !== null && ( +
+ Grenzabstand: + {bauvorschriften.grenzabstand} m +
+ )} + {bauvorschriften.mehrlaengenzuschlag && ( +
+ Mehrlängenzuschlag: + {bauvorschriften.mehrlaengenzuschlag} +
+ )} + {bauvorschriften.hoechstmassMax !== undefined && bauvorschriften.hoechstmassMax !== null && ( +
+ Höchstmass max: + {bauvorschriften.hoechstmassMax} m +
+ )} + {bauvorschriften.fassadenhoehe && ( +
+ Fassadenhöhe: + {bauvorschriften.fassadenhoehe} +
+ )} +
+ + {bauvorschriften.quelleUrl && bauvorschriften.quelleUrl !== 'config' && ( + + )} + + {bauvorschriften.extraktionsDatum && ( +
+ + Extrahiert: {new Date(bauvorschriften.extraktionsDatum).toLocaleString('de-CH')} + +
+ )} +
+ )} +
+ ); +}; diff --git a/src/components/UiComponents/OerebSection/OerebSection.module.css b/src/components/UiComponents/OerebSection/OerebSection.module.css new file mode 100644 index 0000000..7bc42f6 --- /dev/null +++ b/src/components/UiComponents/OerebSection/OerebSection.module.css @@ -0,0 +1,187 @@ +/* ÖREB Section Styles */ +.oerebSection { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--color-border, #e5e7eb); +} + +.oerebHeader { + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + padding: 0.5rem 0; + margin-bottom: 0.75rem; + transition: opacity 0.2s; +} + +.oerebHeader:hover { + opacity: 0.8; +} + +.oerebHeader .subSectionTitle { + margin: 0; + display: flex; + align-items: center; + color: var(--color-primary, #3b82f6); + font-size: 1rem; + font-weight: 600; +} + +.badge { + margin-left: 0.5rem; + padding: 0.125rem 0.5rem; + background-color: var(--color-primary, #3b82f6); + color: white; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 600; +} + +.expandButton { + background: none; + border: none; + cursor: pointer; + padding: 0.25rem; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-secondary, #6b7280); + transition: color 0.2s; +} + +.expandButton:hover { + color: var(--color-text, #111827); +} + +.oerebContent { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.oerebExtractLink { + margin-bottom: 0.5rem; +} + +.oerebLink { + display: inline-flex; + align-items: center; + padding: 0.75rem 1rem; + background: linear-gradient(135deg, var(--color-primary, #3b82f6) 0%, var(--color-primary-dark, #2563eb) 100%); + color: white; + text-decoration: none; + border-radius: 8px; + font-weight: 500; + transition: all 0.2s; + box-shadow: 0 4px 6px rgba(59, 130, 246, 0.3); + border: none; + cursor: pointer; + font-family: inherit; + font-size: inherit; + width: 100%; + justify-content: center; +} + +.oerebLink:hover { + transform: translateY(-2px); + box-shadow: 0 6px 12px rgba(59, 130, 246, 0.4); +} + +.oerebLink:active { + transform: translateY(0); +} + +.restrictionsList { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.restrictionItem { + padding: 1rem; + background: linear-gradient(135deg, rgba(249, 250, 251, 0.8) 0%, rgba(243, 244, 246, 0.8) 100%); + backdrop-filter: blur(10px); + border: 1px solid var(--color-border, #e5e7eb); + border-left: 3px solid var(--color-primary, #3b82f6); + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); +} + +.restrictionHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.restrictionTheme { + font-weight: 600; + color: var(--color-text, #111827); + font-size: 1rem; +} + +.restrictionStatus { + padding: 0.25rem 0.5rem; + background: linear-gradient(135deg, rgba(209, 250, 229, 0.8) 0%, rgba(167, 243, 208, 0.8) 100%); + color: var(--color-success-dark, #065f46); + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + backdrop-filter: blur(5px); +} + +.restrictionType { + display: flex; + gap: 0.5rem; + margin-bottom: 0.5rem; + font-size: 0.875rem; +} + +.restrictionInfo { + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid var(--color-border, #e5e7eb); + font-size: 0.875rem; + color: var(--color-text, #111827); + line-height: 1.5; +} + +.restrictionDocuments { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid var(--color-border, #e5e7eb); + font-size: 0.875rem; +} + +.documentLink { + color: var(--color-primary, #3b82f6); + text-decoration: none; + transition: color 0.2s; +} + +.documentLink:hover { + color: var(--color-primary-dark, #2563eb); + text-decoration: underline; +} + +.noRestrictions { + padding: 1rem; + text-align: center; + color: var(--color-text-secondary, #6b7280); + font-style: italic; +} + +.oerebFooter { + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid var(--color-border, #e5e7eb); +} + +.lastUpdated { + font-size: 0.75rem; + color: var(--color-text-secondary, #6b7280); +} diff --git a/src/components/UiComponents/OerebSection/OerebSection.tsx b/src/components/UiComponents/OerebSection/OerebSection.tsx new file mode 100644 index 0000000..8d407af --- /dev/null +++ b/src/components/UiComponents/OerebSection/OerebSection.tsx @@ -0,0 +1,133 @@ +import React, { useState } from 'react'; +import { FaChevronDown, FaChevronUp, FaFilePdf, FaInfoCircle } from 'react-icons/fa'; +import { UrlContentPreview } from '../../ContentPreview'; +import styles from './OerebSection.module.css'; + +export interface OerebData { + extract_url?: string; + restrictions?: Array<{ + theme: string; + type?: string; + law_status?: string; + information?: string; + documents?: Array<{ reference: string }>; + }>; + last_updated?: string; + canton?: string; +} + +export interface OerebSectionProps { + oereb: OerebData; +} + +export const OerebSection: React.FC = ({ oereb }) => { + const [isExpanded, setIsExpanded] = useState(true); + const [isPreviewOpen, setIsPreviewOpen] = useState(false); + const restrictions = oereb.restrictions || []; + + if (restrictions.length === 0 && !oereb.extract_url) { + return null; + } + + return ( +
+
setIsExpanded(!isExpanded)}> +

+ + ÖREB-Kataster + {restrictions.length > 0 && ( + ({restrictions.length}) + )} +

+ +
+ + {isExpanded && ( +
+ {oereb.extract_url && ( +
+ +
+ )} + + {restrictions.length > 0 ? ( +
+ {restrictions.map((restriction, index) => ( +
+
+ {restriction.theme} + {restriction.law_status && ( + + {restriction.law_status === 'inKraft' || restriction.law_status === 'inForce' + ? 'In Kraft' + : restriction.law_status} + + )} +
+ {restriction.type && ( +
+ Typ: + {restriction.type} +
+ )} + {restriction.information && ( +
+ {restriction.information} +
+ )} + {restriction.documents && restriction.documents.length > 0 && ( +
+ Dokumente: + {restriction.documents.map((doc, docIndex) => ( + + Dokument {docIndex + 1} + + ))} +
+ )} +
+ ))} +
+ ) : ( +
+ Keine öffentlich-rechtlichen Beschränkungen gefunden. +
+ )} + + {oereb.last_updated && ( +
+ + Aktualisiert: {new Date(oereb.last_updated).toLocaleString('de-CH')} + +
+ )} +
+ )} + + {oereb.extract_url && ( + setIsPreviewOpen(false)} + url={oereb.extract_url} + fileName="ÖREB-Auszug.pdf" + mimeType="application/pdf" + /> + )} +
+ ); +}; diff --git a/src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.module.css b/src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.module.css index 3e7797d..ba0c204 100644 --- a/src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.module.css +++ b/src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.module.css @@ -125,6 +125,24 @@ color: var(--color-text-secondary, #6b7280); } +.documentsSection { + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 2px solid var(--color-primary, #3b82f6); + background-color: var(--color-bg-secondary, #f9fafb); + padding: 1.5rem; + border-radius: 8px; + margin-left: -1.5rem; + margin-right: -1.5rem; +} + +.documentsSectionTitle { + margin: 0 0 1rem 0; + font-size: 1.1rem; + font-weight: 600; + color: var(--color-text, #111827); +} + .infoGrid { display: flex; flex-direction: column; @@ -241,6 +259,670 @@ overflow-y: auto; } +.documentsList { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.documentLink { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1rem; + background-color: var(--color-bg, #ffffff); + border: 2px solid var(--color-primary, #3b82f6); + border-radius: 8px; + cursor: pointer; + text-align: left; + transition: all 0.2s ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.documentLink:hover { + background-color: var(--color-primary-light, #eff6ff); + border-color: var(--color-primary-dark, #2563eb); + transform: translateY(-2px); + box-shadow: 0 4px 6px rgba(59, 130, 246, 0.2); +} + +.documentLink:active { + transform: translateY(0); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.documentLabel { + font-weight: 600; + color: var(--color-primary, #3b82f6); + font-size: 1rem; + word-break: break-word; +} + +.documentLink:hover .documentLabel { + color: var(--color-primary-dark, #2563eb); +} + +.documentType { + font-size: 0.8rem; + color: var(--color-text-secondary, #6b7280); + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 500; +} + +/* BZO Information Styles */ +.bzoButtonContainer { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.bzoButton { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + background-color: var(--color-primary, #3b82f6); + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + transition: all 0.2s ease; + width: fit-content; +} + +.bzoButton:hover:not(:disabled) { + background-color: var(--color-primary-dark, #2563eb); + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3); +} + +.bzoButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.spinner { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.errorMessage { + color: var(--color-error, #ef4444); + font-size: 0.875rem; + padding: 0.5rem; + background-color: var(--color-error-light, #fee2e2); + border-radius: 4px; + border: 1px solid var(--color-error, #ef4444); +} + +.bzoSection { + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 2px solid var(--color-primary, #3b82f6); + background-color: var(--color-bg-secondary, #f9fafb); + padding: 1.5rem; + border-radius: 8px; + margin-left: -1.5rem; + margin-right: -1.5rem; +} + +.bzoHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.bzoSectionTitle { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + color: var(--color-text, #111827); +} + +.toggleButton { + background: none; + border: none; + cursor: pointer; + font-size: 1rem; + color: var(--color-text-secondary, #6b7280); + padding: 0.25rem 0.5rem; + border-radius: 4px; + transition: background-color 0.2s; +} + +.toggleButton:hover { + background-color: var(--color-hover, #f3f4f6); +} + +.bzoContent { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.bzoSubSection { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.bzoSubTitle { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--color-text, #111827); + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--color-border, #e5e7eb); +} + +.bzoSummary { + padding: 1rem; + background-color: var(--color-bg, #ffffff); + border-left: 4px solid var(--color-primary, #3b82f6); + border-radius: 4px; + color: var(--color-text, #111827); +} + +/* Markdown Styles for BZO Content */ +.bzoMarkdown { + line-height: 1.6; + color: var(--color-text, #111827); +} + +.bzoMarkdownH1, +.bzoMarkdownH2, +.bzoMarkdownH3, +.bzoMarkdownH4, +.bzoMarkdownH5, +.bzoMarkdownH6 { + margin-top: 1.5rem; + margin-bottom: 0.75rem; + font-weight: 600; + color: var(--color-text, #111827); + line-height: 1.3; +} + +.bzoMarkdownH1 { + font-size: 1.5rem; + border-bottom: 2px solid var(--color-border, #e5e7eb); + padding-bottom: 0.5rem; +} + +.bzoMarkdownH2 { + font-size: 1.25rem; + border-bottom: 1px solid var(--color-border, #e5e7eb); + padding-bottom: 0.25rem; +} + +.bzoMarkdownH3 { + font-size: 1.1rem; +} + +.bzoMarkdownH4 { + font-size: 1rem; +} + +.bzoMarkdownH5 { + font-size: 0.95rem; +} + +.bzoMarkdownH6 { + font-size: 0.9rem; +} + +.bzoMarkdownP { + margin: 0.75rem 0; + line-height: 1.6; +} + +.bzoMarkdownP:first-child { + margin-top: 0; +} + +.bzoMarkdownP:last-child { + margin-bottom: 0; +} + +.bzoMarkdownUl, +.bzoMarkdownOl { + margin: 0.75rem 0; + padding-left: 1.5rem; +} + +.bzoMarkdownLi { + margin: 0.5rem 0; + line-height: 1.6; +} + +.bzoMarkdownUl .bzoMarkdownLi { + list-style-type: disc; +} + +.bzoMarkdownOl .bzoMarkdownLi { + list-style-type: decimal; +} + +.bzoMarkdownTableWrapper { + overflow-x: auto; + margin: 1rem 0; +} + +.bzoMarkdownTable { + width: 100%; + border-collapse: collapse; + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 4px; + overflow: hidden; +} + +.bzoMarkdownThead { + background-color: var(--color-bg-secondary, #f9fafb); +} + +.bzoMarkdownTh { + padding: 0.75rem; + text-align: left; + font-weight: 600; + border-bottom: 2px solid var(--color-border, #e5e7eb); + color: var(--color-text, #111827); +} + +.bzoMarkdownTd { + padding: 0.75rem; + border-bottom: 1px solid var(--color-border, #e5e7eb); + color: var(--color-text, #111827); +} + +.bzoMarkdownTr:last-child .bzoMarkdownTd { + border-bottom: none; +} + +.bzoMarkdownTr:hover { + background-color: var(--color-hover, #f3f4f6); +} + +.bzoMarkdownCodeInline { + background-color: var(--color-bg-secondary, #f9fafb); + padding: 0.2rem 0.4rem; + border-radius: 3px; + font-family: 'Courier New', monospace; + font-size: 0.9em; + color: var(--color-primary, #3b82f6); + border: 1px solid var(--color-border, #e5e7eb); +} + +.bzoMarkdownPre { + background-color: var(--color-bg-secondary, #f9fafb); + padding: 1rem; + border-radius: 4px; + overflow-x: auto; + border: 1px solid var(--color-border, #e5e7eb); + margin: 1rem 0; +} + +.bzoMarkdownCodeBlock { + font-family: 'Courier New', monospace; + font-size: 0.9em; + color: var(--color-text, #111827); +} + +.bzoMarkdownBlockquote { + margin: 1rem 0; + padding: 0.75rem 1rem; + border-left: 4px solid var(--color-primary, #3b82f6); + background-color: var(--color-bg-secondary, #f9fafb); + border-radius: 4px; + color: var(--color-text-secondary, #6b7280); + font-style: italic; +} + +.bzoMarkdownStrong { + font-weight: 600; + color: var(--color-text, #111827); +} + +.bzoMarkdownEm { + font-style: italic; +} + +.bzoMarkdownLink { + color: var(--color-primary, #3b82f6); + text-decoration: none; + font-weight: 500; + transition: color 0.2s; +} + +.bzoMarkdownLink:hover { + color: var(--color-primary-dark, #2563eb); + text-decoration: underline; +} + +.bzoMarkdownHr { + margin: 1.5rem 0; + border: none; + border-top: 1px solid var(--color-border, #e5e7eb); +} + +.bzoInfoGrid { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.bzoInfoItem { + display: flex; + gap: 0.5rem; +} + +.bzoLabel { + font-weight: 500; + color: var(--color-text-secondary, #6b7280); + min-width: 80px; +} + +.bzoValue { + color: var(--color-text, #111827); +} + +.bzoZonesList { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.bzoZoneCard { + padding: 1rem; + background-color: var(--color-bg, #ffffff); + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 6px; + transition: box-shadow 0.2s; +} + +.bzoZoneCard:hover { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.bzoZoneHeader { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--color-border, #e5e7eb); +} + +.bzoZoneCode { + font-weight: 700; + font-size: 1.1rem; + color: var(--color-primary, #3b82f6); +} + +.bzoZoneName { + font-weight: 600; + color: var(--color-text, #111827); +} + +.bzoZoneDetails { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.bzoZoneDetailItem { + display: flex; + gap: 0.5rem; +} + +.bzoDetailLabel { + font-weight: 500; + color: var(--color-text-secondary, #6b7280); + min-width: 140px; + font-size: 0.875rem; +} + +.bzoDetailValue { + color: var(--color-text, #111827); + font-size: 0.875rem; +} + +.bzoRulesList { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.bzoRuleCard { + padding: 1rem; + background-color: var(--color-bg, #ffffff); + border: 1px solid var(--color-border, #e5e7eb); + border-left: 4px solid var(--color-primary, #3b82f6); + border-radius: 6px; + transition: box-shadow 0.2s; +} + +.bzoRuleCard:hover { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.bzoRuleHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.bzoRuleType { + font-weight: 600; + color: var(--color-text, #111827); + text-transform: capitalize; + font-size: 0.95rem; +} + +.bzoConfidence { + font-size: 0.75rem; + color: var(--color-text-secondary, #6b7280); + background-color: var(--color-bg-secondary, #f9fafb); + padding: 0.25rem 0.5rem; + border-radius: 4px; +} + +.bzoRuleValue { + margin-bottom: 0.5rem; +} + +.bzoRuleNumeric { + font-size: 1.1rem; + font-weight: 600; + color: var(--color-primary, #3b82f6); +} + +.bzoRuleText { + color: var(--color-text, #111827); + font-weight: 500; +} + +.bzoRuleSnippet { + margin-top: 0.5rem; + padding: 0.5rem; + background-color: var(--color-bg-secondary, #f9fafb); + border-radius: 4px; + font-style: italic; + color: var(--color-text-secondary, #6b7280); + font-size: 0.875rem; +} + +.bzoRuleZone, +.bzoRulePage { + font-size: 0.75rem; + color: var(--color-text-secondary, #6b7280); + margin-top: 0.25rem; +} + +.bzoRuleMeta { + display: flex; + gap: 1rem; + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid var(--color-border, #e5e7eb); + font-size: 0.75rem; + color: var(--color-text-secondary, #6b7280); +} + +.bzoArticlesList { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.bzoArticleCard { + padding: 1rem; + background-color: var(--color-bg, #ffffff); + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 6px; + transition: box-shadow 0.2s; +} + +.bzoArticleCard:hover { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.bzoArticleHeader { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--color-border, #e5e7eb); +} + +.bzoArticleLabel { + font-weight: 700; + color: var(--color-primary, #3b82f6); + font-size: 1rem; +} + +.bzoArticleTitle { + font-weight: 600; + color: var(--color-text, #111827); +} + +.bzoArticleText { + margin-bottom: 0.75rem; + line-height: 1.6; + color: var(--color-text, #111827); + white-space: pre-wrap; +} + +.bzoArticleMeta { + display: flex; + gap: 1rem; + font-size: 0.75rem; + color: var(--color-text-secondary, #6b7280); + padding-top: 0.5rem; + border-top: 1px solid var(--color-border, #e5e7eb); +} + +.bzoDocumentsList { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.bzoDocumentItem { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem; + background-color: var(--color-bg, #ffffff); + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 4px; +} + +.bzoDocumentLabel { + font-weight: 500; + color: var(--color-text, #111827); +} + +.bzoDocumentType { + font-size: 0.75rem; + color: var(--color-text-secondary, #6b7280); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.bzoErrors, +.bzoWarnings { + margin-top: 1rem; +} + +.bzoErrorTitle { + margin: 0 0 0.5rem 0; + font-size: 0.95rem; + font-weight: 600; + color: var(--color-error, #ef4444); +} + +.bzoWarningTitle { + margin: 0 0 0.5rem 0; + font-size: 0.95rem; + font-weight: 600; + color: #f59e0b; +} + +.bzoErrorList, +.bzoWarningList { + margin: 0; + padding-left: 1.5rem; + color: var(--color-text, #111827); +} + +.bzoErrorList li { + color: var(--color-error, #ef4444); + margin-bottom: 0.25rem; +} + +.bzoWarningList li { + color: #f59e0b; + margin-bottom: 0.25rem; +} + +.bzoStats { + display: flex; + gap: 2rem; + flex-wrap: wrap; +} + +.bzoStatItem { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.bzoStatLabel { + font-size: 0.875rem; + color: var(--color-text-secondary, #6b7280); + font-weight: 500; +} + +.bzoStatValue { + font-size: 1.5rem; + font-weight: 700; + color: var(--color-primary, #3b82f6); +} + @media (max-width: 768px) { .panel { width: 100vw; diff --git a/src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx b/src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx index 5185dca..a0fb542 100644 --- a/src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx +++ b/src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx @@ -1,6 +1,10 @@ -import React from 'react'; +import React, { useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { FaTimes, FaTrash } from 'react-icons/fa'; +import { FaTimes, FaTrash, FaInfoCircle, FaSpinner } from 'react-icons/fa'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { ContentPreview } from '../../ContentPreview'; +import api from '../../../api'; import styles from './ParcelInfoPanel.module.css'; export interface ParcelInfoPanelProps { @@ -11,6 +15,77 @@ export interface ParcelInfoPanelProps { adjacentParcels?: any[]; } +// BZO Information Types +interface BZOGemeinde { + id: string; + label: string; + plz: string; +} + +interface BZOZone { + zone_code: string; + zone_name: string; + zone_category: string; + geschosszahl: number; + gewerbeerleichterung: boolean; + source_article: string; + page: number; +} + +interface BZORule { + rule_type: string; + value_numeric?: number; + value_text: string; + unit?: string; + condition_text?: string | null; + is_table_rule: boolean; + table_zones: string[]; + page: number; + text_snippet: string; + zone_raw: string; + rule_scope: string; + confidence: number; +} + +interface BZOArticle { + article_label: string; + article_title: string; + text: string; + page_start: number; + page_end: number; + section_level_1?: string | null; + section_level_2?: string | null; + section_level_3?: string | null; + zone_raw: string; +} + +interface BZOExtractedContent { + zones: BZOZone[]; + rules: BZORule[]; + articles: BZOArticle[]; + total_zones: number; + total_rules: number; + total_articles: number; +} + +interface BZODocument { + id: string; + label: string; + dokumentTyp: string; +} + +export interface BZOInformationResponse { + parcel_id: string; + bauzone: string; + gemeinde: BZOGemeinde; + extracted_content: BZOExtractedContent; + ai_summary: string; + relevant_rules: BZORule[]; + documents_processed: BZODocument[]; + errors: string[]; + warnings: string[]; +} + const ParcelInfoPanel: React.FC = ({ isOpen, onClose, @@ -18,256 +93,721 @@ const ParcelInfoPanel: React.FC = ({ onRemoveParcel, adjacentParcels = [] }) => { + // State for document preview popup + const [previewDocument, setPreviewDocument] = useState<{ + fileId: string; + fileName: string; + mimeType: string; + } | null>(null); + + // State for BZO information + const [bzoInfo, setBzoInfo] = useState>({}); + const [loadingBzo, setLoadingBzo] = useState>({}); + const [bzoError, setBzoError] = useState>({}); + const [expandedBzo, setExpandedBzo] = useState>({}); + + // Fetch BZO information + const fetchBZOInformation = async (parcelData: any) => { + // Extract gemeinde and bauzone from parcel data + const gemeinde = parcelData.gemeinde?.label || parcelData.parcel?.municipality_name; + const bauzone = parcelData.parcel?.bauzone; + + // Use parcel ID as key for state management + const parcelKey = parcelData.parcel?.id || parcelData.parcel?.number || 'unknown'; + + // Validate required fields + if (!gemeinde) { + const errorMsg = 'Gemeinde-Information fehlt. BZO-Informationen können nicht abgerufen werden.'; + setBzoError(prev => ({ ...prev, [parcelKey]: errorMsg })); + return; + } + + if (!bauzone) { + const errorMsg = 'Bauzone-Information fehlt. BZO-Informationen können nicht abgerufen werden.'; + setBzoError(prev => ({ ...prev, [parcelKey]: errorMsg })); + return; + } + + if (loadingBzo[parcelKey] || bzoInfo[parcelKey]) return; // Already loading or loaded + + setLoadingBzo(prev => ({ ...prev, [parcelKey]: true })); + setBzoError(prev => ({ ...prev, [parcelKey]: '' })); + + try { + const response = await api.get( + '/api/realestate/bzo-information', + { + params: { + gemeinde: gemeinde, + bauzone: bauzone + } + } + ); + setBzoInfo(prev => ({ ...prev, [parcelKey]: response.data })); + setExpandedBzo(prev => ({ ...prev, [parcelKey]: true })); + } catch (error: any) { + const errorMessage = error.response?.data?.detail || error.response?.data?.message || error.message || 'Fehler beim Laden der BZO-Informationen'; + setBzoError(prev => ({ ...prev, [parcelKey]: errorMessage })); + console.error('Error fetching BZO information:', error); + } finally { + setLoadingBzo(prev => ({ ...prev, [parcelKey]: false })); + } + }; + + const toggleBZOExpanded = (parcelId: string) => { + setExpandedBzo(prev => ({ ...prev, [parcelId]: !prev[parcelId] })); + }; + if (!parcels || parcels.length === 0) return null; return ( - - {isOpen && ( - <> - {/* Backdrop */} - - - {/* Panel */} - -
-

Parzellen-Informationen ({parcels.length})

- -
- -
- {/* Selected Parcels List */} -
- {parcels.map((parcelData, index) => ( -
-
-

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

- {onRemoveParcel && ( - - )} -
-
- {parcelData.parcel.id && ( -
- ID: - {parcelData.parcel.id} -
- )} - {parcelData.parcel.number && ( -
- Nummer: - {parcelData.parcel.number} -
- )} - {parcelData.parcel.name && ( -
- Name: - {parcelData.parcel.name} -
- )} - {parcelData.parcel.egrid && ( -
- EGRID: - {parcelData.parcel.egrid} -
- )} - {parcelData.parcel.identnd && ( -
- IdentND: - {parcelData.parcel.identnd} -
- )} - {parcelData.parcel.address && ( -
- Adresse: - {parcelData.parcel.address} -
- )} - {parcelData.parcel.canton && ( -
- Kanton: - {parcelData.parcel.canton} -
- )} - {parcelData.parcel.municipality_name && ( -
- Gemeinde: - {parcelData.parcel.municipality_name} -
- )} - {parcelData.parcel.municipality_code && ( -
- Gemeinde-Code: - {parcelData.parcel.municipality_code} -
- )} - {parcelData.parcel.area_m2 !== undefined && ( -
- Fläche: - - {parcelData.parcel.area_m2.toFixed(2)} m² - {parcelData.parcel.area_m2 >= 10000 && ( - - {' '}({(parcelData.parcel.area_m2 / 10000).toFixed(2)} ha) - - )} - -
- )} - {parcelData.parcel.realestate_type && ( -
- Grundstückstyp: - {parcelData.parcel.realestate_type} -
- )} - {parcelData.parcel.bauzone && ( -
- Bauzone: - {parcelData.parcel.bauzone} -
- )} - {parcelData.parcel.zone && Array.isArray(parcelData.parcel.zone) && parcelData.parcel.zone.length > 0 && ( -
- Zone: - - {parcelData.parcel.zone.length} Zone{parcelData.parcel.zone.length !== 1 ? 'n' : ''} gefunden - {(() => { - // Extract zone types from zone array - const zoneTypes = parcelData.parcel.zone - .map((z: any) => { - const attrs = z.attributes || {}; - return attrs.typ || attrs.zone_typ || attrs.bauzone || attrs.zone || attrs.label || null; - }) - .filter((t: string | null) => t !== null); - - if (zoneTypes.length > 0) { - return ( - - {' '}({zoneTypes.join(', ')}) - - ); - } - return null; - })()} - {import.meta.env.DEV && ( -
- Details anzeigen -
-                                  {JSON.stringify(parcelData.parcel.zone, null, 2)}
-                                
-
- )} -
-
- )} - {parcelData.parcel.centroid && ( -
- Zentrum (LV95): - - {parcelData.parcel.centroid.x.toFixed(2)}, {parcelData.parcel.centroid.y.toFixed(2)} - -
- )} - {parcelData.parcel.geoportal_url && ( -
- Geoportal: - - Link öffnen - -
- )} -
- - {/* Map View Info for this parcel */} - {parcelData.map_view && ( -
-

Kartenansicht

-
- {parcelData.map_view.center && ( -
- Zentrum: - - {parcelData.map_view.center.x.toFixed(2)}, {parcelData.map_view.center.y.toFixed(2)} - -
- )} - {parcelData.map_view.zoom_bounds && ( - <> -
- Bounds Min: - - {parcelData.map_view.zoom_bounds.min_x.toFixed(2)}, {parcelData.map_view.zoom_bounds.min_y.toFixed(2)} - -
-
- Bounds Max: - - {parcelData.map_view.zoom_bounds.max_x.toFixed(2)}, {parcelData.map_view.zoom_bounds.max_y.toFixed(2)} - -
- - )} -
-
- )} -
- ))} + <> + + {isOpen && ( + <> + {/* Backdrop */} + + + {/* Panel */} + +
+

Parzellen-Informationen ({parcels.length})

+
- {/* Adjacent Parcels */} - {adjacentParcels.length > 0 && ( -
-

- Angrenzende Parzellen ({adjacentParcels.length}) -

-
- {adjacentParcels.map((adjacent, index) => ( -
-
- - {adjacent.number || adjacent.id} - - {adjacent.egrid && ( - {adjacent.egrid} - )} -
+
+ {/* Selected Parcels List */} +
+ {parcels.map((parcelData, index) => ( +
+
+

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

+ {onRemoveParcel && ( + + )}
- ))} -
-
- )} -
- - +
+ {parcelData.parcel.id && ( +
+ ID: + {parcelData.parcel.id} +
+ )} + {parcelData.parcel.number && ( +
+ Nummer: + {parcelData.parcel.number} +
+ )} + {parcelData.parcel.name && ( +
+ Name: + {parcelData.parcel.name} +
+ )} + {parcelData.parcel.egrid && ( +
+ EGRID: + {parcelData.parcel.egrid} +
+ )} + {parcelData.parcel.identnd && ( +
+ IdentND: + {parcelData.parcel.identnd} +
+ )} + {parcelData.parcel.address && ( +
+ Adresse: + {parcelData.parcel.address} +
+ )} + {parcelData.parcel.canton && ( +
+ Kanton: + {parcelData.parcel.canton} +
+ )} + {parcelData.parcel.municipality_name && ( +
+ Gemeinde: + {parcelData.parcel.municipality_name} +
+ )} + {parcelData.parcel.municipality_code && ( +
+ Gemeinde-Code: + {parcelData.parcel.municipality_code} +
+ )} + {parcelData.parcel.area_m2 !== undefined && ( +
+ Fläche: + + {parcelData.parcel.area_m2.toFixed(2)} m² + {parcelData.parcel.area_m2 >= 10000 && ( + + {' '}({(parcelData.parcel.area_m2 / 10000).toFixed(2)} ha) + + )} + +
+ )} + {parcelData.parcel.realestate_type && ( +
+ Grundstückstyp: + {parcelData.parcel.realestate_type} +
+ )} + {parcelData.parcel.bauzone && ( +
+ Bauzone: + {parcelData.parcel.bauzone} +
+ )} + {parcelData.parcel.id && ( +
+ BZO-Informationen: +
+ {(() => { + const parcelKey = parcelData.parcel.id || parcelData.parcel.number || 'unknown'; + return ( + <> + {!bzoInfo[parcelKey] && !loadingBzo[parcelKey] && ( + + )} + {loadingBzo[parcelKey] && ( + + )} + {bzoError[parcelKey] && ( +
+ {bzoError[parcelKey]} +
+ )} + + ); + })()} +
+
+ )} + {parcelData.parcel.zone && Array.isArray(parcelData.parcel.zone) && parcelData.parcel.zone.length > 0 && ( +
+ Zone: + + {parcelData.parcel.zone.length} Zone{parcelData.parcel.zone.length !== 1 ? 'n' : ''} gefunden + {(() => { + // Extract zone types from zone array + const zoneTypes = parcelData.parcel.zone + .map((z: any) => { + const attrs = z.attributes || {}; + return attrs.typ || attrs.zone_typ || attrs.bauzone || attrs.zone || attrs.label || null; + }) + .filter((t: string | null) => t !== null); + + if (zoneTypes.length > 0) { + return ( + + {' '}({zoneTypes.join(', ')}) + + ); + } + return null; + })()} + {import.meta.env.DEV && ( +
+ Details anzeigen +
+                                    {JSON.stringify(parcelData.parcel.zone, null, 2)}
+                                  
+
+ )} +
+
+ )} + {parcelData.parcel.centroid && ( +
+ Zentrum (LV95): + + {parcelData.parcel.centroid.x.toFixed(2)}, {parcelData.parcel.centroid.y.toFixed(2)} + +
+ )} + {parcelData.parcel.geoportal_url && ( +
+ Geoportal: + + Link öffnen + +
+ )} + {parcelData.gemeinde && ( +
+ Gemeinde: + + {parcelData.gemeinde.label} + {parcelData.gemeinde.plz && ` (${parcelData.gemeinde.plz})`} + +
+ )} +
+ + {/* BZO Information Section */} + {(() => { + const parcelKey = parcelData.parcel.id || parcelData.parcel.number || 'unknown'; + return bzoInfo[parcelKey] && ( +
+
+

BZO-Informationen

+ +
+ {expandedBzo[parcelKey] && ( + + )} +
+ ); + })()} + + {/* Documents Section */} + {parcelData.documents && Array.isArray(parcelData.documents) && parcelData.documents.length > 0 && ( +
+

Dokumente ({parcelData.documents.length})

+
+ {parcelData.documents.map((document) => ( + + ))} +
+
+ )} + + ))} +
+ + {/* Adjacent Parcels */} + {adjacentParcels.length > 0 && ( +
+

+ Angrenzende Parzellen ({adjacentParcels.length}) +

+
+ {adjacentParcels.map((adjacent, index) => ( +
+
+ + {adjacent.number || adjacent.id} + + {adjacent.egrid && ( + {adjacent.egrid} + )} +
+
+ ))} +
+
+ )} + +
+ + )} +
+ + {/* Document Preview Popup */} + {previewDocument && ( + setPreviewDocument(null)} + fileId={previewDocument.fileId} + fileName={previewDocument.fileName} + mimeType={previewDocument.mimeType} + /> )} - + + ); +}; + +// BZO Information Display Component +interface BZOInformationDisplayProps { + data: BZOInformationResponse; +} + +const BZOInformationDisplay: React.FC = ({ data }) => { + return ( +
+ {/* Summary Section */} + {data.ai_summary && ( +
+
Zusammenfassung
+
+

, + h2: ({node, ...props}) =>

, + h3: ({node, ...props}) =>

, + h4: ({node, ...props}) =>

, + h5: ({node, ...props}) =>

, + h6: ({node, ...props}) =>
, + p: ({node, ...props}) =>

, + ul: ({node, ...props}) =>