RealEstate/PageManager: Dashboard, Parcels, Projects, useRealEstate, realEstateApi, FeatureView, MandateNavigation, UiComponents, docs BACKEND_DRIVEN_RENDERING

This commit is contained in:
Stephan Schellworth 2026-02-03 08:40:23 +01:00
parent 83530a44bd
commit 557deb11ac
19 changed files with 2604 additions and 2 deletions

View file

@ -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).

313
src/api/realEstateApi.ts Normal file
View file

@ -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<string, any>;
search?: string;
}
export interface PaginatedResponse<T> {
items: T[];
pagination?: {
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
sort?: Array<{ field: string; direction: string }>;
filters?: Record<string, any>;
};
}
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
// ============================================================================
// HELPER FUNCTIONS (instanceId-based CRUD)
// ============================================================================
function _getRealEstateBaseUrl(instanceId: string): string {
return `/api/realestate/${instanceId}`;
}
function _buildPaginationParams(params?: PaginationParams): Record<string, string | number | boolean> {
if (!params) return {};
const paginationObj: Record<string, unknown> = {};
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<string, string | number | boolean>;
}
// ============================================================================
// PROJECTS CRUD (instanceId-based)
// ============================================================================
export async function fetchProjects(
request: ApiRequestFunction,
instanceId: string,
params?: PaginationParams
): Promise<PaginatedResponse<RealEstateProject>> {
return await request({
url: `${_getRealEstateBaseUrl(instanceId)}/projects`,
method: 'get',
params: _buildPaginationParams(params)
});
}
export async function fetchProjectById(
request: ApiRequestFunction,
instanceId: string,
id: string
): Promise<RealEstateProject | null> {
try {
return await request({
url: `${_getRealEstateBaseUrl(instanceId)}/projects/${id}`,
method: 'get'
});
} catch {
return null;
}
}
export async function createProject(
request: ApiRequestFunction,
instanceId: string,
data: Partial<RealEstateProject>
): Promise<RealEstateProject> {
return await request({
url: `${_getRealEstateBaseUrl(instanceId)}/projects`,
method: 'post',
data
});
}
export async function updateProject(
request: ApiRequestFunction,
instanceId: string,
id: string,
data: Partial<RealEstateProject>
): Promise<RealEstateProject> {
return await request({
url: `${_getRealEstateBaseUrl(instanceId)}/projects/${id}`,
method: 'put',
data
});
}
export async function deleteProject(
request: ApiRequestFunction,
instanceId: string,
id: string
): Promise<void> {
await request({
url: `${_getRealEstateBaseUrl(instanceId)}/projects/${id}`,
method: 'delete'
});
}
// ============================================================================
// PARCELS CRUD (instanceId-based)
// ============================================================================
export async function fetchParcels(
request: ApiRequestFunction,
instanceId: string,
params?: PaginationParams
): Promise<PaginatedResponse<RealEstateParcel>> {
return await request({
url: `${_getRealEstateBaseUrl(instanceId)}/parcels`,
method: 'get',
params: _buildPaginationParams(params)
});
}
export async function fetchParcelById(
request: ApiRequestFunction,
instanceId: string,
id: string
): Promise<RealEstateParcel | null> {
try {
return await request({
url: `${_getRealEstateBaseUrl(instanceId)}/parcels/${id}`,
method: 'get'
});
} catch {
return null;
}
}
export async function createParcel(
request: ApiRequestFunction,
instanceId: string,
data: Partial<RealEstateParcel>
): Promise<RealEstateParcel> {
return await request({
url: `${_getRealEstateBaseUrl(instanceId)}/parcels`,
method: 'post',
data
});
}
export async function updateParcel(
request: ApiRequestFunction,
instanceId: string,
id: string,
data: Partial<RealEstateParcel>
): Promise<RealEstateParcel> {
return await request({
url: `${_getRealEstateBaseUrl(instanceId)}/parcels/${id}`,
method: 'put',
data
});
}
export async function deleteParcel(
request: ApiRequestFunction,
instanceId: string,
id: string
): Promise<void> {
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<AddressSuggestion[]> {
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<AddressSuggestion[]>('/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 [];
}
}

View file

@ -76,12 +76,15 @@ function featureViewToTreeNode(view: FeatureView): TreeNodeItem {
/** /**
* Convert a FeatureInstance to 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 { function featureInstanceToTreeNode(instance: FeatureInstance): TreeNodeItem {
const children = instance.views.map(featureViewToTreeNode);
return { return {
id: instance.id, id: instance.id,
label: instance.uiLabel, label: instance.uiLabel,
children: instance.views.map(featureViewToTreeNode), path: instance.views.length > 0 ? instance.views[0].uiPath : undefined,
children,
defaultExpanded: false, defaultExpanded: false,
}; };
} }

View file

@ -20,4 +20,4 @@ export * from './AutoScroll';
export * from './Tabs'; export * from './Tabs';
export type { TabsProps, Tab } from './Tabs'; export type { TabsProps, Tab } from './Tabs';
export * from './Toast'; export * from './Toast';
export * from './VoiceLanguageSelect'; export * from './VoiceLanguageSelect';

View file

@ -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<string, {
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
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<void>;
}
const SidebarContext = createContext<SidebarContextType | undefined>(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<SidebarProviderProps> = ({ children }) => {
const [sidebarItems, setSidebarItems] = useState<SidebarItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<React.SVGProps<SVGSVGElement>>;
order: number;
page?: GenericPageData; // If this node represents an actual page
children: Map<string, NavigationNode>; // 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<React.SVGProps<SVGSVGElement>> | 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<string, NavigationNode> => {
const rootNodes = new Map<string, NavigationNode>();
// 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<SidebarSubmenuItemData | null> => {
// 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<string, NavigationNode>): Promise<SidebarItem[]> => {
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<SidebarItem[]> => {
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 (
<SidebarContext.Provider value={contextValue}>
{children}
</SidebarContext.Provider>
);
};
export default SidebarProvider;

View file

@ -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<React.SVGProps<SVGSVGElement>>;
moduleEnabled?: boolean;
order?: number;
submenu?: SidebarSubmenuItemData[];
depth?: number;
}
export interface SidebarSubmenuItemData {
id: string;
name: string;
link?: string;
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
depth?: number;
submenu?: SidebarSubmenuItemData[];
}

View file

@ -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,
];

View file

@ -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,
];

View file

@ -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'); },
};

View file

@ -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'); },
};

View file

@ -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');
}
};

View file

@ -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<React.SVGProps<SVGSVGElement>>;
title?: string;
subtitle?: string;
headerButtons?: Array<Record<string, unknown>>;
content?: Array<Record<string, unknown>>;
moduleEnabled?: boolean;
order?: number;
hide?: boolean;
showInSidebar?: boolean;
showInSidebarIf?: boolean;
hasSubpages?: boolean;
privilegeChecker?: () => Promise<boolean> | boolean;
persistent?: boolean;
preload?: boolean;
preserveState?: boolean;
onActivate?: () => void | Promise<void>;
onLoad?: () => void | Promise<void>;
onUnload?: () => void | Promise<void>;
}
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 ?? '';
}

403
src/hooks/useRealEstate.ts Normal file
View file

@ -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<T> {
entityName: string;
fetchAll: (request: any, instanceId: string, params?: PaginationParams) => Promise<any>;
fetchById: (request: any, instanceId: string, id: string) => Promise<T | null>;
create: (request: any, instanceId: string, data: Partial<T>) => Promise<T>;
update: (request: any, instanceId: string, id: string, data: Partial<T>) => Promise<T>;
deleteItem: (request: any, instanceId: string, id: string) => Promise<void>;
}
function _createRealEstateEntityHook<T extends { id: string }>(config: RealEstateEntityConfig<T>) {
return function useRealEstateEntity() {
const instanceId = useInstanceId();
const [items, setItems] = useState<T[]>([]);
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
const [pagination, setPagination] = useState<{
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
} | null>(null);
const { request, isLoading: loading, error } = useApiRequest<null, T[]>();
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<T>) => {
setItems(prev =>
prev.map(item =>
item.id === itemId ? { ...item, ...updateData } : item
)
);
};
const fetchById = useCallback(async (itemId: string): Promise<T | null> => {
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<T extends { id: string }>(config: RealEstateEntityConfig<T>) {
return function useRealEstateEntityOperations() {
const instanceId = useInstanceId();
const [deletingItems, setDeletingItems] = useState<Set<string>>(new Set());
const [creatingItem, setCreatingItem] = useState(false);
const { request } = useApiRequest();
const [deleteError, setDeleteError] = useState<string | null>(null);
const [createError, setCreateError] = useState<string | null>(null);
const [updateError, setUpdateError] = useState<string | null>(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<T>) => {
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<T>) => {
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<RealEstateProject> = {
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<RealEstateParcel> = {
entityName: 'Parzelle',
fetchAll: fetchParcelsApi,
fetchById: fetchParcelByIdApi,
create: createParcelApi,
update: updateParcelApi,
deleteItem: deleteParcelApi,
};
export const useRealEstateParcels = _createRealEstateEntityHook(parcelConfig);
export const useRealEstateParcelOperations = _createRealEstateOperationsHook(parcelConfig);

View file

@ -22,6 +22,9 @@ import { TrusteeExpenseImportView } from './views/trustee/TrusteeExpenseImportVi
// Chatbot Views // Chatbot Views
import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsView'; import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsView';
// RealEstate Views
import { RealEstatePekView, RealEstateProjectsView, RealEstateParcelsView, RealEstateInstanceRolesPlaceholder } from './views/realestate';
import styles from './FeatureView.module.css'; import styles from './FeatureView.module.css';
// ============================================================================= // =============================================================================
@ -94,6 +97,12 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
conversations: ChatbotConversationsView, conversations: ChatbotConversationsView,
settings: ChatbotSettings, settings: ChatbotSettings,
}, },
realestate: {
dashboard: RealEstatePekView,
projects: RealEstateProjectsView,
parcels: RealEstateParcelsView,
'instance-roles': RealEstateInstanceRolesPlaceholder,
},
}; };
// ============================================================================= // =============================================================================

View file

@ -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 (
<div className={styles.dashboardView}>
<div className={styles.statsGrid}>
{/* Projekte Link-Karte */}
<Link to="projects" className={styles.statCard} style={{ textDecoration: 'none', color: 'inherit' }}>
<div className={styles.statIcon}>📋</div>
<div className={styles.statContent}>
<div className={styles.statValue}>
{isLoading ? '...' : projects.length}
</div>
<div className={styles.statLabel}>Projekte</div>
</div>
</Link>
{/* Parzellen Link-Karte */}
<Link to="parcels" className={styles.statCard} style={{ textDecoration: 'none', color: 'inherit' }}>
<div className={styles.statIcon}>🗺</div>
<div className={styles.statContent}>
<div className={styles.statValue}>
{isLoading ? '...' : parcels.length}
</div>
<div className={styles.statLabel}>Parzellen</div>
</div>
</Link>
{/* Rollen (optional) */}
{instance?.userRoles?.length ? (
<div className={styles.statCard}>
<div className={styles.statIcon}>👤</div>
<div className={styles.statContent}>
<div className={styles.statValueSmall}>
{instance.userRoles.map((role, idx) => (
<div key={idx}>{role}</div>
))}
</div>
<div className={styles.statLabel}>
{instance.userRoles.length === 1 ? 'Deine Rolle' : 'Deine Rollen'}
</div>
</div>
</div>
) : null}
</div>
{/* Instanz-Infos */}
<div className={styles.infoSection}>
<h3>Instanz-Details</h3>
<div className={styles.infoGrid}>
<div className={styles.infoItem}>
<span className={styles.infoLabel}>Instanz:</span>
<span className={styles.infoValue}>{instance?.instanceLabel}</span>
</div>
<div className={styles.infoItem}>
<span className={styles.infoLabel}>Mandant:</span>
<span className={styles.infoValue}>{instance?.mandateName}</span>
</div>
</div>
</div>
</div>
);
};
export default RealEstateDashboardView;

