From 845094a40adddae0b6e25e7a9f01e5d8f8e4a049 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 | 123 +++++- src/api/realEstateApi.ts | 313 +++++++++++++ .../ContentPreview/UrlContentPreview.tsx | 348 +++++++++++++++ .../renderers/PdfJsRenderer.tsx | 238 ++++++++++ .../Navigation/MandateNavigation.tsx | 240 ++++++++++ .../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/realestate/index.ts | 10 + .../data/pages/realestate/parcels.ts | 147 ++++++ .../data/pages/realestate/projects.ts | 146 ++++++ .../data/pages/trustee/position-documents.ts | 241 ++++++++++ src/hooks/useRealEstate.ts | 403 +++++++++++++++++ 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 | 180 ++++++++ .../realestate/RealEstateDashboardView.tsx | 82 ++++ .../RealEstateInstanceRolesPlaceholder.tsx | 32 ++ .../realestate/RealEstateParcelsView.tsx | 266 +++++++++++ .../views/realestate/RealEstatePekView.tsx | 113 +++++ .../realestate/RealEstateProjectsView.tsx | 223 ++++++++++ src/pages/views/realestate/index.ts | 5 + .../pek/PekLocationInput.module.css | 63 +++ .../views/realestate/pek/PekLocationInput.tsx | 80 ++++ src/pages/views/realestate/pek/PekMapView.tsx | 58 +++ 34 files changed, 6928 insertions(+), 14 deletions(-) create mode 100644 src/api/realEstateApi.ts create mode 100644 src/components/ContentPreview/UrlContentPreview.tsx create mode 100644 src/components/ContentPreview/renderers/PdfJsRenderer.tsx create mode 100644 src/components/Navigation/MandateNavigation.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/core/PageManager/data/pages/realestate/index.ts create mode 100644 src/core/PageManager/data/pages/realestate/parcels.ts create mode 100644 src/core/PageManager/data/pages/realestate/projects.ts create mode 100644 src/core/PageManager/data/pages/trustee/position-documents.ts create mode 100644 src/hooks/useRealEstate.ts 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 create mode 100644 src/pages/FeatureView.tsx create mode 100644 src/pages/views/realestate/RealEstateDashboardView.tsx create mode 100644 src/pages/views/realestate/RealEstateInstanceRolesPlaceholder.tsx create mode 100644 src/pages/views/realestate/RealEstateParcelsView.tsx create mode 100644 src/pages/views/realestate/RealEstatePekView.tsx create mode 100644 src/pages/views/realestate/RealEstateProjectsView.tsx create mode 100644 src/pages/views/realestate/index.ts create mode 100644 src/pages/views/realestate/pek/PekLocationInput.module.css create mode 100644 src/pages/views/realestate/pek/PekLocationInput.tsx create mode 100644 src/pages/views/realestate/pek/PekMapView.tsx diff --git a/src/App.tsx b/src/App.tsx index de17105..8eb252e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,17 @@ -import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; +/** + * App.tsx + * + * Haupt-App-Komponente mit Multi-Tenant Router-Setup. + * + * URL-Struktur: + * - / → Dashboard/Übersicht + * - /settings → Benutzer-Einstellungen + * - /gdpr → GDPR / Datenschutz + * - /mandates/:mandateId/:featureCode/:instanceId/* → Feature-Instanz-Routen + * - /admin/* → System-Administration (nur SysAdmin) + */ + +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { useEffect } from 'react'; // Import global CSS reset first @@ -14,7 +27,17 @@ import { ProtectedRoute } from './providers/auth/ProtectedRoute'; import { LanguageProvider } from './providers/language/LanguageContext'; import { WorkflowSelectionProvider } from './contexts/WorkflowSelectionContext'; import { FileProvider } from './contexts/FileContext'; -import Home from './pages/Home/Home'; + +import { MainLayout } from './layouts/MainLayout'; +import { FeatureLayout } from './layouts/FeatureLayout'; +import { DashboardPage } from './pages/Dashboard'; +import { SettingsPage } from './pages/Settings'; +import { GDPRPage } from './pages/GDPR'; +import { FeatureViewPage } from './pages/FeatureView'; +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'; +import { PekPage, SpeechPage } from './pages/migrate'; function App() { // Load saved theme preference and set app name on app mount @@ -52,22 +75,94 @@ function App() { {/* PROTECTED ROUTE - requires authentication */} - - - - - + - } /> + }> + {/* Dashboard (Root) */} + } /> + + {/* System-Seiten (ohne Instanz-Kontext) */} + } /> + } /> + + {/* ============================================== */} + {/* WORKFLOWS ROUTES (global) */} + {/* ============================================== */} + + } /> + } /> + } /> + + + {/* ============================================== */} + {/* BASISDATEN ROUTES (global) */} + {/* ============================================== */} + + } /> + } /> + } /> + + + {/* ============================================== */} + {/* MIGRATE TO FEATURES (temporary) */} + {/* ============================================== */} + } /> + } /> + } /> + + {/* ============================================== */} + {/* FEATURE-INSTANZ ROUTES */} + {/* /mandates/:mandateId/:featureCode/:instanceId */} + {/* ============================================== */} + } + > + {/* Feature Views - dynamisch basierend auf featureCode */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Catch-all für unbekannte Sub-Pfade */} + } /> + + + {/* ============================================== */} + {/* ADMIN ROUTES (nur SysAdmin) */} + {/* ============================================== */} + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + - {/* Catch-all redirect to home */} + {/* ================================================== */} + {/* CATCH-ALL - Redirect to Dashboard */} + {/* ================================================== */} - - - - - + } /> diff --git a/src/api/realEstateApi.ts b/src/api/realEstateApi.ts new file mode 100644 index 0000000..08d8ecc --- /dev/null +++ b/src/api/realEstateApi.ts @@ -0,0 +1,313 @@ +import api from '../api'; +import type { ApiRequestOptions } from '../hooks/useApi'; + +// ============================================================================ +// TYPES & INTERFACES +// ============================================================================ + +export interface AddressSuggestion { + label: string; + value: string; + coordinates?: { + x: number; + y: number; + }; +} + +/** Real Estate Project (Projekt). Backend-driven CRUD uses instanceId. */ +export interface RealEstateProject { + id: string; + label: string; + statusProzess?: string; + mandateId?: string; + featureInstanceId?: string; + perimeter?: any; + parzellen?: RealEstateParcel[]; + _createdAt?: number; + _modifiedAt?: number; + [key: string]: any; +} + +/** Real Estate Parcel (Parzelle). */ +export interface RealEstateParcel { + id: string; + label?: string; + mandateId?: string; + featureInstanceId?: string; + strasseNr?: string; + plz?: string; + perimeter?: any; + bauzone?: string; + _createdAt?: number; + _modifiedAt?: number; + [key: string]: any; +} + +export interface PaginationParams { + page?: number; + pageSize?: number; + sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; + filters?: Record; + search?: string; +} + +export interface PaginatedResponse { + items: T[]; + pagination?: { + currentPage: number; + pageSize: number; + totalItems: number; + totalPages: number; + sort?: Array<{ field: string; direction: string }>; + filters?: Record; + }; +} + +export type ApiRequestFunction = (options: ApiRequestOptions) => Promise; + +// ============================================================================ +// HELPER FUNCTIONS (instanceId-based CRUD) +// ============================================================================ + +function _getRealEstateBaseUrl(instanceId: string): string { + return `/api/realestate/${instanceId}`; +} + +function _buildPaginationParams(params?: PaginationParams): Record { + if (!params) return {}; + const paginationObj: Record = {}; + if (params.page !== undefined) paginationObj.page = params.page; + if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize; + if (params.sort) paginationObj.sort = params.sort; + if (params.filters) paginationObj.filters = params.filters; + if (params.search) paginationObj.search = params.search; + if (Object.keys(paginationObj).length === 0) return {}; + return { pagination: JSON.stringify(paginationObj) } as Record; +} + +// ============================================================================ +// PROJECTS CRUD (instanceId-based) +// ============================================================================ + +export async function fetchProjects( + request: ApiRequestFunction, + instanceId: string, + params?: PaginationParams +): Promise> { + return await request({ + url: `${_getRealEstateBaseUrl(instanceId)}/projects`, + method: 'get', + params: _buildPaginationParams(params) + }); +} + +export async function fetchProjectById( + request: ApiRequestFunction, + instanceId: string, + id: string +): Promise { + try { + return await request({ + url: `${_getRealEstateBaseUrl(instanceId)}/projects/${id}`, + method: 'get' + }); + } catch { + return null; + } +} + +export async function createProject( + request: ApiRequestFunction, + instanceId: string, + data: Partial +): Promise { + return await request({ + url: `${_getRealEstateBaseUrl(instanceId)}/projects`, + method: 'post', + data + }); +} + +export async function updateProject( + request: ApiRequestFunction, + instanceId: string, + id: string, + data: Partial +): Promise { + return await request({ + url: `${_getRealEstateBaseUrl(instanceId)}/projects/${id}`, + method: 'put', + data + }); +} + +export async function deleteProject( + request: ApiRequestFunction, + instanceId: string, + id: string +): Promise { + await request({ + url: `${_getRealEstateBaseUrl(instanceId)}/projects/${id}`, + method: 'delete' + }); +} + +// ============================================================================ +// PARCELS CRUD (instanceId-based) +// ============================================================================ + +export async function fetchParcels( + request: ApiRequestFunction, + instanceId: string, + params?: PaginationParams +): Promise> { + return await request({ + url: `${_getRealEstateBaseUrl(instanceId)}/parcels`, + method: 'get', + params: _buildPaginationParams(params) + }); +} + +export async function fetchParcelById( + request: ApiRequestFunction, + instanceId: string, + id: string +): Promise { + try { + return await request({ + url: `${_getRealEstateBaseUrl(instanceId)}/parcels/${id}`, + method: 'get' + }); + } catch { + return null; + } +} + +export async function createParcel( + request: ApiRequestFunction, + instanceId: string, + data: Partial +): Promise { + return await request({ + url: `${_getRealEstateBaseUrl(instanceId)}/parcels`, + method: 'post', + data + }); +} + +export async function updateParcel( + request: ApiRequestFunction, + instanceId: string, + id: string, + data: Partial +): Promise { + return await request({ + url: `${_getRealEstateBaseUrl(instanceId)}/parcels/${id}`, + method: 'put', + data + }); +} + +export async function deleteParcel( + request: ApiRequestFunction, + instanceId: string, + id: string +): Promise { + await request({ + url: `${_getRealEstateBaseUrl(instanceId)}/parcels/${id}`, + method: 'delete' + }); +} + +// ============================================================================ +// ADDRESS AUTOCOMPLETE (legacy, no instanceId) +// ============================================================================ + +/** + * Get address autocomplete suggestions for Swiss addresses + * Endpoint: GET /api/realestate/address/autocomplete + * + * @param query - Search text (minimum 2 characters) + * @param limit - Maximum number of results (default: 10, max: 20) + * @returns Array of address suggestions + */ +export async function autocompleteAddress( + query: string, + limit: number = 10 +): Promise { + if (query.length < 2) { + return []; + } + + try { + const trimmedQuery = query.trim(); + const requestParams = { + query: trimmedQuery, + limit: Math.min(Math.max(limit, 1), 20) // Clamp between 1 and 20 + }; + + if (import.meta.env.DEV) { + console.log('🔍 [AddressAutocomplete] Requesting suggestions:', { + query: trimmedQuery, + limit: requestParams.limit, + url: '/api/realestate/address/autocomplete' + }); + } + + const response = await api.get('/api/realestate/address/autocomplete', { + params: requestParams + }); + + const results = response.data || []; + + if (import.meta.env.DEV) { + console.log('✅ [AddressAutocomplete] Received suggestions:', { + count: results.length, + results: results.slice(0, 3) // Log first 3 for debugging + }); + } + + return results; + } catch (error: any) { + // Detailed error logging + const errorDetails: any = { + message: error?.message || 'Unknown error', + query: query.trim(), + limit: limit + }; + + if (error?.response) { + // HTTP error response + errorDetails.status = error.response.status; + errorDetails.statusText = error.response.statusText; + errorDetails.data = error.response.data; + errorDetails.headers = error.response.headers; + + console.error('❌ [AddressAutocomplete] API Error Response:', { + status: errorDetails.status, + statusText: errorDetails.statusText, + detail: errorDetails.data?.detail || errorDetails.data, + url: error.config?.url, + method: error.config?.method + }); + } else if (error?.request) { + // Request made but no response received + errorDetails.requestError = true; + console.error('❌ [AddressAutocomplete] Network Error - No response received:', { + message: error.message, + url: error.config?.url + }); + } else { + // Error setting up request + console.error('❌ [AddressAutocomplete] Request Setup Error:', errorDetails); + } + + // Log full error in dev mode + if (import.meta.env.DEV) { + console.error('❌ [AddressAutocomplete] Full error object:', error); + } + + // Return empty array on error to allow graceful degradation + return []; + } +} 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 new file mode 100644 index 0000000..7804c17 --- /dev/null +++ b/src/components/Navigation/MandateNavigation.tsx @@ -0,0 +1,240 @@ +/** + * MandateNavigation + * + * Hierarchische Navigation für das Multi-Tenant-System. + * Verwendet TreeNavigation für flexible Baumstruktur. + * + * Navigation wird vollständig vom Backend geladen (/api/navigation). + * Backend liefert Blocks-Struktur mit Static und Dynamic Blocks. + * UI mappt uiComponent zu Icons via pageRegistry. + * + * 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'; +import { getPageIcon } from '../../config/pageRegistry'; +import { FaSpinner } from 'react-icons/fa'; +import { TreeNavigation, type TreeItem, type TreeNodeItem } from './TreeNavigation'; +import styles from './MandateNavigation.module.css'; + +// ============================================================================= +// HELPER FUNCTIONS - Convert API blocks to TreeItems +// ============================================================================= + +/** + * Convert a NavigationItem (from static block) to TreeNodeItem + */ +function navigationItemToTreeNode(item: NavigationItem): TreeNodeItem { + return { + id: item.objectKey, + label: item.uiLabel, + icon: getPageIcon(item.uiComponent), + path: item.uiPath, + }; +} + +/** + * Convert a StaticBlock to TreeItem (section) + */ +function staticBlockToTreeItem(block: StaticBlock): TreeItem { + return { + type: 'section', + title: block.title, + children: block.items.map(navigationItemToTreeNode), + }; +} + +/** + * Convert a FeatureView to TreeNodeItem + */ +function featureViewToTreeNode(view: FeatureView): TreeNodeItem { + return { + id: view.objectKey, + label: view.uiLabel, + path: view.uiPath, + }; +} + +/** + * Convert a FeatureInstance to TreeNodeItem + */ +function featureInstanceToTreeNode(instance: FeatureInstance): TreeNodeItem { + return { + id: instance.id, + label: instance.uiLabel, + path: instance.views.length > 0 ? instance.views[0].uiPath : undefined, + 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 + */ +function navigationMandateToTreeNode(mandate: NavigationMandate): TreeNodeItem | null { + if (mandate.features.length === 0) { + return null; + } + + const children = mandate.features + .map(mandateFeatureToTreeNode) + .filter((node): node is TreeNodeItem => node !== null); + + if (children.length === 0) { + return null; + } + + return { + id: mandate.id, + label: mandate.uiLabel, + children, + defaultExpanded: true, + }; +} + +/** + * Convert a DynamicBlock to array of TreeNodeItems (mandate nodes) + */ +function dynamicBlockToTreeNodes(block: DynamicBlock): TreeNodeItem[] { + return block.mandates + .map(navigationMandateToTreeNode) + .filter((node): node is TreeNodeItem => node !== null); +} + +// ============================================================================= +// LOADING STATE +// ============================================================================= + +const LoadingState: React.FC = () => ( +
+ + Navigation wird geladen... +
+); + +// ============================================================================= +// EMPTY STATE +// ============================================================================= + +const EmptyState: React.FC = () => ( +
+

