From 78fd0a6b17a069e5df55d1ac3a9b99a4cd8eacdf Mon Sep 17 00:00:00 2001 From: Stephan Schellworth Date: Fri, 30 Jan 2026 11:26:58 +0100 Subject: [PATCH] feat(realestate): PEK map and address UI, realestate views, feature-instance routes --- src/App.tsx | 51 +-- .../ContentPreview/UrlContentPreview.tsx | 348 +++++++++++++++ .../renderers/PdfJsRenderer.tsx | 238 ++++++++++ .../Navigation/MandateNavigation.tsx | 143 +++--- .../AddressAutocomplete.module.css | 211 +++++++++ .../AddressAutocomplete.tsx | 309 +++++++++++++ .../UiComponents/AddressAutocomplete/index.ts | 2 + .../BauvorschriftenSection.module.css | 127 ++++++ .../BauvorschriftenSection.tsx | 109 +++++ .../OerebSection/OerebSection.module.css | 187 ++++++++ .../OerebSection/OerebSection.tsx | 133 ++++++ .../data/pages/trustee/position-documents.ts | 16 +- src/hooks/useTrusteeAccess.ts | 352 +++++++++++++++ src/hooks/useTrusteeContracts.ts | 362 +++++++++++++++ src/hooks/useTrusteeDocuments.ts | 384 ++++++++++++++++ src/hooks/useTrusteeOrganisations.ts | 368 ++++++++++++++++ src/hooks/useTrusteePositionDocuments.ts | 302 +++++++++++++ src/hooks/useTrusteePositions.ts | 417 ++++++++++++++++++ src/hooks/useTrusteeRoles.ts | 368 ++++++++++++++++ src/pages/FeatureView.tsx | 54 ++- .../realestate/RealEstateParcelsView.tsx | 1 - .../realestate/RealEstateProjectsView.tsx | 1 - src/pages/views/realestate/index.ts | 2 +- 23 files changed, 4341 insertions(+), 144 deletions(-) create mode 100644 src/components/ContentPreview/UrlContentPreview.tsx create mode 100644 src/components/ContentPreview/renderers/PdfJsRenderer.tsx create mode 100644 src/components/UiComponents/AddressAutocomplete/AddressAutocomplete.module.css create mode 100644 src/components/UiComponents/AddressAutocomplete/AddressAutocomplete.tsx create mode 100644 src/components/UiComponents/AddressAutocomplete/index.ts create mode 100644 src/components/UiComponents/BauvorschriftenSection/BauvorschriftenSection.module.css create mode 100644 src/components/UiComponents/BauvorschriftenSection/BauvorschriftenSection.tsx create mode 100644 src/components/UiComponents/OerebSection/OerebSection.module.css create mode 100644 src/components/UiComponents/OerebSection/OerebSection.tsx create mode 100644 src/hooks/useTrusteeAccess.ts create mode 100644 src/hooks/useTrusteeContracts.ts create mode 100644 src/hooks/useTrusteeDocuments.ts create mode 100644 src/hooks/useTrusteeOrganisations.ts create mode 100644 src/hooks/useTrusteePositionDocuments.ts create mode 100644 src/hooks/useTrusteePositions.ts create mode 100644 src/hooks/useTrusteeRoles.ts diff --git a/src/App.tsx b/src/App.tsx index 17aa5ef..d468328 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,14 @@ /** * App.tsx +<<<<<<< HEAD * * Haupt-App-Komponente mit Multi-Tenant Router-Setup. * +======= + * + * Haupt-App-Komponente mit Multi-Tenant Router-Setup. + * +>>>>>>> 845094a (feat(realestate): PEK map and address UI, realestate views, feature-instance routes) * URL-Struktur: * - / → Dashboard/Übersicht * - /settings → Benutzer-Einstellungen @@ -32,22 +38,16 @@ 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'; +import { PekPage, SpeechPage } from './pages/migrate'; function App() { // Load saved theme preference and set app name on app mount @@ -93,9 +93,7 @@ function App() { {/* ================================================== */} - - }> {/* Dashboard (Root) */} @@ -105,6 +103,15 @@ function App() { } /> } /> + {/* ============================================== */} + {/* WORKFLOWS ROUTES (global) */} + {/* ============================================== */} + + } /> + } /> + } /> + + {/* ============================================== */} {/* BASISDATEN ROUTES (global) */} {/* ============================================== */} @@ -115,12 +122,11 @@ function App() { {/* ============================================== */} - {/* BILLING ROUTES */} + {/* MIGRATE TO FEATURES (temporary) */} {/* ============================================== */} - - } /> - } /> - + } /> + } /> + } /> {/* ============================================== */} {/* FEATURE-INSTANZ ROUTES */} @@ -148,15 +154,6 @@ function App() { } /> } /> - {/* Chat Playground Feature Views */} - } /> - } /> - - {/* Automation Feature Views */} - } /> - } /> - } /> - {/* Catch-all für unbekannte Sub-Pfade */} } /> @@ -165,8 +162,6 @@ function App() { {/* ADMIN ROUTES (nur SysAdmin) */} {/* ============================================== */} - } /> - } /> } /> } /> } /> @@ -177,8 +172,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/core/PageManager/data/pages/trustee/position-documents.ts b/src/core/PageManager/data/pages/trustee/position-documents.ts index ae0ec8f..31577a3 100644 --- a/src/core/PageManager/data/pages/trustee/position-documents.ts +++ b/src/core/PageManager/data/pages/trustee/position-documents.ts @@ -1,7 +1,7 @@ import { useCallback } from 'react'; import { GenericPageData } from '../../../pageInterface'; import { FaLink, FaPlus } from 'react-icons/fa'; -import { useTrusteePositionDocuments, useTrusteePositionDocumentOperations } from '../../../../../hooks/useTrustee'; +import { useTrusteePositionDocuments, useTrusteePositionDocumentOperations } from '../../../../../hooks/useTrusteePositionDocuments'; // Helper function to convert attribute definitions to column config const attributesToColumns = (attributes: any[]) => { @@ -28,7 +28,7 @@ const attributesToColumns = (attributes: any[]) => { const createPositionDocumentsHook = () => { return () => { const { - items: positionDocuments, + positionDocuments, loading, error, refetch, @@ -36,15 +36,15 @@ const createPositionDocumentsHook = () => { attributes, permissions, pagination, - fetchById: fetchPositionDocumentById, + fetchPositionDocumentById, generateEditFieldsFromAttributes, ensureAttributesLoaded } = useTrusteePositionDocuments(); const { - handleDelete: handlePositionDocumentDelete, - handleCreate: handlePositionDocumentCreate, - deletingItems: deletingPositionDocuments, - creatingItem: creatingPositionDocument, + handlePositionDocumentDelete, + handlePositionDocumentCreate, + deletingPositionDocuments, + creatingPositionDocument, deleteError, createError } = useTrusteePositionDocumentOperations(); @@ -70,7 +70,7 @@ const createPositionDocumentsHook = () => { positionDocumentIds.map(id => handlePositionDocumentDelete(id)) ); - const allSuccessful = results.every((result: boolean) => result); + const allSuccessful = results.every(result => result); if (allSuccessful) { refetch(); } diff --git a/src/hooks/useTrusteeAccess.ts b/src/hooks/useTrusteeAccess.ts new file mode 100644 index 0000000..cf11e14 --- /dev/null +++ b/src/hooks/useTrusteeAccess.ts @@ -0,0 +1,352 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useApiRequest } from './useApi'; +import { getUserDataCache } from '../utils/userCache'; +import api from '../api'; +import { usePermissions, type UserPermissions } from './usePermissions'; +import { + fetchAccess as fetchAccessApi, + fetchAccessById as fetchAccessByIdApi, + createAccess as createAccessApi, + updateAccess as updateAccessApi, + deleteAccess as deleteAccessApi, + type TrusteeAccess, + type AttributeDefinition, + type PaginationParams +} from '../api/trusteeApi'; + +// Re-export types +export type { TrusteeAccess, AttributeDefinition, PaginationParams }; + +// Access list hook +export function useTrusteeAccess() { + const [accessRecords, setAccessRecords] = useState([]); + const [attributes, setAttributes] = useState([]); + const [permissions, setPermissions] = useState(null); + const [pagination, setPagination] = useState<{ + currentPage: number; + pageSize: number; + totalItems: number; + totalPages: number; + } | null>(null); + const { request, isLoading: loading, error } = useApiRequest(); + const { checkPermission } = usePermissions(); + + // Fetch attributes from backend + const fetchAttributes = useCallback(async () => { + try { + const response = await api.get('/api/attributes/TrusteeAccess'); + + let attrs: AttributeDefinition[] = []; + if (response.data?.attributes && Array.isArray(response.data.attributes)) { + attrs = response.data.attributes; + } else if (Array.isArray(response.data)) { + attrs = response.data; + } else if (response.data && typeof response.data === 'object') { + const keys = Object.keys(response.data); + for (const key of keys) { + if (Array.isArray(response.data[key])) { + attrs = response.data[key]; + break; + } + } + } + + setAttributes(attrs); + return attrs; + } catch (error: any) { + console.error('Error fetching attributes:', error); + setAttributes([]); + return []; + } + }, []); + + // Fetch permissions from backend + const fetchPermissions = useCallback(async () => { + try { + const perms = await checkPermission('DATA', 'trustee.access'); + setPermissions(perms); + return perms; + } catch (error: any) { + console.error('Error fetching permissions:', error); + const defaultPerms: UserPermissions = { + view: false, + read: 'n', + create: 'n', + update: 'n', + delete: 'n', + }; + setPermissions(defaultPerms); + return defaultPerms; + } + }, [checkPermission]); + + const fetchAccess = useCallback(async (params?: PaginationParams) => { + try { + const data = await fetchAccessApi(request, params); + + if (data && typeof data === 'object' && 'items' in data) { + const items = Array.isArray(data.items) ? data.items : []; + setAccessRecords(items); + if (data.pagination) { + setPagination(data.pagination); + } + } else { + const items = Array.isArray(data) ? data : []; + setAccessRecords(items); + setPagination(null); + } + } catch (error: any) { + setAccessRecords([]); + setPagination(null); + } + }, [request]); + + // Optimistically remove an access record + const removeOptimistically = (accessId: string) => { + setAccessRecords(prevAccess => prevAccess.filter(acc => acc.id !== accessId)); + }; + + // Optimistically update an access record + const updateOptimistically = (accessId: string, updateData: Partial) => { + setAccessRecords(prevAccess => + prevAccess.map(acc => + acc.id === accessId + ? { ...acc, ...updateData } + : acc + ) + ); + }; + + // Fetch a single access record by ID + const fetchAccessById = useCallback(async (accessId: string): Promise => { + return await fetchAccessByIdApi(request, accessId); + }, [request]); + + // Generate edit fields from attributes dynamically + const generateEditFieldsFromAttributes = useCallback((): Array<{ + key: string; + label: string; + type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly'; + editable?: boolean; + required?: boolean; + validator?: (value: any) => string | null; + options?: Array<{ value: string | number; label: string }>; + optionsReference?: string; + dependsOn?: string; + }> => { + if (!attributes || attributes.length === 0) { + return []; + } + + const editableFields = attributes + .filter(attr => { + if (attr.readonly === true || attr.editable === false) { + return false; + } + const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt']; + return !nonEditableFields.includes(attr.name); + }) + .map(attr => { + let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string'; + let options: Array<{ value: string | number; label: string }> | undefined = undefined; + let optionsReference: string | undefined = undefined; + let dependsOn: string | undefined = undefined; + + if (attr.type === 'checkbox') { + fieldType = 'boolean'; + } else if (attr.type === 'email') { + fieldType = 'email'; + } else if (attr.type === 'date') { + fieldType = 'date'; + } else if (attr.type === 'select') { + fieldType = 'enum'; + if (Array.isArray(attr.options)) { + options = attr.options.map(opt => { + const labelValue = typeof opt.label === 'string' + ? opt.label + : opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value); + return { + value: opt.value, + label: labelValue + }; + }); + } else if (typeof attr.options === 'string') { + optionsReference = attr.options; + } + } else if (attr.type === 'textarea') { + fieldType = 'textarea'; + } + + // contractId dropdown depends on organisationId + if (attr.name === 'contractId') { + dependsOn = 'organisationId'; + } + + let required = attr.required === true; + let validator: ((value: any) => string | null) | undefined = undefined; + + // contractId is optional + if (attr.name === 'contractId') { + required = false; + } else if (attr.name === 'organisationId' || attr.name === 'roleId' || attr.name === 'userId') { + required = true; + validator = (value: any) => { + if (!value || (typeof value === 'string' && value.trim() === '')) { + return `${attr.label || attr.name} is required`; + } + return null; + }; + } + + return { + key: attr.name, + label: attr.label || attr.name, + type: fieldType, + editable: attr.editable !== false && attr.readonly !== true, + required, + validator, + options, + optionsReference, + dependsOn + }; + }); + + return editableFields; + }, [attributes]); + + // Ensure attributes are loaded + const ensureAttributesLoaded = useCallback(async () => { + if (attributes && attributes.length > 0) { + return attributes; + } + const fetchedAttributes = await fetchAttributes(); + return fetchedAttributes; + }, [attributes, fetchAttributes]); + + // Fetch attributes and permissions on mount + useEffect(() => { + fetchAttributes(); + fetchPermissions(); + }, [fetchAttributes, fetchPermissions]); + + // Initial fetch + useEffect(() => { + fetchAccess(); + }, [fetchAccess]); + + return { + accessRecords, + loading, + error, + refetch: fetchAccess, + removeOptimistically, + updateOptimistically, + attributes, + permissions, + pagination, + fetchAccessById, + generateEditFieldsFromAttributes, + ensureAttributesLoaded + }; +} + +// Access operations hook +export function useTrusteeAccessOperations() { + const [deletingAccess, setDeletingAccess] = useState>(new Set()); + const [creatingAccess, setCreatingAccess] = useState(false); + const { request, isLoading } = useApiRequest(); + const [deleteError, setDeleteError] = useState(null); + const [createError, setCreateError] = useState(null); + const [updateError, setUpdateError] = useState(null); + + const handleAccessDelete = async (accessId: string) => { + setDeleteError(null); + setDeletingAccess(prev => new Set(prev).add(accessId)); + + try { + await deleteAccessApi(request, accessId); + await new Promise(resolve => setTimeout(resolve, 300)); + return true; + } catch (error: any) { + setDeleteError(error.message); + return false; + } finally { + setDeletingAccess(prev => { + const newSet = new Set(prev); + newSet.delete(accessId); + return newSet; + }); + } + }; + + const handleAccessCreate = async (accessData: Partial) => { + setCreateError(null); + setCreatingAccess(true); + + try { + const currentUserData = getUserDataCache(); + const mandateId = currentUserData?.mandateId || ''; + + const requestBody = { + ...accessData, + mandate: mandateId + }; + + const newAccess = await createAccessApi(request, requestBody); + + return { success: true, accessData: newAccess }; + } catch (error: any) { + setCreateError(error.message); + return { success: false, error: error.message }; + } finally { + setCreatingAccess(false); + } + }; + + const handleAccessUpdate = async ( + accessId: string, + updateData: Partial, + _originalData?: any + ) => { + setUpdateError(null); + + try { + const currentUserData = getUserDataCache(); + const mandateId = currentUserData?.mandateId || ''; + + const requestBody = { + ...updateData, + mandate: mandateId + }; + + const updatedAccess = await updateAccessApi(request, accessId, requestBody); + + return { success: true, accessData: updatedAccess }; + } catch (error: any) { + const errorMessage = error.response?.data?.message || error.message || 'Failed to update access'; + const statusCode = error.response?.status; + + setUpdateError(errorMessage); + + return { + success: false, + error: errorMessage, + statusCode, + isPermissionError: statusCode === 403, + isValidationError: statusCode === 400 + }; + } + }; + + return { + deletingAccess, + creatingAccess, + deleteError, + createError, + updateError, + handleAccessDelete, + handleAccessCreate, + handleAccessUpdate, + isLoading + }; +} diff --git a/src/hooks/useTrusteeContracts.ts b/src/hooks/useTrusteeContracts.ts new file mode 100644 index 0000000..f543d2e --- /dev/null +++ b/src/hooks/useTrusteeContracts.ts @@ -0,0 +1,362 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useApiRequest } from './useApi'; +import { getUserDataCache } from '../utils/userCache'; +import api from '../api'; +import { usePermissions, type UserPermissions } from './usePermissions'; +import { + fetchContracts as fetchContractsApi, + fetchContractById as fetchContractByIdApi, + createContract as createContractApi, + updateContract as updateContractApi, + deleteContract as deleteContractApi, + type TrusteeContract, + type AttributeDefinition, + type PaginationParams +} from '../api/trusteeApi'; + +// Re-export types +export type { TrusteeContract, AttributeDefinition, PaginationParams }; + +// Contracts list hook +export function useTrusteeContracts() { + const [contracts, setContracts] = useState([]); + const [attributes, setAttributes] = useState([]); + const [permissions, setPermissions] = useState(null); + const [pagination, setPagination] = useState<{ + currentPage: number; + pageSize: number; + totalItems: number; + totalPages: number; + } | null>(null); + const { request, isLoading: loading, error } = useApiRequest(); + const { checkPermission } = usePermissions(); + + // Fetch attributes from backend + const fetchAttributes = useCallback(async () => { + try { + const response = await api.get('/api/attributes/TrusteeContract'); + + let attrs: AttributeDefinition[] = []; + if (response.data?.attributes && Array.isArray(response.data.attributes)) { + attrs = response.data.attributes; + } else if (Array.isArray(response.data)) { + attrs = response.data; + } else if (response.data && typeof response.data === 'object') { + const keys = Object.keys(response.data); + for (const key of keys) { + if (Array.isArray(response.data[key])) { + attrs = response.data[key]; + break; + } + } + } + + setAttributes(attrs); + return attrs; + } catch (error: any) { + console.error('Error fetching attributes:', error); + setAttributes([]); + return []; + } + }, []); + + // Fetch permissions from backend + const fetchPermissions = useCallback(async () => { + try { + const perms = await checkPermission('DATA', 'trustee.contract'); + setPermissions(perms); + return perms; + } catch (error: any) { + console.error('Error fetching permissions:', error); + const defaultPerms: UserPermissions = { + view: false, + read: 'n', + create: 'n', + update: 'n', + delete: 'n', + }; + setPermissions(defaultPerms); + return defaultPerms; + } + }, [checkPermission]); + + const fetchContracts = useCallback(async (params?: PaginationParams) => { + try { + const data = await fetchContractsApi(request, params); + + if (data && typeof data === 'object' && 'items' in data) { + const items = Array.isArray(data.items) ? data.items : []; + setContracts(items); + if (data.pagination) { + setPagination(data.pagination); + } + } else { + const items = Array.isArray(data) ? data : []; + setContracts(items); + setPagination(null); + } + } catch (error: any) { + setContracts([]); + setPagination(null); + } + }, [request]); + + // Optimistically remove a contract + const removeOptimistically = (contractId: string) => { + setContracts(prevContracts => prevContracts.filter(contract => contract.id !== contractId)); + }; + + // Optimistically update a contract + const updateOptimistically = (contractId: string, updateData: Partial) => { + setContracts(prevContracts => + prevContracts.map(contract => + contract.id === contractId + ? { ...contract, ...updateData } + : contract + ) + ); + }; + + // Fetch a single contract by ID + const fetchContractById = useCallback(async (contractId: string): Promise => { + return await fetchContractByIdApi(request, contractId); + }, [request]); + + // Generate edit fields from attributes dynamically + const generateEditFieldsFromAttributes = useCallback((): Array<{ + key: string; + label: string; + type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly'; + editable?: boolean; + required?: boolean; + validator?: (value: any) => string | null; + options?: Array<{ value: string | number; label: string }>; + optionsReference?: string; + readonlyCondition?: (formData: any) => boolean; + }> => { + if (!attributes || attributes.length === 0) { + return []; + } + + const editableFields = attributes + .filter(attr => { + if (attr.readonly === true || attr.editable === false) { + return false; + } + const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt']; + return !nonEditableFields.includes(attr.name); + }) + .map(attr => { + let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string'; + let options: Array<{ value: string | number; label: string }> | undefined = undefined; + let optionsReference: string | undefined = undefined; + let readonlyCondition: ((formData: any) => boolean) | undefined = undefined; + + if (attr.type === 'checkbox') { + fieldType = 'boolean'; + } else if (attr.type === 'email') { + fieldType = 'email'; + } else if (attr.type === 'date') { + fieldType = 'date'; + } else if (attr.type === 'select') { + fieldType = 'enum'; + if (Array.isArray(attr.options)) { + options = attr.options.map(opt => { + const labelValue = typeof opt.label === 'string' + ? opt.label + : opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value); + return { + value: opt.value, + label: labelValue + }; + }); + } else if (typeof attr.options === 'string') { + optionsReference = attr.options; + } + } else if (attr.type === 'textarea') { + fieldType = 'textarea'; + } + + // IMPORTANT: organisationId is immutable after creation + // It's readonly when id is present (non-blank) + if (attr.name === 'organisationId') { + readonlyCondition = (formData: any) => { + return formData && formData.id && formData.id !== ''; + }; + } + + let required = attr.required === true; + let validator: ((value: any) => string | null) | undefined = undefined; + + if (attr.name === 'organisationId') { + required = true; + validator = (value: any) => { + if (!value || (typeof value === 'string' && value.trim() === '')) { + return 'Organisation is required'; + } + return null; + }; + } else if (attr.name === 'label') { + required = true; + validator = (value: string) => { + if (!value || value.trim() === '') { + return 'Label cannot be empty'; + } + return null; + }; + } + + return { + key: attr.name, + label: attr.label || attr.name, + type: fieldType, + editable: attr.editable !== false && attr.readonly !== true, + required, + validator, + options, + optionsReference, + readonlyCondition + }; + }); + + return editableFields; + }, [attributes]); + + // Ensure attributes are loaded + const ensureAttributesLoaded = useCallback(async () => { + if (attributes && attributes.length > 0) { + return attributes; + } + const fetchedAttributes = await fetchAttributes(); + return fetchedAttributes; + }, [attributes, fetchAttributes]); + + // Fetch attributes and permissions on mount + useEffect(() => { + fetchAttributes(); + fetchPermissions(); + }, [fetchAttributes, fetchPermissions]); + + // Initial fetch + useEffect(() => { + fetchContracts(); + }, [fetchContracts]); + + return { + contracts, + loading, + error, + refetch: fetchContracts, + removeOptimistically, + updateOptimistically, + attributes, + permissions, + pagination, + fetchContractById, + generateEditFieldsFromAttributes, + ensureAttributesLoaded + }; +} + +// Contract operations hook +export function useTrusteeContractOperations() { + const [deletingContracts, setDeletingContracts] = useState>(new Set()); + const [creatingContract, setCreatingContract] = useState(false); + const { request, isLoading } = useApiRequest(); + const [deleteError, setDeleteError] = useState(null); + const [createError, setCreateError] = useState(null); + const [updateError, setUpdateError] = useState(null); + + const handleContractDelete = async (contractId: string) => { + setDeleteError(null); + setDeletingContracts(prev => new Set(prev).add(contractId)); + + try { + await deleteContractApi(request, contractId); + await new Promise(resolve => setTimeout(resolve, 300)); + return true; + } catch (error: any) { + setDeleteError(error.message); + return false; + } finally { + setDeletingContracts(prev => { + const newSet = new Set(prev); + newSet.delete(contractId); + return newSet; + }); + } + }; + + const handleContractCreate = async (contractData: Partial) => { + setCreateError(null); + setCreatingContract(true); + + try { + const currentUserData = getUserDataCache(); + const mandateId = currentUserData?.mandateId || ''; + + const requestBody = { + ...contractData, + mandate: mandateId + }; + + const newContract = await createContractApi(request, requestBody); + + return { success: true, contractData: newContract }; + } catch (error: any) { + setCreateError(error.message); + return { success: false, error: error.message }; + } finally { + setCreatingContract(false); + } + }; + + const handleContractUpdate = async ( + contractId: string, + updateData: Partial, + _originalData?: any + ) => { + setUpdateError(null); + + try { + const currentUserData = getUserDataCache(); + const mandateId = currentUserData?.mandateId || ''; + + // Note: organisationId should NOT be included in update if immutable + // Backend will reject if organisationId is changed + const requestBody = { + ...updateData, + mandate: mandateId + }; + + const updatedContract = await updateContractApi(request, contractId, requestBody); + + return { success: true, contractData: updatedContract }; + } catch (error: any) { + const errorMessage = error.response?.data?.message || error.message || 'Failed to update contract'; + const statusCode = error.response?.status; + + setUpdateError(errorMessage); + + return { + success: false, + error: errorMessage, + statusCode, + isPermissionError: statusCode === 403, + isValidationError: statusCode === 400 + }; + } + }; + + return { + deletingContracts, + creatingContract, + deleteError, + createError, + updateError, + handleContractDelete, + handleContractCreate, + handleContractUpdate, + isLoading + }; +} diff --git a/src/hooks/useTrusteeDocuments.ts b/src/hooks/useTrusteeDocuments.ts new file mode 100644 index 0000000..51f9b7d --- /dev/null +++ b/src/hooks/useTrusteeDocuments.ts @@ -0,0 +1,384 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useApiRequest } from './useApi'; +import { getUserDataCache } from '../utils/userCache'; +import api from '../api'; +import { usePermissions, type UserPermissions } from './usePermissions'; +import { + fetchDocuments as fetchDocumentsApi, + fetchDocumentById as fetchDocumentByIdApi, + createDocument as createDocumentApi, + updateDocument as updateDocumentApi, + deleteDocument as deleteDocumentApi, + downloadDocumentData as downloadDocumentDataApi, + type TrusteeDocument, + type AttributeDefinition, + type PaginationParams +} from '../api/trusteeApi'; + +// Re-export types +export type { TrusteeDocument, AttributeDefinition, PaginationParams }; + +// Documents list hook +export function useTrusteeDocuments() { + const [documents, setDocuments] = useState([]); + const [attributes, setAttributes] = useState([]); + const [permissions, setPermissions] = useState(null); + const [pagination, setPagination] = useState<{ + currentPage: number; + pageSize: number; + totalItems: number; + totalPages: number; + } | null>(null); + const { request, isLoading: loading, error } = useApiRequest(); + const { checkPermission } = usePermissions(); + + // Fetch attributes from backend + const fetchAttributes = useCallback(async () => { + try { + const response = await api.get('/api/attributes/TrusteeDocument'); + + let attrs: AttributeDefinition[] = []; + if (response.data?.attributes && Array.isArray(response.data.attributes)) { + attrs = response.data.attributes; + } else if (Array.isArray(response.data)) { + attrs = response.data; + } else if (response.data && typeof response.data === 'object') { + const keys = Object.keys(response.data); + for (const key of keys) { + if (Array.isArray(response.data[key])) { + attrs = response.data[key]; + break; + } + } + } + + setAttributes(attrs); + return attrs; + } catch (error: any) { + console.error('Error fetching attributes:', error); + setAttributes([]); + return []; + } + }, []); + + // Fetch permissions from backend + const fetchPermissions = useCallback(async () => { + try { + const perms = await checkPermission('DATA', 'trustee.document'); + setPermissions(perms); + return perms; + } catch (error: any) { + console.error('Error fetching permissions:', error); + const defaultPerms: UserPermissions = { + view: false, + read: 'n', + create: 'n', + update: 'n', + delete: 'n', + }; + setPermissions(defaultPerms); + return defaultPerms; + } + }, [checkPermission]); + + const fetchDocuments = useCallback(async (params?: PaginationParams) => { + try { + const data = await fetchDocumentsApi(request, params); + + if (data && typeof data === 'object' && 'items' in data) { + const items = Array.isArray(data.items) ? data.items : []; + setDocuments(items); + if (data.pagination) { + setPagination(data.pagination); + } + } else { + const items = Array.isArray(data) ? data : []; + setDocuments(items); + setPagination(null); + } + } catch (error: any) { + setDocuments([]); + setPagination(null); + } + }, [request]); + + // Optimistically remove a document + const removeOptimistically = (documentId: string) => { + setDocuments(prevDocs => prevDocs.filter(doc => doc.id !== documentId)); + }; + + // Optimistically update a document + const updateOptimistically = (documentId: string, updateData: Partial) => { + setDocuments(prevDocs => + prevDocs.map(doc => + doc.id === documentId + ? { ...doc, ...updateData } + : doc + ) + ); + }; + + // Fetch a single document by ID + const fetchDocumentById = useCallback(async (documentId: string): Promise => { + return await fetchDocumentByIdApi(request, documentId); + }, [request]); + + // Generate edit fields from attributes dynamically + const generateEditFieldsFromAttributes = useCallback((): Array<{ + key: string; + label: string; + type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly'; + editable?: boolean; + required?: boolean; + validator?: (value: any) => string | null; + options?: Array<{ value: string | number; label: string }>; + optionsReference?: string; + dependsOn?: string; + }> => { + if (!attributes || attributes.length === 0) { + return []; + } + + const editableFields = attributes + .filter(attr => { + if (attr.readonly === true || attr.editable === false) { + return false; + } + // documentData is handled separately (binary upload) + const nonEditableFields = ['id', 'documentData', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt']; + return !nonEditableFields.includes(attr.name); + }) + .map(attr => { + let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string'; + let options: Array<{ value: string | number; label: string }> | undefined = undefined; + let optionsReference: string | undefined = undefined; + let dependsOn: string | undefined = undefined; + + if (attr.type === 'checkbox') { + fieldType = 'boolean'; + } else if (attr.type === 'email') { + fieldType = 'email'; + } else if (attr.type === 'date') { + fieldType = 'date'; + } else if (attr.type === 'select') { + fieldType = 'enum'; + if (Array.isArray(attr.options)) { + options = attr.options.map(opt => { + const labelValue = typeof opt.label === 'string' + ? opt.label + : opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value); + return { + value: opt.value, + label: labelValue + }; + }); + } else if (typeof attr.options === 'string') { + optionsReference = attr.options; + } + } else if (attr.type === 'textarea') { + fieldType = 'textarea'; + } + + // contractId depends on organisationId + if (attr.name === 'contractId') { + dependsOn = 'organisationId'; + } + + let required = attr.required === true; + let validator: ((value: any) => string | null) | undefined = undefined; + + if (attr.name === 'organisationId' || attr.name === 'contractId' || attr.name === 'documentName') { + required = true; + validator = (value: any) => { + if (!value || (typeof value === 'string' && value.trim() === '')) { + return `${attr.label || attr.name} is required`; + } + return null; + }; + } + + return { + key: attr.name, + label: attr.label || attr.name, + type: fieldType, + editable: attr.editable !== false && attr.readonly !== true, + required, + validator, + options, + optionsReference, + dependsOn + }; + }); + + return editableFields; + }, [attributes]); + + // Ensure attributes are loaded + const ensureAttributesLoaded = useCallback(async () => { + if (attributes && attributes.length > 0) { + return attributes; + } + const fetchedAttributes = await fetchAttributes(); + return fetchedAttributes; + }, [attributes, fetchAttributes]); + + // Fetch attributes and permissions on mount + useEffect(() => { + fetchAttributes(); + fetchPermissions(); + }, [fetchAttributes, fetchPermissions]); + + // Initial fetch + useEffect(() => { + fetchDocuments(); + }, [fetchDocuments]); + + return { + documents, + loading, + error, + refetch: fetchDocuments, + removeOptimistically, + updateOptimistically, + attributes, + permissions, + pagination, + fetchDocumentById, + generateEditFieldsFromAttributes, + ensureAttributesLoaded + }; +} + +// Document operations hook +export function useTrusteeDocumentOperations() { + const [deletingDocuments, setDeletingDocuments] = useState>(new Set()); + const [creatingDocument, setCreatingDocument] = useState(false); + const [downloadingDocuments, setDownloadingDocuments] = useState>(new Set()); + const { request, isLoading } = useApiRequest(); + const [deleteError, setDeleteError] = useState(null); + const [createError, setCreateError] = useState(null); + const [updateError, setUpdateError] = useState(null); + const [downloadError, setDownloadError] = useState(null); + + const handleDocumentDelete = async (documentId: string) => { + setDeleteError(null); + setDeletingDocuments(prev => new Set(prev).add(documentId)); + + try { + await deleteDocumentApi(request, documentId); + await new Promise(resolve => setTimeout(resolve, 300)); + return true; + } catch (error: any) { + setDeleteError(error.message); + return false; + } finally { + setDeletingDocuments(prev => { + const newSet = new Set(prev); + newSet.delete(documentId); + return newSet; + }); + } + }; + + const handleDocumentCreate = async (documentData: FormData) => { + setCreateError(null); + setCreatingDocument(true); + + try { + const currentUserData = getUserDataCache(); + const mandateId = currentUserData?.mandateId || ''; + + documentData.append('mandate', mandateId); + + const newDocument = await createDocumentApi(request, documentData); + + return { success: true, documentData: newDocument }; + } catch (error: any) { + setCreateError(error.message); + return { success: false, error: error.message }; + } finally { + setCreatingDocument(false); + } + }; + + const handleDocumentUpdate = async ( + documentId: string, + updateData: Partial, + _originalData?: any + ) => { + setUpdateError(null); + + try { + const currentUserData = getUserDataCache(); + const mandateId = currentUserData?.mandateId || ''; + + const requestBody = { + ...updateData, + mandate: mandateId + }; + + const updatedDocument = await updateDocumentApi(request, documentId, requestBody); + + return { success: true, documentData: updatedDocument }; + } catch (error: any) { + const errorMessage = error.response?.data?.message || error.message || 'Failed to update document'; + const statusCode = error.response?.status; + + setUpdateError(errorMessage); + + return { + success: false, + error: errorMessage, + statusCode, + isPermissionError: statusCode === 403, + isValidationError: statusCode === 400 + }; + } + }; + + const handleDocumentDownload = async (documentId: string, documentName: string) => { + setDownloadError(null); + setDownloadingDocuments(prev => new Set(prev).add(documentId)); + + try { + const blob = await downloadDocumentDataApi(request, documentId); + + // Create download link + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = documentName || `document-${documentId}`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + return true; + } catch (error: any) { + const errorMessage = error.message || 'Failed to download document'; + setDownloadError(errorMessage); + return false; + } finally { + setDownloadingDocuments(prev => { + const newSet = new Set(prev); + newSet.delete(documentId); + return newSet; + }); + } + }; + + return { + deletingDocuments, + creatingDocument, + downloadingDocuments, + deleteError, + createError, + updateError, + downloadError, + handleDocumentDelete, + handleDocumentCreate, + handleDocumentUpdate, + handleDocumentDownload, + isLoading + }; +} diff --git a/src/hooks/useTrusteeOrganisations.ts b/src/hooks/useTrusteeOrganisations.ts new file mode 100644 index 0000000..0c1f7ef --- /dev/null +++ b/src/hooks/useTrusteeOrganisations.ts @@ -0,0 +1,368 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useApiRequest } from './useApi'; +import { getUserDataCache } from '../utils/userCache'; +import api from '../api'; +import { usePermissions, type UserPermissions } from './usePermissions'; +import { + fetchOrganisations as fetchOrganisationsApi, + fetchOrganisationById as fetchOrganisationByIdApi, + createOrganisation as createOrganisationApi, + updateOrganisation as updateOrganisationApi, + deleteOrganisation as deleteOrganisationApi, + type TrusteeOrganisation, + type AttributeDefinition, + type PaginationParams +} from '../api/trusteeApi'; + +// Re-export types +export type { TrusteeOrganisation, AttributeDefinition, PaginationParams }; + +// Organisations list hook +export function useTrusteeOrganisations() { + const [organisations, setOrganisations] = useState([]); + const [attributes, setAttributes] = useState([]); + const [permissions, setPermissions] = useState(null); + const [pagination, setPagination] = useState<{ + currentPage: number; + pageSize: number; + totalItems: number; + totalPages: number; + } | null>(null); + const { request, isLoading: loading, error } = useApiRequest(); + const { checkPermission } = usePermissions(); + + // Fetch attributes from backend + const fetchAttributes = useCallback(async () => { + try { + const response = await api.get('/api/attributes/TrusteeOrganisation'); + + let attrs: AttributeDefinition[] = []; + if (response.data?.attributes && Array.isArray(response.data.attributes)) { + attrs = response.data.attributes; + } else if (Array.isArray(response.data)) { + attrs = response.data; + } else if (response.data && typeof response.data === 'object') { + const keys = Object.keys(response.data); + for (const key of keys) { + if (Array.isArray(response.data[key])) { + attrs = response.data[key]; + break; + } + } + } + + setAttributes(attrs); + return attrs; + } catch (error: any) { + console.error('Error fetching attributes:', error); + setAttributes([]); + return []; + } + }, []); + + // Fetch permissions from backend + const fetchPermissions = useCallback(async () => { + try { + const perms = await checkPermission('DATA', 'trustee.organisation'); + setPermissions(perms); + return perms; + } catch (error: any) { + console.error('Error fetching permissions:', error); + const defaultPerms: UserPermissions = { + view: false, + read: 'n', + create: 'n', + update: 'n', + delete: 'n', + }; + setPermissions(defaultPerms); + return defaultPerms; + } + }, [checkPermission]); + + const fetchOrganisations = useCallback(async (params?: PaginationParams) => { + try { + const data = await fetchOrganisationsApi(request, params); + + if (data && typeof data === 'object' && 'items' in data) { + const items = Array.isArray(data.items) ? data.items : []; + setOrganisations(items); + if (data.pagination) { + setPagination(data.pagination); + } + } else { + const items = Array.isArray(data) ? data : []; + setOrganisations(items); + setPagination(null); + } + } catch (error: any) { + setOrganisations([]); + setPagination(null); + } + }, [request]); + + // Optimistically remove an organisation + const removeOptimistically = (organisationId: string) => { + setOrganisations(prevOrgs => prevOrgs.filter(org => org.id !== organisationId)); + }; + + // Optimistically update an organisation + const updateOptimistically = (organisationId: string, updateData: Partial) => { + setOrganisations(prevOrgs => + prevOrgs.map(org => + org.id === organisationId + ? { ...org, ...updateData } + : org + ) + ); + }; + + // Fetch a single organisation by ID + const fetchOrganisationById = useCallback(async (organisationId: string): Promise => { + return await fetchOrganisationByIdApi(request, organisationId); + }, [request]); + + // Generate edit fields from attributes dynamically + const generateEditFieldsFromAttributes = useCallback((): Array<{ + key: string; + label: string; + type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly'; + editable?: boolean; + required?: boolean; + validator?: (value: any) => string | null; + options?: Array<{ value: string | number; label: string }>; + optionsReference?: string; + }> => { + if (!attributes || attributes.length === 0) { + return []; + } + + const editableFields = attributes + .filter(attr => { + if (attr.readonly === true || attr.editable === false) { + return false; + } + const nonEditableFields = ['mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt']; + return !nonEditableFields.includes(attr.name); + }) + .map(attr => { + let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string'; + let options: Array<{ value: string | number; label: string }> | undefined = undefined; + let optionsReference: string | undefined = undefined; + + if (attr.type === 'checkbox') { + fieldType = 'boolean'; + } else if (attr.type === 'email') { + fieldType = 'email'; + } else if (attr.type === 'date') { + fieldType = 'date'; + } else if (attr.type === 'select') { + fieldType = 'enum'; + if (Array.isArray(attr.options)) { + options = attr.options.map(opt => { + const labelValue = typeof opt.label === 'string' + ? opt.label + : opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value); + return { + value: opt.value, + label: labelValue + }; + }); + } else if (typeof attr.options === 'string') { + optionsReference = attr.options; + } + } else if (attr.type === 'multiselect') { + fieldType = 'multiselect'; + if (Array.isArray(attr.options)) { + options = attr.options.map(opt => { + const labelValue = typeof opt.label === 'string' + ? opt.label + : opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value); + return { + value: opt.value, + label: labelValue + }; + }); + } else if (typeof attr.options === 'string') { + optionsReference = attr.options; + } + } else if (attr.type === 'textarea') { + fieldType = 'textarea'; + } + + let required = attr.required === true; + let validator: ((value: any) => string | null) | undefined = undefined; + + // Special validation for 'id' field (alphanumeric + dash/underscore, 3-50 chars) + if (attr.name === 'id') { + required = true; + validator = (value: string) => { + if (!value || value.trim() === '') { + return 'Organisation ID cannot be empty'; + } + if (!/^[a-zA-Z0-9_-]{3,50}$/.test(value)) { + return 'ID must be 3-50 characters long (alphanumeric, dash, underscore only)'; + } + return null; + }; + } else if (attr.name === 'label') { + required = true; + validator = (value: string) => { + if (!value || value.trim() === '') { + return 'Label cannot be empty'; + } + return null; + }; + } + + return { + key: attr.name, + label: attr.label || attr.name, + type: fieldType, + editable: attr.editable !== false && attr.readonly !== true, + required, + validator, + options, + optionsReference + }; + }); + + return editableFields; + }, [attributes]); + + // Ensure attributes are loaded + const ensureAttributesLoaded = useCallback(async () => { + if (attributes && attributes.length > 0) { + return attributes; + } + const fetchedAttributes = await fetchAttributes(); + return fetchedAttributes; + }, [attributes, fetchAttributes]); + + // Fetch attributes and permissions on mount + useEffect(() => { + fetchAttributes(); + fetchPermissions(); + }, [fetchAttributes, fetchPermissions]); + + // Initial fetch + useEffect(() => { + fetchOrganisations(); + }, [fetchOrganisations]); + + return { + organisations, + loading, + error, + refetch: fetchOrganisations, + removeOptimistically, + updateOptimistically, + attributes, + permissions, + pagination, + fetchOrganisationById, + generateEditFieldsFromAttributes, + ensureAttributesLoaded + }; +} + +// Organisation operations hook +export function useTrusteeOrganisationOperations() { + const [deletingOrganisations, setDeletingOrganisations] = useState>(new Set()); + const [creatingOrganisation, setCreatingOrganisation] = useState(false); + const { request, isLoading } = useApiRequest(); + const [deleteError, setDeleteError] = useState(null); + const [createError, setCreateError] = useState(null); + const [updateError, setUpdateError] = useState(null); + + const handleOrganisationDelete = async (organisationId: string) => { + setDeleteError(null); + setDeletingOrganisations(prev => new Set(prev).add(organisationId)); + + try { + await deleteOrganisationApi(request, organisationId); + await new Promise(resolve => setTimeout(resolve, 300)); + return true; + } catch (error: any) { + setDeleteError(error.message); + return false; + } finally { + setDeletingOrganisations(prev => { + const newSet = new Set(prev); + newSet.delete(organisationId); + return newSet; + }); + } + }; + + const handleOrganisationCreate = async (organisationData: Partial) => { + setCreateError(null); + setCreatingOrganisation(true); + + try { + const currentUserData = getUserDataCache(); + const mandateId = currentUserData?.mandateId || ''; + + const requestBody = { + ...organisationData, + mandate: mandateId + }; + + const newOrganisation = await createOrganisationApi(request, requestBody); + + return { success: true, organisationData: newOrganisation }; + } catch (error: any) { + setCreateError(error.message); + return { success: false, error: error.message }; + } finally { + setCreatingOrganisation(false); + } + }; + + const handleOrganisationUpdate = async ( + organisationId: string, + updateData: Partial, + _originalData?: any + ) => { + setUpdateError(null); + + try { + const currentUserData = getUserDataCache(); + const mandateId = currentUserData?.mandateId || ''; + + const requestBody = { + ...updateData, + mandate: mandateId + }; + + const updatedOrganisation = await updateOrganisationApi(request, organisationId, requestBody); + + return { success: true, organisationData: updatedOrganisation }; + } catch (error: any) { + const errorMessage = error.response?.data?.message || error.message || 'Failed to update organisation'; + const statusCode = error.response?.status; + + setUpdateError(errorMessage); + + return { + success: false, + error: errorMessage, + statusCode, + isPermissionError: statusCode === 403, + isValidationError: statusCode === 400 + }; + } + }; + + return { + deletingOrganisations, + creatingOrganisation, + deleteError, + createError, + updateError, + handleOrganisationDelete, + handleOrganisationCreate, + handleOrganisationUpdate, + isLoading + }; +} diff --git a/src/hooks/useTrusteePositionDocuments.ts b/src/hooks/useTrusteePositionDocuments.ts new file mode 100644 index 0000000..7040daf --- /dev/null +++ b/src/hooks/useTrusteePositionDocuments.ts @@ -0,0 +1,302 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useApiRequest } from './useApi'; +import { getUserDataCache } from '../utils/userCache'; +import api from '../api'; +import { usePermissions, type UserPermissions } from './usePermissions'; +import { + fetchPositionDocuments as fetchPositionDocumentsApi, + fetchPositionDocumentById as fetchPositionDocumentByIdApi, + createPositionDocument as createPositionDocumentApi, + deletePositionDocument as deletePositionDocumentApi, + type TrusteePositionDocument, + type AttributeDefinition, + type PaginationParams +} from '../api/trusteeApi'; + +// Re-export types +export type { TrusteePositionDocument, AttributeDefinition, PaginationParams }; + +// Position-Documents list hook +export function useTrusteePositionDocuments() { + const [positionDocuments, setPositionDocuments] = useState([]); + const [attributes, setAttributes] = useState([]); + const [permissions, setPermissions] = useState(null); + const [pagination, setPagination] = useState<{ + currentPage: number; + pageSize: number; + totalItems: number; + totalPages: number; + } | null>(null); + const { request, isLoading: loading, error } = useApiRequest(); + const { checkPermission } = usePermissions(); + + // Fetch attributes from backend + const fetchAttributes = useCallback(async () => { + try { + const response = await api.get('/api/attributes/TrusteePositionDocument'); + + let attrs: AttributeDefinition[] = []; + if (response.data?.attributes && Array.isArray(response.data.attributes)) { + attrs = response.data.attributes; + } else if (Array.isArray(response.data)) { + attrs = response.data; + } else if (response.data && typeof response.data === 'object') { + const keys = Object.keys(response.data); + for (const key of keys) { + if (Array.isArray(response.data[key])) { + attrs = response.data[key]; + break; + } + } + } + + setAttributes(attrs); + return attrs; + } catch (error: any) { + console.error('Error fetching attributes:', error); + setAttributes([]); + return []; + } + }, []); + + // Fetch permissions from backend + const fetchPermissions = useCallback(async () => { + try { + const perms = await checkPermission('DATA', 'trustee.xpositiondocument'); + setPermissions(perms); + return perms; + } catch (error: any) { + console.error('Error fetching permissions:', error); + const defaultPerms: UserPermissions = { + view: false, + read: 'n', + create: 'n', + update: 'n', + delete: 'n', + }; + setPermissions(defaultPerms); + return defaultPerms; + } + }, [checkPermission]); + + const fetchPositionDocuments = useCallback(async (params?: PaginationParams) => { + try { + const data = await fetchPositionDocumentsApi(request, params); + + if (data && typeof data === 'object' && 'items' in data) { + const items = Array.isArray(data.items) ? data.items : []; + setPositionDocuments(items); + if (data.pagination) { + setPagination(data.pagination); + } + } else { + const items = Array.isArray(data) ? data : []; + setPositionDocuments(items); + setPagination(null); + } + } catch (error: any) { + setPositionDocuments([]); + setPagination(null); + } + }, [request]); + + // Optimistically remove a position-document link + const removeOptimistically = (positionDocumentId: string) => { + setPositionDocuments(prevPD => prevPD.filter(pd => pd.id !== positionDocumentId)); + }; + + // Fetch a single position-document by ID + const fetchPositionDocumentById = useCallback(async (positionDocumentId: string): Promise => { + return await fetchPositionDocumentByIdApi(request, positionDocumentId); + }, [request]); + + // Generate edit fields from attributes dynamically + const generateEditFieldsFromAttributes = useCallback((): Array<{ + key: string; + label: string; + type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly'; + editable?: boolean; + required?: boolean; + validator?: (value: any) => string | null; + options?: Array<{ value: string | number; label: string }>; + optionsReference?: string; + dependsOn?: string; + }> => { + if (!attributes || attributes.length === 0) { + return []; + } + + const editableFields = attributes + .filter(attr => { + if (attr.readonly === true || attr.editable === false) { + return false; + } + const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt']; + return !nonEditableFields.includes(attr.name); + }) + .map(attr => { + let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string'; + let options: Array<{ value: string | number; label: string }> | undefined = undefined; + let optionsReference: string | undefined = undefined; + let dependsOn: string | undefined = undefined; + + if (attr.type === 'checkbox') { + fieldType = 'boolean'; + } else if (attr.type === 'email') { + fieldType = 'email'; + } else if (attr.type === 'date') { + fieldType = 'date'; + } else if (attr.type === 'select') { + fieldType = 'enum'; + if (Array.isArray(attr.options)) { + options = attr.options.map(opt => { + const labelValue = typeof opt.label === 'string' + ? opt.label + : opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value); + return { + value: opt.value, + label: labelValue + }; + }); + } else if (typeof attr.options === 'string') { + optionsReference = attr.options; + } + } else if (attr.type === 'textarea') { + fieldType = 'textarea'; + } + + // Dependency chain: contractId depends on organisationId + // positionId and documentId depend on contractId + if (attr.name === 'contractId') { + dependsOn = 'organisationId'; + } else if (attr.name === 'positionId' || attr.name === 'documentId') { + dependsOn = 'contractId'; + } + + let required = attr.required === true; + let validator: ((value: any) => string | null) | undefined = undefined; + + if (attr.name === 'organisationId' || attr.name === 'contractId' || + attr.name === 'positionId' || attr.name === 'documentId') { + required = true; + validator = (value: any) => { + if (!value || (typeof value === 'string' && value.trim() === '')) { + return `${attr.label || attr.name} is required`; + } + return null; + }; + } + + return { + key: attr.name, + label: attr.label || attr.name, + type: fieldType, + editable: attr.editable !== false && attr.readonly !== true, + required, + validator, + options, + optionsReference, + dependsOn + }; + }); + + return editableFields; + }, [attributes]); + + // Ensure attributes are loaded + const ensureAttributesLoaded = useCallback(async () => { + if (attributes && attributes.length > 0) { + return attributes; + } + const fetchedAttributes = await fetchAttributes(); + return fetchedAttributes; + }, [attributes, fetchAttributes]); + + // Fetch attributes and permissions on mount + useEffect(() => { + fetchAttributes(); + fetchPermissions(); + }, [fetchAttributes, fetchPermissions]); + + // Initial fetch + useEffect(() => { + fetchPositionDocuments(); + }, [fetchPositionDocuments]); + + return { + positionDocuments, + loading, + error, + refetch: fetchPositionDocuments, + removeOptimistically, + attributes, + permissions, + pagination, + fetchPositionDocumentById, + generateEditFieldsFromAttributes, + ensureAttributesLoaded + }; +} + +// Position-Document operations hook +export function useTrusteePositionDocumentOperations() { + const [deletingPositionDocuments, setDeletingPositionDocuments] = useState>(new Set()); + const [creatingPositionDocument, setCreatingPositionDocument] = useState(false); + const { request, isLoading } = useApiRequest(); + const [deleteError, setDeleteError] = useState(null); + const [createError, setCreateError] = useState(null); + + const handlePositionDocumentDelete = async (positionDocumentId: string) => { + setDeleteError(null); + setDeletingPositionDocuments(prev => new Set(prev).add(positionDocumentId)); + + try { + await deletePositionDocumentApi(request, positionDocumentId); + await new Promise(resolve => setTimeout(resolve, 300)); + return true; + } catch (error: any) { + setDeleteError(error.message); + return false; + } finally { + setDeletingPositionDocuments(prev => { + const newSet = new Set(prev); + newSet.delete(positionDocumentId); + return newSet; + }); + } + }; + + const handlePositionDocumentCreate = async (positionDocumentData: Partial) => { + setCreateError(null); + setCreatingPositionDocument(true); + + try { + const currentUserData = getUserDataCache(); + const mandateId = currentUserData?.mandateId || ''; + + const requestBody = { + ...positionDocumentData, + mandate: mandateId + }; + + const newPositionDocument = await createPositionDocumentApi(request, requestBody); + + return { success: true, positionDocumentData: newPositionDocument }; + } catch (error: any) { + setCreateError(error.message); + return { success: false, error: error.message }; + } finally { + setCreatingPositionDocument(false); + } + }; + + return { + deletingPositionDocuments, + creatingPositionDocument, + deleteError, + createError, + handlePositionDocumentDelete, + handlePositionDocumentCreate, + isLoading + }; +} diff --git a/src/hooks/useTrusteePositions.ts b/src/hooks/useTrusteePositions.ts new file mode 100644 index 0000000..7aff430 --- /dev/null +++ b/src/hooks/useTrusteePositions.ts @@ -0,0 +1,417 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useApiRequest } from './useApi'; +import { getUserDataCache } from '../utils/userCache'; +import api from '../api'; +import { usePermissions, type UserPermissions } from './usePermissions'; +import { + fetchPositions as fetchPositionsApi, + fetchPositionById as fetchPositionByIdApi, + createPosition as createPositionApi, + updatePosition as updatePositionApi, + deletePosition as deletePositionApi, + type TrusteePosition, + type AttributeDefinition, + type PaginationParams +} from '../api/trusteeApi'; + +// Re-export types +export type { TrusteePosition, AttributeDefinition, PaginationParams }; + +// Positions list hook +export function useTrusteePositions() { + const [positions, setPositions] = useState([]); + const [attributes, setAttributes] = useState([]); + const [permissions, setPermissions] = useState(null); + const [pagination, setPagination] = useState<{ + currentPage: number; + pageSize: number; + totalItems: number; + totalPages: number; + } | null>(null); + const { request, isLoading: loading, error } = useApiRequest(); + const { checkPermission } = usePermissions(); + + // Fetch attributes from backend + const fetchAttributes = useCallback(async () => { + try { + const response = await api.get('/api/attributes/TrusteePosition'); + + let attrs: AttributeDefinition[] = []; + if (response.data?.attributes && Array.isArray(response.data.attributes)) { + attrs = response.data.attributes; + } else if (Array.isArray(response.data)) { + attrs = response.data; + } else if (response.data && typeof response.data === 'object') { + const keys = Object.keys(response.data); + for (const key of keys) { + if (Array.isArray(response.data[key])) { + attrs = response.data[key]; + break; + } + } + } + + setAttributes(attrs); + return attrs; + } catch (error: any) { + console.error('Error fetching attributes:', error); + setAttributes([]); + return []; + } + }, []); + + // Fetch permissions from backend + const fetchPermissions = useCallback(async () => { + try { + const perms = await checkPermission('DATA', 'trustee.position'); + setPermissions(perms); + return perms; + } catch (error: any) { + console.error('Error fetching permissions:', error); + const defaultPerms: UserPermissions = { + view: false, + read: 'n', + create: 'n', + update: 'n', + delete: 'n', + }; + setPermissions(defaultPerms); + return defaultPerms; + } + }, [checkPermission]); + + const fetchPositions = useCallback(async (params?: PaginationParams) => { + try { + const data = await fetchPositionsApi(request, params); + + if (data && typeof data === 'object' && 'items' in data) { + const items = Array.isArray(data.items) ? data.items : []; + setPositions(items); + if (data.pagination) { + setPagination(data.pagination); + } + } else { + const items = Array.isArray(data) ? data : []; + setPositions(items); + setPagination(null); + } + } catch (error: any) { + setPositions([]); + setPagination(null); + } + }, [request]); + + // Optimistically remove a position + const removeOptimistically = (positionId: string) => { + setPositions(prevPositions => prevPositions.filter(pos => pos.id !== positionId)); + }; + + // Optimistically update a position + const updateOptimistically = (positionId: string, updateData: Partial) => { + setPositions(prevPositions => + prevPositions.map(pos => + pos.id === positionId + ? { ...pos, ...updateData } + : pos + ) + ); + }; + + // Fetch a single position by ID + const fetchPositionById = useCallback(async (positionId: string): Promise => { + return await fetchPositionByIdApi(request, positionId); + }, [request]); + + // Generate edit fields from attributes dynamically with MwSt calculation logic + const generateEditFieldsFromAttributes = useCallback((): Array<{ + key: string; + label: string; + type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' | 'number'; + editable?: boolean; + required?: boolean; + validator?: (value: any, formData?: any) => string | null; + onChange?: (value: any, formData: any) => Partial; + options?: Array<{ value: string | number; label: string }>; + optionsReference?: string; + dependsOn?: string; + minRows?: number; + maxRows?: number; + }> => { + if (!attributes || attributes.length === 0) { + return []; + } + + const editableFields = attributes + .filter(attr => { + if (attr.readonly === true || attr.editable === false) { + return false; + } + const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt']; + return !nonEditableFields.includes(attr.name); + }) + .map(attr => { + const isDescField = attr.name === 'desc' || attr.name.toLowerCase().includes('description'); + + let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' | 'number' = 'string'; + let options: Array<{ value: string | number; label: string }> | undefined = undefined; + let optionsReference: string | undefined = undefined; + let dependsOn: string | undefined = undefined; + let minRows: number | undefined = undefined; + let maxRows: number | undefined = undefined; + let onChange: ((value: any, formData: any) => Partial) | undefined = undefined; + + if (isDescField) { + fieldType = 'textarea'; + minRows = 3; + maxRows = 8; + } else if (attr.type === 'checkbox') { + fieldType = 'boolean'; + } else if (attr.type === 'email') { + fieldType = 'email'; + } else if (attr.type === 'date') { + fieldType = 'date'; + } else if (attr.type === 'number') { + fieldType = 'number'; + } else if (attr.type === 'select') { + fieldType = 'enum'; + if (Array.isArray(attr.options)) { + options = attr.options.map(opt => { + const labelValue = typeof opt.label === 'string' + ? opt.label + : opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value); + return { + value: opt.value, + label: labelValue + }; + }); + } else if (typeof attr.options === 'string') { + optionsReference = attr.options; + } + } else if (attr.type === 'textarea') { + fieldType = 'textarea'; + minRows = minRows || 3; + maxRows = maxRows || 8; + } + + // contractId depends on organisationId + if (attr.name === 'contractId') { + dependsOn = 'organisationId'; + } + + // CUSTOM LOGIC: MwSt-Berechnung + // When bookingAmount or vatPercentage changes, auto-calculate vatAmount + if (attr.name === 'bookingAmount') { + onChange = (value: number, formData: any) => { + const amount = parseFloat(String(value)) || 0; + const percentage = parseFloat(String(formData.vatPercentage)) || 0; + const calculatedVat = amount * (percentage / 100); + return { vatAmount: calculatedVat }; + }; + } else if (attr.name === 'vatPercentage') { + onChange = (value: number, formData: any) => { + const percentage = parseFloat(String(value)) || 0; + const amount = parseFloat(String(formData.bookingAmount)) || 0; + const calculatedVat = amount * (percentage / 100); + return { vatAmount: calculatedVat }; + }; + } + + let required = attr.required === true; + let validator: ((value: any, formData?: any) => string | null) | undefined = undefined; + + // CUSTOM LOGIC: vatAmount validator - warn if manually overridden + if (attr.name === 'vatAmount') { + validator = (value: any, formData?: any) => { + if (!formData) return null; + + const vatAmount = parseFloat(String(value)) || 0; + const bookingAmount = parseFloat(String(formData.bookingAmount)) || 0; + const vatPercentage = parseFloat(String(formData.vatPercentage)) || 0; + const calculatedVat = bookingAmount * (vatPercentage / 100); + + if (Math.abs(vatAmount - calculatedVat) > 0.01) { + return 'MwSt-Betrag weicht von Berechnung ab (manuell überschrieben)'; + } + return null; + }; + } + + // Standard validators + if (attr.name === 'organisationId' || attr.name === 'contractId') { + required = true; + validator = (value: any) => { + if (!value || (typeof value === 'string' && value.trim() === '')) { + return `${attr.label || attr.name} is required`; + } + return null; + }; + } else if (attr.name === 'valuta' || attr.name === 'transactionDateTime') { + required = true; + } else if (attr.name === 'bookingCurrency' || attr.name === 'originalCurrency') { + required = true; + } else if (attr.name === 'bookingAmount' || attr.name === 'originalAmount') { + required = true; + validator = (value: any) => { + const num = parseFloat(String(value)); + if (isNaN(num)) { + return 'Must be a valid number'; + } + return null; + }; + } + + return { + key: attr.name, + label: attr.label || attr.name, + type: fieldType, + editable: attr.editable !== false && attr.readonly !== true, + required, + validator, + onChange, + options, + optionsReference, + dependsOn, + minRows, + maxRows + }; + }); + + return editableFields; + }, [attributes]); + + // Ensure attributes are loaded + const ensureAttributesLoaded = useCallback(async () => { + if (attributes && attributes.length > 0) { + return attributes; + } + const fetchedAttributes = await fetchAttributes(); + return fetchedAttributes; + }, [attributes, fetchAttributes]); + + // Fetch attributes and permissions on mount + useEffect(() => { + fetchAttributes(); + fetchPermissions(); + }, [fetchAttributes, fetchPermissions]); + + // Initial fetch + useEffect(() => { + fetchPositions(); + }, [fetchPositions]); + + return { + positions, + loading, + error, + refetch: fetchPositions, + removeOptimistically, + updateOptimistically, + attributes, + permissions, + pagination, + fetchPositionById, + generateEditFieldsFromAttributes, + ensureAttributesLoaded + }; +} + +// Position operations hook +export function useTrusteePositionOperations() { + const [deletingPositions, setDeletingPositions] = useState>(new Set()); + const [creatingPosition, setCreatingPosition] = useState(false); + const { request, isLoading } = useApiRequest(); + const [deleteError, setDeleteError] = useState(null); + const [createError, setCreateError] = useState(null); + const [updateError, setUpdateError] = useState(null); + + const handlePositionDelete = async (positionId: string) => { + setDeleteError(null); + setDeletingPositions(prev => new Set(prev).add(positionId)); + + try { + await deletePositionApi(request, positionId); + await new Promise(resolve => setTimeout(resolve, 300)); + return true; + } catch (error: any) { + setDeleteError(error.message); + return false; + } finally { + setDeletingPositions(prev => { + const newSet = new Set(prev); + newSet.delete(positionId); + return newSet; + }); + } + }; + + const handlePositionCreate = async (positionData: Partial) => { + setCreateError(null); + setCreatingPosition(true); + + try { + const currentUserData = getUserDataCache(); + const mandateId = currentUserData?.mandateId || ''; + + const requestBody = { + ...positionData, + mandate: mandateId + }; + + const newPosition = await createPositionApi(request, requestBody); + + return { success: true, positionData: newPosition }; + } catch (error: any) { + setCreateError(error.message); + return { success: false, error: error.message }; + } finally { + setCreatingPosition(false); + } + }; + + const handlePositionUpdate = async ( + positionId: string, + updateData: Partial, + _originalData?: any + ) => { + setUpdateError(null); + + try { + const currentUserData = getUserDataCache(); + const mandateId = currentUserData?.mandateId || ''; + + const requestBody = { + ...updateData, + mandate: mandateId + }; + + const updatedPosition = await updatePositionApi(request, positionId, requestBody); + + return { success: true, positionData: updatedPosition }; + } catch (error: any) { + const errorMessage = error.response?.data?.message || error.message || 'Failed to update position'; + const statusCode = error.response?.status; + + setUpdateError(errorMessage); + + return { + success: false, + error: errorMessage, + statusCode, + isPermissionError: statusCode === 403, + isValidationError: statusCode === 400 + }; + } + }; + + return { + deletingPositions, + creatingPosition, + deleteError, + createError, + updateError, + handlePositionDelete, + handlePositionCreate, + handlePositionUpdate, + isLoading + }; +} diff --git a/src/hooks/useTrusteeRoles.ts b/src/hooks/useTrusteeRoles.ts new file mode 100644 index 0000000..8940c40 --- /dev/null +++ b/src/hooks/useTrusteeRoles.ts @@ -0,0 +1,368 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useApiRequest } from './useApi'; +import { getUserDataCache } from '../utils/userCache'; +import api from '../api'; +import { usePermissions, type UserPermissions } from './usePermissions'; +import { + fetchRoles as fetchRolesApi, + fetchRoleById as fetchRoleByIdApi, + createRole as createRoleApi, + updateRole as updateRoleApi, + deleteRole as deleteRoleApi, + type TrusteeRole, + type AttributeDefinition, + type PaginationParams +} from '../api/trusteeApi'; + +// Re-export types +export type { TrusteeRole, AttributeDefinition, PaginationParams }; + +// Roles list hook +export function useTrusteeRoles() { + const [roles, setRoles] = useState([]); + const [attributes, setAttributes] = useState([]); + const [permissions, setPermissions] = useState(null); + const [pagination, setPagination] = useState<{ + currentPage: number; + pageSize: number; + totalItems: number; + totalPages: number; + } | null>(null); + const { request, isLoading: loading, error } = useApiRequest(); + const { checkPermission } = usePermissions(); + + // Fetch attributes from backend + const fetchAttributes = useCallback(async () => { + try { + const response = await api.get('/api/attributes/TrusteeRole'); + + let attrs: AttributeDefinition[] = []; + if (response.data?.attributes && Array.isArray(response.data.attributes)) { + attrs = response.data.attributes; + } else if (Array.isArray(response.data)) { + attrs = response.data; + } else if (response.data && typeof response.data === 'object') { + const keys = Object.keys(response.data); + for (const key of keys) { + if (Array.isArray(response.data[key])) { + attrs = response.data[key]; + break; + } + } + } + + setAttributes(attrs); + return attrs; + } catch (error: any) { + console.error('Error fetching attributes:', error); + setAttributes([]); + return []; + } + }, []); + + // Fetch permissions from backend + const fetchPermissions = useCallback(async () => { + try { + const perms = await checkPermission('DATA', 'trustee.role'); + setPermissions(perms); + return perms; + } catch (error: any) { + console.error('Error fetching permissions:', error); + const defaultPerms: UserPermissions = { + view: false, + read: 'n', + create: 'n', + update: 'n', + delete: 'n', + }; + setPermissions(defaultPerms); + return defaultPerms; + } + }, [checkPermission]); + + const fetchRoles = useCallback(async (params?: PaginationParams) => { + try { + const data = await fetchRolesApi(request, params); + + if (data && typeof data === 'object' && 'items' in data) { + const items = Array.isArray(data.items) ? data.items : []; + setRoles(items); + if (data.pagination) { + setPagination(data.pagination); + } + } else { + const items = Array.isArray(data) ? data : []; + setRoles(items); + setPagination(null); + } + } catch (error: any) { + setRoles([]); + setPagination(null); + } + }, [request]); + + // Optimistically remove a role + const removeOptimistically = (roleId: string) => { + setRoles(prevRoles => prevRoles.filter(role => role.id !== roleId)); + }; + + // Optimistically update a role + const updateOptimistically = (roleId: string, updateData: Partial) => { + setRoles(prevRoles => + prevRoles.map(role => + role.id === roleId + ? { ...role, ...updateData } + : role + ) + ); + }; + + // Fetch a single role by ID + const fetchRoleById = useCallback(async (roleId: string): Promise => { + return await fetchRoleByIdApi(request, roleId); + }, [request]); + + // Generate edit fields from attributes dynamically + // Ensure attributes are loaded + const ensureAttributesLoaded = useCallback(async () => { + if (attributes && attributes.length > 0) { + return attributes; + } + const fetchedAttributes = await fetchAttributes(); + return fetchedAttributes; + }, [attributes, fetchAttributes]); + + // Fetch attributes and permissions on mount + useEffect(() => { + fetchAttributes(); + fetchPermissions(); + }, [fetchAttributes, fetchPermissions]); + + // Initial fetch + useEffect(() => { + fetchRoles(); + }, [fetchRoles]); + + const generateEditFieldsFromAttributes = useCallback((): Array<{ + key: string; + label: string; + type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly'; + editable?: boolean; + required?: boolean; + validator?: (value: any) => string | null; + minRows?: number; + maxRows?: number; + options?: Array<{ value: string | number; label: string }>; + optionsReference?: string; + }> => { + if (!attributes || attributes.length === 0) { + return []; + } + + const editableFields = attributes + .filter(attr => { + if (attr.readonly === true || attr.editable === false) { + return false; + } + const nonEditableFields = ['mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt']; + return !nonEditableFields.includes(attr.name); + }) + .map(attr => { + const isDescField = attr.name === 'desc' || attr.name.toLowerCase().includes('description'); + + let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string'; + let options: Array<{ value: string | number; label: string }> | undefined = undefined; + let optionsReference: string | undefined = undefined; + let minRows: number | undefined = undefined; + let maxRows: number | undefined = undefined; + + if (isDescField) { + fieldType = 'textarea'; + minRows = 3; + maxRows = 8; + } else if (attr.type === 'checkbox') { + fieldType = 'boolean'; + } else if (attr.type === 'email') { + fieldType = 'email'; + } else if (attr.type === 'date') { + fieldType = 'date'; + } else if (attr.type === 'select') { + fieldType = 'enum'; + if (Array.isArray(attr.options)) { + options = attr.options.map(opt => { + const labelValue = typeof opt.label === 'string' + ? opt.label + : opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value); + return { + value: opt.value, + label: labelValue + }; + }); + } else if (typeof attr.options === 'string') { + optionsReference = attr.options; + } + } else if (attr.type === 'textarea') { + fieldType = 'textarea'; + minRows = minRows || 3; + maxRows = maxRows || 8; + } + + let required = attr.required === true; + let validator: ((value: any) => string | null) | undefined = undefined; + + if (attr.name === 'id') { + required = true; + validator = (value: string) => { + if (!value || value.trim() === '') { + return 'Role ID cannot be empty'; + } + if (!/^[a-zA-Z0-9_-]{3,50}$/.test(value)) { + return 'ID must be 3-50 characters long (alphanumeric, dash, underscore only)'; + } + return null; + }; + } else if (attr.name === 'desc') { + required = true; + validator = (value: string) => { + if (!value || value.trim() === '') { + return 'Description cannot be empty'; + } + return null; + }; + } + + return { + key: attr.name, + label: attr.label || attr.name, + type: fieldType, + editable: attr.editable !== false && attr.readonly !== true, + required, + validator, + minRows, + maxRows, + options, + optionsReference + }; + }); + + return editableFields; + }, [attributes]); + + return { + roles, + loading, + error, + refetch: fetchRoles, + removeOptimistically, + updateOptimistically, + attributes, + permissions, + pagination, + fetchRoleById, + generateEditFieldsFromAttributes, + ensureAttributesLoaded + }; +} + +// Role operations hook +export function useTrusteeRoleOperations() { + const [deletingRoles, setDeletingRoles] = useState>(new Set()); + const [creatingRole, setCreatingRole] = useState(false); + const { request, isLoading } = useApiRequest(); + const [deleteError, setDeleteError] = useState(null); + const [createError, setCreateError] = useState(null); + const [updateError, setUpdateError] = useState(null); + + const handleRoleDelete = async (roleId: string) => { + setDeleteError(null); + setDeletingRoles(prev => new Set(prev).add(roleId)); + + try { + await deleteRoleApi(request, roleId); + await new Promise(resolve => setTimeout(resolve, 300)); + return true; + } catch (error: any) { + const errorMessage = error.response?.data?.detail || error.message || 'Failed to delete role'; + // Backend returns error if role is in use + setDeleteError(errorMessage); + return false; + } finally { + setDeletingRoles(prev => { + const newSet = new Set(prev); + newSet.delete(roleId); + return newSet; + }); + } + }; + + const handleRoleCreate = async (roleData: Partial) => { + setCreateError(null); + setCreatingRole(true); + + try { + const currentUserData = getUserDataCache(); + const mandateId = currentUserData?.mandateId || ''; + + const requestBody = { + ...roleData, + mandate: mandateId + }; + + const newRole = await createRoleApi(request, requestBody); + + return { success: true, roleData: newRole }; + } catch (error: any) { + setCreateError(error.message); + return { success: false, error: error.message }; + } finally { + setCreatingRole(false); + } + }; + + const handleRoleUpdate = async ( + roleId: string, + updateData: Partial, + _originalData?: any + ) => { + setUpdateError(null); + + try { + const currentUserData = getUserDataCache(); + const mandateId = currentUserData?.mandateId || ''; + + const requestBody = { + ...updateData, + mandate: mandateId + }; + + const updatedRole = await updateRoleApi(request, roleId, requestBody); + + return { success: true, roleData: updatedRole }; + } catch (error: any) { + const errorMessage = error.response?.data?.message || error.message || 'Failed to update role'; + const statusCode = error.response?.status; + + setUpdateError(errorMessage); + + return { + success: false, + error: errorMessage, + statusCode, + isPermissionError: statusCode === 403, + isValidationError: statusCode === 400 + }; + } + }; + + return { + deletingRoles, + creatingRole, + deleteError, + createError, + updateError, + handleRoleDelete, + handleRoleCreate, + handleRoleUpdate, + isLoading + }; +} diff --git a/src/pages/FeatureView.tsx b/src/pages/FeatureView.tsx index 7314af7..d49700c 100644 --- a/src/pages/FeatureView.tsx +++ b/src/pages/FeatureView.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { useCurrentInstance } from '../hooks/useCurrentInstance'; import { useCanViewFeatureView } from '../hooks/useInstancePermissions'; -import useNavigation from '../hooks/useNavigation'; +import { getLabel, FEATURE_REGISTRY } from '../types/mandate'; // Trustee Views // Note: TrusteeOrganisationsView and TrusteeContractsView removed - Feature-Instanz = Organisation @@ -25,12 +25,6 @@ import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsVi // RealEstate Views import { RealEstatePekView, RealEstateProjectsView, RealEstateParcelsView, RealEstateInstanceRolesPlaceholder } from './views/realestate'; -// Chat Playground Views (reusing existing workflow pages) -import { PlaygroundPage, WorkflowsPage } from './workflows'; - -// Automation Views (reusing existing workflow pages) -import { AutomationsPage, AutomationTemplatesPage } from './workflows'; - import styles from './FeatureView.module.css'; // ============================================================================= @@ -109,15 +103,6 @@ const VIEW_COMPONENTS: Record> = { parcels: RealEstateParcelsView, 'instance-roles': RealEstateInstanceRolesPlaceholder, }, - chatplayground: { - playground: PlaygroundPage, - workflows: WorkflowsPage, - }, - automation: { - definitions: AutomationsPage, - templates: AutomationTemplatesPage, - logs: () => , - }, }; // ============================================================================= @@ -129,13 +114,31 @@ interface FeatureViewPageProps { } export const FeatureViewPage: React.FC = ({ view }) => { - const { instance, featureCode, mandateId, isValid } = useCurrentInstance(); - const { dynamicBlock } = useNavigation(); + const { instance, featureCode, isValid } = useCurrentInstance(); // Berechtigungs-Check const viewCode = `${featureCode}-${view}`; const canView = useCanViewFeatureView(viewCode); + // DEBUG: Log permission check for chatbot + if (featureCode === 'chatbot') { + console.log('🔍 [DEBUG] FeatureView Permission Check:', { + featureCode, + view, + viewCode, + instanceId: instance?.id, + instanceLabel: instance?.instanceLabel, + isValid, + canView, + permissions: instance?.permissions, + views: instance?.permissions?.views, + viewKeys: instance?.permissions?.views ? Object.keys(instance.permissions.views) : [], + hasLegacyView: instance?.permissions?.views?.[viewCode], + hasFullObjectKey: instance?.permissions?.views?.[`ui.feature.${featureCode}.${view}`], + hasWildcard: instance?.permissions?.views?.['_all'], + }); + } + // Nicht valider Kontext if (!isValid || !featureCode || !instance) { return ; @@ -157,17 +160,10 @@ export const FeatureViewPage: React.FC = ({ view }) => { return ; } - // View-Label aus Backend-Navigation ermitteln - let viewLabel = view; - if (dynamicBlock) { - const navMandate = dynamicBlock.mandates.find(m => m.id === mandateId); - const navFeature = navMandate?.features.find(f => f.uiComponent.includes(featureCode)); - const navInstance = navFeature?.instances.find(i => i.id === instance.id); - const navView = navInstance?.views.find(v => v.uiComponent.includes(view)); - if (navView) { - viewLabel = navView.uiLabel; - } - } + // View-Info aus Registry + const featureConfig = FEATURE_REGISTRY[featureCode]; + const viewConfig = featureConfig?.views?.find(v => v.code === view); + const viewLabel = viewConfig ? getLabel(viewConfig.label) : view; return (
diff --git a/src/pages/views/realestate/RealEstateParcelsView.tsx b/src/pages/views/realestate/RealEstateParcelsView.tsx index 065ad2d..9042cb7 100644 --- a/src/pages/views/realestate/RealEstateParcelsView.tsx +++ b/src/pages/views/realestate/RealEstateParcelsView.tsx @@ -185,7 +185,6 @@ export const RealEstateParcelsView: React.FC = () => { {