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
|
* Convert a FeatureInstance to TreeNodeItem
|
||||||
|
* Instance node gets path to first view so clicking the instance name (e.g. PEK) navigates to dashboard.
|
||||||
*/
|
*/
|
||||||
function featureInstanceToTreeNode(instance: FeatureInstance): TreeNodeItem {
|
function featureInstanceToTreeNode(instance: FeatureInstance): TreeNodeItem {
|
||||||
|
const children = instance.views.map(featureViewToTreeNode);
|
||||||
return {
|
return {
|
||||||
id: instance.id,
|
id: instance.id,
|
||||||
label: instance.uiLabel,
|
label: instance.uiLabel,
|
||||||
children: instance.views.map(featureViewToTreeNode),
|
path: instance.views.length > 0 ? instance.views[0].uiPath : undefined,
|
||||||
|
children,
|
||||||
defaultExpanded: false,
|
defaultExpanded: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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
|
// Chatbot Views
|
||||||
import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsView';
|
import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsView';
|
||||||
|
|
||||||
|
// RealEstate Views
|
||||||
|
import { RealEstatePekView, RealEstateProjectsView, RealEstateParcelsView, RealEstateInstanceRolesPlaceholder } from './views/realestate';
|
||||||
|
|
||||||
import styles from './FeatureView.module.css';
|
import styles from './FeatureView.module.css';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -94,6 +97,12 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
||||||
conversations: ChatbotConversationsView,
|
conversations: ChatbotConversationsView,
|
||||||
settings: ChatbotSettings,
|
settings: ChatbotSettings,
|
||||||
},
|
},
|
||||||
|
realestate: {
|
||||||
|
dashboard: RealEstatePekView,
|
||||||
|
projects: RealEstateProjectsView,
|
||||||
|
parcels: RealEstateParcelsView,
|
||||||
|
'instance-roles': RealEstateInstanceRolesPlaceholder,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
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