Keine Feature-Instanzen verfügbar.

+

+ Kontaktiere einen Administrator, um Zugriff zu erhalten. +

+
+); + +// ============================================================================= +// MAIN COMPONENT +// ============================================================================= + +export const MandateNavigation: React.FC = () => { + // Fetch navigation from new API (blocks structure, already filtered by permissions) + const { blocks, loading } = useNavigation('de'); + + // Build navigation items from blocks + const navigationItems: TreeItem[] = useMemo(() => { + const items: TreeItem[] = []; + + // Process blocks in order (already sorted by backend) + for (const block of blocks) { + if (block.type === 'static') { + // 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)); + } + } 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) { + items.push(...mandateNodes); + } + + // Add separator after dynamic block (before next static blocks) + items.push({ type: 'separator' }); + } + } + + // Remove trailing separator if present + while (items.length > 0 && (items[items.length - 1] as TreeItem & { type?: string }).type === 'separator') { + items.pop(); + } + + return items; + }, [blocks]); + + // Check if user has any navigation (static or dynamic) + const hasNavigation = blocks.length > 0; + + // Show loading state while navigation is being fetched + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ {hasNavigation ? ( + + ) : ( + + )} +
+ ); +}; + +export default MandateNavigation; 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/realestate/index.ts b/src/core/PageManager/data/pages/realestate/index.ts new file mode 100644 index 0000000..8596282 --- /dev/null +++ b/src/core/PageManager/data/pages/realestate/index.ts @@ -0,0 +1,10 @@ +import { GenericPageData } from '../../../pageInterface'; +import { realEstateProjectsPageData } from './projects'; +import { realEstateParcelsPageData } from './parcels'; + +export { realEstateProjectsPageData, realEstateParcelsPageData }; + +export const realEstatePages: GenericPageData[] = [ + realEstateProjectsPageData, + realEstateParcelsPageData, +]; diff --git a/src/core/PageManager/data/pages/realestate/parcels.ts b/src/core/PageManager/data/pages/realestate/parcels.ts new file mode 100644 index 0000000..4ce2e8c --- /dev/null +++ b/src/core/PageManager/data/pages/realestate/parcels.ts @@ -0,0 +1,147 @@ +import { useCallback } from 'react'; +import { GenericPageData } from '../../../pageInterface'; +import { FaMapMarkerAlt, FaPlus } from 'react-icons/fa'; +import { useRealEstateParcels, useRealEstateParcelOperations } from '../../../../../hooks/useRealEstate'; + +const attributesToColumns = (attributes: any[]) => { + return attributes.map(attr => { + const isDateField = attr.type === 'date' || attr.type === 'timestamp' || + /(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name); + return { + key: attr.name, + label: attr.label || attr.name, + type: attr.type || 'string', + width: attr.width || 200, + minWidth: attr.minWidth || 100, + maxWidth: attr.maxWidth || 400, + sortable: attr.sortable !== false, + filterable: isDateField ? false : (attr.filterable !== false), + searchable: attr.searchable !== false, + filterOptions: attr.filterOptions, + }; + }); +}; + +const createParcelsHook = () => { + return () => { + const { + items: parcels, + loading, + error, + refetch, + removeOptimistically, + updateOptimistically, + attributes, + permissions, + fetchById, + generateEditFieldsFromAttributes, + generateCreateFieldsFromAttributes, + ensureAttributesLoaded, + } = useRealEstateParcels(); + const { + handleDelete, + handleCreate, + handleUpdate, + deletingItems, + creatingItem, + deleteError, + createError, + updateError, + } = useRealEstateParcelOperations(); + + const generatedColumns = attributes && attributes.length > 0 + ? attributesToColumns(attributes) + : undefined; + + const wrappedHandleCreate = useCallback(async (formData: any) => { + return await handleCreate(formData); + }, [handleCreate]); + + const handleDeleteSingle = useCallback(async (item: any) => { + const success = await handleDelete(item.id); + if (success) refetch(); + }, [handleDelete, refetch]); + + const handleDeleteMultiple = useCallback(async (selectedItems: any[]) => { + const ids = selectedItems.map(item => item.id); + const results = await Promise.all(ids.map(id => handleDelete(id))); + if (results.every(Boolean)) refetch(); + }, [handleDelete, refetch]); + + return { + data: parcels, + loading, + error, + refetch, + removeOptimistically, + updateOptimistically, + handleDelete, + handleDeleteMultiple, + handleCreate: wrappedHandleCreate, + handleUpdate, + onDelete: handleDeleteSingle, + onDeleteMultiple: handleDeleteMultiple, + deletingItems, + creatingItem, + deleteError, + createError, + updateError, + attributes, + permissions, + columns: generatedColumns, + fetchById, + generateEditFieldsFromAttributes, + generateCreateFieldsFromAttributes, + ensureAttributesLoaded, + }; + }; +}; + +export const realEstateParcelsPageData: GenericPageData = { + id: 'realestate-parcels', + path: 'realestate/parcels', + name: 'realestate.parcels.title', + description: 'realestate.parcels.description', + parentPath: 'start.realestate', + icon: FaMapMarkerAlt, + title: 'realestate.parcels.title', + subtitle: 'realestate.parcels.subtitle', + headerButtons: [ + { + id: 'new-parcel', + label: 'realestate.parcels.new_button', + icon: FaPlus, + variant: 'primary', + formConfig: { + fields: [ + { key: 'label', label: 'realestate.parcels.field.label', type: 'string', required: true }, + { key: 'strasseNr', label: 'realestate.parcels.field.strasseNr', type: 'string' }, + { key: 'plz', label: 'realestate.parcels.field.plz', type: 'string' }, + ], + popupTitle: 'realestate.parcels.modal.create.title', + popupSize: 'medium', + createOperationName: 'handleCreate', + successMessage: 'realestate.parcels.create.success', + errorMessage: 'realestate.parcels.create.error', + }, + }, + ], + content: [ + { + id: 'parcels-table', + type: 'table', + tableConfig: { + hookFactory: createParcelsHook, + actionButtons: [ + { type: 'edit', operationName: 'handleUpdate', fetchItemFunctionName: 'fetchById' }, + { type: 'delete', operationName: 'handleDelete' }, + ], + className: 'realestate-parcels-table', + }, + }, + ], + moduleEnabled: true, + onActivate: () => { if (import.meta.env.DEV) console.log('RealEstate Parcels activated'); }, + onLoad: () => { if (import.meta.env.DEV) console.log('RealEstate Parcels loaded'); }, + onUnload: () => { if (import.meta.env.DEV) console.log('RealEstate Parcels unloaded'); }, +}; diff --git a/src/core/PageManager/data/pages/realestate/projects.ts b/src/core/PageManager/data/pages/realestate/projects.ts new file mode 100644 index 0000000..79a7669 --- /dev/null +++ b/src/core/PageManager/data/pages/realestate/projects.ts @@ -0,0 +1,146 @@ +import { useCallback } from 'react'; +import { GenericPageData } from '../../../pageInterface'; +import { FaBuilding, FaPlus } from 'react-icons/fa'; +import { useRealEstateProjects, useRealEstateProjectOperations } from '../../../../../hooks/useRealEstate'; + +const attributesToColumns = (attributes: any[]) => { + return attributes.map(attr => { + const isDateField = attr.type === 'date' || attr.type === 'timestamp' || + /(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name); + return { + key: attr.name, + label: attr.label || attr.name, + type: attr.type || 'string', + width: attr.width || 200, + minWidth: attr.minWidth || 100, + maxWidth: attr.maxWidth || 400, + sortable: attr.sortable !== false, + filterable: isDateField ? false : (attr.filterable !== false), + searchable: attr.searchable !== false, + filterOptions: attr.filterOptions, + }; + }); +}; + +const createProjectsHook = () => { + return () => { + const { + items: projects, + loading, + error, + refetch, + removeOptimistically, + updateOptimistically, + attributes, + permissions, + fetchById, + generateEditFieldsFromAttributes, + generateCreateFieldsFromAttributes, + ensureAttributesLoaded, + } = useRealEstateProjects(); + const { + handleDelete, + handleCreate, + handleUpdate, + deletingItems, + creatingItem, + deleteError, + createError, + updateError, + } = useRealEstateProjectOperations(); + + const generatedColumns = attributes && attributes.length > 0 + ? attributesToColumns(attributes) + : undefined; + + const wrappedHandleCreate = useCallback(async (formData: any) => { + return await handleCreate(formData); + }, [handleCreate]); + + const handleDeleteSingle = useCallback(async (item: any) => { + const success = await handleDelete(item.id); + if (success) refetch(); + }, [handleDelete, refetch]); + + const handleDeleteMultiple = useCallback(async (selectedItems: any[]) => { + const ids = selectedItems.map(item => item.id); + const results = await Promise.all(ids.map(id => handleDelete(id))); + if (results.every(Boolean)) refetch(); + }, [handleDelete, refetch]); + + return { + data: projects, + loading, + error, + refetch, + removeOptimistically, + updateOptimistically, + handleDelete, + handleDeleteMultiple, + handleCreate: wrappedHandleCreate, + handleUpdate, + onDelete: handleDeleteSingle, + onDeleteMultiple: handleDeleteMultiple, + deletingItems, + creatingItem, + deleteError, + createError, + updateError, + attributes, + permissions, + columns: generatedColumns, + fetchById, + generateEditFieldsFromAttributes, + generateCreateFieldsFromAttributes, + ensureAttributesLoaded, + }; + }; +}; + +export const realEstateProjectsPageData: GenericPageData = { + id: 'realestate-projects', + path: 'realestate/projects', + name: 'realestate.projects.title', + description: 'realestate.projects.description', + parentPath: 'start.realestate', + icon: FaBuilding, + title: 'realestate.projects.title', + subtitle: 'realestate.projects.subtitle', + headerButtons: [ + { + id: 'new-project', + label: 'realestate.projects.new_button', + icon: FaPlus, + variant: 'primary', + formConfig: { + fields: [ + { key: 'label', label: 'realestate.projects.field.label', type: 'string', required: true }, + { key: 'statusProzess', label: 'realestate.projects.field.statusProzess', type: 'string' }, + ], + popupTitle: 'realestate.projects.modal.create.title', + popupSize: 'medium', + createOperationName: 'handleCreate', + successMessage: 'realestate.projects.create.success', + errorMessage: 'realestate.projects.create.error', + }, + }, + ], + content: [ + { + id: 'projects-table', + type: 'table', + tableConfig: { + hookFactory: createProjectsHook, + actionButtons: [ + { type: 'edit', operationName: 'handleUpdate', fetchItemFunctionName: 'fetchById' }, + { type: 'delete', operationName: 'handleDelete' }, + ], + className: 'realestate-projects-table', + }, + }, + ], + moduleEnabled: true, + onActivate: () => { if (import.meta.env.DEV) console.log('RealEstate Projects activated'); }, + onLoad: () => { if (import.meta.env.DEV) console.log('RealEstate Projects loaded'); }, + onUnload: () => { if (import.meta.env.DEV) console.log('RealEstate Projects unloaded'); }, +}; diff --git a/src/core/PageManager/data/pages/trustee/position-documents.ts b/src/core/PageManager/data/pages/trustee/position-documents.ts new file mode 100644 index 0000000..31577a3 --- /dev/null +++ b/src/core/PageManager/data/pages/trustee/position-documents.ts @@ -0,0 +1,241 @@ +import { useCallback } from 'react'; +import { GenericPageData } from '../../../pageInterface'; +import { FaLink, FaPlus } from 'react-icons/fa'; +import { useTrusteePositionDocuments, useTrusteePositionDocumentOperations } from '../../../../../hooks/useTrusteePositionDocuments'; + +// Helper function to convert attribute definitions to column config +const attributesToColumns = (attributes: any[]) => { + return attributes.map(attr => { + const isDateField = attr.type === 'date' || + /(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name); + + return { + key: attr.name, + label: attr.label || attr.name, + type: attr.type || 'string', + width: attr.width || 200, + minWidth: attr.minWidth || 100, + maxWidth: attr.maxWidth || 400, + sortable: attr.sortable !== false, + filterable: isDateField ? false : (attr.filterable !== false), + searchable: attr.searchable !== false, + filterOptions: attr.filterOptions + }; + }); +}; + +// Hook factory function for position-documents data +const createPositionDocumentsHook = () => { + return () => { + const { + positionDocuments, + loading, + error, + refetch, + removeOptimistically, + attributes, + permissions, + pagination, + fetchPositionDocumentById, + generateEditFieldsFromAttributes, + ensureAttributesLoaded + } = useTrusteePositionDocuments(); + const { + handlePositionDocumentDelete, + handlePositionDocumentCreate, + deletingPositionDocuments, + creatingPositionDocument, + deleteError, + createError + } = useTrusteePositionDocumentOperations(); + + const generatedColumns = attributes && attributes.length > 0 + ? attributesToColumns(attributes) + : undefined; + + const wrappedHandlePositionDocumentCreate = useCallback(async (formData: any) => { + return await handlePositionDocumentCreate(formData); + }, [handlePositionDocumentCreate]); + + const handleDeleteSingle = useCallback(async (positionDocument: any) => { + const success = await handlePositionDocumentDelete(positionDocument.id); + if (success) { + refetch(); + } + }, [handlePositionDocumentDelete, refetch]); + + const handleDeleteMultiple = useCallback(async (selectedPositionDocuments: any[]) => { + const positionDocumentIds = selectedPositionDocuments.map(pd => pd.id); + const results = await Promise.all( + positionDocumentIds.map(id => handlePositionDocumentDelete(id)) + ); + + const allSuccessful = results.every(result => result); + if (allSuccessful) { + refetch(); + } + }, [handlePositionDocumentDelete, refetch]); + + return { + data: positionDocuments, + loading, + error, + refetch, + removeOptimistically, + handleDelete: handlePositionDocumentDelete, + handleDeleteMultiple, + handlePositionDocumentCreate: wrappedHandlePositionDocumentCreate, + onDelete: handleDeleteSingle, + onDeleteMultiple: handleDeleteMultiple, + deletingPositionDocuments, + creatingPositionDocument, + deleteError, + createError, + attributes, + permissions, + columns: generatedColumns, + pagination, + fetchPositionDocumentById, + generateEditFieldsFromAttributes, + ensureAttributesLoaded + }; + }; +}; + +export const trusteePositionDocumentsPageData: GenericPageData = { + id: 'administration-trustee-position-documents', + path: 'administration/trustee/position-documents', + name: 'trustee.positionDocuments.title', + description: 'trustee.positionDocuments.description', + + // Parent page + parentPath: 'administration/trustee', + + // Visual + icon: FaLink, + title: 'trustee.positionDocuments.title', + subtitle: 'trustee.positionDocuments.subtitle', + + // Header buttons + headerButtons: [ + { + id: 'new-position-document', + label: 'trustee.positionDocuments.new', + icon: FaPlus, + variant: 'primary', + formConfig: { + fields: [ + { + key: 'organisationId', + label: 'trustee.positionDocuments.field.organisationId', + type: 'enum', + required: true, + optionsReference: 'trustee.organisation', + validator: (value: any) => { + if (!value || (typeof value === 'string' && value.trim() === '')) { + return 'Organisation is required'; + } + return null; + } + }, + { + key: 'contractId', + label: 'trustee.positionDocuments.field.contractId', + type: 'enum', + required: true, + optionsReference: 'trustee.contract', + dependsOn: 'organisationId', + validator: (value: any) => { + if (!value || (typeof value === 'string' && value.trim() === '')) { + return 'Contract is required'; + } + return null; + } + }, + { + key: 'positionId', + label: 'trustee.positionDocuments.field.positionId', + type: 'enum', + required: true, + optionsReference: 'trustee.position', + dependsOn: 'contractId', + validator: (value: any) => { + if (!value || (typeof value === 'string' && value.trim() === '')) { + return 'Position is required'; + } + return null; + } + }, + { + key: 'documentId', + label: 'trustee.positionDocuments.field.documentId', + type: 'enum', + required: true, + optionsReference: 'trustee.document', + dependsOn: 'contractId', + validator: (value: any) => { + if (!value || (typeof value === 'string' && value.trim() === '')) { + return 'Document is required'; + } + return null; + } + } + ], + popupTitle: 'trustee.positionDocuments.modal.create.title', + popupSize: 'medium', + createOperationName: 'handlePositionDocumentCreate', + successMessage: 'trustee.positionDocuments.create.success', + errorMessage: 'trustee.positionDocuments.create.error' + } + } + ], + + // Content sections + content: [ + { + id: 'position-documents-table', + type: 'table', + tableConfig: { + hookFactory: createPositionDocumentsHook, + actionButtons: [ + { + type: 'delete', + title: 'trustee.positionDocuments.action.delete', + idField: 'id', + operationName: 'handleDelete', + loadingStateName: 'deletingPositionDocuments', + disabled: (hookData: any) => { + if (!hookData?.permissions) return { disabled: false }; + const hasDelete = hookData.permissions.delete !== 'n' && hookData.permissions.view; + return { disabled: !hasDelete, message: 'No permission to delete links' }; + } + } + ], + searchable: true, + filterable: true, + sortable: true, + resizable: true, + pagination: true, + pageSize: 10, + className: 'position-documents-table' + } + } + ], + + // Page behavior + persistent: false, + preload: false, + preserveState: true, + moduleEnabled: true, + + // Lifecycle hooks + onActivate: async () => { + if (import.meta.env.DEV) console.log('Position-Documents activated'); + }, + onLoad: async () => { + if (import.meta.env.DEV) console.log('Position-Documents loaded'); + }, + onUnload: async () => { + if (import.meta.env.DEV) console.log('Position-Documents unloaded'); + } +}; diff --git a/src/hooks/useRealEstate.ts b/src/hooks/useRealEstate.ts new file mode 100644 index 0000000..c710d74 --- /dev/null +++ b/src/hooks/useRealEstate.ts @@ -0,0 +1,403 @@ +/** + * Real Estate Hooks + * + * Hooks für das Real Estate/PEK-Feature mit Instanz-Kontext. + * Die instanceId wird automatisch aus der URL gelesen (Feature-Instanz-Route). + * Analog zu useTrustee.ts für backend-driven FormGeneratorTable. + */ + +import { useState, useEffect, useCallback } from 'react'; +import { useApiRequest } from './useApi'; +import api from '../api'; +import { usePermissions, type UserPermissions } from './usePermissions'; +import { useInstanceId } from './useCurrentInstance'; +import { + type RealEstateProject, + type RealEstateParcel, + type PaginationParams, + fetchProjects as fetchProjectsApi, + fetchProjectById as fetchProjectByIdApi, + createProject as createProjectApi, + updateProject as updateProjectApi, + deleteProject as deleteProjectApi, + fetchParcels as fetchParcelsApi, + fetchParcelById as fetchParcelByIdApi, + createParcel as createParcelApi, + updateParcel as updateParcelApi, + deleteParcel as deleteParcelApi, +} from '../api/realEstateApi'; + +export type { RealEstateProject, RealEstateParcel, PaginationParams }; + +export interface AttributeDefinition { + name: string; + type: 'text' | 'email' | 'date' | 'checkbox' | 'select' | 'multiselect' | 'number' | 'textarea' | 'timestamp' | 'file'; + label: string; + description?: string; + required?: boolean; + default?: any; + options?: any[] | string; + readonly?: boolean; + editable?: boolean; + visible?: boolean; + order?: number; + sortable?: boolean; + filterable?: boolean; + searchable?: boolean; + width?: number; + minWidth?: number; + maxWidth?: number; + filterOptions?: string[]; + dependsOn?: string; +} + +// ============================================================================ +// GENERIC REAL ESTATE ENTITY HOOK FACTORY +// ============================================================================ + +interface RealEstateEntityConfig { + entityName: string; + fetchAll: (request: any, instanceId: string, params?: PaginationParams) => Promise; + fetchById: (request: any, instanceId: string, id: string) => Promise; + create: (request: any, instanceId: string, data: Partial) => Promise; + update: (request: any, instanceId: string, id: string, data: Partial) => Promise; + deleteItem: (request: any, instanceId: string, id: string) => Promise; +} + +function _createRealEstateEntityHook(config: RealEstateEntityConfig) { + return function useRealEstateEntity() { + const instanceId = useInstanceId(); + const [items, setItems] = 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(); + + const fetchAttributes = useCallback(async () => { + if (!instanceId) return []; + try { + const response = await api.get(`/api/realestate/${instanceId}/attributes/${config.entityName}`); + 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; + } + setAttributes(attrs); + return attrs; + } catch (err: any) { + console.error(`Error fetching ${config.entityName} attributes:`, err); + setAttributes([]); + return []; + } + }, [instanceId]); + + const fetchPermissions = useCallback(async () => { + try { + const objectKey = `data.feature.realestate.${config.entityName}`; + const perms = await checkPermission('DATA', objectKey); + setPermissions(perms); + return perms; + } catch (err: any) { + console.error(`Error fetching ${config.entityName} permissions:`, err); + const defaultPerms: UserPermissions = { + view: false, + read: 'n', + create: 'n', + update: 'n', + delete: 'n', + }; + setPermissions(defaultPerms); + return defaultPerms; + } + }, [checkPermission]); + + const fetchItems = useCallback(async (params?: PaginationParams) => { + if (!instanceId) { + setItems([]); + return; + } + try { + const data = await config.fetchAll(request, instanceId, params); + if (data && typeof data === 'object' && 'items' in data) { + const fetchedItems = Array.isArray(data.items) ? data.items : []; + setItems(fetchedItems); + if (data.pagination) { + setPagination(data.pagination); + } + } else { + const fetchedItems = Array.isArray(data) ? data : []; + setItems(fetchedItems); + setPagination(null); + } + } catch { + setItems([]); + setPagination(null); + } + }, [request, instanceId]); + + const removeOptimistically = (itemId: string) => { + setItems(prev => prev.filter(item => item.id !== itemId)); + }; + + const updateOptimistically = (itemId: string, updateData: Partial) => { + setItems(prev => + prev.map(item => + item.id === itemId ? { ...item, ...updateData } : item + ) + ); + }; + + const fetchById = useCallback(async (itemId: string): Promise => { + if (!instanceId) return null; + return await config.fetchById(request, instanceId, itemId); + }, [request, instanceId]); + + const generateEditFieldsFromAttributes = useCallback(() => { + if (!attributes || attributes.length === 0) return []; + return attributes + .filter(attr => { + if (attr.readonly === true || attr.editable === false) return false; + if (attr.name === 'id') return false; + const nonEditable = ['_createdBy', '_createdAt', '_modifiedBy', '_modifiedAt']; + return !nonEditable.includes(attr.name); + }) + .map(attr => { + let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' | 'number' = 'string'; + let options: Array<{ value: string | number; label: string }> | undefined; + let optionsReference: string | 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 === 'number') fieldType = 'number'; + else if (attr.type === 'select') { + fieldType = 'enum'; + if (Array.isArray(attr.options)) { + options = (attr.options as any[]).map((opt: any) => ({ + value: opt.value, + label: typeof opt.label === 'string' ? opt.label : opt.label?.en || String(opt.value), + })); + } else if (typeof attr.options === 'string') optionsReference = attr.options; + } else if (attr.type === 'multiselect') { + fieldType = 'multiselect'; + if (Array.isArray(attr.options)) { + options = (attr.options as any[]).map((opt: any) => ({ + value: opt.value, + label: typeof opt.label === 'string' ? opt.label : opt.label?.en || String(opt.value), + })); + } else if (typeof attr.options === 'string') optionsReference = attr.options; + } else if (attr.type === 'textarea') fieldType = 'textarea'; + else if (attr.type === 'timestamp') fieldType = 'readonly'; + return { + key: attr.name, + label: attr.label || attr.name, + type: fieldType, + editable: attr.editable !== false && attr.readonly !== true, + required: attr.required === true, + options, + optionsReference, + dependsOn: attr.dependsOn, + }; + }); + }, [attributes]); + + const generateCreateFieldsFromAttributes = useCallback(() => { + if (!attributes || attributes.length === 0) return []; + return attributes + .filter(attr => !['_createdBy', '_createdAt', '_modifiedBy', '_modifiedAt', 'mandateId', 'featureInstanceId'].includes(attr.name)) + .map(attr => { + let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' | 'number' = 'string'; + let options: Array<{ value: string | number; label: string }> | undefined; + let optionsReference: string | 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 === 'number') fieldType = 'number'; + else if (attr.type === 'select') { + fieldType = 'enum'; + if (Array.isArray(attr.options)) { + options = (attr.options as any[]).map((opt: any) => ({ + value: opt.value, + label: typeof opt.label === 'string' ? opt.label : opt.label?.en || String(opt.value), + })); + } else if (typeof attr.options === 'string') optionsReference = attr.options; + } else if (attr.type === 'multiselect') { + fieldType = 'multiselect'; + if (Array.isArray(attr.options)) { + options = (attr.options as any[]).map((opt: any) => ({ + value: opt.value, + label: typeof opt.label === 'string' ? opt.label : opt.label?.en || String(opt.value), + })); + } else if (typeof attr.options === 'string') optionsReference = attr.options; + } else if (attr.type === 'textarea') fieldType = 'textarea'; + else if (attr.type === 'timestamp') fieldType = 'readonly'; + return { + key: attr.name, + label: attr.label || attr.name, + type: fieldType, + editable: true, + required: attr.required === true, + options, + optionsReference, + dependsOn: attr.dependsOn, + }; + }); + }, [attributes]); + + const ensureAttributesLoaded = useCallback(async () => { + if (attributes && attributes.length > 0) return attributes; + return await fetchAttributes(); + }, [attributes, fetchAttributes]); + + useEffect(() => { + if (instanceId) { + fetchAttributes(); + fetchPermissions(); + fetchItems(); + } + }, [instanceId, fetchAttributes, fetchPermissions, fetchItems]); + + return { + items, + loading, + error, + refetch: fetchItems, + removeOptimistically, + updateOptimistically, + attributes, + permissions, + pagination, + fetchById, + generateEditFieldsFromAttributes, + generateCreateFieldsFromAttributes, + ensureAttributesLoaded, + instanceId, + }; + }; +} + +function _createRealEstateOperationsHook(config: RealEstateEntityConfig) { + return function useRealEstateEntityOperations() { + const instanceId = useInstanceId(); + const [deletingItems, setDeletingItems] = useState>(new Set()); + const [creatingItem, setCreatingItem] = useState(false); + const { request } = useApiRequest(); + const [deleteError, setDeleteError] = useState(null); + const [createError, setCreateError] = useState(null); + const [updateError, setUpdateError] = useState(null); + + const handleDelete = useCallback(async (itemId: string) => { + if (!instanceId) { + setDeleteError('No instance context'); + return false; + } + setDeleteError(null); + setDeletingItems(prev => new Set(prev).add(itemId)); + try { + await config.deleteItem(request, instanceId, itemId); + await new Promise(resolve => setTimeout(resolve, 300)); + return true; + } catch (err: any) { + setDeleteError(err.message); + return false; + } finally { + setDeletingItems(prev => { + const next = new Set(prev); + next.delete(itemId); + return next; + }); + } + }, [request, instanceId]); + + const handleCreate = useCallback(async (itemData: Partial) => { + if (!instanceId) { + setCreateError('No instance context'); + return { success: false, error: 'No instance context' }; + } + setCreateError(null); + setCreatingItem(true); + try { + const newItem = await config.create(request, instanceId, itemData); + return { success: true, data: newItem }; + } catch (err: any) { + const errorMessage = err.response?.data?.detail || err.message; + setCreateError(errorMessage); + return { success: false, error: errorMessage }; + } finally { + setCreatingItem(false); + } + }, [request, instanceId]); + + const handleUpdate = useCallback(async (itemId: string, updateData: Partial) => { + if (!instanceId) { + setUpdateError('No instance context'); + return { success: false, error: 'No instance context' }; + } + setUpdateError(null); + try { + const updatedItem = await config.update(request, instanceId, itemId, updateData); + return { success: true, data: updatedItem }; + } catch (err: any) { + const errorMessage = err.response?.data?.message || err.message || 'Failed to update'; + setUpdateError(errorMessage); + return { + success: false, + error: errorMessage, + statusCode: err.response?.status, + isPermissionError: err.response?.status === 403, + isValidationError: err.response?.status === 400, + }; + } + }, [request, instanceId]); + + return { + deletingItems, + creatingItem, + deleteError, + createError, + updateError, + handleDelete, + handleCreate, + handleUpdate, + instanceId, + }; + }; +} + +// ============================================================================ +// PROJECT HOOKS +// ============================================================================ + +const projectConfig: RealEstateEntityConfig = { + entityName: 'Projekt', + fetchAll: fetchProjectsApi, + fetchById: fetchProjectByIdApi, + create: createProjectApi, + update: updateProjectApi, + deleteItem: deleteProjectApi, +}; + +export const useRealEstateProjects = _createRealEstateEntityHook(projectConfig); +export const useRealEstateProjectOperations = _createRealEstateOperationsHook(projectConfig); + +// ============================================================================ +// PARCEL HOOKS +// ============================================================================ + +const parcelConfig: RealEstateEntityConfig = { + entityName: 'Parzelle', + fetchAll: fetchParcelsApi, + fetchById: fetchParcelByIdApi, + create: createParcelApi, + update: updateParcelApi, + deleteItem: deleteParcelApi, +}; + +export const useRealEstateParcels = _createRealEstateEntityHook(parcelConfig); +export const useRealEstateParcelOperations = _createRealEstateOperationsHook(parcelConfig); 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 new file mode 100644 index 0000000..d49700c --- /dev/null +++ b/src/pages/FeatureView.tsx @@ -0,0 +1,180 @@ +/** + * FeatureView Page + * + * Generische Feature-View-Komponente. + * Rendert den entsprechenden Content basierend auf Feature-Code und View. + */ + +import React from 'react'; +import { useCurrentInstance } from '../hooks/useCurrentInstance'; +import { useCanViewFeatureView } from '../hooks/useInstancePermissions'; +import { getLabel, FEATURE_REGISTRY } from '../types/mandate'; + +// Trustee Views +// Note: TrusteeOrganisationsView and TrusteeContractsView removed - Feature-Instanz = Organisation +import { TrusteeDocumentsView } from './views/trustee/TrusteeDocumentsView'; +import { TrusteePositionsView } from './views/trustee/TrusteePositionsView'; +import { TrusteePositionDocumentsView } from './views/trustee/TrusteePositionDocumentsView'; +import { TrusteeDashboardView } from './views/trustee/TrusteeDashboardView'; +import { TrusteeInstanceRolesView } from './views/trustee/TrusteeInstanceRolesView'; +import { TrusteeExpenseImportView } from './views/trustee/TrusteeExpenseImportView'; + +// Chatbot Views +import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsView'; + +// RealEstate Views +import { RealEstatePekView, RealEstateProjectsView, RealEstateParcelsView, RealEstateInstanceRolesPlaceholder } from './views/realestate'; + +import styles from './FeatureView.module.css'; + +// ============================================================================= +// PLACEHOLDER VIEWS (für nicht implementierte Features) +// ============================================================================= + +const PlaceholderView: React.FC<{ title: string; description: string }> = ({ title, description }) => ( +
+

