From 557deb11ace84cb42520c394b14d2aaaca5c64b4 Mon Sep 17 00:00:00 2001 From: Stephan Schellworth Date: Tue, 3 Feb 2026 08:40:23 +0100 Subject: [PATCH] RealEstate/PageManager: Dashboard, Parcels, Projects, useRealEstate, realEstateApi, FeatureView, MandateNavigation, UiComponents, docs BACKEND_DRIVEN_RENDERING --- docs/BACKEND_DRIVEN_RENDERING_TRUSTEE.md | 145 ++++++ src/api/realEstateApi.ts | 313 +++++++++++ .../Navigation/MandateNavigation.tsx | 5 +- src/components/UiComponents/index.ts | 2 +- src/core/PageManager/SidebarProvider.tsx | 486 ++++++++++++++++++ src/core/PageManager/data/index.ts | 27 + src/core/PageManager/data/pages/index.ts | 15 + .../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/core/PageManager/pageInterface.ts | 49 ++ src/hooks/useRealEstate.ts | 403 +++++++++++++++ src/pages/FeatureView.tsx | 9 + .../realestate/RealEstateDashboardView.tsx | 82 +++ .../RealEstateInstanceRolesPlaceholder.tsx | 32 ++ .../realestate/RealEstateParcelsView.tsx | 266 ++++++++++ .../realestate/RealEstateProjectsView.tsx | 223 ++++++++ src/pages/views/realestate/index.ts | 5 + 19 files changed, 2604 insertions(+), 2 deletions(-) create mode 100644 docs/BACKEND_DRIVEN_RENDERING_TRUSTEE.md create mode 100644 src/api/realEstateApi.ts create mode 100644 src/core/PageManager/SidebarProvider.tsx create mode 100644 src/core/PageManager/data/index.ts create mode 100644 src/core/PageManager/data/pages/index.ts 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/core/PageManager/pageInterface.ts create mode 100644 src/hooks/useRealEstate.ts 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/RealEstateProjectsView.tsx create mode 100644 src/pages/views/realestate/index.ts diff --git a/docs/BACKEND_DRIVEN_RENDERING_TRUSTEE.md b/docs/BACKEND_DRIVEN_RENDERING_TRUSTEE.md new file mode 100644 index 0000000..38c217d --- /dev/null +++ b/docs/BACKEND_DRIVEN_RENDERING_TRUSTEE.md @@ -0,0 +1,145 @@ +# Frontend (Backend-Driven) Rendering am Beispiel Trustee + +## Kurzfassung der Architektur + +Das Frontend rendert Trustee-Inhalte auf **zwei Wegen**: + +1. **Path-basiert (PageManager)** – z.B. Pfad `trustee/organisations` → PageManager lädt PageData → PageRenderer nutzt `tableConfig.hookFactory()` → Trustee-Hook (z.B. `useTrusteeOrganisations`) → Backend-APIs → FormGeneratorTable mit Spalten/Formularen aus Backend-Attributen. + +2. **Feature-Instanz-Route** – URL `mandates/:mandateId/trustee/:instanceId/documents` → FeatureLayout → FeatureViewPage (view=documents) → TrusteeDocumentsView → gleiche Hooks und gleiche Backend-APIs → FormGeneratorTable/FormGeneratorForm. + +Backend-seitig liefern die Trustee-Routes (Prefix `/api/trustee`) Attribute, CRUD und Options; die Pydantic-Modelle bestimmen die Felddefinitionen. + +--- + +## Mermaid-Diagramm: Backend-Driven Rendering (Trustee) + +```mermaid +flowchart TB + subgraph Router["Router (App.tsx)"] + RouteHome["/ → Home"] + RouteFeature["/mandates/:mandateId/:featureCode/:instanceId/*"] + end + + subgraph PathBased["Path-basierter Pfad (z.B. trustee/organisations)"] + Home[Home] + SidebarProvider[SidebarProvider] + PageManager[PageManager] + Location[useLocation → path] + GetPageData[getPageDataByPath(path)] + PageData[trusteeOrganisationsPageData etc.] + PageRenderer[PageRenderer] + HookFactory[tableConfig.hookFactory] + CreateHook[createOrganisationsHook] + end + + subgraph FeatureRoute["Feature-Instanz-Pfad"] + FeatureLayout[FeatureLayout] + Outlet[Outlet] + FeatureViewPage[FeatureViewPage view=documents|positions|...] + ViewRegistry[VIEW_COMPONENTS.trustee] + TrusteeView[TrusteeDocumentsView / TrusteePositionsView / ...] + end + + subgraph Hooks["Trustee Hooks (useTrustee.ts)"] + UseTrusteeOrgs[useTrusteeOrganisations] + UseTrusteeDocs[useTrusteeDocuments] + UseTrusteePositions[useTrusteePositions] + UseInstanceId[useInstanceId aus URL] + end + + subgraph BackendCalls["Backend-API-Aufrufe"] + AttrAPI["GET /api/trustee/instanceId/attributes/EntityType"] + ListAPI["GET /api/trustee/instanceId/organisations|documents|positions|..."] + CrudAPI["POST|PUT|DELETE /api/trustee/instanceId/..."] + OptionsAPI["GET /api/trustee/instanceId/organisations/options etc."] + end + + subgraph Backend["Gateway (Backend)"] + RouteTrustee[routeFeatureTrustee] + AttrEndpoint["get_entity_attributes → getModelAttributeDefinitions"] + Interface[interfaceFeatureTrustee] + DB[(PostgreSQL poweron_trustee)] + end + + subgraph UI["Generische UI-Komponenten"] + FormGeneratorTable[FormGeneratorTable] + FormGeneratorForm[FormGeneratorForm] + end + + RouteHome --> Home + Home --> SidebarProvider + Home --> PageManager + PageManager --> Location + Location --> GetPageData + GetPageData --> PageData + PageData --> PageRenderer + PageRenderer --> HookFactory + HookFactory --> CreateHook + CreateHook --> UseTrusteeOrgs + + RouteFeature --> FeatureLayout + FeatureLayout --> Outlet + Outlet --> FeatureViewPage + FeatureViewPage --> ViewRegistry + ViewRegistry --> TrusteeView + TrusteeView --> UseTrusteeDocs + TrusteeView --> UseTrusteePositions + + UseTrusteeOrgs --> UseInstanceId + UseTrusteeDocs --> UseInstanceId + UseTrusteePositions --> UseInstanceId + UseInstanceId --> AttrAPI + UseInstanceId --> ListAPI + UseInstanceId --> CrudAPI + UseInstanceId --> OptionsAPI + + AttrAPI --> RouteTrustee + ListAPI --> RouteTrustee + CrudAPI --> RouteTrustee + OptionsAPI --> RouteTrustee + RouteTrustee --> AttrEndpoint + RouteTrustee --> Interface + Interface --> DB + AttrEndpoint --> AttrAPI + + UseTrusteeOrgs --> FormGeneratorTable + UseTrusteeDocs --> FormGeneratorTable + UseTrusteePositions --> FormGeneratorTable + FormGeneratorTable --> FormGeneratorForm +``` + +--- + +## Wichtige Datenflüsse (Backend-Driven) + +| Was | Wo definiert / geladen | Verwendung im Frontend | +|-----|------------------------|-------------------------| +| **Spalten (Table)** | Backend: `GET .../attributes/{EntityType}` → `getModelAttributeDefinitions(PydanticModel)` | Hook setzt `attributes` → `attributesToColumns()` bzw. `hookData.columns` → FormGeneratorTable | +| **Formularfelder (Create/Edit)** | Entweder `formConfig.fields` in PageData (statisch) oder aus Hook: `generateCreateFieldsFromAttributes` / `generateEditFieldsFromAttributes` (Backend-Attribute) | PageRenderer / CreateButton → FormGeneratorForm(attributes=...) | +| **Dropdown-Optionen** | Backend: `GET .../organisations/options`, `.../contracts/options` etc. | `optionsReference: 'TrusteeOrganisation'` → useTrusteeOptions lädt Options → FormGeneratorForm | +| **CRUD** | Backend: POST/PUT/DELETE unter `/api/trustee/{instanceId}/{entity}` | Hook-Operationen (handleCreate, handleUpdate, handleDelete) → FormGeneratorTable Actions / Modals | + +--- + +## Relevante Dateien + +- **Frontend:** `src/core/PageManager/PageManager.tsx`, `src/core/PageManager/PageRenderer.tsx`, `src/core/PageManager/pageInterface.ts`, `src/core/PageManager/data/pages/trustee/organisations.ts`, `src/pages/FeatureView.tsx`, `src/pages/views/trustee/TrusteeDocumentsView.tsx`, `src/hooks/useTrustee.ts`, `src/api/trusteeApi.ts`. +- **Backend (Gateway):** `modules/features/trustee/routeFeatureTrustee.py` (Attributes, CRUD, Options), `modules/features/trustee/interfaceFeatureTrustee.py`, `modules/features/trustee/datamodelFeatureTrustee.py`. + +--- + +## Prüfung: Chatbot gleiche Logik wie Trustee? + +Der Chatbot wird im Frontend an **zwei Stellen** angebunden. Nur eine nutzt dieselbe backend-driven Logik wie Trustee. + +| Kriterium | Trustee (path-basiert) | Chatbot Route `/chatbot` | Chatbot Path `start/chatbot` | +|-----------|------------------------|---------------------------|-------------------------------| +| PageData (GenericPageData) | Ja | Nein | Ja (chatbotPageData) | +| PageRenderer | Ja | Nein | Ja | +| hookFactory in Config | Ja (tableConfig.hookFactory) | Nein | Ja (inputFormConfig.hookFactory) | +| Hook ruft Backend-API auf | Ja | Nein (Platzhalter) | Ja (chatbotApi) | +| Generische UI | FormGeneratorTable/Form | Eigene UI (ChatbotPage) | Messages, InputForm, ChatHistory | + +- **Route `/chatbot`** → rendert `ChatbotPage` (pages/migrate): eigenständige Komponente, kein PageManager/PageRenderer, simulierte Antwort (TODO: echte API). **Nicht** gleiche Logik wie Trustee. +- **Path `start/chatbot`** (PageManager/Sidebar) → `chatbotPageData` mit `createChatbotHook` → PageRenderer → useChatbot → echte APIs (`/api/chatbot/start/stream` etc.). **Gleiche** Logik wie Trustee (PageData + hookFactory + PageRenderer + Hook + Backend-API). 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/Navigation/MandateNavigation.tsx b/src/components/Navigation/MandateNavigation.tsx index 2c36e7a..85f5398 100644 --- a/src/components/Navigation/MandateNavigation.tsx +++ b/src/components/Navigation/MandateNavigation.tsx @@ -76,12 +76,15 @@ function featureViewToTreeNode(view: FeatureView): TreeNodeItem { /** * Convert a FeatureInstance to TreeNodeItem + * Instance node gets path to first view so clicking the instance name (e.g. PEK) navigates to dashboard. */ function featureInstanceToTreeNode(instance: FeatureInstance): TreeNodeItem { + const children = instance.views.map(featureViewToTreeNode); return { id: instance.id, label: instance.uiLabel, - children: instance.views.map(featureViewToTreeNode), + path: instance.views.length > 0 ? instance.views[0].uiPath : undefined, + children, defaultExpanded: false, }; } diff --git a/src/components/UiComponents/index.ts b/src/components/UiComponents/index.ts index c8e9de0..92ca1e4 100644 --- a/src/components/UiComponents/index.ts +++ b/src/components/UiComponents/index.ts @@ -20,4 +20,4 @@ export * from './AutoScroll'; export * from './Tabs'; export type { TabsProps, Tab } from './Tabs'; export * from './Toast'; -export * from './VoiceLanguageSelect'; +export * from './VoiceLanguageSelect'; \ No newline at end of file diff --git a/src/core/PageManager/SidebarProvider.tsx b/src/core/PageManager/SidebarProvider.tsx new file mode 100644 index 0000000..e73da7e --- /dev/null +++ b/src/core/PageManager/SidebarProvider.tsx @@ -0,0 +1,486 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; +import { allPageData, SidebarItem, SidebarSubmenuItemData } from './data'; +import { useLanguage } from '../../providers/language/LanguageContext'; +import { resolveLanguageText, GenericPageData } from './pageInterface'; +import { usePermissions } from '../../hooks/usePermissions'; +import { FaHome, FaHatWizard, FaBriefcase, FaBuilding, FaProjectDiagram } from 'react-icons/fa'; +import { RiFolderSettingsFill } from 'react-icons/ri'; + +// Configuration for parent groups that don't have a page definition +// Maps parentPath (can be nested like "start.real-estate") to icon and default order +const parentGroupConfig: Record>; + defaultOrder?: number; +}> = { + 'start': { + icon: FaHome, + defaultOrder: 1 + }, + 'workflows': { + icon: FaProjectDiagram, + defaultOrder: 2 + }, + 'trustee': { + icon: FaBriefcase, + defaultOrder: 3 + }, + 'basedata': { + icon: RiFolderSettingsFill, + defaultOrder: 4 + }, + 'admin': { + icon: FaHatWizard, + defaultOrder: 5 + }, + 'start.realestate': { + icon: FaBuilding, + defaultOrder: 2 + } +}; + +interface SidebarContextType { + sidebarItems: SidebarItem[]; + loading: boolean; + error: string | null; + refreshSidebar: () => Promise; +} + +const SidebarContext = createContext(undefined); + +export const useSidebar = () => { + const context = useContext(SidebarContext); + if (!context) { + throw new Error('useSidebar must be used within a SidebarProvider'); + } + return context; +}; + +interface SidebarProviderProps { + children: React.ReactNode; +} + +export const SidebarProvider: React.FC = ({ children }) => { + const [sidebarItems, setSidebarItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Get translation function from language context + const { t } = useLanguage(); + const { canView, preloadUiPermissions } = usePermissions(); + + // Helper type for navigation tree nodes + interface NavigationNode { + id: string; + pathSegment: string; + fullPath: string; // Full dot-notation path (e.g., "start.real-estate") + name: string; + icon?: React.ComponentType>; + order: number; + page?: GenericPageData; // If this node represents an actual page + children: Map; // Keyed by path segment + pages: GenericPageData[]; // Direct child pages + } + + // Helper function to resolve node name + const resolveNodeName = (pathSegment: string, fullPath: string, page?: GenericPageData): string => { + if (page) { + return resolveLanguageText(page.name, t); + } + // Try translation key (e.g., "start.real-estate.title") + const translationKey = `${fullPath}.title`; + const translated = t(translationKey); + if (translated !== translationKey) { + return translated; + } + // Try just the segment (e.g., "real-estate.title") + const segmentKey = `${pathSegment}.title`; + const segmentTranslated = t(segmentKey); + if (segmentTranslated !== segmentKey) { + return segmentTranslated; + } + // Fallback to capitalized segment + return pathSegment.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(' '); + }; + + // Helper function to resolve node icon + const resolveNodeIcon = (pathSegment: string, fullPath: string, page?: GenericPageData): React.ComponentType> | undefined => { + if (page?.icon) { + return page.icon; + } + // Check parentGroupConfig for nested paths first (e.g., "start.real-estate") + if (parentGroupConfig[fullPath]?.icon) { + return parentGroupConfig[fullPath].icon; + } + // Check parentGroupConfig for top-level segments (e.g., "start") + if (fullPath === pathSegment && parentGroupConfig[pathSegment]?.icon) { + return parentGroupConfig[pathSegment].icon; + } + return undefined; + }; + + // Helper function to resolve node order + const resolveNodeOrder = (pathSegment: string, fullPath: string, page?: GenericPageData, childPages: GenericPageData[] = []): number => { + if (page?.order !== undefined) { + return page.order; + } + // Check parentGroupConfig for top-level segments + if (fullPath === pathSegment && parentGroupConfig[pathSegment]?.defaultOrder !== undefined) { + return parentGroupConfig[pathSegment].defaultOrder!; + } + // Use minimum order of child pages + if (childPages.length > 0) { + const childOrders = childPages.map(p => p.order ?? 0); + return Math.min(...childOrders); + } + return 0; + }; + + // Build navigation tree from page data + const buildNavigationTree = (): Map => { + const rootNodes = new Map(); + + // Process all pages with parent paths + const pagesWithParents = allPageData.filter( + page => page.parentPath && !page.hide && page.showInSidebar !== false + ); + + for (const page of pagesWithParents) { + if (!page.parentPath) continue; + + // Parse parent path segments (e.g., "start.real-estate" -> ["start", "real-estate"]) + const pathSegments = page.parentPath.split('.'); + + // Build path to root, creating nodes as needed + let currentMap = rootNodes; + let currentFullPath = ''; + + for (let i = 0; i < pathSegments.length; i++) { + const segment = pathSegments[i]; + currentFullPath = currentFullPath ? `${currentFullPath}.${segment}` : segment; + + // Get or create node for this segment + if (!currentMap.has(segment)) { + // Check if there's a page for this path segment + const segmentPage = allPageData.find( + p => p.path === currentFullPath && !p.hide + ); + + const node: NavigationNode = { + id: segmentPage?.id || currentFullPath, + pathSegment: segment, + fullPath: currentFullPath, + name: '', // Will be resolved later + icon: undefined, // Will be resolved later + order: 0, // Will be resolved later + page: segmentPage, + children: new Map(), + pages: [] + }; + + currentMap.set(segment, node); + } + + const node = currentMap.get(segment)!; + + // If this is the last segment, add the page as a child page + if (i === pathSegments.length - 1) { + node.pages.push(page); + } + + // Move to next level + currentMap = node.children; + } + } + + // Resolve names, icons, and orders for all nodes + const resolveNode = (node: NavigationNode): void => { + // Resolve children first (bottom-up) + for (const childNode of node.children.values()) { + resolveNode(childNode); + } + + // Resolve this node + node.name = resolveNodeName(node.pathSegment, node.fullPath, node.page); + node.icon = resolveNodeIcon(node.pathSegment, node.fullPath, node.page); + + // Collect all child pages (from direct pages and nested children) + const allChildPages = [...node.pages]; + for (const childNode of node.children.values()) { + if (childNode.page) { + allChildPages.push(childNode.page); + } + allChildPages.push(...childNode.pages); + } + + node.order = resolveNodeOrder(node.pathSegment, node.fullPath, node.page, allChildPages); + }; + + // Resolve all root nodes + for (const node of rootNodes.values()) { + resolveNode(node); + } + + return rootNodes; + }; + + // Convert navigation tree node to sidebar submenu item (recursive) + const nodeToSubmenuItem = async (node: NavigationNode, depth: number = 0): Promise => { + // Filter child pages by RBAC and privilegeChecker + const accessiblePages: GenericPageData[] = []; + for (const page of node.pages) { + try { + const hasRBACAccess = await canView('UI', page.path); + if (!hasRBACAccess) continue; + + if (page.privilegeChecker) { + try { + const hasPrivilege = await page.privilegeChecker(); + if (!hasPrivilege) continue; + } catch (error) { + console.error(`Error checking privilegeChecker for page ${page.path}:`, error); + continue; + } + } + + accessiblePages.push(page); + } catch (error) { + console.error(`Error checking RBAC access for page ${page.path}:`, error); + } + } + + // Process child nodes recursively (increment depth) + const accessibleChildren: SidebarSubmenuItemData[] = []; + for (const childNode of node.children.values()) { + const childItem = await nodeToSubmenuItem(childNode, depth + 1); + if (childItem) { + accessibleChildren.push(childItem); + } + } + + // Combine pages and child nodes, assigning depth + const allChildren: SidebarSubmenuItemData[] = [ + ...accessiblePages.map(page => ({ + id: page.id, + name: resolveLanguageText(page.name, t), + link: `/${page.path}`, + icon: page.icon, + depth: depth + 1 // Child pages are one level deeper + })), + ...accessibleChildren + ]; + + // If no accessible children, don't create this node + if (allChildren.length === 0) { + return null; + } + + // If this node has a page itself, it shouldn't be a navigation node + // But according to requirements: if it has subpages, it is NOT a page itself + // So we create a navigation node without a link + return { + id: node.id, + name: node.name, + link: undefined, // Navigation node - not a clickable page + icon: node.icon, + submenu: allChildren.length > 0 ? allChildren : undefined, + depth: depth // Current depth level + }; + }; + + // Convert navigation tree to sidebar items + const treeToSidebarItems = async (tree: Map): Promise => { + const items: SidebarItem[] = []; + + // Process each root node (depth 0 for top-level items) + for (const node of tree.values()) { + const submenuItem = await nodeToSubmenuItem(node, 0); + if (submenuItem && submenuItem.submenu && submenuItem.submenu.length > 0) { + items.push({ + id: node.id, + name: node.name, + link: undefined, // Navigation node - not a clickable page + icon: node.icon, + moduleEnabled: true, + order: node.order, + submenu: submenuItem.submenu, + depth: 0 // Top-level items have depth 0 + }); + } + } + + return items; + }; + + // Get sidebar items from page data + const getSidebarItems = async (): Promise => { + const items: SidebarItem[] = []; + + // Build navigation tree + const navigationTree = buildNavigationTree(); + + // Convert tree to sidebar items + const treeItems = await treeToSidebarItems(navigationTree); + items.push(...treeItems); + + // Get main pages (no parent path) + const mainPages = allPageData + .filter(page => !page.parentPath && !page.hide && page.showInSidebar !== false) + .sort((a, b) => (a.order || 0) - (b.order || 0)); + + // Process each main page + for (const pageData of mainPages) { + // Check RBAC permissions + try { + const hasRBACAccess = await canView('UI', pageData.path); + if (!hasRBACAccess) { + continue; + } + + // Check client-side privilegeChecker if provided + if (pageData.privilegeChecker) { + try { + const hasPrivilege = await pageData.privilegeChecker(); + if (!hasPrivilege) { + continue; + } + } catch (error) { + console.error(`Error checking privilegeChecker for ${pageData.path}:`, error); + continue; + } + } + } catch (error) { + console.error(`Error checking RBAC access for ${pageData.path}:`, error); + continue; + } + + // Check if this page has subpages (legacy support) + if (pageData.hasSubpages) { + // Find all subpages for this parent + const allSubpages = allPageData.filter(p => + p.parentPath === pageData.path && + !p.hide && + p.showInSidebar !== false + ); + + // Filter subpages by RBAC access + const accessibleSubpages: GenericPageData[] = []; + for (const subpage of allSubpages) { + try { + const hasSubpageRBACAccess = await canView('UI', subpage.path); + if (!hasSubpageRBACAccess) { + continue; + } + + if (subpage.privilegeChecker) { + try { + const hasPrivilege = await subpage.privilegeChecker(); + if (!hasPrivilege) { + continue; + } + } catch (error) { + console.error(`Error checking privilegeChecker for subpage ${subpage.path}:`, error); + continue; + } + } + + accessibleSubpages.push(subpage); + } catch (error) { + console.error(`Error checking RBAC access for subpage ${subpage.path}:`, error); + } + } + + if (accessibleSubpages.length > 0) { + // Create item with submenu (no link since it has subpages) + items.push({ + id: pageData.id, + name: resolveLanguageText(pageData.name, t), + link: undefined, // No link - has subpages, so it's a navigation node + icon: pageData.icon, + moduleEnabled: pageData.moduleEnabled ?? true, + order: pageData.order || 0, + depth: 0, // Top-level items have depth 0 + submenu: accessibleSubpages.map(subpage => ({ + id: subpage.id, + name: resolveLanguageText(subpage.name, t), + link: `/${subpage.path}`, + icon: subpage.icon, + depth: 1 // First level of submenu + })) + }); + } else { + // No accessible subpages, show as regular item + items.push({ + id: pageData.id, + name: resolveLanguageText(pageData.name, t), + link: `/${pageData.path}`, + icon: pageData.icon, + moduleEnabled: pageData.moduleEnabled ?? true, + order: pageData.order || 0, + depth: 0 // Top-level items have depth 0 + }); + } + } else { + // Regular items without subpages + items.push({ + id: pageData.id, + name: resolveLanguageText(pageData.name, t), + link: `/${pageData.path}`, + icon: pageData.icon, + moduleEnabled: pageData.moduleEnabled ?? true, + order: pageData.order || 0, + depth: 0 // Top-level items have depth 0 + }); + } + } + + // Sort all items by order + const sortedItems = items.sort((a, b) => (a.order || 0) - (b.order || 0)); + + return sortedItems; + }; + + // Refresh sidebar items + const refreshSidebar = async () => { + console.log('🔄 SidebarProvider: Refreshing sidebar items...'); + setLoading(true); + setError(null); + + try { + // Preload all UI permissions in a single API call + // This caches all permissions before iterating through pages + await preloadUiPermissions(); + + const items = await getSidebarItems(); + console.log('✅ SidebarProvider: Setting sidebar items:', { + count: items.length, + items: items.map(item => ({ id: item.id, link: item.link, name: item.name })) + }); + setSidebarItems(items); + } catch (err) { + console.error('❌ SidebarProvider: Error refreshing sidebar:', err); + setError(err instanceof Error ? err.message : 'Failed to load sidebar items'); + } finally { + setLoading(false); + } + }; + + // Load sidebar items on mount and when language changes + useEffect(() => { + refreshSidebar(); + }, [t]); + + const contextValue: SidebarContextType = { + sidebarItems, + loading, + error, + refreshSidebar + }; + + return ( + + {children} + + ); +}; + +export default SidebarProvider; diff --git a/src/core/PageManager/data/index.ts b/src/core/PageManager/data/index.ts new file mode 100644 index 0000000..33ee74d --- /dev/null +++ b/src/core/PageManager/data/index.ts @@ -0,0 +1,27 @@ +/** + * PageManager data: allPageData and SidebarItem type. + */ + +import type React from 'react'; + +export { allPageData } from './pages'; + +export interface SidebarItem { + id: string; + name: string; + link?: string; + icon?: React.ComponentType>; + moduleEnabled?: boolean; + order?: number; + submenu?: SidebarSubmenuItemData[]; + depth?: number; +} + +export interface SidebarSubmenuItemData { + id: string; + name: string; + link?: string; + icon?: React.ComponentType>; + depth?: number; + submenu?: SidebarSubmenuItemData[]; +} diff --git a/src/core/PageManager/data/pages/index.ts b/src/core/PageManager/data/pages/index.ts new file mode 100644 index 0000000..e524954 --- /dev/null +++ b/src/core/PageManager/data/pages/index.ts @@ -0,0 +1,15 @@ +/** + * Central page registry: all PageData for PageManager/Sidebar. + */ + +import type { GenericPageData } from '../../pageInterface'; +import { trusteePositionDocumentsPageData } from './trustee/position-documents'; +import { realEstatePages } from './realestate'; + +export { realEstatePages } from './realestate'; +export { trusteePositionDocumentsPageData } from './trustee/position-documents'; + +export const allPageData: GenericPageData[] = [ + trusteePositionDocumentsPageData, + ...realEstatePages, +]; 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..ae0ec8f --- /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/useTrustee'; + +// 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 { + items: positionDocuments, + loading, + error, + refetch, + removeOptimistically, + attributes, + permissions, + pagination, + fetchById: fetchPositionDocumentById, + generateEditFieldsFromAttributes, + ensureAttributesLoaded + } = useTrusteePositionDocuments(); + const { + handleDelete: handlePositionDocumentDelete, + handleCreate: handlePositionDocumentCreate, + deletingItems: deletingPositionDocuments, + creatingItem: 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: boolean) => 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/core/PageManager/pageInterface.ts b/src/core/PageManager/pageInterface.ts new file mode 100644 index 0000000..a53ba2a --- /dev/null +++ b/src/core/PageManager/pageInterface.ts @@ -0,0 +1,49 @@ +/** + * PageManager page interface and helpers. + * Used by PageData definitions and SidebarProvider. + */ + +import type React from 'react'; + +export interface GenericPageData { + id: string; + path: string; + name: string; + description?: string; + parentPath?: string; + icon?: React.ComponentType>; + title?: string; + subtitle?: string; + headerButtons?: Array>; + content?: Array>; + moduleEnabled?: boolean; + order?: number; + hide?: boolean; + showInSidebar?: boolean; + showInSidebarIf?: boolean; + hasSubpages?: boolean; + privilegeChecker?: () => Promise | boolean; + persistent?: boolean; + preload?: boolean; + preserveState?: boolean; + onActivate?: () => void | Promise; + onLoad?: () => void | Promise; + onUnload?: () => void | Promise; +} + +type TranslationFunction = (key: string, fallback?: string) => string; + +/** + * Resolve display text from a page name that may be a i18n key or { de?, en? }. + */ +export function resolveLanguageText( + name: string | { de?: string; en?: string }, + t: TranslationFunction +): string { + if (typeof name === 'string') { + const resolved = t(name); + return resolved !== name ? resolved : name; + } + const lang = (typeof navigator !== 'undefined' && navigator.language?.startsWith('en')) ? 'en' : 'de'; + return name[lang] ?? name.de ?? name.en ?? ''; +} 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/pages/FeatureView.tsx b/src/pages/FeatureView.tsx index 9e237d6..d49700c 100644 --- a/src/pages/FeatureView.tsx +++ b/src/pages/FeatureView.tsx @@ -22,6 +22,9 @@ import { TrusteeExpenseImportView } from './views/trustee/TrusteeExpenseImportVi // Chatbot Views import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsView'; +// RealEstate Views +import { RealEstatePekView, RealEstateProjectsView, RealEstateParcelsView, RealEstateInstanceRolesPlaceholder } from './views/realestate'; + import styles from './FeatureView.module.css'; // ============================================================================= @@ -94,6 +97,12 @@ const VIEW_COMPONENTS: Record> = { conversations: ChatbotConversationsView, settings: ChatbotSettings, }, + realestate: { + dashboard: RealEstatePekView, + projects: RealEstateProjectsView, + parcels: RealEstateParcelsView, + 'instance-roles': RealEstateInstanceRolesPlaceholder, + }, }; // ============================================================================= 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/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..105f1ae --- /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'; \ No newline at end of file