View file

@ -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 (
<div className={styles.infoSection} style={{ maxWidth: 560 }}>
<h3>Rollen & Rechte</h3>
<p style={{ margin: '0 0 1rem', color: 'var(--text-secondary, #666)', fontSize: '0.9375rem', lineHeight: 1.5 }}>
Die Verwaltung von Rollen und Benutzern für diese Instanz erfolgt in der Administration.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<Link to="/admin/feature-users" className={styles.primaryButton} style={{ display: 'inline-block', textAlign: 'center', textDecoration: 'none' }}>
Feature-Instanz Benutzer
</Link>
<Link to="/admin/feature-roles" className={styles.secondaryButton} style={{ display: 'inline-block', textAlign: 'center', textDecoration: 'none' }}>
Feature-Rollen
</Link>
</div>
</div>
);
};
export default RealEstateInstanceRolesPlaceholder;

View file

@ -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<RealEstateParcel | null>(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<RealEstateParcel>) => {
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<RealEstateParcel>,
row: RealEstateParcel
) => {
updateOptimistically(itemId, updateData);
const result = await handleUpdate(itemId, { ...row, ...updateData });
if (!result.success) {
refetch();
}
};
if (error) {
return (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler beim Laden der Parzellen: {error}</p>
<button className={styles.secondaryButton} onClick={() => refetch()}>
<FaSync /> Erneut versuchen
</button>
</div>
</div>
);
}
return (
<div className={styles.adminPage}>
<div className={styles.pageHeader}>
<div>
<p className={styles.pageSubtitle}>Parzellen verwalten</p>
</div>
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => refetch()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
{canCreate && (
<button className={styles.primaryButton} onClick={handleCreateClick}>
+ Neue Parzelle
</button>
)}
</div>
</div>
<div className={styles.tableContainer}>
{loading && (!parcels || parcels.length === 0) ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Parzellen...</span>
</div>
) : !parcels || parcels.length === 0 ? (
<div className={styles.emptyState}>
<FaMapMarkerAlt className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Parzellen vorhanden</h3>
<p className={styles.emptyDescription}>
Erstellen Sie eine neue Parzelle, um zu beginnen.
</p>
{canCreate && (
<button className={styles.primaryButton} onClick={handleCreateClick}>
+ Neue Parzelle
</button>
)}
</div>
) : (
<FormGeneratorTable
data={parcels}
columns={columns}
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
actionButtons={[
...(canUpdate
? [
{
type: 'edit' as const,
onAction: handleEditClick,
title: 'Bearbeiten',
},
]
: []),
...(canDelete
? [
{
type: 'delete' as const,
title: 'Löschen',
loading: (row: RealEstateParcel) => deletingItems.has(row.id),
},
]
: []),
]}
onDelete={handleDeleteParcel}
hookData={{
refetch,
permissions,
pagination,
handleDelete,
handleInlineUpdate,
updateOptimistically,
}}
emptyMessage="Keine Parzellen gefunden"
/>
)}
</div>
{(editingParcel || isCreateMode) && (
<div className={styles.modalOverlay} onClick={handleCloseModal}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>
{isCreateMode ? 'Neue Parzelle' : 'Parzelle bearbeiten'}
</h2>
<button className={styles.modalClose} onClick={handleCloseModal}>
</button>
</div>
<div className={styles.modalContent}>
{formAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
</div>
) : (
<FormGeneratorForm
attributes={formAttributes}
data={editingParcel || {}}
mode={isCreateMode ? 'create' : 'edit'}
onSubmit={handleFormSubmit}
onCancel={handleCloseModal}
submitButtonText={isCreateMode ? 'Erstellen' : 'Speichern'}
cancelButtonText="Abbrechen"
instanceId={instanceId}
/>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default RealEstateParcelsView;

View file

@ -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<RealEstateProject | null>(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<RealEstateProject>) => {
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<RealEstateProject>, row: RealEstateProject) => {
updateOptimistically(itemId, updateData);
const result = await handleUpdate(itemId, { ...row, ...updateData });
if (!result.success) refetch();
};
if (error) {
return (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler beim Laden der Projekte: {error}</p>
<button className={styles.secondaryButton} onClick={() => refetch()}>
<FaSync /> Erneut versuchen
</button>
</div>
</div>
);
}
return (
<div className={styles.adminPage}>
<div className={styles.pageHeader}>
<div>
<p className={styles.pageSubtitle}>Projekte verwalten</p>
</div>
<div className={styles.headerActions}>
<button className={styles.secondaryButton} onClick={() => refetch()} disabled={loading}>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
{canCreate && (
<button className={styles.primaryButton} onClick={handleCreateClick}>
+ Neues Projekt
</button>
)}
</div>
</div>
<div className={styles.tableContainer}>
{loading && (!projects || projects.length === 0) ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Projekte...</span>
</div>
) : !projects || projects.length === 0 ? (
<div className={styles.emptyState}>
<FaBuilding className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Projekte vorhanden</h3>
<p className={styles.emptyDescription}>Erstellen Sie ein neues Projekt, um zu beginnen.</p>
{canCreate && (
<button className={styles.primaryButton} onClick={handleCreateClick}>
+ Neues Projekt
</button>
)}
</div>
) : (
<FormGeneratorTable
data={projects}
columns={columns}
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
actionButtons={[
...(canUpdate ? [{ type: 'edit' as const, onAction: handleEditClick, title: 'Bearbeiten' }] : []),
...(canDelete ? [{ type: 'delete' as const, title: 'Löschen', loading: (row: RealEstateProject) => deletingItems.has(row.id) }] : []),
]}
onDelete={handleDeleteProject}
hookData={{ refetch, permissions, pagination, handleDelete, handleInlineUpdate, updateOptimistically }}
emptyMessage="Keine Projekte gefunden"
/>
)}
</div>
{(editingProject || isCreateMode) && (
<div className={styles.modalOverlay} onClick={handleCloseModal}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{isCreateMode ? 'Neues Projekt' : 'Projekt bearbeiten'}</h2>
<button className={styles.modalClose} onClick={handleCloseModal}></button>
</div>
<div className={styles.modalContent}>
{formAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
</div>
) : (
<FormGeneratorForm
attributes={formAttributes}
data={editingProject || {}}
mode={isCreateMode ? 'create' : 'edit'}
onSubmit={handleFormSubmit}
onCancel={handleCloseModal}
submitButtonText={isCreateMode ? 'Erstellen' : 'Speichern'}
cancelButtonText="Abbrechen"
instanceId={instanceId}
/>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default RealEstateProjectsView;

View file

@ -0,0 +1,5 @@
export { RealEstateDashboardView } from './RealEstateDashboardView';
export { RealEstatePekView } from './RealEstatePekView';
export { RealEstateProjectsView } from './RealEstateProjectsView';
export { RealEstateParcelsView } from './RealEstateParcelsView';
export { RealEstateInstanceRolesPlaceholder } from './RealEstateInstanceRolesPlaceholder';