{title}

+

{description}

+
+); + +// Chatworkflow Views +const ChatworkflowDashboard: React.FC = () => ( + +); + +const ChatworkflowRuns: React.FC = () => ( + +); + +const ChatworkflowFiles: React.FC = () => ( + +); + +// Chatbot Views +// ChatbotConversationsView is imported above + +const ChatbotSettings: React.FC = () => ( + +); + +// Generic/Fallback +const NotFound: React.FC = () => ( +
+

Seite nicht gefunden

+

Diese View existiert nicht oder wurde noch nicht implementiert.

+
+); + +const AccessDenied: React.FC = () => ( +
+

Zugriff verweigert

+

Du hast keine Berechtigung für diese Ansicht.

+
+); + +// ============================================================================= +// VIEW REGISTRY +// ============================================================================= + +type ViewComponent = React.FC; + +const VIEW_COMPONENTS: Record> = { + trustee: { + dashboard: TrusteeDashboardView, + documents: TrusteeDocumentsView, + positions: TrusteePositionsView, + 'position-documents': TrusteePositionDocumentsView, + 'instance-roles': TrusteeInstanceRolesView, + 'expense-import': TrusteeExpenseImportView, + }, + chatworkflow: { + dashboard: ChatworkflowDashboard, + runs: ChatworkflowRuns, + files: ChatworkflowFiles, + }, + chatbot: { + conversations: ChatbotConversationsView, + settings: ChatbotSettings, + }, + realestate: { + dashboard: RealEstatePekView, + projects: RealEstateProjectsView, + parcels: RealEstateParcelsView, + 'instance-roles': RealEstateInstanceRolesPlaceholder, + }, +}; + +// ============================================================================= +// FEATURE VIEW PAGE +// ============================================================================= + +interface FeatureViewPageProps { + view: string; +} + +export const FeatureViewPage: React.FC = ({ view }) => { + 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 ; + } + + // Keine Berechtigung + if (!canView && view !== 'not-found') { + return ; + } + + // View-Komponente finden + const featureViews = VIEW_COMPONENTS[featureCode]; + if (!featureViews) { + return ; + } + + const ViewComponent = featureViews[view]; + if (!ViewComponent) { + return ; + } + + // 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 ( +
+
+

