RealEstate/PageManager: Dashboard, Parcels, Projects, useRealEstate, realEstateApi, FeatureView, MandateNavigation, UiComponents, docs BACKEND_DRIVEN_RENDERING
This commit is contained in:
parent
83530a44bd
commit
557deb11ac
19 changed files with 2604 additions and 2 deletions
145
docs/BACKEND_DRIVEN_RENDERING_TRUSTEE.md
Normal file
145
docs/BACKEND_DRIVEN_RENDERING_TRUSTEE.md
Normal 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
313
src/api/realEstateApi.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
486
src/core/PageManager/SidebarProvider.tsx
Normal file
486
src/core/PageManager/SidebarProvider.tsx
Normal 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;
|
||||
27
src/core/PageManager/data/index.ts
Normal file
27
src/core/PageManager/data/index.ts
Normal 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[];
|
||||
}
|
||||
15
src/core/PageManager/data/pages/index.ts
Normal file
15
src/core/PageManager/data/pages/index.ts
Normal 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,
|
||||
];
|
||||
10
src/core/PageManager/data/pages/realestate/index.ts
Normal file
10
src/core/PageManager/data/pages/realestate/index.ts
Normal 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,
|
||||
];
|
||||
147
src/core/PageManager/data/pages/realestate/parcels.ts
Normal file
147
src/core/PageManager/data/pages/realestate/parcels.ts
Normal 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'); },
|
||||
};
|
||||
146
src/core/PageManager/data/pages/realestate/projects.ts
Normal file
146
src/core/PageManager/data/pages/realestate/projects.ts
Normal 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'); },
|
||||
};
|
||||
241
src/core/PageManager/data/pages/trustee/position-documents.ts
Normal file
241
src/core/PageManager/data/pages/trustee/position-documents.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
49
src/core/PageManager/pageInterface.ts
Normal file
49
src/core/PageManager/pageInterface.ts
Normal 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
403
src/hooks/useRealEstate.ts
Normal 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);
|
||||
|
|
@ -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<string, Record<string, ViewComponent>> = {
|
|||
conversations: ChatbotConversationsView,
|
||||
settings: ChatbotSettings,
|
||||
},
|
||||
realestate: {
|
||||
dashboard: RealEstatePekView,
|
||||
projects: RealEstateProjectsView,
|
||||
parcels: RealEstateParcelsView,
|
||||
'instance-roles': RealEstateInstanceRolesPlaceholder,
|
||||
},
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
82
src/pages/views/realestate/RealEstateDashboardView.tsx
Normal file
82
src/pages/views/realestate/RealEstateDashboardView.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
266
src/pages/views/realestate/RealEstateParcelsView.tsx
Normal file
266
src/pages/views/realestate/RealEstateParcelsView.tsx
Normal 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;
|
||||
223
src/pages/views/realestate/RealEstateProjectsView.tsx
Normal file
223
src/pages/views/realestate/RealEstateProjectsView.tsx
Normal 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;
|
||||
5
src/pages/views/realestate/index.ts
Normal file
5
src/pages/views/realestate/index.ts
Normal 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';
|
||||
Loading…
Reference in a new issue