diff --git a/src/App.tsx b/src/App.tsx index b0e18ef..81b8c58 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 */} @@ -166,8 +160,6 @@ function App() { {/* ADMIN ROUTES (nur SysAdmin) */} {/* ============================================== */} - } /> - } /> } /> } /> } /> @@ -178,8 +170,6 @@ function App() { } /> } /> } /> - } /> - } /> diff --git a/src/components/ContentPreview/UrlContentPreview.tsx b/src/components/ContentPreview/UrlContentPreview.tsx new file mode 100644 index 0000000..4b83f01 --- /dev/null +++ b/src/components/ContentPreview/UrlContentPreview.tsx @@ -0,0 +1,344 @@ +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, LoadingRenderer } 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'); + } + }; + + // PDF load is handled by the PdfRenderer's onError callback; + // successful load is implicit when no error occurs. + + 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/index.ts b/src/components/ContentPreview/index.ts index 88f1fae..ef362b7 100644 --- a/src/components/ContentPreview/index.ts +++ b/src/components/ContentPreview/index.ts @@ -1,3 +1,5 @@ export { ContentPreview } from './ContentPreview'; export type { ContentPreviewProps } from './ContentPreview'; +export { UrlContentPreview } from './UrlContentPreview'; +export type { UrlContentPreviewProps } from './UrlContentPreview'; diff --git a/src/components/ContentPreview/renderers/PdfJsRenderer.tsx b/src/components/ContentPreview/renderers/PdfJsRenderer.tsx new file mode 100644 index 0000000..80d7a0e --- /dev/null +++ b/src/components/ContentPreview/renderers/PdfJsRenderer.tsx @@ -0,0 +1,239 @@ +import { useEffect, useRef, useState } from 'react'; +// @ts-ignore +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: _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..2a2b7ec --- /dev/null +++ b/src/components/UiComponents/AddressAutocomplete/AddressAutocomplete.tsx @@ -0,0 +1,311 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import TextField from '../TextField/TextField'; +import { BaseTextFieldProps } from '../TextField/TextFieldTypes'; +import { autocompleteAddress, AddressSuggestion } from '../../../api/realEstateApi'; +import styles from './AddressAutocomplete.module.css'; + +interface AddressAutocompleteProps extends BaseTextFieldProps { + onSelect?: (suggestion: AddressSuggestion) => void; + onKeyDown?: (e: React.KeyboardEvent) => 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/OerebSection/index.ts b/src/components/UiComponents/OerebSection/index.ts new file mode 100644 index 0000000..de8ed0f --- /dev/null +++ b/src/components/UiComponents/OerebSection/index.ts @@ -0,0 +1 @@ +export { OerebSection } from './OerebSection'; diff --git a/src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.module.css b/src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.module.css index 81b04d8..98de9ea 100644 --- a/src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.module.css +++ b/src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.module.css @@ -163,6 +163,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; diff --git a/src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx b/src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx index b353fc0..24c0039 100644 --- a/src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx +++ b/src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx @@ -1,5 +1,7 @@ import React, { useState, useCallback, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; import { FaTimes, FaTrash, FaFileAlt, FaSync, FaEye } from 'react-icons/fa'; import api from '../../../api'; import { ContentPreview } from '../../ContentPreview'; @@ -35,10 +37,83 @@ interface BzoResult { sonderregeln?: { apply: boolean; details: string }; machbarkeitsstudie?: Record>; vorschlaege?: string[]; + fakten?: Array<{ item: string; value: string; source?: string }>; + zusatzinformationen?: Array<{ article_label: string; article_title: string; text: string; source?: string }>; }; errors?: string[]; } +// 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, @@ -725,5 +800,305 @@ const ParcelInfoPanel: React.FC = ({ ); }; +// BZO Information Display Component +interface BZOInformationDisplayProps { + data: BZOInformationResponse; +} + +type MdComponentProps = { node?: unknown; [key: string]: unknown }; + +const bzoMarkdownComponents: Record | React.ComponentType> = { + h1: ({ node, ...props }: MdComponentProps) =>

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

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

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

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

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

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