{viewLabel}

+
+
+ +
+
+ ); +}; + +export default FeatureViewPage; diff --git a/src/pages/views/realestate/RealEstateDashboardView.tsx b/src/pages/views/realestate/RealEstateDashboardView.tsx new file mode 100644 index 0000000..7e4ac11 --- /dev/null +++ b/src/pages/views/realestate/RealEstateDashboardView.tsx @@ -0,0 +1,82 @@ +/** + * RealEstateDashboardView + * + * Übersicht/Dashboard für eine Real-Estate-Instanz (PEK). + * Zeigt Kennzahlen und Links zu Projekten und Parzellen. + */ + +import React from 'react'; +import { Link } from 'react-router-dom'; +import { useCurrentInstance } from '../../../hooks/useCurrentInstance'; +import { useRealEstateProjects, useRealEstateParcels } from '../../../hooks/useRealEstate'; +import styles from '../trustee/TrusteeViews.module.css'; + +export const RealEstateDashboardView: React.FC = () => { + const { instance } = useCurrentInstance(); + const { items: projects, loading: projectsLoading } = useRealEstateProjects(); + const { items: parcels, loading: parcelsLoading } = useRealEstateParcels(); + + const isLoading = projectsLoading || parcelsLoading; + + return ( +
+
+ {/* Projekte – Link-Karte */} + +
📋
+
+
+ {isLoading ? '...' : projects.length} +
+
Projekte
+
+ + + {/* Parzellen – Link-Karte */} + +
🗺️
+
+
+ {isLoading ? '...' : parcels.length} +
+
Parzellen
+
+ + + {/* Rollen (optional) */} + {instance?.userRoles?.length ? ( +
+
👤
+
+
+ {instance.userRoles.map((role, idx) => ( +
{role}
+ ))} +
+
+ {instance.userRoles.length === 1 ? 'Deine Rolle' : 'Deine Rollen'} +
+
+
+ ) : null} +
+ + {/* Instanz-Infos */} +
+

Instanz-Details

+
+
+ Instanz: + {instance?.instanceLabel} +
+
+ Mandant: + {instance?.mandateName} +
+
+
+
+ ); +}; + +export default RealEstateDashboardView; diff --git a/src/pages/views/realestate/RealEstateInstanceRolesPlaceholder.tsx b/src/pages/views/realestate/RealEstateInstanceRolesPlaceholder.tsx new file mode 100644 index 0000000..b55586d --- /dev/null +++ b/src/pages/views/realestate/RealEstateInstanceRolesPlaceholder.tsx @@ -0,0 +1,32 @@ +/** + * RealEstateInstanceRolesPlaceholder + * + * Platzhalter für die View "Rollen & Rechte" bei Real-Estate-Instanzen. + * Zeigt einen Hinweis und Link zur Administration (Feature-Instanz Benutzer / Feature-Rollen), + * bis ein generisches Instance-Roles-UI verfügbar ist. + */ + +import React from 'react'; +import { Link } from 'react-router-dom'; +import styles from '../trustee/TrusteeViews.module.css'; + +export const RealEstateInstanceRolesPlaceholder: React.FC = () => { + return ( +
+

Rollen & Rechte

+

+ Die Verwaltung von Rollen und Benutzern für diese Instanz erfolgt in der Administration. +

+
+ + Feature-Instanz Benutzer + + + Feature-Rollen + +
+
+ ); +}; + +export default RealEstateInstanceRolesPlaceholder; diff --git a/src/pages/views/realestate/RealEstateParcelsView.tsx b/src/pages/views/realestate/RealEstateParcelsView.tsx new file mode 100644 index 0000000..9042cb7 --- /dev/null +++ b/src/pages/views/realestate/RealEstateParcelsView.tsx @@ -0,0 +1,266 @@ +/** + * RealEstateParcelsView + * + * Parzellen-Verwaltung für eine Real Estate/PEK-Instanz. + * Verwendet FormGeneratorTable analog zu TrusteeDocumentsView. + */ + +import React, { useState, useMemo, useEffect } from 'react'; +import { + useRealEstateParcels, + useRealEstateParcelOperations, + type RealEstateParcel, +} from '../../../hooks/useRealEstate'; +import { useInstanceId } from '../../../hooks/useCurrentInstance'; +import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable'; +import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm'; +import { FaSync, FaMapMarkerAlt } from 'react-icons/fa'; +import styles from '../../admin/Admin.module.css'; + +export const RealEstateParcelsView: React.FC = () => { + const instanceId = useInstanceId(); + + const { + items: parcels, + attributes, + permissions, + pagination, + loading, + error, + refetch, + fetchById, + updateOptimistically, + removeOptimistically, + } = useRealEstateParcels(); + + const { + handleDelete, + handleCreate, + handleUpdate, + deletingItems, + } = useRealEstateParcelOperations(); + + const [editingParcel, setEditingParcel] = useState(null); + const [isCreateMode, setIsCreateMode] = useState(false); + + useEffect(() => { + if (instanceId) { + refetch(); + } + }, [instanceId, refetch]); + + const columns = useMemo(() => { + return (attributes || []).map(attr => ({ + key: attr.name, + label: attr.label || attr.name, + type: attr.type as 'string' | 'number' | 'date' | 'boolean', + sortable: attr.sortable !== false, + filterable: attr.filterable !== false, + searchable: attr.searchable !== false, + width: attr.width || 150, + minWidth: attr.minWidth || 100, + maxWidth: attr.maxWidth || 400, + })); + }, [attributes]); + + const canCreate = permissions?.create !== 'n'; + const canUpdate = permissions?.update !== 'n'; + const canDelete = permissions?.delete !== 'n'; + + const handleEditClick = async (parcel: RealEstateParcel) => { + const full = await fetchById(parcel.id); + if (full) { + setEditingParcel(full); + setIsCreateMode(false); + } + }; + + const handleCreateClick = () => { + setEditingParcel(null); + setIsCreateMode(true); + }; + + const handleFormSubmit = async (data: Partial) => { + if (isCreateMode) { + const result = await handleCreate(data); + if (result.success) { + setIsCreateMode(false); + refetch(); + } + } else if (editingParcel) { + const result = await handleUpdate(editingParcel.id, data); + if (result.success) { + setEditingParcel(null); + refetch(); + } + } + }; + + const handleDeleteParcel = async (parcel: RealEstateParcel) => { + removeOptimistically(parcel.id); + const success = await handleDelete(parcel.id); + if (!success) { + refetch(); + } + }; + + const handleCloseModal = () => { + setEditingParcel(null); + setIsCreateMode(false); + }; + + const formAttributes = useMemo(() => { + const excluded = ['id', 'mandateId', 'instanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy']; + return (attributes || []).filter(attr => !excluded.includes(attr.name)); + }, [attributes]); + + const handleInlineUpdate = async ( + itemId: string, + updateData: Partial, + row: RealEstateParcel + ) => { + updateOptimistically(itemId, updateData); + const result = await handleUpdate(itemId, { ...row, ...updateData }); + if (!result.success) { + refetch(); + } + }; + + if (error) { + return ( +
+
+ ⚠️ +

Fehler beim Laden der Parzellen: {error}

+ +
+
+ ); + } + + return ( +
+
+
+

Parzellen verwalten

+
+
+ + {canCreate && ( + + )} +
+
+ +
+ {loading && (!parcels || parcels.length === 0) ? ( +
+
+ Lade Parzellen... +
+ ) : !parcels || parcels.length === 0 ? ( +
+ +

Keine Parzellen vorhanden

+

+ Erstellen Sie eine neue Parzelle, um zu beginnen. +

+ {canCreate && ( + + )} +
+ ) : ( + deletingItems.has(row.id), + }, + ] + : []), + ]} + onDelete={handleDeleteParcel} + hookData={{ + refetch, + permissions, + pagination, + handleDelete, + handleInlineUpdate, + updateOptimistically, + }} + emptyMessage="Keine Parzellen gefunden" + /> + )} +
+ + {(editingParcel || isCreateMode) && ( +
+
e.stopPropagation()}> +
+

+ {isCreateMode ? 'Neue Parzelle' : 'Parzelle bearbeiten'} +

+ +
+
+ {formAttributes.length === 0 ? ( +
+
+ Lade Formular... +
+ ) : ( + + )} +
+
+
+ )} +
+ ); +}; + +export default RealEstateParcelsView; diff --git a/src/pages/views/realestate/RealEstatePekView.tsx b/src/pages/views/realestate/RealEstatePekView.tsx new file mode 100644 index 0000000..6eb244d --- /dev/null +++ b/src/pages/views/realestate/RealEstatePekView.tsx @@ -0,0 +1,113 @@ +/** + * RealEstatePekView + * + * PEK-UI für eine Real-Estate-Instanz: Karte, Adresseingabe, optional Command-Eingabe und Nachrichten. + * Wird als Dashboard-View gerendert, wenn der Benutzer auf "PEK" in der Sidebar klickt. + */ + +import React from 'react'; +import { IoMdSend } from 'react-icons/io'; +import { PekProvider, usePekContext } from '../../../contexts/PekContext'; +import { Button, TextField } from '../../../components/UiComponents'; +import PekLocationInput from './pek/PekLocationInput'; +import PekMapView from './pek/PekMapView'; +import { useLanguage } from '../../../providers/language/LanguageContext'; +import styles from '../trustee/TrusteeViews.module.css'; + +function RealEstatePekViewContent() { + const { + commandInput, + setCommandInput, + processCommand, + isProcessingCommand, + commandResults + } = usePekContext(); + const { t } = useLanguage(); + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (commandInput.trim()) { + processCommand(commandInput.trim()); + } + }; + + return ( +
+

+ {t('projects.description_text')} +

+ + + + + {/* Optional: Command input and results */} +
+
+
+ +
+
+ +
+ +
+
+
+ + {commandResults.length > 0 && ( +
+

Antworten

+
+ {commandResults.map((msg: any) => ( +
+ {msg.role === 'user' ? 'Sie' : 'Assistent'}:{' '} + {typeof msg.message === 'string' ? msg.message : JSON.stringify(msg.message)} +
+ ))} +
+
+ )} +
+
+ ); +} + +export const RealEstatePekView: React.FC = () => { + return ( + + + + ); +}; + +export default RealEstatePekView; diff --git a/src/pages/views/realestate/RealEstateProjectsView.tsx b/src/pages/views/realestate/RealEstateProjectsView.tsx new file mode 100644 index 0000000..41124f9 --- /dev/null +++ b/src/pages/views/realestate/RealEstateProjectsView.tsx @@ -0,0 +1,223 @@ +/** + * RealEstateProjectsView + * + * Projekt-Verwaltung für eine Real Estate/PEK-Instanz. + * Verwendet FormGeneratorTable analog zu TrusteeDocumentsView. + */ + +import React, { useState, useMemo, useEffect } from 'react'; +import { + useRealEstateProjects, + useRealEstateProjectOperations, + type RealEstateProject, +} from '../../../hooks/useRealEstate'; +import { useInstanceId } from '../../../hooks/useCurrentInstance'; +import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable'; +import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm'; +import { FaSync, FaBuilding } from 'react-icons/fa'; +import styles from '../../admin/Admin.module.css'; + +export const RealEstateProjectsView: React.FC = () => { + const instanceId = useInstanceId(); + + const { + items: projects, + attributes, + permissions, + pagination, + loading, + error, + refetch, + fetchById, + updateOptimistically, + removeOptimistically, + } = useRealEstateProjects(); + + const { + handleDelete, + handleCreate, + handleUpdate, + deletingItems, + } = useRealEstateProjectOperations(); + + const [editingProject, setEditingProject] = useState(null); + const [isCreateMode, setIsCreateMode] = useState(false); + + useEffect(() => { + if (instanceId) refetch(); + }, [instanceId, refetch]); + + const columns = useMemo(() => { + return (attributes || []).map(attr => ({ + key: attr.name, + label: attr.label || attr.name, + type: (attr.type || 'string') as 'string' | 'number' | 'date' | 'boolean', + sortable: attr.sortable !== false, + filterable: attr.filterable !== false, + searchable: attr.searchable !== false, + width: attr.width || 150, + minWidth: attr.minWidth || 100, + maxWidth: attr.maxWidth || 400, + })); + }, [attributes]); + + const canCreate = permissions?.create !== 'n'; + const canUpdate = permissions?.update !== 'n'; + const canDelete = permissions?.delete !== 'n'; + + const handleEditClick = async (project: RealEstateProject) => { + const full = await fetchById(project.id); + if (full) { + setEditingProject(full); + setIsCreateMode(false); + } + }; + + const handleCreateClick = () => { + setEditingProject(null); + setIsCreateMode(true); + }; + + const handleFormSubmit = async (data: Partial) => { + if (isCreateMode) { + const result = await handleCreate(data); + if (result.success) { + setIsCreateMode(false); + refetch(); + } + } else if (editingProject) { + const result = await handleUpdate(editingProject.id, data); + if (result.success) { + setEditingProject(null); + refetch(); + } + } + }; + + const handleDeleteProject = async (project: RealEstateProject) => { + removeOptimistically(project.id); + const success = await handleDelete(project.id); + if (!success) refetch(); + }; + + const handleCloseModal = () => { + setEditingProject(null); + setIsCreateMode(false); + }; + + const formAttributes = useMemo(() => { + const excluded = ['id', 'mandateId', 'instanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy']; + return (attributes || []).filter(attr => !excluded.includes(attr.name)); + }, [attributes]); + + const handleInlineUpdate = async (itemId: string, updateData: Partial, row: RealEstateProject) => { + updateOptimistically(itemId, updateData); + const result = await handleUpdate(itemId, { ...row, ...updateData }); + if (!result.success) refetch(); + }; + + if (error) { + return ( +
+
+ ⚠️ +

Fehler beim Laden der Projekte: {error}

+ +
+
+ ); + } + + return ( +
+
+
+

Projekte verwalten

+
+
+ + {canCreate && ( + + )} +
+
+ +
+ {loading && (!projects || projects.length === 0) ? ( +
+
+ Lade Projekte... +
+ ) : !projects || projects.length === 0 ? ( +
+ +

Keine Projekte vorhanden

+

Erstellen Sie ein neues Projekt, um zu beginnen.

+ {canCreate && ( + + )} +
+ ) : ( + deletingItems.has(row.id) }] : []), + ]} + onDelete={handleDeleteProject} + hookData={{ refetch, permissions, pagination, handleDelete, handleInlineUpdate, updateOptimistically }} + emptyMessage="Keine Projekte gefunden" + /> + )} +
+ + {(editingProject || isCreateMode) && ( +
+
e.stopPropagation()}> +
+

{isCreateMode ? 'Neues Projekt' : 'Projekt bearbeiten'}

+ +
+
+ {formAttributes.length === 0 ? ( +
+
+ Lade Formular... +
+ ) : ( + + )} +
+
+
+ )} +
+ ); +}; + +export default RealEstateProjectsView; diff --git a/src/pages/views/realestate/index.ts b/src/pages/views/realestate/index.ts new file mode 100644 index 0000000..f18fef1 --- /dev/null +++ b/src/pages/views/realestate/index.ts @@ -0,0 +1,5 @@ +export { RealEstateDashboardView } from './RealEstateDashboardView'; +export { RealEstatePekView } from './RealEstatePekView'; +export { RealEstateProjectsView } from './RealEstateProjectsView'; +export { RealEstateParcelsView } from './RealEstateParcelsView'; +export { RealEstateInstanceRolesPlaceholder } from './RealEstateInstanceRolesPlaceholder'; diff --git a/src/pages/views/realestate/pek/PekLocationInput.module.css b/src/pages/views/realestate/pek/PekLocationInput.module.css new file mode 100644 index 0000000..7425a23 --- /dev/null +++ b/src/pages/views/realestate/pek/PekLocationInput.module.css @@ -0,0 +1,63 @@ +.locationInputContainer { + width: 100%; + margin-bottom: 1.5rem; +} + +.fieldsRow { + display: flex; + gap: 1rem; + align-items: flex-end; +} + +.fieldWrapper { + flex: 1; +} + +.buttonsWrapper { + display: flex; + flex-direction: row; + gap: 0.5rem; + min-width: 150px; +} + +.searchButton { + white-space: nowrap; +} + +.locationButton { + white-space: nowrap; +} + +@media (max-width: 1024px) { + .fieldsRow { + flex-wrap: wrap; + } + + .buttonsWrapper { + width: 100%; + } + + .fieldWrapper { + min-width: calc(50% - 0.5rem); + } +} + +@media (max-width: 768px) { + .fieldsRow { + flex-direction: column; + } + + .fieldWrapper { + width: 100%; + min-width: 100%; + } + + .buttonsWrapper { + width: 100%; + } + + .searchButton, + .locationButton { + flex: 1; + } +} diff --git a/src/pages/views/realestate/pek/PekLocationInput.tsx b/src/pages/views/realestate/pek/PekLocationInput.tsx new file mode 100644 index 0000000..37c5d22 --- /dev/null +++ b/src/pages/views/realestate/pek/PekLocationInput.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { TextField, Button } from '../../../../components/UiComponents'; +import { FaLocationArrow } from 'react-icons/fa'; +import { IoMdSend } from 'react-icons/io'; +import { usePekContext } from '../../../../contexts/PekContext'; +import styles from './PekLocationInput.module.css'; + +const PekLocationInput: React.FC = () => { + const { + adresse, + setAdresse, + buildLocationString, + useCurrentLocation, + isGettingLocation, + searchParcel, + isSearchingParcel + } = usePekContext(); + + const handleSearch = async () => { + const locationString = buildLocationString(); + if (locationString.trim()) { + await searchParcel(locationString.trim(), true); + } + }; + + const handleUseCurrentLocation = async () => { + await useCurrentLocation(); + }; + + return ( +
+
+
+ { + if (e.key === 'Enter') { + e.preventDefault(); + handleSearch(); + } + }} + /> +
+
+ + +
+
+
+ ); +}; + +export default PekLocationInput; diff --git a/src/pages/views/realestate/pek/PekMapView.tsx b/src/pages/views/realestate/pek/PekMapView.tsx new file mode 100644 index 0000000..d11364c --- /dev/null +++ b/src/pages/views/realestate/pek/PekMapView.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { MapView, ParcelInfoPanel } from '../../../../components/UiComponents'; +import { usePekContext } from '../../../../contexts/PekContext'; + +const PekMapView: React.FC = () => { + const { + mapCenter, + mapZoomBounds, + parcelGeometries, + handleMapClick, + handleParcelClick, + selectedParcels, + removeParcel, + isPanelOpen, + setIsPanelOpen + } = usePekContext(); + + // Aggregate all adjacent parcels from all selected parcels + const allAdjacentParcels = React.useMemo(() => { + const adjacentSet = new Map(); + selectedParcels.forEach((parcel) => { + if (parcel.adjacent_parcels) { + parcel.adjacent_parcels.forEach((adj: { id: string }) => { + if (!adjacentSet.has(adj.id)) { + adjacentSet.set(adj.id, adj); + } + }); + } + }); + return Array.from(adjacentSet.values()); + }, [selectedParcels]); + + return ( + <> +
+ +
+ + setIsPanelOpen(false)} + parcels={selectedParcels} + onRemoveParcel={removeParcel} + adjacentParcels={allAdjacentParcels} + /> + + ); +}; + +export default PekMapView;