feat(realestate): PEK map and address UI, realestate views, feature-instance routes

This commit is contained in:
Stephan Schellworth 2026-01-30 11:26:58 +01:00
parent 1f87e00339
commit 845094a40a
34 changed files with 6928 additions and 14 deletions

View file

@ -1,4 +1,17 @@
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
/**
* App.tsx
*
* Haupt-App-Komponente mit Multi-Tenant Router-Setup.
*
* URL-Struktur:
* - / Dashboard/Übersicht
* - /settings Benutzer-Einstellungen
* - /gdpr → GDPR / Datenschutz
* - /mandates/:mandateId/:featureCode/:instanceId/* Feature-Instanz-Routen
* - /admin/* System-Administration (nur SysAdmin)
*/
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { useEffect } from 'react';
// Import global CSS reset first
@ -14,7 +27,17 @@ import { ProtectedRoute } from './providers/auth/ProtectedRoute';
import { LanguageProvider } from './providers/language/LanguageContext';
import { WorkflowSelectionProvider } from './contexts/WorkflowSelectionContext';
import { FileProvider } from './contexts/FileContext';
import Home from './pages/Home/Home';
import { MainLayout } from './layouts/MainLayout';
import { FeatureLayout } from './layouts/FeatureLayout';
import { DashboardPage } from './pages/Dashboard';
import { SettingsPage } from './pages/Settings';
import { GDPRPage } from './pages/GDPR';
import { FeatureViewPage } from './pages/FeatureView';
import { AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolesPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage } from './pages/admin';
import { PlaygroundPage, WorkflowsPage, AutomationsPage } from './pages/workflows';
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
import { PekPage, SpeechPage } from './pages/migrate';
function App() {
// Load saved theme preference and set app name on app mount
@ -52,22 +75,94 @@ function App() {
{/* PROTECTED ROUTE - requires authentication */}
<Route path="/" element={
<ProtectedRoute>
<FileProvider>
<WorkflowSelectionProvider>
<Home />
</WorkflowSelectionProvider>
</FileProvider>
<MainLayout />
</ProtectedRoute>
} />
}>
{/* Dashboard (Root) */}
<Route index element={<DashboardPage />} />
{/* System-Seiten (ohne Instanz-Kontext) */}
<Route path="settings" element={<SettingsPage />} />
<Route path="gdpr" element={<GDPRPage />} />
{/* ============================================== */}
{/* WORKFLOWS ROUTES (global) */}
{/* ============================================== */}
<Route path="workflows">
<Route path="playground" element={<PlaygroundPage />} />
<Route path="list" element={<WorkflowsPage />} />
<Route path="automations" element={<AutomationsPage />} />
</Route>
{/* ============================================== */}
{/* BASISDATEN ROUTES (global) */}
{/* ============================================== */}
<Route path="basedata">
<Route path="prompts" element={<PromptsPage />} />
<Route path="files" element={<FilesPage />} />
<Route path="connections" element={<ConnectionsPage />} />
</Route>
{/* ============================================== */}
{/* MIGRATE TO FEATURES (temporary) */}
{/* ============================================== */}
<Route path="chatbot" element={<Navigate to="/" replace />} />
<Route path="pek" element={<PekPage />} />
<Route path="speech" element={<SpeechPage />} />
{/* ============================================== */}
{/* FEATURE-INSTANZ ROUTES */}
{/* /mandates/:mandateId/:featureCode/:instanceId */}
{/* ============================================== */}
<Route
path="mandates/:mandateId/:featureCode/:instanceId"
element={<FeatureLayout />}
>
{/* Feature Views - dynamisch basierend auf featureCode */}
<Route index element={<FeatureViewPage view="dashboard" />} />
<Route path="dashboard" element={<FeatureViewPage view="dashboard" />} />
<Route path="organisations" element={<FeatureViewPage view="organisations" />} />
<Route path="contracts" element={<FeatureViewPage view="contracts" />} />
<Route path="documents" element={<FeatureViewPage view="documents" />} />
<Route path="positions" element={<FeatureViewPage view="positions" />} />
<Route path="roles" element={<FeatureViewPage view="roles" />} />
<Route path="access" element={<FeatureViewPage view="access" />} />
<Route path="runs" element={<FeatureViewPage view="runs" />} />
<Route path="files" element={<FeatureViewPage view="files" />} />
<Route path="conversations" element={<FeatureViewPage view="conversations" />} />
<Route path="position-documents" element={<FeatureViewPage view="position-documents" />} />
<Route path="expense-import" element={<FeatureViewPage view="expense-import" />} />
<Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} />
<Route path="projects" element={<FeatureViewPage view="projects" />} />
<Route path="parcels" element={<FeatureViewPage view="parcels" />} />
{/* Catch-all für unbekannte Sub-Pfade */}
<Route path="*" element={<FeatureViewPage view="not-found" />} />
</Route>
{/* ============================================== */}
{/* ADMIN ROUTES (nur SysAdmin) */}
{/* ============================================== */}
<Route path="admin">
<Route path="mandates" element={<AdminMandatesPage />} />
<Route path="users" element={<AdminUsersPage />} />
<Route path="user-mandates" element={<AdminUserMandatesPage />} />
<Route path="feature-instances" element={<AdminFeatureAccessPage />} />
<Route path="feature-roles" element={<AdminFeatureRolesPage />} />
<Route path="feature-users" element={<AdminFeatureInstanceUsersPage />} />
<Route path="invitations" element={<AdminInvitationsPage />} />
<Route path="mandate-roles" element={<AdminMandateRolesPage />} />
<Route path="mandate-role-permissions" element={<AdminMandateRolePermissionsPage />} />
<Route path="user-access-overview" element={<AdminUserAccessOverviewPage />} />
</Route>
</Route>
{/* Catch-all redirect to home */}
{/* ================================================== */}
{/* CATCH-ALL - Redirect to Dashboard */}
{/* ================================================== */}
<Route path="*" element={
<ProtectedRoute>
<FileProvider>
<WorkflowSelectionProvider>
<Home />
</WorkflowSelectionProvider>
</FileProvider>
<MainLayout />
</ProtectedRoute>
} />
</Routes>

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

@ -0,0 +1,313 @@
import api from '../api';
import type { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================
// TYPES & INTERFACES
// ============================================================================
export interface AddressSuggestion {
label: string;
value: string;
coordinates?: {
x: number;
y: number;
};
}
/** Real Estate Project (Projekt). Backend-driven CRUD uses instanceId. */
export interface RealEstateProject {
id: string;
label: string;
statusProzess?: string;
mandateId?: string;
featureInstanceId?: string;
perimeter?: any;
parzellen?: RealEstateParcel[];
_createdAt?: number;
_modifiedAt?: number;
[key: string]: any;
}
/** Real Estate Parcel (Parzelle). */
export interface RealEstateParcel {
id: string;
label?: string;
mandateId?: string;
featureInstanceId?: string;
strasseNr?: string;
plz?: string;
perimeter?: any;
bauzone?: string;
_createdAt?: number;
_modifiedAt?: number;
[key: string]: any;
}
export interface PaginationParams {
page?: number;
pageSize?: number;
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>;
search?: string;
}
export interface PaginatedResponse<T> {
items: T[];
pagination?: {
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
sort?: Array<{ field: string; direction: string }>;
filters?: Record<string, any>;
};
}
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
// ============================================================================
// HELPER FUNCTIONS (instanceId-based CRUD)
// ============================================================================
function _getRealEstateBaseUrl(instanceId: string): string {
return `/api/realestate/${instanceId}`;
}
function _buildPaginationParams(params?: PaginationParams): Record<string, string | number | boolean> {
if (!params) return {};
const paginationObj: Record<string, unknown> = {};
if (params.page !== undefined) paginationObj.page = params.page;
if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search;
if (Object.keys(paginationObj).length === 0) return {};
return { pagination: JSON.stringify(paginationObj) } as Record<string, string | number | boolean>;
}
// ============================================================================
// PROJECTS CRUD (instanceId-based)
// ============================================================================
export async function fetchProjects(
request: ApiRequestFunction,
instanceId: string,
params?: PaginationParams
): Promise<PaginatedResponse<RealEstateProject>> {
return await request({
url: `${_getRealEstateBaseUrl(instanceId)}/projects`,
method: 'get',
params: _buildPaginationParams(params)
});
}
export async function fetchProjectById(
request: ApiRequestFunction,
instanceId: string,
id: string
): Promise<RealEstateProject | null> {
try {
return await request({
url: `${_getRealEstateBaseUrl(instanceId)}/projects/${id}`,
method: 'get'
});
} catch {
return null;
}
}
export async function createProject(
request: ApiRequestFunction,
instanceId: string,
data: Partial<RealEstateProject>
): Promise<RealEstateProject> {
return await request({
url: `${_getRealEstateBaseUrl(instanceId)}/projects`,
method: 'post',
data
});
}
export async function updateProject(
request: ApiRequestFunction,
instanceId: string,
id: string,
data: Partial<RealEstateProject>
): Promise<RealEstateProject> {
return await request({
url: `${_getRealEstateBaseUrl(instanceId)}/projects/${id}`,
method: 'put',
data
});
}
export async function deleteProject(
request: ApiRequestFunction,
instanceId: string,
id: string
): Promise<void> {
await request({
url: `${_getRealEstateBaseUrl(instanceId)}/projects/${id}`,
method: 'delete'
});
}
// ============================================================================
// PARCELS CRUD (instanceId-based)
// ============================================================================
export async function fetchParcels(
request: ApiRequestFunction,
instanceId: string,
params?: PaginationParams
): Promise<PaginatedResponse<RealEstateParcel>> {
return await request({
url: `${_getRealEstateBaseUrl(instanceId)}/parcels`,
method: 'get',
params: _buildPaginationParams(params)
});
}
export async function fetchParcelById(
request: ApiRequestFunction,
instanceId: string,
id: string
): Promise<RealEstateParcel | null> {
try {
return await request({
url: `${_getRealEstateBaseUrl(instanceId)}/parcels/${id}`,
method: 'get'
});
} catch {
return null;
}
}
export async function createParcel(
request: ApiRequestFunction,
instanceId: string,
data: Partial<RealEstateParcel>
): Promise<RealEstateParcel> {
return await request({
url: `${_getRealEstateBaseUrl(instanceId)}/parcels`,
method: 'post',
data
});
}
export async function updateParcel(
request: ApiRequestFunction,
instanceId: string,
id: string,
data: Partial<RealEstateParcel>
): Promise<RealEstateParcel> {
return await request({
url: `${_getRealEstateBaseUrl(instanceId)}/parcels/${id}`,
method: 'put',
data
});
}
export async function deleteParcel(
request: ApiRequestFunction,
instanceId: string,
id: string
): Promise<void> {
await request({
url: `${_getRealEstateBaseUrl(instanceId)}/parcels/${id}`,
method: 'delete'
});
}
// ============================================================================
// ADDRESS AUTOCOMPLETE (legacy, no instanceId)
// ============================================================================
/**
* Get address autocomplete suggestions for Swiss addresses
* Endpoint: GET /api/realestate/address/autocomplete
*
* @param query - Search text (minimum 2 characters)
* @param limit - Maximum number of results (default: 10, max: 20)
* @returns Array of address suggestions
*/
export async function autocompleteAddress(
query: string,
limit: number = 10
): Promise<AddressSuggestion[]> {
if (query.length < 2) {
return [];
}
try {
const trimmedQuery = query.trim();
const requestParams = {
query: trimmedQuery,
limit: Math.min(Math.max(limit, 1), 20) // Clamp between 1 and 20
};
if (import.meta.env.DEV) {
console.log('🔍 [AddressAutocomplete] Requesting suggestions:', {
query: trimmedQuery,
limit: requestParams.limit,
url: '/api/realestate/address/autocomplete'
});
}
const response = await api.get<AddressSuggestion[]>('/api/realestate/address/autocomplete', {
params: requestParams
});
const results = response.data || [];
if (import.meta.env.DEV) {
console.log('✅ [AddressAutocomplete] Received suggestions:', {
count: results.length,
results: results.slice(0, 3) // Log first 3 for debugging
});
}
return results;
} catch (error: any) {
// Detailed error logging
const errorDetails: any = {
message: error?.message || 'Unknown error',
query: query.trim(),
limit: limit
};
if (error?.response) {
// HTTP error response
errorDetails.status = error.response.status;
errorDetails.statusText = error.response.statusText;
errorDetails.data = error.response.data;
errorDetails.headers = error.response.headers;
console.error('❌ [AddressAutocomplete] API Error Response:', {
status: errorDetails.status,
statusText: errorDetails.statusText,
detail: errorDetails.data?.detail || errorDetails.data,
url: error.config?.url,
method: error.config?.method
});
} else if (error?.request) {
// Request made but no response received
errorDetails.requestError = true;
console.error('❌ [AddressAutocomplete] Network Error - No response received:', {
message: error.message,
url: error.config?.url
});
} else {
// Error setting up request
console.error('❌ [AddressAutocomplete] Request Setup Error:', errorDetails);
}
// Log full error in dev mode
if (import.meta.env.DEV) {
console.error('❌ [AddressAutocomplete] Full error object:', error);
}
// Return empty array on error to allow graceful degradation
return [];
}
}

View file

@ -0,0 +1,348 @@
import { useState, useEffect } from 'react';
import { IoIosDownload } from 'react-icons/io';
import { Popup, PopupAction } from '../UiComponents/Popup/Popup';
import { useLanguage } from '../../providers/language/LanguageContext';
import { PdfRenderer, PdfJsRenderer, LoadingRenderer, ErrorRenderer } from './renderers';
import styles from './ContentPreview.module.css';
export interface UrlContentPreviewProps {
isOpen: boolean;
onClose: () => void;
url: string;
fileName: string;
mimeType?: string;
}
export function UrlContentPreview({
isOpen,
onClose,
url,
fileName,
mimeType = 'application/pdf'
}: UrlContentPreviewProps) {
const { t } = useLanguage();
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [hasLoaded, setHasLoaded] = useState(false);
const [warning, setWarning] = useState<string | null>(null);
const [showPdfAnyway, setShowPdfAnyway] = useState(false);
const [usePdfJs, setUsePdfJs] = useState(false);
// Reset state when modal opens/closes
useEffect(() => {
if (isOpen && url) {
setIsLoading(true);
setError(null);
setWarning(null);
setHasLoaded(false);
setShowPdfAnyway(false);
setUsePdfJs(false); // Start with iframe
} else {
setIsLoading(false);
setError(null);
setWarning(null);
setHasLoaded(false);
setShowPdfAnyway(false);
setUsePdfJs(false);
}
}, [isOpen, url]);
const handleDownload = () => {
try {
const link = document.createElement('a');
link.href = url;
link.download = fileName;
link.target = '_blank';
link.rel = 'noopener noreferrer';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (err) {
console.error('Failed to download file:', err);
// Fallback: open in new tab
window.open(url, '_blank', 'noopener,noreferrer');
}
};
const handlePdfLoad = () => {
setIsLoading(false);
setHasLoaded(true);
setError(null);
};
const handlePdfError = () => {
// Try PDF.js as fallback instead of showing error immediately
if (!usePdfJs) {
console.log('Iframe failed, switching to PDF.js fallback');
setUsePdfJs(true);
setIsLoading(true); // Restart loading with PDF.js
setError(null);
setWarning(null);
return;
}
// If PDF.js also fails, show error
setIsLoading(false);
setError('Failed to load PDF. This might be due to CORS restrictions. You can try downloading the file or opening it in a new tab.');
setShowPdfAnyway(true);
};
const handleOpenInNewTab = () => {
window.open(url, '_blank', 'noopener,noreferrer');
};
// Set up progressive timeout for loading (schnellerer Fallback)
useEffect(() => {
if (isOpen && isLoading && !hasLoaded) {
// Schnellerer Timeout für externe PDFs: Warning after 3s, Error after 5s
const QUICK_TIMEOUT = 5000; // 5 Sekunden
const WARNING_TIMEOUT = 3000; // 3 Sekunden Warnung
const warningTimeout = setTimeout(() => {
if (isLoading && !hasLoaded) {
setWarning('PDF lädt langsam. Sie können es auch direkt herunterladen oder in einem neuen Tab öffnen.');
// Don't set isLoading to false - let it continue
}
}, WARNING_TIMEOUT);
const errorTimeout = setTimeout(() => {
if (isLoading && !hasLoaded && !usePdfJs) {
// Try PDF.js as fallback after 5 seconds
console.log('PDF loading timeout, switching to PDF.js fallback');
setUsePdfJs(true);
setIsLoading(true); // Restart loading with PDF.js
setWarning('PDF lädt langsam. Versuche alternative Anzeigemethode...');
} else if (isLoading && !hasLoaded && usePdfJs) {
// PDF.js also failed, show error
setShowPdfAnyway(true);
setError('PDF lädt langsam. Bitte verwenden Sie den Download-Button oder öffnen Sie es in einem neuen Tab.');
setIsLoading(false);
}
}, QUICK_TIMEOUT);
return () => {
clearTimeout(warningTimeout);
clearTimeout(errorTimeout);
};
}
}, [isOpen, isLoading, hasLoaded, usePdfJs]);
// Validate URL
useEffect(() => {
if (isOpen && url) {
try {
new URL(url);
} catch (e) {
setError('Invalid URL');
setIsLoading(false);
}
}
}, [isOpen, url]);
// Create action buttons for the popup header
const actions: PopupAction[] = [
{
label: String(''),
icon: <IoIosDownload />,
onClick: handleDownload,
disabled: false,
variant: 'success' as const
}
];
const renderPreview = () => {
// Show warning but continue loading
const showWarning = warning && !error;
// For PDF files, always try to show PDF (even if there's an error)
if (mimeType === 'application/pdf' && (hasLoaded || showPdfAnyway || !error)) {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{showWarning && (
<div style={{
padding: '0.75rem 1rem',
background: 'var(--color-warning-bg, #fef3c7)',
borderBottom: '1px solid var(--color-border, #e5e7eb)',
color: 'var(--color-warning-text, #92400e)',
fontSize: '0.875rem'
}}>
{warning}
</div>
)}
{error && (
<div style={{
padding: '0.75rem 1rem',
background: 'var(--color-error-bg, #fee2e2)',
borderBottom: '1px solid var(--color-border, #e5e7eb)',
color: 'var(--color-error-text, #991b1b)',
fontSize: '0.875rem'
}}>
{error}
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem', flexWrap: 'wrap' }}>
<button
onClick={handleOpenInNewTab}
className={styles.retryButton}
style={{
background: 'var(--color-primary, #3b82f6)',
fontSize: '0.75rem',
padding: '0.5rem 1rem'
}}
>
In neuem Tab öffnen
</button>
<button
onClick={handleDownload}
className={styles.retryButton}
style={{
background: 'var(--color-success, #10b981)',
fontSize: '0.75rem',
padding: '0.5rem 1rem'
}}
>
Download
</button>
</div>
</div>
)}
<div style={{ flex: 1, position: 'relative' }}>
<PdfRenderer
previewUrl={url}
fileName={fileName}
onError={handlePdfError}
onLoad={handlePdfLoad}
/>
</div>
</div>
);
}
// Show error only if we're not showing PDF anyway
if (error && !showPdfAnyway) {
return (
<div className={styles.errorContainer}>
<div className={styles.errorIcon}></div>
<p>{error}</p>
<div style={{ display: 'flex', gap: '1rem', marginTop: '1rem', flexWrap: 'wrap' }}>
<button
onClick={() => {
setError(null);
setWarning(null);
setIsLoading(true);
setHasLoaded(false);
setShowPdfAnyway(false);
setUsePdfJs(false); // Reset to iframe
}}
className={styles.retryButton}
>
{t('common.retry', 'Retry')}
</button>
<button
onClick={handleOpenInNewTab}
className={styles.retryButton}
style={{
background: 'var(--color-primary, #3b82f6)',
fontSize: '0.875rem',
padding: '0.625rem 1.25rem',
fontWeight: '500'
}}
>
In neuem Tab öffnen
</button>
<button
onClick={handleDownload}
className={styles.retryButton}
style={{
background: 'var(--color-success, #10b981)',
fontSize: '0.875rem',
padding: '0.625rem 1.25rem',
fontWeight: '500'
}}
>
Download File
</button>
</div>
</div>
);
}
if (isLoading && !hasLoaded && !showPdfAnyway) {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{warning && (
<div style={{
padding: '1rem',
background: 'var(--color-warning-bg, #fef3c7)',
borderBottom: '1px solid var(--color-border, #e5e7eb)',
color: 'var(--color-warning-text, #92400e)',
fontSize: '0.875rem',
marginBottom: '1rem'
}}>
{warning}
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.75rem', flexWrap: 'wrap' }}>
<button
onClick={handleOpenInNewTab}
className={styles.retryButton}
style={{
background: 'var(--color-primary, #3b82f6)',
fontSize: '0.875rem',
padding: '0.625rem 1.25rem',
fontWeight: '500'
}}
>
In neuem Tab öffnen
</button>
<button
onClick={handleDownload}
className={styles.retryButton}
style={{
background: 'var(--color-success, #10b981)',
fontSize: '0.875rem',
padding: '0.625rem 1.25rem',
fontWeight: '500'
}}
>
Download
</button>
</div>
</div>
)}
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<LoadingRenderer />
</div>
</div>
);
}
// For other file types, show unsupported message
if (mimeType !== 'application/pdf') {
return (
<div className={styles.unsupportedContainer}>
<div className={styles.unsupportedIcon}>📄</div>
<div className={styles.fileName}>{fileName}</div>
<p>Preview not supported for this file type. Please download the file to view it.</p>
<button onClick={handleDownload} className={styles.retryButton}>
Download File
</button>
</div>
);
}
return null;
};
return (
<Popup
isOpen={isOpen}
onClose={onClose}
title={`${t('files.preview.title', 'Content Preview')}: ${fileName}`}
size="fullscreen"
className={styles.contentPreviewPopup}
actions={actions}
>
<div className={styles.previewContainer}>
{renderPreview()}
</div>
</Popup>
);
}
export default UrlContentPreview;

View file

@ -0,0 +1,238 @@
import { useEffect, useRef, useState } from 'react';
import * as pdfjsLib from 'pdfjs-dist';
import styles from '../ContentPreview.module.css';
// Set worker source for PDF.js
if (typeof window !== 'undefined') {
// Try to use local worker first, fallback to CDN
try {
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'../../../../node_modules/pdfjs-dist/build/pdf.worker.min.js',
import.meta.url
).toString();
} catch (e) {
// Fallback to CDN if local worker not available
pdfjsLib.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.js`;
}
}
interface PdfJsRendererProps {
previewUrl: string;
fileName: string;
onError: () => void;
onLoad?: () => void;
}
export function PdfJsRenderer({ previewUrl, fileName, onError, onLoad }: PdfJsRendererProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [numPages, setNumPages] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [scale, setScale] = useState(1.5);
useEffect(() => {
let pdfDoc: pdfjsLib.PDFDocumentProxy | null = null;
let isMounted = true;
const loadPdf = async () => {
try {
setIsLoading(true);
setError(null);
// Load PDF using fetch (like download)
const response = await fetch(previewUrl);
if (!response.ok) {
throw new Error(`Failed to fetch PDF: ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
pdfDoc = await loadingTask.promise;
if (!isMounted) return;
setNumPages(pdfDoc.numPages);
setIsLoading(false);
if (onLoad) {
onLoad();
}
} catch (err) {
console.error('Error loading PDF with PDF.js:', err);
if (isMounted) {
setError(err instanceof Error ? err.message : 'Failed to load PDF');
setIsLoading(false);
onError();
}
}
};
loadPdf();
return () => {
isMounted = false;
};
}, [previewUrl, onLoad, onError]);
useEffect(() => {
if (!canvasRef.current || isLoading || error) return;
let isMounted = true;
const renderPage = async (pageNum: number) => {
try {
// Load PDF again for rendering (could be optimized with caching)
const response = await fetch(previewUrl);
const arrayBuffer = await response.arrayBuffer();
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
const pdfDoc = await loadingTask.promise;
if (!isMounted) return;
const page = await pdfDoc.getPage(pageNum);
const viewport = page.getViewport({ scale });
const canvas = canvasRef.current;
if (!canvas) return;
canvas.height = viewport.height;
canvas.width = viewport.width;
const context = canvas.getContext('2d');
if (!context) return;
const renderContext = {
canvasContext: context,
viewport: viewport,
};
await page.render(renderContext).promise;
} catch (err) {
console.error('Error rendering PDF page:', err);
if (isMounted) {
setError(err instanceof Error ? err.message : 'Failed to render PDF page');
}
}
};
renderPage(currentPage);
return () => {
isMounted = false;
};
}, [previewUrl, currentPage, scale, isLoading, error]);
if (error) {
return (
<div className={styles.errorContainer}>
<div className={styles.errorIcon}></div>
<p>Fehler beim Laden der PDF: {error}</p>
</div>
);
}
if (isLoading) {
return (
<div className={styles.loadingContainer}>
<div className={styles.spinner}></div>
<p>PDF wird geladen...</p>
</div>
);
}
return (
<div ref={containerRef} style={{ width: '100%', height: '100%', overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
{/* Navigation Controls */}
{numPages > 1 && (
<div style={{
padding: '0.75rem 1rem',
background: 'var(--color-background-secondary, #f3f4f6)',
borderBottom: '1px solid var(--color-border, #e5e7eb)',
display: 'flex',
alignItems: 'center',
gap: '1rem',
flexWrap: 'wrap'
}}>
<button
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
style={{
padding: '0.5rem 1rem',
background: 'var(--color-primary, #3b82f6)',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: currentPage === 1 ? 'not-allowed' : 'pointer',
opacity: currentPage === 1 ? 0.5 : 1
}}
>
Zurück
</button>
<span style={{ fontSize: '0.875rem', color: 'var(--color-text, #1f2937)' }}>
Seite {currentPage} von {numPages}
</span>
<button
onClick={() => setCurrentPage(prev => Math.min(numPages, prev + 1))}
disabled={currentPage === numPages}
style={{
padding: '0.5rem 1rem',
background: 'var(--color-primary, #3b82f6)',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: currentPage === numPages ? 'not-allowed' : 'pointer',
opacity: currentPage === numPages ? 0.5 : 1
}}
>
Weiter
</button>
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<button
onClick={() => setScale(prev => Math.max(0.5, prev - 0.25))}
style={{
padding: '0.25rem 0.75rem',
background: 'var(--color-background, #ffffff)',
border: '1px solid var(--color-border, #e5e7eb)',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '0.75rem'
}}
>
-
</button>
<span style={{ fontSize: '0.75rem', minWidth: '3rem', textAlign: 'center' }}>
{Math.round(scale * 100)}%
</span>
<button
onClick={() => setScale(prev => Math.min(3, prev + 0.25))}
style={{
padding: '0.25rem 0.75rem',
background: 'var(--color-background, #ffffff)',
border: '1px solid var(--color-border, #e5e7eb)',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '0.75rem'
}}
>
+
</button>
</div>
</div>
)}
{/* PDF Canvas */}
<div style={{ flex: 1, display: 'flex', justifyContent: 'center', alignItems: 'flex-start', padding: '1rem', overflow: 'auto' }}>
<canvas
ref={canvasRef}
style={{
maxWidth: '100%',
height: 'auto',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
background: 'white'
}}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,240 @@
/**
* MandateNavigation
*
* Hierarchische Navigation für das Multi-Tenant-System.
* Verwendet TreeNavigation für flexible Baumstruktur.
*
* Navigation wird vollständig vom Backend geladen (/api/navigation).
* Backend liefert Blocks-Struktur mit Static und Dynamic Blocks.
* UI mappt uiComponent zu Icons via pageRegistry.
*
* Struktur (gemäss Navigation-API-Konzept):
* - SYSTEM (static block, order: 10)
* - MEINE FEATURES (dynamic block, order: 15)
* - Mandant 1
* - Feature A
* - Instanz 1 (mit Views)
* - WORKFLOWS (static block, order: 20)
* - BASISDATEN (static block, order: 30)
* - MIGRATE TO FEATURES (static block, order: 40)
* - ADMINISTRATION (static block, order: 200)
*/
import React, { useMemo } from 'react';
import { useNavigation } from '../../hooks/useNavigation';
import type {
StaticBlock,
DynamicBlock,
NavigationItem,
NavigationMandate,
MandateFeature,
FeatureInstance,
FeatureView
} from '../../hooks/useNavigation';
import { getPageIcon } from '../../config/pageRegistry';
import { FaSpinner } from 'react-icons/fa';
import { TreeNavigation, type TreeItem, type TreeNodeItem } from './TreeNavigation';
import styles from './MandateNavigation.module.css';
// =============================================================================
// HELPER FUNCTIONS - Convert API blocks to TreeItems
// =============================================================================
/**
* Convert a NavigationItem (from static block) to TreeNodeItem
*/
function navigationItemToTreeNode(item: NavigationItem): TreeNodeItem {
return {
id: item.objectKey,
label: item.uiLabel,
icon: getPageIcon(item.uiComponent),
path: item.uiPath,
};
}
/**
* Convert a StaticBlock to TreeItem (section)
*/
function staticBlockToTreeItem(block: StaticBlock): TreeItem {
return {
type: 'section',
title: block.title,
children: block.items.map(navigationItemToTreeNode),
};
}
/**
* Convert a FeatureView to TreeNodeItem
*/
function featureViewToTreeNode(view: FeatureView): TreeNodeItem {
return {
id: view.objectKey,
label: view.uiLabel,
path: view.uiPath,
};
}
/**
* Convert a FeatureInstance to TreeNodeItem
*/
function featureInstanceToTreeNode(instance: FeatureInstance): TreeNodeItem {
return {
id: instance.id,
label: instance.uiLabel,
path: instance.views.length > 0 ? instance.views[0].uiPath : undefined,
children: instance.views.map(featureViewToTreeNode),
defaultExpanded: false,
};
}
/**
* Convert a MandateFeature to TreeNodeItem
*/
function mandateFeatureToTreeNode(feature: MandateFeature): TreeNodeItem | null {
if (feature.instances.length === 0) {
return null;
}
return {
id: feature.uiComponent,
label: feature.uiLabel,
icon: getPageIcon(feature.uiComponent),
badge: feature.instances.length,
path: feature.instances.length > 0 && feature.instances[0].views.length > 0
? feature.instances[0].views[0].uiPath
: undefined,
children: feature.instances.map(featureInstanceToTreeNode),
defaultExpanded: false,
};
}
/**
* Convert a NavigationMandate to TreeNodeItem
*/
function navigationMandateToTreeNode(mandate: NavigationMandate): TreeNodeItem | null {
if (mandate.features.length === 0) {
return null;
}
const children = mandate.features
.map(mandateFeatureToTreeNode)
.filter((node): node is TreeNodeItem => node !== null);
if (children.length === 0) {
return null;
}
return {
id: mandate.id,
label: mandate.uiLabel,
children,
defaultExpanded: true,
};
}
/**
* Convert a DynamicBlock to array of TreeNodeItems (mandate nodes)
*/
function dynamicBlockToTreeNodes(block: DynamicBlock): TreeNodeItem[] {
return block.mandates
.map(navigationMandateToTreeNode)
.filter((node): node is TreeNodeItem => node !== null);
}
// =============================================================================
// LOADING STATE
// =============================================================================
const LoadingState: React.FC = () => (
<div className={styles.loadingState}>
<FaSpinner className={styles.spinner} />
<span>Navigation wird geladen...</span>
</div>
);
// =============================================================================
// EMPTY STATE
// =============================================================================
const EmptyState: React.FC = () => (
<div className={styles.emptyState}>
<p>Keine Feature-Instanzen verfügbar.</p>
<p className={styles.emptyHint}>
Kontaktiere einen Administrator, um Zugriff zu erhalten.
</p>
</div>
);
// =============================================================================
// MAIN COMPONENT
// =============================================================================
export const MandateNavigation: React.FC = () => {
// Fetch navigation from new API (blocks structure, already filtered by permissions)
const { blocks, loading } = useNavigation('de');
// Build navigation items from blocks
const navigationItems: TreeItem[] = useMemo(() => {
const items: TreeItem[] = [];
// Process blocks in order (already sorted by backend)
for (const block of blocks) {
if (block.type === 'static') {
// Static block: system, workflows, basedata, migrate, admin
if (block.items.length > 0) {
// Add separator before admin block
if (block.id === 'admin') {
items.push({ type: 'separator' });
}
items.push(staticBlockToTreeItem(block));
}
} else if (block.type === 'dynamic') {
// Dynamic block: features/mandates
// Add separator before dynamic block
items.push({ type: 'separator' });
const mandateNodes = dynamicBlockToTreeNodes(block);
if (mandateNodes.length > 0) {
items.push(...mandateNodes);
}
// Add separator after dynamic block (before next static blocks)
items.push({ type: 'separator' });
}
}
// Remove trailing separator if present
while (items.length > 0 && (items[items.length - 1] as TreeItem & { type?: string }).type === 'separator') {
items.pop();
}
return items;
}, [blocks]);
// Check if user has any navigation (static or dynamic)
const hasNavigation = blocks.length > 0;
// Show loading state while navigation is being fetched
if (loading) {
return (
<div className={styles.navigation}>
<LoadingState />
</div>
);
}
return (
<div className={styles.navigation}>
{hasNavigation ? (
<TreeNavigation
items={navigationItems}
autoExpandActive={true}
/>
) : (
<EmptyState />
)}
</div>
);
};
export default MandateNavigation;

View file

@ -0,0 +1,211 @@
.autocompleteContainer {
position: relative;
width: 100%;
}
.suggestionsWrapper {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
z-index: 1000;
max-height: 300px;
overflow: hidden;
border-radius: 12px;
/* Glassmorphism effect */
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.1),
0 0 0 1px rgba(255, 255, 255, 0.2) inset;
}
/* Dark theme support */
[data-theme="dark"] .suggestionsWrapper {
background: rgba(30, 30, 30, 0.85);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.3),
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
}
.suggestionsList {
list-style: none;
margin: 0;
padding: 8px;
max-height: 300px;
overflow-y: auto;
overflow-x: hidden;
}
.suggestionItem {
padding: 12px 16px;
cursor: pointer;
border-radius: 8px;
transition: all 0.2s ease;
margin-bottom: 4px;
position: relative;
/* Subtle background for better visibility */
background: rgba(255, 255, 255, 0.5);
}
.suggestionItem:last-child {
margin-bottom: 0;
}
.suggestionItem:hover {
background: rgba(59, 130, 246, 0.1);
transform: translateX(2px);
}
.suggestionItemSelected {
background: rgba(59, 130, 246, 0.15) !important;
/* Glow effect for selected item */
box-shadow:
0 0 12px rgba(59, 130, 246, 0.4),
0 0 24px rgba(59, 130, 246, 0.2),
inset 0 0 8px rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.3);
transform: translateX(4px);
}
/* Dark theme adjustments */
[data-theme="dark"] .suggestionItem {
background: rgba(255, 255, 255, 0.05);
}
[data-theme="dark"] .suggestionItem:hover {
background: rgba(59, 130, 246, 0.2);
}
[data-theme="dark"] .suggestionItemSelected {
background: rgba(59, 130, 246, 0.25) !important;
box-shadow:
0 0 16px rgba(59, 130, 246, 0.5),
0 0 32px rgba(59, 130, 246, 0.3),
inset 0 0 12px rgba(59, 130, 246, 0.15);
border: 1px solid rgba(59, 130, 246, 0.4);
}
.suggestionText {
display: block;
color: var(--color-text, #111827);
font-size: 14px;
line-height: 1.5;
word-break: break-word;
}
[data-theme="dark"] .suggestionText {
color: var(--color-text, #f9fafb);
}
.highlight {
background: rgba(59, 130, 246, 0.2);
color: var(--color-primary, #3b82f6);
font-weight: 600;
padding: 0 2px;
border-radius: 2px;
}
[data-theme="dark"] .highlight {
background: rgba(59, 130, 246, 0.3);
color: #60a5fa;
}
.loadingText,
.noResultsText {
display: block;
padding: 12px 16px;
color: var(--color-text-secondary, #6b7280);
font-size: 14px;
text-align: center;
font-style: italic;
}
.errorText {
display: block;
padding: 12px 16px;
color: #ef4444;
font-size: 14px;
text-align: center;
font-weight: 500;
}
[data-theme="dark"] .loadingText,
[data-theme="dark"] .noResultsText {
color: var(--color-text-secondary, #9ca3af);
}
[data-theme="dark"] .errorText {
color: #f87171;
}
/* Scrollbar styling */
.suggestionsList::-webkit-scrollbar {
width: 8px;
}
.suggestionsList::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.05);
border-radius: 4px;
}
.suggestionsList::-webkit-scrollbar-thumb {
background: rgba(59, 130, 246, 0.3);
border-radius: 4px;
transition: background 0.2s;
}
.suggestionsList::-webkit-scrollbar-thumb:hover {
background: rgba(59, 130, 246, 0.5);
}
[data-theme="dark"] .suggestionsList::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
}
[data-theme="dark"] .suggestionsList::-webkit-scrollbar-thumb {
background: rgba(59, 130, 246, 0.4);
}
[data-theme="dark"] .suggestionsList::-webkit-scrollbar-thumb:hover {
background: rgba(59, 130, 246, 0.6);
}
/* Responsive design */
@media (max-width: 640px) {
.suggestionsWrapper {
border-radius: 8px;
max-height: 250px;
}
.suggestionItem {
padding: 10px 12px;
}
.suggestionText {
font-size: 13px;
}
}
/* Animation for dropdown appearance */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.suggestionsWrapper {
animation: fadeIn 0.2s ease-out;
}

View file

@ -0,0 +1,309 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import TextField, { BaseTextFieldProps } from '../TextField/TextField';
import { autocompleteAddress, AddressSuggestion } from '../../../api/realEstateApi';
import styles from './AddressAutocomplete.module.css';
interface AddressAutocompleteProps extends BaseTextFieldProps {
onSelect?: (suggestion: AddressSuggestion) => void;
debounceMs?: number;
minQueryLength?: number;
maxSuggestions?: number;
}
const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({
value = '',
onChange,
onSelect,
placeholder,
disabled = false,
required = false,
readonly = false,
size = 'md',
error,
helperText,
label,
className = '',
type = 'text',
name,
id,
onKeyDown,
debounceMs = 300,
minQueryLength = 2,
maxSuggestions = 10,
...props
}) => {
const [suggestions, setSuggestions] = useState<AddressSuggestion[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [showSuggestions, setShowSuggestions] = useState(false);
const [query, setQuery] = useState(value);
const [autocompleteError, setAutocompleteError] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const suggestionsRef = useRef<HTMLUListElement>(null);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
// Sync query with value prop
useEffect(() => {
setQuery(value);
}, [value]);
// Debounced search function
const performSearch = useCallback(async (searchQuery: string) => {
if (searchQuery.length < minQueryLength) {
if (import.meta.env.DEV) {
console.log('🔍 [AddressAutocomplete] Query too short:', {
query: searchQuery,
length: searchQuery.length,
minLength: minQueryLength
});
}
setSuggestions([]);
setShowSuggestions(false);
setIsLoading(false);
return;
}
if (import.meta.env.DEV) {
console.log('🔍 [AddressAutocomplete] Starting search:', {
query: searchQuery,
length: searchQuery.length,
maxSuggestions: maxSuggestions
});
}
setIsLoading(true);
setAutocompleteError(null); // Clear previous errors
try {
const results = await autocompleteAddress(searchQuery, maxSuggestions);
if (import.meta.env.DEV) {
console.log('✅ [AddressAutocomplete] Search completed:', {
query: searchQuery,
resultCount: results.length,
results: results.slice(0, 3) // Log first 3
});
}
setSuggestions(results);
setShowSuggestions(results.length > 0 || true); // Show dropdown even if empty to show "no results" or error
setSelectedIndex(-1);
setAutocompleteError(null); // Clear any previous errors on success
} catch (err: any) {
console.error('❌ [AddressAutocomplete] Error in performSearch:', err);
const errorMessage = err?.response?.data?.detail || err?.message || 'Fehler beim Laden der Adressvorschläge';
setAutocompleteError(errorMessage);
setSuggestions([]);
setShowSuggestions(true); // Show dropdown to display error
setSelectedIndex(-1);
} finally {
setIsLoading(false);
}
}, [minQueryLength, maxSuggestions]);
// Handle input change with debouncing
const handleInputChange = useCallback((newValue: string) => {
if (import.meta.env.DEV) {
console.log('⌨️ [AddressAutocomplete] Input changed:', {
newValue: newValue,
length: newValue.length,
willSearch: newValue.length >= minQueryLength
});
}
setQuery(newValue);
setAutocompleteError(null); // Clear error on new input
// Update parent component immediately
if (onChange) {
onChange(newValue);
}
// Clear existing timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
// Set new timer for debounced search
debounceTimerRef.current = setTimeout(() => {
if (import.meta.env.DEV) {
console.log('⏱️ [AddressAutocomplete] Debounce timer fired, calling performSearch');
}
performSearch(newValue);
}, debounceMs);
}, [onChange, debounceMs, performSearch, minQueryLength]);
// Handle suggestion selection
const handleSelectSuggestion = useCallback((suggestion: AddressSuggestion) => {
setQuery(suggestion.value);
setShowSuggestions(false);
setSelectedIndex(-1);
if (onChange) {
onChange(suggestion.value);
}
if (onSelect) {
onSelect(suggestion);
}
}, [onChange, onSelect]);
// Handle keyboard navigation
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
if (!showSuggestions || suggestions.length === 0) {
if (onKeyDown) {
onKeyDown(e);
}
return;
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex(prev =>
prev < suggestions.length - 1 ? prev + 1 : prev
);
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex(prev => prev > 0 ? prev - 1 : -1);
break;
case 'Enter':
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
e.preventDefault();
handleSelectSuggestion(suggestions[selectedIndex]);
} else if (onKeyDown) {
onKeyDown(e);
}
break;
case 'Escape':
e.preventDefault();
setShowSuggestions(false);
setSelectedIndex(-1);
break;
default:
if (onKeyDown) {
onKeyDown(e);
}
}
}, [showSuggestions, suggestions, selectedIndex, handleSelectSuggestion, onKeyDown]);
// Click outside handler
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setShowSuggestions(false);
setSelectedIndex(-1);
}
};
if (showSuggestions) {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}
}, [showSuggestions]);
// Scroll selected item into view
useEffect(() => {
if (selectedIndex >= 0 && suggestionsRef.current) {
const selectedElement = suggestionsRef.current.children[selectedIndex] as HTMLElement;
if (selectedElement) {
selectedElement.scrollIntoView({
block: 'nearest',
behavior: 'smooth'
});
}
}
}, [selectedIndex]);
// Highlight matching text in suggestion
const highlightText = (text: string, query: string): React.ReactNode => {
if (!query || query.length < minQueryLength) {
return text;
}
const parts = text.split(new RegExp(`(${query})`, 'gi'));
return (
<>
{parts.map((part, index) =>
part.toLowerCase() === query.toLowerCase() ? (
<mark key={index} className={styles.highlight}>{part}</mark>
) : (
<span key={index}>{part}</span>
)
)}
</>
);
};
// Cleanup on unmount
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
return (
<div ref={containerRef} className={`${styles.autocompleteContainer} ${className}`}>
<TextField
value={query}
onChange={handleInputChange}
placeholder={placeholder}
disabled={disabled}
required={required}
readonly={readonly}
size={size}
error={undefined}
helperText={helperText}
label={label}
type={type}
name={name}
id={id}
onKeyDown={handleKeyDown}
{...props}
/>
{showSuggestions && (
<div className={styles.suggestionsWrapper}>
<ul ref={suggestionsRef} className={styles.suggestionsList}>
{isLoading && (
<li className={styles.suggestionItem}>
<span className={styles.loadingText}>Suche Adressen...</span>
</li>
)}
{!isLoading && autocompleteError && (
<li className={styles.suggestionItem}>
<span className={styles.errorText}>{autocompleteError}</span>
</li>
)}
{!isLoading && !autocompleteError && suggestions.length === 0 && query.length >= minQueryLength && (
<li className={styles.suggestionItem}>
<span className={styles.noResultsText}>Keine Adressen gefunden</span>
</li>
)}
{!isLoading && suggestions.map((suggestion, index) => (
<li
key={`${suggestion.value}-${index}`}
className={`${styles.suggestionItem} ${
index === selectedIndex ? styles.suggestionItemSelected : ''
}`}
onClick={() => handleSelectSuggestion(suggestion)}
onMouseEnter={() => setSelectedIndex(index)}
>
<span className={styles.suggestionText}>
{highlightText(suggestion.label, query)}
</span>
</li>
))}
</ul>
</div>
)}
</div>
);
};
export default AddressAutocomplete;

View file

@ -0,0 +1,2 @@
export { default } from './AddressAutocomplete';
export type { AddressSuggestion } from '../../../api/realEstateApi';

View file

@ -0,0 +1,127 @@
/* Bauvorschriften Section Styles */
.bauvorschriftenSection {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border, #e5e7eb);
}
.bauvorschriftenHeader {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
padding: 0.5rem 0;
margin-bottom: 0.75rem;
transition: opacity 0.2s;
}
.bauvorschriftenHeader:hover {
opacity: 0.8;
}
.bauvorschriftenHeader .subSectionTitle {
margin: 0;
display: flex;
align-items: center;
color: var(--color-accent, #10b981);
font-size: 1rem;
font-weight: 600;
}
.expandButton {
background: none;
border: none;
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-secondary, #6b7280);
transition: color 0.2s;
}
.expandButton:hover {
color: var(--color-text, #111827);
}
.bauvorschriftenContent {
display: flex;
flex-direction: column;
gap: 1rem;
}
.bauvorschriftenGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
margin-top: 0.5rem;
}
.bauvorschriftItem {
padding: 0.75rem;
background: linear-gradient(135deg, rgba(243, 244, 246, 0.8) 0%, rgba(249, 250, 251, 0.8) 100%);
backdrop-filter: blur(10px);
border-radius: 8px;
border-left: 3px solid var(--color-accent, #10b981);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
transition: transform 0.2s, box-shadow 0.2s;
}
.bauvorschriftItem:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(16, 185, 129, 0.2);
}
.bauvorschriftItem .label {
display: block;
font-size: 0.75rem;
font-weight: 500;
color: var(--color-text-secondary, #6b7280);
margin-bottom: 0.25rem;
}
.bauvorschriftItem .value {
display: block;
font-size: 1rem;
font-weight: 600;
color: var(--color-text, #111827);
}
.sourceLink {
margin-top: 0.5rem;
}
.sourceLinkButton {
display: inline-flex;
align-items: center;
padding: 0.75rem 1rem;
background: linear-gradient(135deg, var(--color-accent, #10b981) 0%, #059669 100%);
color: white;
text-decoration: none;
border-radius: 8px;
font-weight: 500;
transition: all 0.2s;
box-shadow: 0 4px 6px rgba(16, 185, 129, 0.3);
}
.sourceLinkButton:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(16, 185, 129, 0.4);
}
.bauvorschriftenFooter {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--color-border, #e5e7eb);
}
.lastUpdated {
font-size: 0.75rem;
color: var(--color-text-secondary, #6b7280);
}
@media (max-width: 768px) {
.bauvorschriftenGrid {
grid-template-columns: 1fr;
}
}

View file

@ -0,0 +1,109 @@
import React, { useState } from 'react';
import { FaChevronDown, FaChevronUp, FaFilePdf, FaRuler } from 'react-icons/fa';
import styles from './BauvorschriftenSection.module.css';
export interface BauvorschriftenZone {
zonenbezeichnung: string;
ausnuetzungsziffer?: number;
vollgeschosse?: number;
gebaeudelaengeMax?: number;
grenzabstand?: number;
mehrlaengenzuschlag?: string;
hoechstmassMax?: number;
fassadenhoehe?: string;
quelleUrl?: string;
extraktionsDatum?: string;
}
export interface BauvorschriftenSectionProps {
bauvorschriften: BauvorschriftenZone;
}
export const BauvorschriftenSection: React.FC<BauvorschriftenSectionProps> = ({ bauvorschriften }) => {
const [isExpanded, setIsExpanded] = useState(true);
return (
<div className={styles.bauvorschriftenSection}>
<div className={styles.bauvorschriftenHeader} onClick={() => setIsExpanded(!isExpanded)}>
<h4 className={styles.subSectionTitle}>
<FaRuler style={{ marginRight: '8px', display: 'inline' }} />
Bauvorschriften - {bauvorschriften.zonenbezeichnung}
</h4>
<button className={styles.expandButton}>
{isExpanded ? <FaChevronUp /> : <FaChevronDown />}
</button>
</div>
{isExpanded && (
<div className={styles.bauvorschriftenContent}>
<div className={styles.bauvorschriftenGrid}>
{bauvorschriften.ausnuetzungsziffer !== undefined && bauvorschriften.ausnuetzungsziffer !== null && (
<div className={styles.bauvorschriftItem}>
<span className={styles.label}>Ausnützungsziffer:</span>
<span className={styles.value}>{bauvorschriften.ausnuetzungsziffer}%</span>
</div>
)}
{bauvorschriften.vollgeschosse !== undefined && bauvorschriften.vollgeschosse !== null && (
<div className={styles.bauvorschriftItem}>
<span className={styles.label}>Vollgeschosse:</span>
<span className={styles.value}>{bauvorschriften.vollgeschosse}</span>
</div>
)}
{bauvorschriften.gebaeudelaengeMax !== undefined && bauvorschriften.gebaeudelaengeMax !== null && (
<div className={styles.bauvorschriftItem}>
<span className={styles.label}>Gebäudelänge max:</span>
<span className={styles.value}>{bauvorschriften.gebaeudelaengeMax} m</span>
</div>
)}
{bauvorschriften.grenzabstand !== undefined && bauvorschriften.grenzabstand !== null && (
<div className={styles.bauvorschriftItem}>
<span className={styles.label}>Grenzabstand:</span>
<span className={styles.value}>{bauvorschriften.grenzabstand} m</span>
</div>
)}
{bauvorschriften.mehrlaengenzuschlag && (
<div className={styles.bauvorschriftItem}>
<span className={styles.label}>Mehrlängenzuschlag:</span>
<span className={styles.value}>{bauvorschriften.mehrlaengenzuschlag}</span>
</div>
)}
{bauvorschriften.hoechstmassMax !== undefined && bauvorschriften.hoechstmassMax !== null && (
<div className={styles.bauvorschriftItem}>
<span className={styles.label}>Höchstmass max:</span>
<span className={styles.value}>{bauvorschriften.hoechstmassMax} m</span>
</div>
)}
{bauvorschriften.fassadenhoehe && (
<div className={styles.bauvorschriftItem}>
<span className={styles.label}>Fassadenhöhe:</span>
<span className={styles.value}>{bauvorschriften.fassadenhoehe}</span>
</div>
)}
</div>
{bauvorschriften.quelleUrl && bauvorschriften.quelleUrl !== 'config' && (
<div className={styles.sourceLink}>
<a
href={bauvorschriften.quelleUrl}
target="_blank"
rel="noopener noreferrer"
className={styles.sourceLinkButton}
>
<FaFilePdf style={{ marginRight: '8px' }} />
Nutzungsplan öffnen
</a>
</div>
)}
{bauvorschriften.extraktionsDatum && (
<div className={styles.bauvorschriftenFooter}>
<span className={styles.lastUpdated}>
Extrahiert: {new Date(bauvorschriften.extraktionsDatum).toLocaleString('de-CH')}
</span>
</div>
)}
</div>
)}
</div>
);
};

View file

@ -0,0 +1,187 @@
/* ÖREB Section Styles */
.oerebSection {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border, #e5e7eb);
}
.oerebHeader {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
padding: 0.5rem 0;
margin-bottom: 0.75rem;
transition: opacity 0.2s;
}
.oerebHeader:hover {
opacity: 0.8;
}
.oerebHeader .subSectionTitle {
margin: 0;
display: flex;
align-items: center;
color: var(--color-primary, #3b82f6);
font-size: 1rem;
font-weight: 600;
}
.badge {
margin-left: 0.5rem;
padding: 0.125rem 0.5rem;
background-color: var(--color-primary, #3b82f6);
color: white;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
}
.expandButton {
background: none;
border: none;
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-secondary, #6b7280);
transition: color 0.2s;
}
.expandButton:hover {
color: var(--color-text, #111827);
}
.oerebContent {
display: flex;
flex-direction: column;
gap: 1rem;
}
.oerebExtractLink {
margin-bottom: 0.5rem;
}
.oerebLink {
display: inline-flex;
align-items: center;
padding: 0.75rem 1rem;
background: linear-gradient(135deg, var(--color-primary, #3b82f6) 0%, var(--color-primary-dark, #2563eb) 100%);
color: white;
text-decoration: none;
border-radius: 8px;
font-weight: 500;
transition: all 0.2s;
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.3);
border: none;
cursor: pointer;
font-family: inherit;
font-size: inherit;
width: 100%;
justify-content: center;
}
.oerebLink:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(59, 130, 246, 0.4);
}
.oerebLink:active {
transform: translateY(0);
}
.restrictionsList {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.restrictionItem {
padding: 1rem;
background: linear-gradient(135deg, rgba(249, 250, 251, 0.8) 0%, rgba(243, 244, 246, 0.8) 100%);
backdrop-filter: blur(10px);
border: 1px solid var(--color-border, #e5e7eb);
border-left: 3px solid var(--color-primary, #3b82f6);
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.restrictionHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.restrictionTheme {
font-weight: 600;
color: var(--color-text, #111827);
font-size: 1rem;
}
.restrictionStatus {
padding: 0.25rem 0.5rem;
background: linear-gradient(135deg, rgba(209, 250, 229, 0.8) 0%, rgba(167, 243, 208, 0.8) 100%);
color: var(--color-success-dark, #065f46);
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
backdrop-filter: blur(5px);
}
.restrictionType {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.restrictionInfo {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--color-border, #e5e7eb);
font-size: 0.875rem;
color: var(--color-text, #111827);
line-height: 1.5;
}
.restrictionDocuments {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--color-border, #e5e7eb);
font-size: 0.875rem;
}
.documentLink {
color: var(--color-primary, #3b82f6);
text-decoration: none;
transition: color 0.2s;
}
.documentLink:hover {
color: var(--color-primary-dark, #2563eb);
text-decoration: underline;
}
.noRestrictions {
padding: 1rem;
text-align: center;
color: var(--color-text-secondary, #6b7280);
font-style: italic;
}
.oerebFooter {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--color-border, #e5e7eb);
}
.lastUpdated {
font-size: 0.75rem;
color: var(--color-text-secondary, #6b7280);
}

View file

@ -0,0 +1,133 @@
import React, { useState } from 'react';
import { FaChevronDown, FaChevronUp, FaFilePdf, FaInfoCircle } from 'react-icons/fa';
import { UrlContentPreview } from '../../ContentPreview';
import styles from './OerebSection.module.css';
export interface OerebData {
extract_url?: string;
restrictions?: Array<{
theme: string;
type?: string;
law_status?: string;
information?: string;
documents?: Array<{ reference: string }>;
}>;
last_updated?: string;
canton?: string;
}
export interface OerebSectionProps {
oereb: OerebData;
}
export const OerebSection: React.FC<OerebSectionProps> = ({ oereb }) => {
const [isExpanded, setIsExpanded] = useState(true);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const restrictions = oereb.restrictions || [];
if (restrictions.length === 0 && !oereb.extract_url) {
return null;
}
return (
<div className={styles.oerebSection}>
<div className={styles.oerebHeader} onClick={() => setIsExpanded(!isExpanded)}>
<h4 className={styles.subSectionTitle}>
<FaInfoCircle style={{ marginRight: '8px', display: 'inline' }} />
ÖREB-Kataster
{restrictions.length > 0 && (
<span className={styles.badge}>({restrictions.length})</span>
)}
</h4>
<button className={styles.expandButton}>
{isExpanded ? <FaChevronUp /> : <FaChevronDown />}
</button>
</div>
{isExpanded && (
<div className={styles.oerebContent}>
{oereb.extract_url && (
<div className={styles.oerebExtractLink}>
<button
onClick={() => setIsPreviewOpen(true)}
className={styles.oerebLink}
type="button"
>
<FaFilePdf style={{ marginRight: '8px' }} />
Vollständigen ÖREB-Auszug öffnen (PDF)
</button>
</div>
)}
{restrictions.length > 0 ? (
<div className={styles.restrictionsList}>
{restrictions.map((restriction, index) => (
<div key={index} className={styles.restrictionItem}>
<div className={styles.restrictionHeader}>
<span className={styles.restrictionTheme}>{restriction.theme}</span>
{restriction.law_status && (
<span className={styles.restrictionStatus}>
{restriction.law_status === 'inKraft' || restriction.law_status === 'inForce'
? 'In Kraft'
: restriction.law_status}
</span>
)}
</div>
{restriction.type && (
<div className={styles.restrictionType}>
<span className={styles.label}>Typ:</span>
<span className={styles.value}>{restriction.type}</span>
</div>
)}
{restriction.information && (
<div className={styles.restrictionInfo}>
<span className={styles.value}>{restriction.information}</span>
</div>
)}
{restriction.documents && restriction.documents.length > 0 && (
<div className={styles.restrictionDocuments}>
<span className={styles.label}>Dokumente:</span>
{restriction.documents.map((doc, docIndex) => (
<a
key={docIndex}
href={doc.reference}
target="_blank"
rel="noopener noreferrer"
className={styles.documentLink}
>
Dokument {docIndex + 1}
</a>
))}
</div>
)}
</div>
))}
</div>
) : (
<div className={styles.noRestrictions}>
Keine öffentlich-rechtlichen Beschränkungen gefunden.
</div>
)}
{oereb.last_updated && (
<div className={styles.oerebFooter}>
<span className={styles.lastUpdated}>
Aktualisiert: {new Date(oereb.last_updated).toLocaleString('de-CH')}
</span>
</div>
)}
</div>
)}
{oereb.extract_url && (
<UrlContentPreview
isOpen={isPreviewOpen}
onClose={() => setIsPreviewOpen(false)}
url={oereb.extract_url}
fileName="ÖREB-Auszug.pdf"
mimeType="application/pdf"
/>
)}
</div>
);
};

View file

@ -0,0 +1,10 @@
import { GenericPageData } from '../../../pageInterface';
import { realEstateProjectsPageData } from './projects';
import { realEstateParcelsPageData } from './parcels';
export { realEstateProjectsPageData, realEstateParcelsPageData };
export const realEstatePages: GenericPageData[] = [
realEstateProjectsPageData,
realEstateParcelsPageData,
];

View file

@ -0,0 +1,147 @@
import { useCallback } from 'react';
import { GenericPageData } from '../../../pageInterface';
import { FaMapMarkerAlt, FaPlus } from 'react-icons/fa';
import { useRealEstateParcels, useRealEstateParcelOperations } from '../../../../../hooks/useRealEstate';
const attributesToColumns = (attributes: any[]) => {
return attributes.map(attr => {
const isDateField = attr.type === 'date' || attr.type === 'timestamp' ||
/(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name);
return {
key: attr.name,
label: attr.label || attr.name,
type: attr.type || 'string',
width: attr.width || 200,
minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400,
sortable: attr.sortable !== false,
filterable: isDateField ? false : (attr.filterable !== false),
searchable: attr.searchable !== false,
filterOptions: attr.filterOptions,
};
});
};
const createParcelsHook = () => {
return () => {
const {
items: parcels,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
fetchById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded,
} = useRealEstateParcels();
const {
handleDelete,
handleCreate,
handleUpdate,
deletingItems,
creatingItem,
deleteError,
createError,
updateError,
} = useRealEstateParcelOperations();
const generatedColumns = attributes && attributes.length > 0
? attributesToColumns(attributes)
: undefined;
const wrappedHandleCreate = useCallback(async (formData: any) => {
return await handleCreate(formData);
}, [handleCreate]);
const handleDeleteSingle = useCallback(async (item: any) => {
const success = await handleDelete(item.id);
if (success) refetch();
}, [handleDelete, refetch]);
const handleDeleteMultiple = useCallback(async (selectedItems: any[]) => {
const ids = selectedItems.map(item => item.id);
const results = await Promise.all(ids.map(id => handleDelete(id)));
if (results.every(Boolean)) refetch();
}, [handleDelete, refetch]);
return {
data: parcels,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
handleDelete,
handleDeleteMultiple,
handleCreate: wrappedHandleCreate,
handleUpdate,
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
deletingItems,
creatingItem,
deleteError,
createError,
updateError,
attributes,
permissions,
columns: generatedColumns,
fetchById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded,
};
};
};
export const realEstateParcelsPageData: GenericPageData = {
id: 'realestate-parcels',
path: 'realestate/parcels',
name: 'realestate.parcels.title',
description: 'realestate.parcels.description',
parentPath: 'start.realestate',
icon: FaMapMarkerAlt,
title: 'realestate.parcels.title',
subtitle: 'realestate.parcels.subtitle',
headerButtons: [
{
id: 'new-parcel',
label: 'realestate.parcels.new_button',
icon: FaPlus,
variant: 'primary',
formConfig: {
fields: [
{ key: 'label', label: 'realestate.parcels.field.label', type: 'string', required: true },
{ key: 'strasseNr', label: 'realestate.parcels.field.strasseNr', type: 'string' },
{ key: 'plz', label: 'realestate.parcels.field.plz', type: 'string' },
],
popupTitle: 'realestate.parcels.modal.create.title',
popupSize: 'medium',
createOperationName: 'handleCreate',
successMessage: 'realestate.parcels.create.success',
errorMessage: 'realestate.parcels.create.error',
},
},
],
content: [
{
id: 'parcels-table',
type: 'table',
tableConfig: {
hookFactory: createParcelsHook,
actionButtons: [
{ type: 'edit', operationName: 'handleUpdate', fetchItemFunctionName: 'fetchById' },
{ type: 'delete', operationName: 'handleDelete' },
],
className: 'realestate-parcels-table',
},
},
],
moduleEnabled: true,
onActivate: () => { if (import.meta.env.DEV) console.log('RealEstate Parcels activated'); },
onLoad: () => { if (import.meta.env.DEV) console.log('RealEstate Parcels loaded'); },
onUnload: () => { if (import.meta.env.DEV) console.log('RealEstate Parcels unloaded'); },
};

View file

@ -0,0 +1,146 @@
import { useCallback } from 'react';
import { GenericPageData } from '../../../pageInterface';
import { FaBuilding, FaPlus } from 'react-icons/fa';
import { useRealEstateProjects, useRealEstateProjectOperations } from '../../../../../hooks/useRealEstate';
const attributesToColumns = (attributes: any[]) => {
return attributes.map(attr => {
const isDateField = attr.type === 'date' || attr.type === 'timestamp' ||
/(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name);
return {
key: attr.name,
label: attr.label || attr.name,
type: attr.type || 'string',
width: attr.width || 200,
minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400,
sortable: attr.sortable !== false,
filterable: isDateField ? false : (attr.filterable !== false),
searchable: attr.searchable !== false,
filterOptions: attr.filterOptions,
};
});
};
const createProjectsHook = () => {
return () => {
const {
items: projects,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
fetchById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded,
} = useRealEstateProjects();
const {
handleDelete,
handleCreate,
handleUpdate,
deletingItems,
creatingItem,
deleteError,
createError,
updateError,
} = useRealEstateProjectOperations();
const generatedColumns = attributes && attributes.length > 0
? attributesToColumns(attributes)
: undefined;
const wrappedHandleCreate = useCallback(async (formData: any) => {
return await handleCreate(formData);
}, [handleCreate]);
const handleDeleteSingle = useCallback(async (item: any) => {
const success = await handleDelete(item.id);
if (success) refetch();
}, [handleDelete, refetch]);
const handleDeleteMultiple = useCallback(async (selectedItems: any[]) => {
const ids = selectedItems.map(item => item.id);
const results = await Promise.all(ids.map(id => handleDelete(id)));
if (results.every(Boolean)) refetch();
}, [handleDelete, refetch]);
return {
data: projects,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
handleDelete,
handleDeleteMultiple,
handleCreate: wrappedHandleCreate,
handleUpdate,
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
deletingItems,
creatingItem,
deleteError,
createError,
updateError,
attributes,
permissions,
columns: generatedColumns,
fetchById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded,
};
};
};
export const realEstateProjectsPageData: GenericPageData = {
id: 'realestate-projects',
path: 'realestate/projects',
name: 'realestate.projects.title',
description: 'realestate.projects.description',
parentPath: 'start.realestate',
icon: FaBuilding,
title: 'realestate.projects.title',
subtitle: 'realestate.projects.subtitle',
headerButtons: [
{
id: 'new-project',
label: 'realestate.projects.new_button',
icon: FaPlus,
variant: 'primary',
formConfig: {
fields: [
{ key: 'label', label: 'realestate.projects.field.label', type: 'string', required: true },
{ key: 'statusProzess', label: 'realestate.projects.field.statusProzess', type: 'string' },
],
popupTitle: 'realestate.projects.modal.create.title',
popupSize: 'medium',
createOperationName: 'handleCreate',
successMessage: 'realestate.projects.create.success',
errorMessage: 'realestate.projects.create.error',
},
},
],
content: [
{
id: 'projects-table',
type: 'table',
tableConfig: {
hookFactory: createProjectsHook,
actionButtons: [
{ type: 'edit', operationName: 'handleUpdate', fetchItemFunctionName: 'fetchById' },
{ type: 'delete', operationName: 'handleDelete' },
],
className: 'realestate-projects-table',
},
},
],
moduleEnabled: true,
onActivate: () => { if (import.meta.env.DEV) console.log('RealEstate Projects activated'); },
onLoad: () => { if (import.meta.env.DEV) console.log('RealEstate Projects loaded'); },
onUnload: () => { if (import.meta.env.DEV) console.log('RealEstate Projects unloaded'); },
};

View file

@ -0,0 +1,241 @@
import { useCallback } from 'react';
import { GenericPageData } from '../../../pageInterface';
import { FaLink, FaPlus } from 'react-icons/fa';
import { useTrusteePositionDocuments, useTrusteePositionDocumentOperations } from '../../../../../hooks/useTrusteePositionDocuments';
// Helper function to convert attribute definitions to column config
const attributesToColumns = (attributes: any[]) => {
return attributes.map(attr => {
const isDateField = attr.type === 'date' ||
/(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name);
return {
key: attr.name,
label: attr.label || attr.name,
type: attr.type || 'string',
width: attr.width || 200,
minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400,
sortable: attr.sortable !== false,
filterable: isDateField ? false : (attr.filterable !== false),
searchable: attr.searchable !== false,
filterOptions: attr.filterOptions
};
});
};
// Hook factory function for position-documents data
const createPositionDocumentsHook = () => {
return () => {
const {
positionDocuments,
loading,
error,
refetch,
removeOptimistically,
attributes,
permissions,
pagination,
fetchPositionDocumentById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
} = useTrusteePositionDocuments();
const {
handlePositionDocumentDelete,
handlePositionDocumentCreate,
deletingPositionDocuments,
creatingPositionDocument,
deleteError,
createError
} = useTrusteePositionDocumentOperations();
const generatedColumns = attributes && attributes.length > 0
? attributesToColumns(attributes)
: undefined;
const wrappedHandlePositionDocumentCreate = useCallback(async (formData: any) => {
return await handlePositionDocumentCreate(formData);
}, [handlePositionDocumentCreate]);
const handleDeleteSingle = useCallback(async (positionDocument: any) => {
const success = await handlePositionDocumentDelete(positionDocument.id);
if (success) {
refetch();
}
}, [handlePositionDocumentDelete, refetch]);
const handleDeleteMultiple = useCallback(async (selectedPositionDocuments: any[]) => {
const positionDocumentIds = selectedPositionDocuments.map(pd => pd.id);
const results = await Promise.all(
positionDocumentIds.map(id => handlePositionDocumentDelete(id))
);
const allSuccessful = results.every(result => result);
if (allSuccessful) {
refetch();
}
}, [handlePositionDocumentDelete, refetch]);
return {
data: positionDocuments,
loading,
error,
refetch,
removeOptimistically,
handleDelete: handlePositionDocumentDelete,
handleDeleteMultiple,
handlePositionDocumentCreate: wrappedHandlePositionDocumentCreate,
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
deletingPositionDocuments,
creatingPositionDocument,
deleteError,
createError,
attributes,
permissions,
columns: generatedColumns,
pagination,
fetchPositionDocumentById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
};
};
};
export const trusteePositionDocumentsPageData: GenericPageData = {
id: 'administration-trustee-position-documents',
path: 'administration/trustee/position-documents',
name: 'trustee.positionDocuments.title',
description: 'trustee.positionDocuments.description',
// Parent page
parentPath: 'administration/trustee',
// Visual
icon: FaLink,
title: 'trustee.positionDocuments.title',
subtitle: 'trustee.positionDocuments.subtitle',
// Header buttons
headerButtons: [
{
id: 'new-position-document',
label: 'trustee.positionDocuments.new',
icon: FaPlus,
variant: 'primary',
formConfig: {
fields: [
{
key: 'organisationId',
label: 'trustee.positionDocuments.field.organisationId',
type: 'enum',
required: true,
optionsReference: 'trustee.organisation',
validator: (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
return 'Organisation is required';
}
return null;
}
},
{
key: 'contractId',
label: 'trustee.positionDocuments.field.contractId',
type: 'enum',
required: true,
optionsReference: 'trustee.contract',
dependsOn: 'organisationId',
validator: (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
return 'Contract is required';
}
return null;
}
},
{
key: 'positionId',
label: 'trustee.positionDocuments.field.positionId',
type: 'enum',
required: true,
optionsReference: 'trustee.position',
dependsOn: 'contractId',
validator: (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
return 'Position is required';
}
return null;
}
},
{
key: 'documentId',
label: 'trustee.positionDocuments.field.documentId',
type: 'enum',
required: true,
optionsReference: 'trustee.document',
dependsOn: 'contractId',
validator: (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
return 'Document is required';
}
return null;
}
}
],
popupTitle: 'trustee.positionDocuments.modal.create.title',
popupSize: 'medium',
createOperationName: 'handlePositionDocumentCreate',
successMessage: 'trustee.positionDocuments.create.success',
errorMessage: 'trustee.positionDocuments.create.error'
}
}
],
// Content sections
content: [
{
id: 'position-documents-table',
type: 'table',
tableConfig: {
hookFactory: createPositionDocumentsHook,
actionButtons: [
{
type: 'delete',
title: 'trustee.positionDocuments.action.delete',
idField: 'id',
operationName: 'handleDelete',
loadingStateName: 'deletingPositionDocuments',
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasDelete = hookData.permissions.delete !== 'n' && hookData.permissions.view;
return { disabled: !hasDelete, message: 'No permission to delete links' };
}
}
],
searchable: true,
filterable: true,
sortable: true,
resizable: true,
pagination: true,
pageSize: 10,
className: 'position-documents-table'
}
}
],
// Page behavior
persistent: false,
preload: false,
preserveState: true,
moduleEnabled: true,
// Lifecycle hooks
onActivate: async () => {
if (import.meta.env.DEV) console.log('Position-Documents activated');
},
onLoad: async () => {
if (import.meta.env.DEV) console.log('Position-Documents loaded');
},
onUnload: async () => {
if (import.meta.env.DEV) console.log('Position-Documents unloaded');
}
};

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

@ -0,0 +1,403 @@
/**
* Real Estate Hooks
*
* Hooks für das Real Estate/PEK-Feature mit Instanz-Kontext.
* Die instanceId wird automatisch aus der URL gelesen (Feature-Instanz-Route).
* Analog zu useTrustee.ts für backend-driven FormGeneratorTable.
*/
import { useState, useEffect, useCallback } from 'react';
import { useApiRequest } from './useApi';
import api from '../api';
import { usePermissions, type UserPermissions } from './usePermissions';
import { useInstanceId } from './useCurrentInstance';
import {
type RealEstateProject,
type RealEstateParcel,
type PaginationParams,
fetchProjects as fetchProjectsApi,
fetchProjectById as fetchProjectByIdApi,
createProject as createProjectApi,
updateProject as updateProjectApi,
deleteProject as deleteProjectApi,
fetchParcels as fetchParcelsApi,
fetchParcelById as fetchParcelByIdApi,
createParcel as createParcelApi,
updateParcel as updateParcelApi,
deleteParcel as deleteParcelApi,
} from '../api/realEstateApi';
export type { RealEstateProject, RealEstateParcel, PaginationParams };
export interface AttributeDefinition {
name: string;
type: 'text' | 'email' | 'date' | 'checkbox' | 'select' | 'multiselect' | 'number' | 'textarea' | 'timestamp' | 'file';
label: string;
description?: string;
required?: boolean;
default?: any;
options?: any[] | string;
readonly?: boolean;
editable?: boolean;
visible?: boolean;
order?: number;
sortable?: boolean;
filterable?: boolean;
searchable?: boolean;
width?: number;
minWidth?: number;
maxWidth?: number;
filterOptions?: string[];
dependsOn?: string;
}
// ============================================================================
// GENERIC REAL ESTATE ENTITY HOOK FACTORY
// ============================================================================
interface RealEstateEntityConfig<T> {
entityName: string;
fetchAll: (request: any, instanceId: string, params?: PaginationParams) => Promise<any>;
fetchById: (request: any, instanceId: string, id: string) => Promise<T | null>;
create: (request: any, instanceId: string, data: Partial<T>) => Promise<T>;
update: (request: any, instanceId: string, id: string, data: Partial<T>) => Promise<T>;
deleteItem: (request: any, instanceId: string, id: string) => Promise<void>;
}
function _createRealEstateEntityHook<T extends { id: string }>(config: RealEstateEntityConfig<T>) {
return function useRealEstateEntity() {
const instanceId = useInstanceId();
const [items, setItems] = useState<T[]>([]);
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
const [pagination, setPagination] = useState<{
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
} | null>(null);
const { request, isLoading: loading, error } = useApiRequest<null, T[]>();
const { checkPermission } = usePermissions();
const fetchAttributes = useCallback(async () => {
if (!instanceId) return [];
try {
const response = await api.get(`/api/realestate/${instanceId}/attributes/${config.entityName}`);
let attrs: AttributeDefinition[] = [];
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
attrs = response.data.attributes;
} else if (Array.isArray(response.data)) {
attrs = response.data;
}
setAttributes(attrs);
return attrs;
} catch (err: any) {
console.error(`Error fetching ${config.entityName} attributes:`, err);
setAttributes([]);
return [];
}
}, [instanceId]);
const fetchPermissions = useCallback(async () => {
try {
const objectKey = `data.feature.realestate.${config.entityName}`;
const perms = await checkPermission('DATA', objectKey);
setPermissions(perms);
return perms;
} catch (err: any) {
console.error(`Error fetching ${config.entityName} permissions:`, err);
const defaultPerms: UserPermissions = {
view: false,
read: 'n',
create: 'n',
update: 'n',
delete: 'n',
};
setPermissions(defaultPerms);
return defaultPerms;
}
}, [checkPermission]);
const fetchItems = useCallback(async (params?: PaginationParams) => {
if (!instanceId) {
setItems([]);
return;
}
try {
const data = await config.fetchAll(request, instanceId, params);
if (data && typeof data === 'object' && 'items' in data) {
const fetchedItems = Array.isArray(data.items) ? data.items : [];
setItems(fetchedItems);
if (data.pagination) {
setPagination(data.pagination);
}
} else {
const fetchedItems = Array.isArray(data) ? data : [];
setItems(fetchedItems);
setPagination(null);
}
} catch {
setItems([]);
setPagination(null);
}
}, [request, instanceId]);
const removeOptimistically = (itemId: string) => {
setItems(prev => prev.filter(item => item.id !== itemId));
};
const updateOptimistically = (itemId: string, updateData: Partial<T>) => {
setItems(prev =>
prev.map(item =>
item.id === itemId ? { ...item, ...updateData } : item
)
);
};
const fetchById = useCallback(async (itemId: string): Promise<T | null> => {
if (!instanceId) return null;
return await config.fetchById(request, instanceId, itemId);
}, [request, instanceId]);
const generateEditFieldsFromAttributes = useCallback(() => {
if (!attributes || attributes.length === 0) return [];
return attributes
.filter(attr => {
if (attr.readonly === true || attr.editable === false) return false;
if (attr.name === 'id') return false;
const nonEditable = ['_createdBy', '_createdAt', '_modifiedBy', '_modifiedAt'];
return !nonEditable.includes(attr.name);
})
.map(attr => {
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' | 'number' = 'string';
let options: Array<{ value: string | number; label: string }> | undefined;
let optionsReference: string | undefined;
if (attr.type === 'checkbox') fieldType = 'boolean';
else if (attr.type === 'email') fieldType = 'email';
else if (attr.type === 'date') fieldType = 'date';
else if (attr.type === 'number') fieldType = 'number';
else if (attr.type === 'select') {
fieldType = 'enum';
if (Array.isArray(attr.options)) {
options = (attr.options as any[]).map((opt: any) => ({
value: opt.value,
label: typeof opt.label === 'string' ? opt.label : opt.label?.en || String(opt.value),
}));
} else if (typeof attr.options === 'string') optionsReference = attr.options;
} else if (attr.type === 'multiselect') {
fieldType = 'multiselect';
if (Array.isArray(attr.options)) {
options = (attr.options as any[]).map((opt: any) => ({
value: opt.value,
label: typeof opt.label === 'string' ? opt.label : opt.label?.en || String(opt.value),
}));
} else if (typeof attr.options === 'string') optionsReference = attr.options;
} else if (attr.type === 'textarea') fieldType = 'textarea';
else if (attr.type === 'timestamp') fieldType = 'readonly';
return {
key: attr.name,
label: attr.label || attr.name,
type: fieldType,
editable: attr.editable !== false && attr.readonly !== true,
required: attr.required === true,
options,
optionsReference,
dependsOn: attr.dependsOn,
};
});
}, [attributes]);
const generateCreateFieldsFromAttributes = useCallback(() => {
if (!attributes || attributes.length === 0) return [];
return attributes
.filter(attr => !['_createdBy', '_createdAt', '_modifiedBy', '_modifiedAt', 'mandateId', 'featureInstanceId'].includes(attr.name))
.map(attr => {
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' | 'number' = 'string';
let options: Array<{ value: string | number; label: string }> | undefined;
let optionsReference: string | undefined;
if (attr.type === 'checkbox') fieldType = 'boolean';
else if (attr.type === 'email') fieldType = 'email';
else if (attr.type === 'date') fieldType = 'date';
else if (attr.type === 'number') fieldType = 'number';
else if (attr.type === 'select') {
fieldType = 'enum';
if (Array.isArray(attr.options)) {
options = (attr.options as any[]).map((opt: any) => ({
value: opt.value,
label: typeof opt.label === 'string' ? opt.label : opt.label?.en || String(opt.value),
}));
} else if (typeof attr.options === 'string') optionsReference = attr.options;
} else if (attr.type === 'multiselect') {
fieldType = 'multiselect';
if (Array.isArray(attr.options)) {
options = (attr.options as any[]).map((opt: any) => ({
value: opt.value,
label: typeof opt.label === 'string' ? opt.label : opt.label?.en || String(opt.value),
}));
} else if (typeof attr.options === 'string') optionsReference = attr.options;
} else if (attr.type === 'textarea') fieldType = 'textarea';
else if (attr.type === 'timestamp') fieldType = 'readonly';
return {
key: attr.name,
label: attr.label || attr.name,
type: fieldType,
editable: true,
required: attr.required === true,
options,
optionsReference,
dependsOn: attr.dependsOn,
};
});
}, [attributes]);
const ensureAttributesLoaded = useCallback(async () => {
if (attributes && attributes.length > 0) return attributes;
return await fetchAttributes();
}, [attributes, fetchAttributes]);
useEffect(() => {
if (instanceId) {
fetchAttributes();
fetchPermissions();
fetchItems();
}
}, [instanceId, fetchAttributes, fetchPermissions, fetchItems]);
return {
items,
loading,
error,
refetch: fetchItems,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
pagination,
fetchById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded,
instanceId,
};
};
}
function _createRealEstateOperationsHook<T extends { id: string }>(config: RealEstateEntityConfig<T>) {
return function useRealEstateEntityOperations() {
const instanceId = useInstanceId();
const [deletingItems, setDeletingItems] = useState<Set<string>>(new Set());
const [creatingItem, setCreatingItem] = useState(false);
const { request } = useApiRequest();
const [deleteError, setDeleteError] = useState<string | null>(null);
const [createError, setCreateError] = useState<string | null>(null);
const [updateError, setUpdateError] = useState<string | null>(null);
const handleDelete = useCallback(async (itemId: string) => {
if (!instanceId) {
setDeleteError('No instance context');
return false;
}
setDeleteError(null);
setDeletingItems(prev => new Set(prev).add(itemId));
try {
await config.deleteItem(request, instanceId, itemId);
await new Promise(resolve => setTimeout(resolve, 300));
return true;
} catch (err: any) {
setDeleteError(err.message);
return false;
} finally {
setDeletingItems(prev => {
const next = new Set(prev);
next.delete(itemId);
return next;
});
}
}, [request, instanceId]);
const handleCreate = useCallback(async (itemData: Partial<T>) => {
if (!instanceId) {
setCreateError('No instance context');
return { success: false, error: 'No instance context' };
}
setCreateError(null);
setCreatingItem(true);
try {
const newItem = await config.create(request, instanceId, itemData);
return { success: true, data: newItem };
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message;
setCreateError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setCreatingItem(false);
}
}, [request, instanceId]);
const handleUpdate = useCallback(async (itemId: string, updateData: Partial<T>) => {
if (!instanceId) {
setUpdateError('No instance context');
return { success: false, error: 'No instance context' };
}
setUpdateError(null);
try {
const updatedItem = await config.update(request, instanceId, itemId, updateData);
return { success: true, data: updatedItem };
} catch (err: any) {
const errorMessage = err.response?.data?.message || err.message || 'Failed to update';
setUpdateError(errorMessage);
return {
success: false,
error: errorMessage,
statusCode: err.response?.status,
isPermissionError: err.response?.status === 403,
isValidationError: err.response?.status === 400,
};
}
}, [request, instanceId]);
return {
deletingItems,
creatingItem,
deleteError,
createError,
updateError,
handleDelete,
handleCreate,
handleUpdate,
instanceId,
};
};
}
// ============================================================================
// PROJECT HOOKS
// ============================================================================
const projectConfig: RealEstateEntityConfig<RealEstateProject> = {
entityName: 'Projekt',
fetchAll: fetchProjectsApi,
fetchById: fetchProjectByIdApi,
create: createProjectApi,
update: updateProjectApi,
deleteItem: deleteProjectApi,
};
export const useRealEstateProjects = _createRealEstateEntityHook(projectConfig);
export const useRealEstateProjectOperations = _createRealEstateOperationsHook(projectConfig);
// ============================================================================
// PARCEL HOOKS
// ============================================================================
const parcelConfig: RealEstateEntityConfig<RealEstateParcel> = {
entityName: 'Parzelle',
fetchAll: fetchParcelsApi,
fetchById: fetchParcelByIdApi,
create: createParcelApi,
update: updateParcelApi,
deleteItem: deleteParcelApi,
};
export const useRealEstateParcels = _createRealEstateEntityHook(parcelConfig);
export const useRealEstateParcelOperations = _createRealEstateOperationsHook(parcelConfig);

View file

@ -0,0 +1,352 @@
import { useState, useEffect, useCallback } from 'react';
import { useApiRequest } from './useApi';
import { getUserDataCache } from '../utils/userCache';
import api from '../api';
import { usePermissions, type UserPermissions } from './usePermissions';
import {
fetchAccess as fetchAccessApi,
fetchAccessById as fetchAccessByIdApi,
createAccess as createAccessApi,
updateAccess as updateAccessApi,
deleteAccess as deleteAccessApi,
type TrusteeAccess,
type AttributeDefinition,
type PaginationParams
} from '../api/trusteeApi';
// Re-export types
export type { TrusteeAccess, AttributeDefinition, PaginationParams };
// Access list hook
export function useTrusteeAccess() {
const [accessRecords, setAccessRecords] = useState<TrusteeAccess[]>([]);
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, TrusteeAccess[]>();
const { checkPermission } = usePermissions();
// Fetch attributes from backend
const fetchAttributes = useCallback(async () => {
try {
const response = await api.get('/api/attributes/TrusteeAccess');
let attrs: AttributeDefinition[] = [];
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
attrs = response.data.attributes;
} else if (Array.isArray(response.data)) {
attrs = response.data;
} else if (response.data && typeof response.data === 'object') {
const keys = Object.keys(response.data);
for (const key of keys) {
if (Array.isArray(response.data[key])) {
attrs = response.data[key];
break;
}
}
}
setAttributes(attrs);
return attrs;
} catch (error: any) {
console.error('Error fetching attributes:', error);
setAttributes([]);
return [];
}
}, []);
// Fetch permissions from backend
const fetchPermissions = useCallback(async () => {
try {
const perms = await checkPermission('DATA', 'trustee.access');
setPermissions(perms);
return perms;
} catch (error: any) {
console.error('Error fetching permissions:', error);
const defaultPerms: UserPermissions = {
view: false,
read: 'n',
create: 'n',
update: 'n',
delete: 'n',
};
setPermissions(defaultPerms);
return defaultPerms;
}
}, [checkPermission]);
const fetchAccess = useCallback(async (params?: PaginationParams) => {
try {
const data = await fetchAccessApi(request, params);
if (data && typeof data === 'object' && 'items' in data) {
const items = Array.isArray(data.items) ? data.items : [];
setAccessRecords(items);
if (data.pagination) {
setPagination(data.pagination);
}
} else {
const items = Array.isArray(data) ? data : [];
setAccessRecords(items);
setPagination(null);
}
} catch (error: any) {
setAccessRecords([]);
setPagination(null);
}
}, [request]);
// Optimistically remove an access record
const removeOptimistically = (accessId: string) => {
setAccessRecords(prevAccess => prevAccess.filter(acc => acc.id !== accessId));
};
// Optimistically update an access record
const updateOptimistically = (accessId: string, updateData: Partial<TrusteeAccess>) => {
setAccessRecords(prevAccess =>
prevAccess.map(acc =>
acc.id === accessId
? { ...acc, ...updateData }
: acc
)
);
};
// Fetch a single access record by ID
const fetchAccessById = useCallback(async (accessId: string): Promise<TrusteeAccess | null> => {
return await fetchAccessByIdApi(request, accessId);
}, [request]);
// Generate edit fields from attributes dynamically
const generateEditFieldsFromAttributes = useCallback((): Array<{
key: string;
label: string;
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly';
editable?: boolean;
required?: boolean;
validator?: (value: any) => string | null;
options?: Array<{ value: string | number; label: string }>;
optionsReference?: string;
dependsOn?: string;
}> => {
if (!attributes || attributes.length === 0) {
return [];
}
const editableFields = attributes
.filter(attr => {
if (attr.readonly === true || attr.editable === false) {
return false;
}
const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string';
let options: Array<{ value: string | number; label: string }> | undefined = undefined;
let optionsReference: string | undefined = undefined;
let dependsOn: string | undefined = undefined;
if (attr.type === 'checkbox') {
fieldType = 'boolean';
} else if (attr.type === 'email') {
fieldType = 'email';
} else if (attr.type === 'date') {
fieldType = 'date';
} else if (attr.type === 'select') {
fieldType = 'enum';
if (Array.isArray(attr.options)) {
options = attr.options.map(opt => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return {
value: opt.value,
label: labelValue
};
});
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}
} else if (attr.type === 'textarea') {
fieldType = 'textarea';
}
// contractId dropdown depends on organisationId
if (attr.name === 'contractId') {
dependsOn = 'organisationId';
}
let required = attr.required === true;
let validator: ((value: any) => string | null) | undefined = undefined;
// contractId is optional
if (attr.name === 'contractId') {
required = false;
} else if (attr.name === 'organisationId' || attr.name === 'roleId' || attr.name === 'userId') {
required = true;
validator = (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
return `${attr.label || attr.name} is required`;
}
return null;
};
}
return {
key: attr.name,
label: attr.label || attr.name,
type: fieldType,
editable: attr.editable !== false && attr.readonly !== true,
required,
validator,
options,
optionsReference,
dependsOn
};
});
return editableFields;
}, [attributes]);
// Ensure attributes are loaded
const ensureAttributesLoaded = useCallback(async () => {
if (attributes && attributes.length > 0) {
return attributes;
}
const fetchedAttributes = await fetchAttributes();
return fetchedAttributes;
}, [attributes, fetchAttributes]);
// Fetch attributes and permissions on mount
useEffect(() => {
fetchAttributes();
fetchPermissions();
}, [fetchAttributes, fetchPermissions]);
// Initial fetch
useEffect(() => {
fetchAccess();
}, [fetchAccess]);
return {
accessRecords,
loading,
error,
refetch: fetchAccess,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
pagination,
fetchAccessById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
};
}
// Access operations hook
export function useTrusteeAccessOperations() {
const [deletingAccess, setDeletingAccess] = useState<Set<string>>(new Set());
const [creatingAccess, setCreatingAccess] = useState(false);
const { request, isLoading } = useApiRequest();
const [deleteError, setDeleteError] = useState<string | null>(null);
const [createError, setCreateError] = useState<string | null>(null);
const [updateError, setUpdateError] = useState<string | null>(null);
const handleAccessDelete = async (accessId: string) => {
setDeleteError(null);
setDeletingAccess(prev => new Set(prev).add(accessId));
try {
await deleteAccessApi(request, accessId);
await new Promise(resolve => setTimeout(resolve, 300));
return true;
} catch (error: any) {
setDeleteError(error.message);
return false;
} finally {
setDeletingAccess(prev => {
const newSet = new Set(prev);
newSet.delete(accessId);
return newSet;
});
}
};
const handleAccessCreate = async (accessData: Partial<TrusteeAccess>) => {
setCreateError(null);
setCreatingAccess(true);
try {
const currentUserData = getUserDataCache();
const mandateId = currentUserData?.mandateId || '';
const requestBody = {
...accessData,
mandate: mandateId
};
const newAccess = await createAccessApi(request, requestBody);
return { success: true, accessData: newAccess };
} catch (error: any) {
setCreateError(error.message);
return { success: false, error: error.message };
} finally {
setCreatingAccess(false);
}
};
const handleAccessUpdate = async (
accessId: string,
updateData: Partial<TrusteeAccess>,
_originalData?: any
) => {
setUpdateError(null);
try {
const currentUserData = getUserDataCache();
const mandateId = currentUserData?.mandateId || '';
const requestBody = {
...updateData,
mandate: mandateId
};
const updatedAccess = await updateAccessApi(request, accessId, requestBody);
return { success: true, accessData: updatedAccess };
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.message || 'Failed to update access';
const statusCode = error.response?.status;
setUpdateError(errorMessage);
return {
success: false,
error: errorMessage,
statusCode,
isPermissionError: statusCode === 403,
isValidationError: statusCode === 400
};
}
};
return {
deletingAccess,
creatingAccess,
deleteError,
createError,
updateError,
handleAccessDelete,
handleAccessCreate,
handleAccessUpdate,
isLoading
};
}

View file

@ -0,0 +1,362 @@
import { useState, useEffect, useCallback } from 'react';
import { useApiRequest } from './useApi';
import { getUserDataCache } from '../utils/userCache';
import api from '../api';
import { usePermissions, type UserPermissions } from './usePermissions';
import {
fetchContracts as fetchContractsApi,
fetchContractById as fetchContractByIdApi,
createContract as createContractApi,
updateContract as updateContractApi,
deleteContract as deleteContractApi,
type TrusteeContract,
type AttributeDefinition,
type PaginationParams
} from '../api/trusteeApi';
// Re-export types
export type { TrusteeContract, AttributeDefinition, PaginationParams };
// Contracts list hook
export function useTrusteeContracts() {
const [contracts, setContracts] = useState<TrusteeContract[]>([]);
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, TrusteeContract[]>();
const { checkPermission } = usePermissions();
// Fetch attributes from backend
const fetchAttributes = useCallback(async () => {
try {
const response = await api.get('/api/attributes/TrusteeContract');
let attrs: AttributeDefinition[] = [];
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
attrs = response.data.attributes;
} else if (Array.isArray(response.data)) {
attrs = response.data;
} else if (response.data && typeof response.data === 'object') {
const keys = Object.keys(response.data);
for (const key of keys) {
if (Array.isArray(response.data[key])) {
attrs = response.data[key];
break;
}
}
}
setAttributes(attrs);
return attrs;
} catch (error: any) {
console.error('Error fetching attributes:', error);
setAttributes([]);
return [];
}
}, []);
// Fetch permissions from backend
const fetchPermissions = useCallback(async () => {
try {
const perms = await checkPermission('DATA', 'trustee.contract');
setPermissions(perms);
return perms;
} catch (error: any) {
console.error('Error fetching permissions:', error);
const defaultPerms: UserPermissions = {
view: false,
read: 'n',
create: 'n',
update: 'n',
delete: 'n',
};
setPermissions(defaultPerms);
return defaultPerms;
}
}, [checkPermission]);
const fetchContracts = useCallback(async (params?: PaginationParams) => {
try {
const data = await fetchContractsApi(request, params);
if (data && typeof data === 'object' && 'items' in data) {
const items = Array.isArray(data.items) ? data.items : [];
setContracts(items);
if (data.pagination) {
setPagination(data.pagination);
}
} else {
const items = Array.isArray(data) ? data : [];
setContracts(items);
setPagination(null);
}
} catch (error: any) {
setContracts([]);
setPagination(null);
}
}, [request]);
// Optimistically remove a contract
const removeOptimistically = (contractId: string) => {
setContracts(prevContracts => prevContracts.filter(contract => contract.id !== contractId));
};
// Optimistically update a contract
const updateOptimistically = (contractId: string, updateData: Partial<TrusteeContract>) => {
setContracts(prevContracts =>
prevContracts.map(contract =>
contract.id === contractId
? { ...contract, ...updateData }
: contract
)
);
};
// Fetch a single contract by ID
const fetchContractById = useCallback(async (contractId: string): Promise<TrusteeContract | null> => {
return await fetchContractByIdApi(request, contractId);
}, [request]);
// Generate edit fields from attributes dynamically
const generateEditFieldsFromAttributes = useCallback((): Array<{
key: string;
label: string;
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly';
editable?: boolean;
required?: boolean;
validator?: (value: any) => string | null;
options?: Array<{ value: string | number; label: string }>;
optionsReference?: string;
readonlyCondition?: (formData: any) => boolean;
}> => {
if (!attributes || attributes.length === 0) {
return [];
}
const editableFields = attributes
.filter(attr => {
if (attr.readonly === true || attr.editable === false) {
return false;
}
const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string';
let options: Array<{ value: string | number; label: string }> | undefined = undefined;
let optionsReference: string | undefined = undefined;
let readonlyCondition: ((formData: any) => boolean) | undefined = undefined;
if (attr.type === 'checkbox') {
fieldType = 'boolean';
} else if (attr.type === 'email') {
fieldType = 'email';
} else if (attr.type === 'date') {
fieldType = 'date';
} else if (attr.type === 'select') {
fieldType = 'enum';
if (Array.isArray(attr.options)) {
options = attr.options.map(opt => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return {
value: opt.value,
label: labelValue
};
});
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}
} else if (attr.type === 'textarea') {
fieldType = 'textarea';
}
// IMPORTANT: organisationId is immutable after creation
// It's readonly when id is present (non-blank)
if (attr.name === 'organisationId') {
readonlyCondition = (formData: any) => {
return formData && formData.id && formData.id !== '';
};
}
let required = attr.required === true;
let validator: ((value: any) => string | null) | undefined = undefined;
if (attr.name === 'organisationId') {
required = true;
validator = (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
return 'Organisation is required';
}
return null;
};
} else if (attr.name === 'label') {
required = true;
validator = (value: string) => {
if (!value || value.trim() === '') {
return 'Label cannot be empty';
}
return null;
};
}
return {
key: attr.name,
label: attr.label || attr.name,
type: fieldType,
editable: attr.editable !== false && attr.readonly !== true,
required,
validator,
options,
optionsReference,
readonlyCondition
};
});
return editableFields;
}, [attributes]);
// Ensure attributes are loaded
const ensureAttributesLoaded = useCallback(async () => {
if (attributes && attributes.length > 0) {
return attributes;
}
const fetchedAttributes = await fetchAttributes();
return fetchedAttributes;
}, [attributes, fetchAttributes]);
// Fetch attributes and permissions on mount
useEffect(() => {
fetchAttributes();
fetchPermissions();
}, [fetchAttributes, fetchPermissions]);
// Initial fetch
useEffect(() => {
fetchContracts();
}, [fetchContracts]);
return {
contracts,
loading,
error,
refetch: fetchContracts,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
pagination,
fetchContractById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
};
}
// Contract operations hook
export function useTrusteeContractOperations() {
const [deletingContracts, setDeletingContracts] = useState<Set<string>>(new Set());
const [creatingContract, setCreatingContract] = useState(false);
const { request, isLoading } = useApiRequest();
const [deleteError, setDeleteError] = useState<string | null>(null);
const [createError, setCreateError] = useState<string | null>(null);
const [updateError, setUpdateError] = useState<string | null>(null);
const handleContractDelete = async (contractId: string) => {
setDeleteError(null);
setDeletingContracts(prev => new Set(prev).add(contractId));
try {
await deleteContractApi(request, contractId);
await new Promise(resolve => setTimeout(resolve, 300));
return true;
} catch (error: any) {
setDeleteError(error.message);
return false;
} finally {
setDeletingContracts(prev => {
const newSet = new Set(prev);
newSet.delete(contractId);
return newSet;
});
}
};
const handleContractCreate = async (contractData: Partial<TrusteeContract>) => {
setCreateError(null);
setCreatingContract(true);
try {
const currentUserData = getUserDataCache();
const mandateId = currentUserData?.mandateId || '';
const requestBody = {
...contractData,
mandate: mandateId
};
const newContract = await createContractApi(request, requestBody);
return { success: true, contractData: newContract };
} catch (error: any) {
setCreateError(error.message);
return { success: false, error: error.message };
} finally {
setCreatingContract(false);
}
};
const handleContractUpdate = async (
contractId: string,
updateData: Partial<TrusteeContract>,
_originalData?: any
) => {
setUpdateError(null);
try {
const currentUserData = getUserDataCache();
const mandateId = currentUserData?.mandateId || '';
// Note: organisationId should NOT be included in update if immutable
// Backend will reject if organisationId is changed
const requestBody = {
...updateData,
mandate: mandateId
};
const updatedContract = await updateContractApi(request, contractId, requestBody);
return { success: true, contractData: updatedContract };
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.message || 'Failed to update contract';
const statusCode = error.response?.status;
setUpdateError(errorMessage);
return {
success: false,
error: errorMessage,
statusCode,
isPermissionError: statusCode === 403,
isValidationError: statusCode === 400
};
}
};
return {
deletingContracts,
creatingContract,
deleteError,
createError,
updateError,
handleContractDelete,
handleContractCreate,
handleContractUpdate,
isLoading
};
}

View file

@ -0,0 +1,384 @@
import { useState, useEffect, useCallback } from 'react';
import { useApiRequest } from './useApi';
import { getUserDataCache } from '../utils/userCache';
import api from '../api';
import { usePermissions, type UserPermissions } from './usePermissions';
import {
fetchDocuments as fetchDocumentsApi,
fetchDocumentById as fetchDocumentByIdApi,
createDocument as createDocumentApi,
updateDocument as updateDocumentApi,
deleteDocument as deleteDocumentApi,
downloadDocumentData as downloadDocumentDataApi,
type TrusteeDocument,
type AttributeDefinition,
type PaginationParams
} from '../api/trusteeApi';
// Re-export types
export type { TrusteeDocument, AttributeDefinition, PaginationParams };
// Documents list hook
export function useTrusteeDocuments() {
const [documents, setDocuments] = useState<TrusteeDocument[]>([]);
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, TrusteeDocument[]>();
const { checkPermission } = usePermissions();
// Fetch attributes from backend
const fetchAttributes = useCallback(async () => {
try {
const response = await api.get('/api/attributes/TrusteeDocument');
let attrs: AttributeDefinition[] = [];
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
attrs = response.data.attributes;
} else if (Array.isArray(response.data)) {
attrs = response.data;
} else if (response.data && typeof response.data === 'object') {
const keys = Object.keys(response.data);
for (const key of keys) {
if (Array.isArray(response.data[key])) {
attrs = response.data[key];
break;
}
}
}
setAttributes(attrs);
return attrs;
} catch (error: any) {
console.error('Error fetching attributes:', error);
setAttributes([]);
return [];
}
}, []);
// Fetch permissions from backend
const fetchPermissions = useCallback(async () => {
try {
const perms = await checkPermission('DATA', 'trustee.document');
setPermissions(perms);
return perms;
} catch (error: any) {
console.error('Error fetching permissions:', error);
const defaultPerms: UserPermissions = {
view: false,
read: 'n',
create: 'n',
update: 'n',
delete: 'n',
};
setPermissions(defaultPerms);
return defaultPerms;
}
}, [checkPermission]);
const fetchDocuments = useCallback(async (params?: PaginationParams) => {
try {
const data = await fetchDocumentsApi(request, params);
if (data && typeof data === 'object' && 'items' in data) {
const items = Array.isArray(data.items) ? data.items : [];
setDocuments(items);
if (data.pagination) {
setPagination(data.pagination);
}
} else {
const items = Array.isArray(data) ? data : [];
setDocuments(items);
setPagination(null);
}
} catch (error: any) {
setDocuments([]);
setPagination(null);
}
}, [request]);
// Optimistically remove a document
const removeOptimistically = (documentId: string) => {
setDocuments(prevDocs => prevDocs.filter(doc => doc.id !== documentId));
};
// Optimistically update a document
const updateOptimistically = (documentId: string, updateData: Partial<TrusteeDocument>) => {
setDocuments(prevDocs =>
prevDocs.map(doc =>
doc.id === documentId
? { ...doc, ...updateData }
: doc
)
);
};
// Fetch a single document by ID
const fetchDocumentById = useCallback(async (documentId: string): Promise<TrusteeDocument | null> => {
return await fetchDocumentByIdApi(request, documentId);
}, [request]);
// Generate edit fields from attributes dynamically
const generateEditFieldsFromAttributes = useCallback((): Array<{
key: string;
label: string;
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly';
editable?: boolean;
required?: boolean;
validator?: (value: any) => string | null;
options?: Array<{ value: string | number; label: string }>;
optionsReference?: string;
dependsOn?: string;
}> => {
if (!attributes || attributes.length === 0) {
return [];
}
const editableFields = attributes
.filter(attr => {
if (attr.readonly === true || attr.editable === false) {
return false;
}
// documentData is handled separately (binary upload)
const nonEditableFields = ['id', 'documentData', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string';
let options: Array<{ value: string | number; label: string }> | undefined = undefined;
let optionsReference: string | undefined = undefined;
let dependsOn: string | undefined = undefined;
if (attr.type === 'checkbox') {
fieldType = 'boolean';
} else if (attr.type === 'email') {
fieldType = 'email';
} else if (attr.type === 'date') {
fieldType = 'date';
} else if (attr.type === 'select') {
fieldType = 'enum';
if (Array.isArray(attr.options)) {
options = attr.options.map(opt => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return {
value: opt.value,
label: labelValue
};
});
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}
} else if (attr.type === 'textarea') {
fieldType = 'textarea';
}
// contractId depends on organisationId
if (attr.name === 'contractId') {
dependsOn = 'organisationId';
}
let required = attr.required === true;
let validator: ((value: any) => string | null) | undefined = undefined;
if (attr.name === 'organisationId' || attr.name === 'contractId' || attr.name === 'documentName') {
required = true;
validator = (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
return `${attr.label || attr.name} is required`;
}
return null;
};
}
return {
key: attr.name,
label: attr.label || attr.name,
type: fieldType,
editable: attr.editable !== false && attr.readonly !== true,
required,
validator,
options,
optionsReference,
dependsOn
};
});
return editableFields;
}, [attributes]);
// Ensure attributes are loaded
const ensureAttributesLoaded = useCallback(async () => {
if (attributes && attributes.length > 0) {
return attributes;
}
const fetchedAttributes = await fetchAttributes();
return fetchedAttributes;
}, [attributes, fetchAttributes]);
// Fetch attributes and permissions on mount
useEffect(() => {
fetchAttributes();
fetchPermissions();
}, [fetchAttributes, fetchPermissions]);
// Initial fetch
useEffect(() => {
fetchDocuments();
}, [fetchDocuments]);
return {
documents,
loading,
error,
refetch: fetchDocuments,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
pagination,
fetchDocumentById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
};
}
// Document operations hook
export function useTrusteeDocumentOperations() {
const [deletingDocuments, setDeletingDocuments] = useState<Set<string>>(new Set());
const [creatingDocument, setCreatingDocument] = useState(false);
const [downloadingDocuments, setDownloadingDocuments] = useState<Set<string>>(new Set());
const { request, isLoading } = useApiRequest();
const [deleteError, setDeleteError] = useState<string | null>(null);
const [createError, setCreateError] = useState<string | null>(null);
const [updateError, setUpdateError] = useState<string | null>(null);
const [downloadError, setDownloadError] = useState<string | null>(null);
const handleDocumentDelete = async (documentId: string) => {
setDeleteError(null);
setDeletingDocuments(prev => new Set(prev).add(documentId));
try {
await deleteDocumentApi(request, documentId);
await new Promise(resolve => setTimeout(resolve, 300));
return true;
} catch (error: any) {
setDeleteError(error.message);
return false;
} finally {
setDeletingDocuments(prev => {
const newSet = new Set(prev);
newSet.delete(documentId);
return newSet;
});
}
};
const handleDocumentCreate = async (documentData: FormData) => {
setCreateError(null);
setCreatingDocument(true);
try {
const currentUserData = getUserDataCache();
const mandateId = currentUserData?.mandateId || '';
documentData.append('mandate', mandateId);
const newDocument = await createDocumentApi(request, documentData);
return { success: true, documentData: newDocument };
} catch (error: any) {
setCreateError(error.message);
return { success: false, error: error.message };
} finally {
setCreatingDocument(false);
}
};
const handleDocumentUpdate = async (
documentId: string,
updateData: Partial<TrusteeDocument>,
_originalData?: any
) => {
setUpdateError(null);
try {
const currentUserData = getUserDataCache();
const mandateId = currentUserData?.mandateId || '';
const requestBody = {
...updateData,
mandate: mandateId
};
const updatedDocument = await updateDocumentApi(request, documentId, requestBody);
return { success: true, documentData: updatedDocument };
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.message || 'Failed to update document';
const statusCode = error.response?.status;
setUpdateError(errorMessage);
return {
success: false,
error: errorMessage,
statusCode,
isPermissionError: statusCode === 403,
isValidationError: statusCode === 400
};
}
};
const handleDocumentDownload = async (documentId: string, documentName: string) => {
setDownloadError(null);
setDownloadingDocuments(prev => new Set(prev).add(documentId));
try {
const blob = await downloadDocumentDataApi(request, documentId);
// Create download link
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = documentName || `document-${documentId}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
return true;
} catch (error: any) {
const errorMessage = error.message || 'Failed to download document';
setDownloadError(errorMessage);
return false;
} finally {
setDownloadingDocuments(prev => {
const newSet = new Set(prev);
newSet.delete(documentId);
return newSet;
});
}
};
return {
deletingDocuments,
creatingDocument,
downloadingDocuments,
deleteError,
createError,
updateError,
downloadError,
handleDocumentDelete,
handleDocumentCreate,
handleDocumentUpdate,
handleDocumentDownload,
isLoading
};
}

View file

@ -0,0 +1,368 @@
import { useState, useEffect, useCallback } from 'react';
import { useApiRequest } from './useApi';
import { getUserDataCache } from '../utils/userCache';
import api from '../api';
import { usePermissions, type UserPermissions } from './usePermissions';
import {
fetchOrganisations as fetchOrganisationsApi,
fetchOrganisationById as fetchOrganisationByIdApi,
createOrganisation as createOrganisationApi,
updateOrganisation as updateOrganisationApi,
deleteOrganisation as deleteOrganisationApi,
type TrusteeOrganisation,
type AttributeDefinition,
type PaginationParams
} from '../api/trusteeApi';
// Re-export types
export type { TrusteeOrganisation, AttributeDefinition, PaginationParams };
// Organisations list hook
export function useTrusteeOrganisations() {
const [organisations, setOrganisations] = useState<TrusteeOrganisation[]>([]);
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, TrusteeOrganisation[]>();
const { checkPermission } = usePermissions();
// Fetch attributes from backend
const fetchAttributes = useCallback(async () => {
try {
const response = await api.get('/api/attributes/TrusteeOrganisation');
let attrs: AttributeDefinition[] = [];
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
attrs = response.data.attributes;
} else if (Array.isArray(response.data)) {
attrs = response.data;
} else if (response.data && typeof response.data === 'object') {
const keys = Object.keys(response.data);
for (const key of keys) {
if (Array.isArray(response.data[key])) {
attrs = response.data[key];
break;
}
}
}
setAttributes(attrs);
return attrs;
} catch (error: any) {
console.error('Error fetching attributes:', error);
setAttributes([]);
return [];
}
}, []);
// Fetch permissions from backend
const fetchPermissions = useCallback(async () => {
try {
const perms = await checkPermission('DATA', 'trustee.organisation');
setPermissions(perms);
return perms;
} catch (error: any) {
console.error('Error fetching permissions:', error);
const defaultPerms: UserPermissions = {
view: false,
read: 'n',
create: 'n',
update: 'n',
delete: 'n',
};
setPermissions(defaultPerms);
return defaultPerms;
}
}, [checkPermission]);
const fetchOrganisations = useCallback(async (params?: PaginationParams) => {
try {
const data = await fetchOrganisationsApi(request, params);
if (data && typeof data === 'object' && 'items' in data) {
const items = Array.isArray(data.items) ? data.items : [];
setOrganisations(items);
if (data.pagination) {
setPagination(data.pagination);
}
} else {
const items = Array.isArray(data) ? data : [];
setOrganisations(items);
setPagination(null);
}
} catch (error: any) {
setOrganisations([]);
setPagination(null);
}
}, [request]);
// Optimistically remove an organisation
const removeOptimistically = (organisationId: string) => {
setOrganisations(prevOrgs => prevOrgs.filter(org => org.id !== organisationId));
};
// Optimistically update an organisation
const updateOptimistically = (organisationId: string, updateData: Partial<TrusteeOrganisation>) => {
setOrganisations(prevOrgs =>
prevOrgs.map(org =>
org.id === organisationId
? { ...org, ...updateData }
: org
)
);
};
// Fetch a single organisation by ID
const fetchOrganisationById = useCallback(async (organisationId: string): Promise<TrusteeOrganisation | null> => {
return await fetchOrganisationByIdApi(request, organisationId);
}, [request]);
// Generate edit fields from attributes dynamically
const generateEditFieldsFromAttributes = useCallback((): Array<{
key: string;
label: string;
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly';
editable?: boolean;
required?: boolean;
validator?: (value: any) => string | null;
options?: Array<{ value: string | number; label: string }>;
optionsReference?: string;
}> => {
if (!attributes || attributes.length === 0) {
return [];
}
const editableFields = attributes
.filter(attr => {
if (attr.readonly === true || attr.editable === false) {
return false;
}
const nonEditableFields = ['mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string';
let options: Array<{ value: string | number; label: string }> | undefined = undefined;
let optionsReference: string | undefined = undefined;
if (attr.type === 'checkbox') {
fieldType = 'boolean';
} else if (attr.type === 'email') {
fieldType = 'email';
} else if (attr.type === 'date') {
fieldType = 'date';
} else if (attr.type === 'select') {
fieldType = 'enum';
if (Array.isArray(attr.options)) {
options = attr.options.map(opt => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return {
value: opt.value,
label: labelValue
};
});
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}
} else if (attr.type === 'multiselect') {
fieldType = 'multiselect';
if (Array.isArray(attr.options)) {
options = attr.options.map(opt => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return {
value: opt.value,
label: labelValue
};
});
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}
} else if (attr.type === 'textarea') {
fieldType = 'textarea';
}
let required = attr.required === true;
let validator: ((value: any) => string | null) | undefined = undefined;
// Special validation for 'id' field (alphanumeric + dash/underscore, 3-50 chars)
if (attr.name === 'id') {
required = true;
validator = (value: string) => {
if (!value || value.trim() === '') {
return 'Organisation ID cannot be empty';
}
if (!/^[a-zA-Z0-9_-]{3,50}$/.test(value)) {
return 'ID must be 3-50 characters long (alphanumeric, dash, underscore only)';
}
return null;
};
} else if (attr.name === 'label') {
required = true;
validator = (value: string) => {
if (!value || value.trim() === '') {
return 'Label cannot be empty';
}
return null;
};
}
return {
key: attr.name,
label: attr.label || attr.name,
type: fieldType,
editable: attr.editable !== false && attr.readonly !== true,
required,
validator,
options,
optionsReference
};
});
return editableFields;
}, [attributes]);
// Ensure attributes are loaded
const ensureAttributesLoaded = useCallback(async () => {
if (attributes && attributes.length > 0) {
return attributes;
}
const fetchedAttributes = await fetchAttributes();
return fetchedAttributes;
}, [attributes, fetchAttributes]);
// Fetch attributes and permissions on mount
useEffect(() => {
fetchAttributes();
fetchPermissions();
}, [fetchAttributes, fetchPermissions]);
// Initial fetch
useEffect(() => {
fetchOrganisations();
}, [fetchOrganisations]);
return {
organisations,
loading,
error,
refetch: fetchOrganisations,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
pagination,
fetchOrganisationById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
};
}
// Organisation operations hook
export function useTrusteeOrganisationOperations() {
const [deletingOrganisations, setDeletingOrganisations] = useState<Set<string>>(new Set());
const [creatingOrganisation, setCreatingOrganisation] = useState(false);
const { request, isLoading } = useApiRequest();
const [deleteError, setDeleteError] = useState<string | null>(null);
const [createError, setCreateError] = useState<string | null>(null);
const [updateError, setUpdateError] = useState<string | null>(null);
const handleOrganisationDelete = async (organisationId: string) => {
setDeleteError(null);
setDeletingOrganisations(prev => new Set(prev).add(organisationId));
try {
await deleteOrganisationApi(request, organisationId);
await new Promise(resolve => setTimeout(resolve, 300));
return true;
} catch (error: any) {
setDeleteError(error.message);
return false;
} finally {
setDeletingOrganisations(prev => {
const newSet = new Set(prev);
newSet.delete(organisationId);
return newSet;
});
}
};
const handleOrganisationCreate = async (organisationData: Partial<TrusteeOrganisation>) => {
setCreateError(null);
setCreatingOrganisation(true);
try {
const currentUserData = getUserDataCache();
const mandateId = currentUserData?.mandateId || '';
const requestBody = {
...organisationData,
mandate: mandateId
};
const newOrganisation = await createOrganisationApi(request, requestBody);
return { success: true, organisationData: newOrganisation };
} catch (error: any) {
setCreateError(error.message);
return { success: false, error: error.message };
} finally {
setCreatingOrganisation(false);
}
};
const handleOrganisationUpdate = async (
organisationId: string,
updateData: Partial<TrusteeOrganisation>,
_originalData?: any
) => {
setUpdateError(null);
try {
const currentUserData = getUserDataCache();
const mandateId = currentUserData?.mandateId || '';
const requestBody = {
...updateData,
mandate: mandateId
};
const updatedOrganisation = await updateOrganisationApi(request, organisationId, requestBody);
return { success: true, organisationData: updatedOrganisation };
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.message || 'Failed to update organisation';
const statusCode = error.response?.status;
setUpdateError(errorMessage);
return {
success: false,
error: errorMessage,
statusCode,
isPermissionError: statusCode === 403,
isValidationError: statusCode === 400
};
}
};
return {
deletingOrganisations,
creatingOrganisation,
deleteError,
createError,
updateError,
handleOrganisationDelete,
handleOrganisationCreate,
handleOrganisationUpdate,
isLoading
};
}

View file

@ -0,0 +1,302 @@
import { useState, useEffect, useCallback } from 'react';
import { useApiRequest } from './useApi';
import { getUserDataCache } from '../utils/userCache';
import api from '../api';
import { usePermissions, type UserPermissions } from './usePermissions';
import {
fetchPositionDocuments as fetchPositionDocumentsApi,
fetchPositionDocumentById as fetchPositionDocumentByIdApi,
createPositionDocument as createPositionDocumentApi,
deletePositionDocument as deletePositionDocumentApi,
type TrusteePositionDocument,
type AttributeDefinition,
type PaginationParams
} from '../api/trusteeApi';
// Re-export types
export type { TrusteePositionDocument, AttributeDefinition, PaginationParams };
// Position-Documents list hook
export function useTrusteePositionDocuments() {
const [positionDocuments, setPositionDocuments] = useState<TrusteePositionDocument[]>([]);
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, TrusteePositionDocument[]>();
const { checkPermission } = usePermissions();
// Fetch attributes from backend
const fetchAttributes = useCallback(async () => {
try {
const response = await api.get('/api/attributes/TrusteePositionDocument');
let attrs: AttributeDefinition[] = [];
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
attrs = response.data.attributes;
} else if (Array.isArray(response.data)) {
attrs = response.data;
} else if (response.data && typeof response.data === 'object') {
const keys = Object.keys(response.data);
for (const key of keys) {
if (Array.isArray(response.data[key])) {
attrs = response.data[key];
break;
}
}
}
setAttributes(attrs);
return attrs;
} catch (error: any) {
console.error('Error fetching attributes:', error);
setAttributes([]);
return [];
}
}, []);
// Fetch permissions from backend
const fetchPermissions = useCallback(async () => {
try {
const perms = await checkPermission('DATA', 'trustee.xpositiondocument');
setPermissions(perms);
return perms;
} catch (error: any) {
console.error('Error fetching permissions:', error);
const defaultPerms: UserPermissions = {
view: false,
read: 'n',
create: 'n',
update: 'n',
delete: 'n',
};
setPermissions(defaultPerms);
return defaultPerms;
}
}, [checkPermission]);
const fetchPositionDocuments = useCallback(async (params?: PaginationParams) => {
try {
const data = await fetchPositionDocumentsApi(request, params);
if (data && typeof data === 'object' && 'items' in data) {
const items = Array.isArray(data.items) ? data.items : [];
setPositionDocuments(items);
if (data.pagination) {
setPagination(data.pagination);
}
} else {
const items = Array.isArray(data) ? data : [];
setPositionDocuments(items);
setPagination(null);
}
} catch (error: any) {
setPositionDocuments([]);
setPagination(null);
}
}, [request]);
// Optimistically remove a position-document link
const removeOptimistically = (positionDocumentId: string) => {
setPositionDocuments(prevPD => prevPD.filter(pd => pd.id !== positionDocumentId));
};
// Fetch a single position-document by ID
const fetchPositionDocumentById = useCallback(async (positionDocumentId: string): Promise<TrusteePositionDocument | null> => {
return await fetchPositionDocumentByIdApi(request, positionDocumentId);
}, [request]);
// Generate edit fields from attributes dynamically
const generateEditFieldsFromAttributes = useCallback((): Array<{
key: string;
label: string;
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly';
editable?: boolean;
required?: boolean;
validator?: (value: any) => string | null;
options?: Array<{ value: string | number; label: string }>;
optionsReference?: string;
dependsOn?: string;
}> => {
if (!attributes || attributes.length === 0) {
return [];
}
const editableFields = attributes
.filter(attr => {
if (attr.readonly === true || attr.editable === false) {
return false;
}
const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string';
let options: Array<{ value: string | number; label: string }> | undefined = undefined;
let optionsReference: string | undefined = undefined;
let dependsOn: string | undefined = undefined;
if (attr.type === 'checkbox') {
fieldType = 'boolean';
} else if (attr.type === 'email') {
fieldType = 'email';
} else if (attr.type === 'date') {
fieldType = 'date';
} else if (attr.type === 'select') {
fieldType = 'enum';
if (Array.isArray(attr.options)) {
options = attr.options.map(opt => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return {
value: opt.value,
label: labelValue
};
});
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}
} else if (attr.type === 'textarea') {
fieldType = 'textarea';
}
// Dependency chain: contractId depends on organisationId
// positionId and documentId depend on contractId
if (attr.name === 'contractId') {
dependsOn = 'organisationId';
} else if (attr.name === 'positionId' || attr.name === 'documentId') {
dependsOn = 'contractId';
}
let required = attr.required === true;
let validator: ((value: any) => string | null) | undefined = undefined;
if (attr.name === 'organisationId' || attr.name === 'contractId' ||
attr.name === 'positionId' || attr.name === 'documentId') {
required = true;
validator = (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
return `${attr.label || attr.name} is required`;
}
return null;
};
}
return {
key: attr.name,
label: attr.label || attr.name,
type: fieldType,
editable: attr.editable !== false && attr.readonly !== true,
required,
validator,
options,
optionsReference,
dependsOn
};
});
return editableFields;
}, [attributes]);
// Ensure attributes are loaded
const ensureAttributesLoaded = useCallback(async () => {
if (attributes && attributes.length > 0) {
return attributes;
}
const fetchedAttributes = await fetchAttributes();
return fetchedAttributes;
}, [attributes, fetchAttributes]);
// Fetch attributes and permissions on mount
useEffect(() => {
fetchAttributes();
fetchPermissions();
}, [fetchAttributes, fetchPermissions]);
// Initial fetch
useEffect(() => {
fetchPositionDocuments();
}, [fetchPositionDocuments]);
return {
positionDocuments,
loading,
error,
refetch: fetchPositionDocuments,
removeOptimistically,
attributes,
permissions,
pagination,
fetchPositionDocumentById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
};
}
// Position-Document operations hook
export function useTrusteePositionDocumentOperations() {
const [deletingPositionDocuments, setDeletingPositionDocuments] = useState<Set<string>>(new Set());
const [creatingPositionDocument, setCreatingPositionDocument] = useState(false);
const { request, isLoading } = useApiRequest();
const [deleteError, setDeleteError] = useState<string | null>(null);
const [createError, setCreateError] = useState<string | null>(null);
const handlePositionDocumentDelete = async (positionDocumentId: string) => {
setDeleteError(null);
setDeletingPositionDocuments(prev => new Set(prev).add(positionDocumentId));
try {
await deletePositionDocumentApi(request, positionDocumentId);
await new Promise(resolve => setTimeout(resolve, 300));
return true;
} catch (error: any) {
setDeleteError(error.message);
return false;
} finally {
setDeletingPositionDocuments(prev => {
const newSet = new Set(prev);
newSet.delete(positionDocumentId);
return newSet;
});
}
};
const handlePositionDocumentCreate = async (positionDocumentData: Partial<TrusteePositionDocument>) => {
setCreateError(null);
setCreatingPositionDocument(true);
try {
const currentUserData = getUserDataCache();
const mandateId = currentUserData?.mandateId || '';
const requestBody = {
...positionDocumentData,
mandate: mandateId
};
const newPositionDocument = await createPositionDocumentApi(request, requestBody);
return { success: true, positionDocumentData: newPositionDocument };
} catch (error: any) {
setCreateError(error.message);
return { success: false, error: error.message };
} finally {
setCreatingPositionDocument(false);
}
};
return {
deletingPositionDocuments,
creatingPositionDocument,
deleteError,
createError,
handlePositionDocumentDelete,
handlePositionDocumentCreate,
isLoading
};
}

View file

@ -0,0 +1,417 @@
import { useState, useEffect, useCallback } from 'react';
import { useApiRequest } from './useApi';
import { getUserDataCache } from '../utils/userCache';
import api from '../api';
import { usePermissions, type UserPermissions } from './usePermissions';
import {
fetchPositions as fetchPositionsApi,
fetchPositionById as fetchPositionByIdApi,
createPosition as createPositionApi,
updatePosition as updatePositionApi,
deletePosition as deletePositionApi,
type TrusteePosition,
type AttributeDefinition,
type PaginationParams
} from '../api/trusteeApi';
// Re-export types
export type { TrusteePosition, AttributeDefinition, PaginationParams };
// Positions list hook
export function useTrusteePositions() {
const [positions, setPositions] = useState<TrusteePosition[]>([]);
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, TrusteePosition[]>();
const { checkPermission } = usePermissions();
// Fetch attributes from backend
const fetchAttributes = useCallback(async () => {
try {
const response = await api.get('/api/attributes/TrusteePosition');
let attrs: AttributeDefinition[] = [];
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
attrs = response.data.attributes;
} else if (Array.isArray(response.data)) {
attrs = response.data;
} else if (response.data && typeof response.data === 'object') {
const keys = Object.keys(response.data);
for (const key of keys) {
if (Array.isArray(response.data[key])) {
attrs = response.data[key];
break;
}
}
}
setAttributes(attrs);
return attrs;
} catch (error: any) {
console.error('Error fetching attributes:', error);
setAttributes([]);
return [];
}
}, []);
// Fetch permissions from backend
const fetchPermissions = useCallback(async () => {
try {
const perms = await checkPermission('DATA', 'trustee.position');
setPermissions(perms);
return perms;
} catch (error: any) {
console.error('Error fetching permissions:', error);
const defaultPerms: UserPermissions = {
view: false,
read: 'n',
create: 'n',
update: 'n',
delete: 'n',
};
setPermissions(defaultPerms);
return defaultPerms;
}
}, [checkPermission]);
const fetchPositions = useCallback(async (params?: PaginationParams) => {
try {
const data = await fetchPositionsApi(request, params);
if (data && typeof data === 'object' && 'items' in data) {
const items = Array.isArray(data.items) ? data.items : [];
setPositions(items);
if (data.pagination) {
setPagination(data.pagination);
}
} else {
const items = Array.isArray(data) ? data : [];
setPositions(items);
setPagination(null);
}
} catch (error: any) {
setPositions([]);
setPagination(null);
}
}, [request]);
// Optimistically remove a position
const removeOptimistically = (positionId: string) => {
setPositions(prevPositions => prevPositions.filter(pos => pos.id !== positionId));
};
// Optimistically update a position
const updateOptimistically = (positionId: string, updateData: Partial<TrusteePosition>) => {
setPositions(prevPositions =>
prevPositions.map(pos =>
pos.id === positionId
? { ...pos, ...updateData }
: pos
)
);
};
// Fetch a single position by ID
const fetchPositionById = useCallback(async (positionId: string): Promise<TrusteePosition | null> => {
return await fetchPositionByIdApi(request, positionId);
}, [request]);
// Generate edit fields from attributes dynamically with MwSt calculation logic
const generateEditFieldsFromAttributes = useCallback((): Array<{
key: string;
label: string;
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' | 'number';
editable?: boolean;
required?: boolean;
validator?: (value: any, formData?: any) => string | null;
onChange?: (value: any, formData: any) => Partial<any>;
options?: Array<{ value: string | number; label: string }>;
optionsReference?: string;
dependsOn?: string;
minRows?: number;
maxRows?: number;
}> => {
if (!attributes || attributes.length === 0) {
return [];
}
const editableFields = attributes
.filter(attr => {
if (attr.readonly === true || attr.editable === false) {
return false;
}
const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {
const isDescField = attr.name === 'desc' || attr.name.toLowerCase().includes('description');
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' | 'number' = 'string';
let options: Array<{ value: string | number; label: string }> | undefined = undefined;
let optionsReference: string | undefined = undefined;
let dependsOn: string | undefined = undefined;
let minRows: number | undefined = undefined;
let maxRows: number | undefined = undefined;
let onChange: ((value: any, formData: any) => Partial<any>) | undefined = undefined;
if (isDescField) {
fieldType = 'textarea';
minRows = 3;
maxRows = 8;
} else if (attr.type === 'checkbox') {
fieldType = 'boolean';
} else if (attr.type === 'email') {
fieldType = 'email';
} else if (attr.type === 'date') {
fieldType = 'date';
} else if (attr.type === 'number') {
fieldType = 'number';
} else if (attr.type === 'select') {
fieldType = 'enum';
if (Array.isArray(attr.options)) {
options = attr.options.map(opt => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return {
value: opt.value,
label: labelValue
};
});
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}
} else if (attr.type === 'textarea') {
fieldType = 'textarea';
minRows = minRows || 3;
maxRows = maxRows || 8;
}
// contractId depends on organisationId
if (attr.name === 'contractId') {
dependsOn = 'organisationId';
}
// CUSTOM LOGIC: MwSt-Berechnung
// When bookingAmount or vatPercentage changes, auto-calculate vatAmount
if (attr.name === 'bookingAmount') {
onChange = (value: number, formData: any) => {
const amount = parseFloat(String(value)) || 0;
const percentage = parseFloat(String(formData.vatPercentage)) || 0;
const calculatedVat = amount * (percentage / 100);
return { vatAmount: calculatedVat };
};
} else if (attr.name === 'vatPercentage') {
onChange = (value: number, formData: any) => {
const percentage = parseFloat(String(value)) || 0;
const amount = parseFloat(String(formData.bookingAmount)) || 0;
const calculatedVat = amount * (percentage / 100);
return { vatAmount: calculatedVat };
};
}
let required = attr.required === true;
let validator: ((value: any, formData?: any) => string | null) | undefined = undefined;
// CUSTOM LOGIC: vatAmount validator - warn if manually overridden
if (attr.name === 'vatAmount') {
validator = (value: any, formData?: any) => {
if (!formData) return null;
const vatAmount = parseFloat(String(value)) || 0;
const bookingAmount = parseFloat(String(formData.bookingAmount)) || 0;
const vatPercentage = parseFloat(String(formData.vatPercentage)) || 0;
const calculatedVat = bookingAmount * (vatPercentage / 100);
if (Math.abs(vatAmount - calculatedVat) > 0.01) {
return 'MwSt-Betrag weicht von Berechnung ab (manuell überschrieben)';
}
return null;
};
}
// Standard validators
if (attr.name === 'organisationId' || attr.name === 'contractId') {
required = true;
validator = (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
return `${attr.label || attr.name} is required`;
}
return null;
};
} else if (attr.name === 'valuta' || attr.name === 'transactionDateTime') {
required = true;
} else if (attr.name === 'bookingCurrency' || attr.name === 'originalCurrency') {
required = true;
} else if (attr.name === 'bookingAmount' || attr.name === 'originalAmount') {
required = true;
validator = (value: any) => {
const num = parseFloat(String(value));
if (isNaN(num)) {
return 'Must be a valid number';
}
return null;
};
}
return {
key: attr.name,
label: attr.label || attr.name,
type: fieldType,
editable: attr.editable !== false && attr.readonly !== true,
required,
validator,
onChange,
options,
optionsReference,
dependsOn,
minRows,
maxRows
};
});
return editableFields;
}, [attributes]);
// Ensure attributes are loaded
const ensureAttributesLoaded = useCallback(async () => {
if (attributes && attributes.length > 0) {
return attributes;
}
const fetchedAttributes = await fetchAttributes();
return fetchedAttributes;
}, [attributes, fetchAttributes]);
// Fetch attributes and permissions on mount
useEffect(() => {
fetchAttributes();
fetchPermissions();
}, [fetchAttributes, fetchPermissions]);
// Initial fetch
useEffect(() => {
fetchPositions();
}, [fetchPositions]);
return {
positions,
loading,
error,
refetch: fetchPositions,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
pagination,
fetchPositionById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
};
}
// Position operations hook
export function useTrusteePositionOperations() {
const [deletingPositions, setDeletingPositions] = useState<Set<string>>(new Set());
const [creatingPosition, setCreatingPosition] = useState(false);
const { request, isLoading } = useApiRequest();
const [deleteError, setDeleteError] = useState<string | null>(null);
const [createError, setCreateError] = useState<string | null>(null);
const [updateError, setUpdateError] = useState<string | null>(null);
const handlePositionDelete = async (positionId: string) => {
setDeleteError(null);
setDeletingPositions(prev => new Set(prev).add(positionId));
try {
await deletePositionApi(request, positionId);
await new Promise(resolve => setTimeout(resolve, 300));
return true;
} catch (error: any) {
setDeleteError(error.message);
return false;
} finally {
setDeletingPositions(prev => {
const newSet = new Set(prev);
newSet.delete(positionId);
return newSet;
});
}
};
const handlePositionCreate = async (positionData: Partial<TrusteePosition>) => {
setCreateError(null);
setCreatingPosition(true);
try {
const currentUserData = getUserDataCache();
const mandateId = currentUserData?.mandateId || '';
const requestBody = {
...positionData,
mandate: mandateId
};
const newPosition = await createPositionApi(request, requestBody);
return { success: true, positionData: newPosition };
} catch (error: any) {
setCreateError(error.message);
return { success: false, error: error.message };
} finally {
setCreatingPosition(false);
}
};
const handlePositionUpdate = async (
positionId: string,
updateData: Partial<TrusteePosition>,
_originalData?: any
) => {
setUpdateError(null);
try {
const currentUserData = getUserDataCache();
const mandateId = currentUserData?.mandateId || '';
const requestBody = {
...updateData,
mandate: mandateId
};
const updatedPosition = await updatePositionApi(request, positionId, requestBody);
return { success: true, positionData: updatedPosition };
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.message || 'Failed to update position';
const statusCode = error.response?.status;
setUpdateError(errorMessage);
return {
success: false,
error: errorMessage,
statusCode,
isPermissionError: statusCode === 403,
isValidationError: statusCode === 400
};
}
};
return {
deletingPositions,
creatingPosition,
deleteError,
createError,
updateError,
handlePositionDelete,
handlePositionCreate,
handlePositionUpdate,
isLoading
};
}

View file

@ -0,0 +1,368 @@
import { useState, useEffect, useCallback } from 'react';
import { useApiRequest } from './useApi';
import { getUserDataCache } from '../utils/userCache';
import api from '../api';
import { usePermissions, type UserPermissions } from './usePermissions';
import {
fetchRoles as fetchRolesApi,
fetchRoleById as fetchRoleByIdApi,
createRole as createRoleApi,
updateRole as updateRoleApi,
deleteRole as deleteRoleApi,
type TrusteeRole,
type AttributeDefinition,
type PaginationParams
} from '../api/trusteeApi';
// Re-export types
export type { TrusteeRole, AttributeDefinition, PaginationParams };
// Roles list hook
export function useTrusteeRoles() {
const [roles, setRoles] = useState<TrusteeRole[]>([]);
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, TrusteeRole[]>();
const { checkPermission } = usePermissions();
// Fetch attributes from backend
const fetchAttributes = useCallback(async () => {
try {
const response = await api.get('/api/attributes/TrusteeRole');
let attrs: AttributeDefinition[] = [];
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
attrs = response.data.attributes;
} else if (Array.isArray(response.data)) {
attrs = response.data;
} else if (response.data && typeof response.data === 'object') {
const keys = Object.keys(response.data);
for (const key of keys) {
if (Array.isArray(response.data[key])) {
attrs = response.data[key];
break;
}
}
}
setAttributes(attrs);
return attrs;
} catch (error: any) {
console.error('Error fetching attributes:', error);
setAttributes([]);
return [];
}
}, []);
// Fetch permissions from backend
const fetchPermissions = useCallback(async () => {
try {
const perms = await checkPermission('DATA', 'trustee.role');
setPermissions(perms);
return perms;
} catch (error: any) {
console.error('Error fetching permissions:', error);
const defaultPerms: UserPermissions = {
view: false,
read: 'n',
create: 'n',
update: 'n',
delete: 'n',
};
setPermissions(defaultPerms);
return defaultPerms;
}
}, [checkPermission]);
const fetchRoles = useCallback(async (params?: PaginationParams) => {
try {
const data = await fetchRolesApi(request, params);
if (data && typeof data === 'object' && 'items' in data) {
const items = Array.isArray(data.items) ? data.items : [];
setRoles(items);
if (data.pagination) {
setPagination(data.pagination);
}
} else {
const items = Array.isArray(data) ? data : [];
setRoles(items);
setPagination(null);
}
} catch (error: any) {
setRoles([]);
setPagination(null);
}
}, [request]);
// Optimistically remove a role
const removeOptimistically = (roleId: string) => {
setRoles(prevRoles => prevRoles.filter(role => role.id !== roleId));
};
// Optimistically update a role
const updateOptimistically = (roleId: string, updateData: Partial<TrusteeRole>) => {
setRoles(prevRoles =>
prevRoles.map(role =>
role.id === roleId
? { ...role, ...updateData }
: role
)
);
};
// Fetch a single role by ID
const fetchRoleById = useCallback(async (roleId: string): Promise<TrusteeRole | null> => {
return await fetchRoleByIdApi(request, roleId);
}, [request]);
// Generate edit fields from attributes dynamically
// Ensure attributes are loaded
const ensureAttributesLoaded = useCallback(async () => {
if (attributes && attributes.length > 0) {
return attributes;
}
const fetchedAttributes = await fetchAttributes();
return fetchedAttributes;
}, [attributes, fetchAttributes]);
// Fetch attributes and permissions on mount
useEffect(() => {
fetchAttributes();
fetchPermissions();
}, [fetchAttributes, fetchPermissions]);
// Initial fetch
useEffect(() => {
fetchRoles();
}, [fetchRoles]);
const generateEditFieldsFromAttributes = useCallback((): Array<{
key: string;
label: string;
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly';
editable?: boolean;
required?: boolean;
validator?: (value: any) => string | null;
minRows?: number;
maxRows?: number;
options?: Array<{ value: string | number; label: string }>;
optionsReference?: string;
}> => {
if (!attributes || attributes.length === 0) {
return [];
}
const editableFields = attributes
.filter(attr => {
if (attr.readonly === true || attr.editable === false) {
return false;
}
const nonEditableFields = ['mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {
const isDescField = attr.name === 'desc' || attr.name.toLowerCase().includes('description');
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string';
let options: Array<{ value: string | number; label: string }> | undefined = undefined;
let optionsReference: string | undefined = undefined;
let minRows: number | undefined = undefined;
let maxRows: number | undefined = undefined;
if (isDescField) {
fieldType = 'textarea';
minRows = 3;
maxRows = 8;
} else if (attr.type === 'checkbox') {
fieldType = 'boolean';
} else if (attr.type === 'email') {
fieldType = 'email';
} else if (attr.type === 'date') {
fieldType = 'date';
} else if (attr.type === 'select') {
fieldType = 'enum';
if (Array.isArray(attr.options)) {
options = attr.options.map(opt => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return {
value: opt.value,
label: labelValue
};
});
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}
} else if (attr.type === 'textarea') {
fieldType = 'textarea';
minRows = minRows || 3;
maxRows = maxRows || 8;
}
let required = attr.required === true;
let validator: ((value: any) => string | null) | undefined = undefined;
if (attr.name === 'id') {
required = true;
validator = (value: string) => {
if (!value || value.trim() === '') {
return 'Role ID cannot be empty';
}
if (!/^[a-zA-Z0-9_-]{3,50}$/.test(value)) {
return 'ID must be 3-50 characters long (alphanumeric, dash, underscore only)';
}
return null;
};
} else if (attr.name === 'desc') {
required = true;
validator = (value: string) => {
if (!value || value.trim() === '') {
return 'Description cannot be empty';
}
return null;
};
}
return {
key: attr.name,
label: attr.label || attr.name,
type: fieldType,
editable: attr.editable !== false && attr.readonly !== true,
required,
validator,
minRows,
maxRows,
options,
optionsReference
};
});
return editableFields;
}, [attributes]);
return {
roles,
loading,
error,
refetch: fetchRoles,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
pagination,
fetchRoleById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
};
}
// Role operations hook
export function useTrusteeRoleOperations() {
const [deletingRoles, setDeletingRoles] = useState<Set<string>>(new Set());
const [creatingRole, setCreatingRole] = useState(false);
const { request, isLoading } = useApiRequest();
const [deleteError, setDeleteError] = useState<string | null>(null);
const [createError, setCreateError] = useState<string | null>(null);
const [updateError, setUpdateError] = useState<string | null>(null);
const handleRoleDelete = async (roleId: string) => {
setDeleteError(null);
setDeletingRoles(prev => new Set(prev).add(roleId));
try {
await deleteRoleApi(request, roleId);
await new Promise(resolve => setTimeout(resolve, 300));
return true;
} catch (error: any) {
const errorMessage = error.response?.data?.detail || error.message || 'Failed to delete role';
// Backend returns error if role is in use
setDeleteError(errorMessage);
return false;
} finally {
setDeletingRoles(prev => {
const newSet = new Set(prev);
newSet.delete(roleId);
return newSet;
});
}
};
const handleRoleCreate = async (roleData: Partial<TrusteeRole>) => {
setCreateError(null);
setCreatingRole(true);
try {
const currentUserData = getUserDataCache();
const mandateId = currentUserData?.mandateId || '';
const requestBody = {
...roleData,
mandate: mandateId
};
const newRole = await createRoleApi(request, requestBody);
return { success: true, roleData: newRole };
} catch (error: any) {
setCreateError(error.message);
return { success: false, error: error.message };
} finally {
setCreatingRole(false);
}
};
const handleRoleUpdate = async (
roleId: string,
updateData: Partial<TrusteeRole>,
_originalData?: any
) => {
setUpdateError(null);
try {
const currentUserData = getUserDataCache();
const mandateId = currentUserData?.mandateId || '';
const requestBody = {
...updateData,
mandate: mandateId
};
const updatedRole = await updateRoleApi(request, roleId, requestBody);
return { success: true, roleData: updatedRole };
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.message || 'Failed to update role';
const statusCode = error.response?.status;
setUpdateError(errorMessage);
return {
success: false,
error: errorMessage,
statusCode,
isPermissionError: statusCode === 403,
isValidationError: statusCode === 400
};
}
};
return {
deletingRoles,
creatingRole,
deleteError,
createError,
updateError,
handleRoleDelete,
handleRoleCreate,
handleRoleUpdate,
isLoading
};
}

180
src/pages/FeatureView.tsx Normal file
View file

@ -0,0 +1,180 @@
/**
* FeatureView Page
*
* Generische Feature-View-Komponente.
* Rendert den entsprechenden Content basierend auf Feature-Code und View.
*/
import React from 'react';
import { useCurrentInstance } from '../hooks/useCurrentInstance';
import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
import { getLabel, FEATURE_REGISTRY } from '../types/mandate';
// Trustee Views
// Note: TrusteeOrganisationsView and TrusteeContractsView removed - Feature-Instanz = Organisation
import { TrusteeDocumentsView } from './views/trustee/TrusteeDocumentsView';
import { TrusteePositionsView } from './views/trustee/TrusteePositionsView';
import { TrusteePositionDocumentsView } from './views/trustee/TrusteePositionDocumentsView';
import { TrusteeDashboardView } from './views/trustee/TrusteeDashboardView';
import { TrusteeInstanceRolesView } from './views/trustee/TrusteeInstanceRolesView';
import { TrusteeExpenseImportView } from './views/trustee/TrusteeExpenseImportView';
// Chatbot Views
import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsView';
// RealEstate Views
import { RealEstatePekView, RealEstateProjectsView, RealEstateParcelsView, RealEstateInstanceRolesPlaceholder } from './views/realestate';
import styles from './FeatureView.module.css';
// =============================================================================
// PLACEHOLDER VIEWS (für nicht implementierte Features)
// =============================================================================
const PlaceholderView: React.FC<{ title: string; description: string }> = ({ title, description }) => (
<div className={styles.placeholder}>
<h2>{title}</h2>
<p>{description}</p>
</div>
);
// Chatworkflow Views
const ChatworkflowDashboard: React.FC = () => (
<PlaceholderView title="Workflow Dashboard" description="Übersicht der Workflows" />
);
const ChatworkflowRuns: React.FC = () => (
<PlaceholderView title="Runs" description="Workflow-Ausführungen" />
);
const ChatworkflowFiles: React.FC = () => (
<PlaceholderView title="Dateien" description="Workflow-Dateien" />
);
// Chatbot Views
// ChatbotConversationsView is imported above
const ChatbotSettings: React.FC = () => (
<PlaceholderView title="Chatbot Einstellungen" description="Konfiguration des Chatbots" />
);
// Generic/Fallback
const NotFound: React.FC = () => (
<div className={styles.notFound}>
<h2>Seite nicht gefunden</h2>
<p>Diese View existiert nicht oder wurde noch nicht implementiert.</p>
</div>
);
const AccessDenied: React.FC = () => (
<div className={styles.accessDenied}>
<h2>Zugriff verweigert</h2>
<p>Du hast keine Berechtigung für diese Ansicht.</p>
</div>
);
// =============================================================================
// VIEW REGISTRY
// =============================================================================
type ViewComponent = React.FC;
const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
trustee: {
dashboard: TrusteeDashboardView,
documents: TrusteeDocumentsView,
positions: TrusteePositionsView,
'position-documents': TrusteePositionDocumentsView,
'instance-roles': TrusteeInstanceRolesView,
'expense-import': TrusteeExpenseImportView,
},
chatworkflow: {
dashboard: ChatworkflowDashboard,
runs: ChatworkflowRuns,
files: ChatworkflowFiles,
},
chatbot: {
conversations: ChatbotConversationsView,
settings: ChatbotSettings,
},
realestate: {
dashboard: RealEstatePekView,
projects: RealEstateProjectsView,
parcels: RealEstateParcelsView,
'instance-roles': RealEstateInstanceRolesPlaceholder,
},
};
// =============================================================================
// FEATURE VIEW PAGE
// =============================================================================
interface FeatureViewPageProps {
view: string;
}
export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
const { instance, featureCode, isValid } = useCurrentInstance();
// Berechtigungs-Check
const viewCode = `${featureCode}-${view}`;
const canView = useCanViewFeatureView(viewCode);
// DEBUG: Log permission check for chatbot
if (featureCode === 'chatbot') {
console.log('🔍 [DEBUG] FeatureView Permission Check:', {
featureCode,
view,
viewCode,
instanceId: instance?.id,
instanceLabel: instance?.instanceLabel,
isValid,
canView,
permissions: instance?.permissions,
views: instance?.permissions?.views,
viewKeys: instance?.permissions?.views ? Object.keys(instance.permissions.views) : [],
hasLegacyView: instance?.permissions?.views?.[viewCode],
hasFullObjectKey: instance?.permissions?.views?.[`ui.feature.${featureCode}.${view}`],
hasWildcard: instance?.permissions?.views?.['_all'],
});
}
// Nicht valider Kontext
if (!isValid || !featureCode || !instance) {
return <NotFound />;
}
// Keine Berechtigung
if (!canView && view !== 'not-found') {
return <AccessDenied />;
}
// View-Komponente finden
const featureViews = VIEW_COMPONENTS[featureCode];
if (!featureViews) {
return <NotFound />;
}
const ViewComponent = featureViews[view];
if (!ViewComponent) {
return <NotFound />;
}
// View-Info aus Registry
const featureConfig = FEATURE_REGISTRY[featureCode];
const viewConfig = featureConfig?.views?.find(v => v.code === view);
const viewLabel = viewConfig ? getLabel(viewConfig.label) : view;
return (
<div className={styles.featureView}>
<header className={styles.viewHeader}>
<h1 className={styles.viewTitle}>{viewLabel}</h1>
</header>
<main className={styles.viewContent}>
<ViewComponent />
</main>
</div>
);
};
export default FeatureViewPage;

View file

@ -0,0 +1,82 @@
/**
* RealEstateDashboardView
*
* Übersicht/Dashboard für eine Real-Estate-Instanz (PEK).
* Zeigt Kennzahlen und Links zu Projekten und Parzellen.
*/
import React from 'react';
import { Link } from 'react-router-dom';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import { useRealEstateProjects, useRealEstateParcels } from '../../../hooks/useRealEstate';
import styles from '../trustee/TrusteeViews.module.css';
export const RealEstateDashboardView: React.FC = () => {
const { instance } = useCurrentInstance();
const { items: projects, loading: projectsLoading } = useRealEstateProjects();
const { items: parcels, loading: parcelsLoading } = useRealEstateParcels();
const isLoading = projectsLoading || parcelsLoading;
return (
<div className={styles.dashboardView}>
<div className={styles.statsGrid}>
{/* Projekte Link-Karte */}
<Link to="projects" className={styles.statCard} style={{ textDecoration: 'none', color: 'inherit' }}>
<div className={styles.statIcon}>📋</div>
<div className={styles.statContent}>
<div className={styles.statValue}>
{isLoading ? '...' : projects.length}
</div>
<div className={styles.statLabel}>Projekte</div>
</div>
</Link>
{/* Parzellen Link-Karte */}
<Link to="parcels" className={styles.statCard} style={{ textDecoration: 'none', color: 'inherit' }}>
<div className={styles.statIcon}>🗺</div>
<div className={styles.statContent}>
<div className={styles.statValue}>
{isLoading ? '...' : parcels.length}
</div>
<div className={styles.statLabel}>Parzellen</div>
</div>
</Link>
{/* Rollen (optional) */}
{instance?.userRoles?.length ? (
<div className={styles.statCard}>
<div className={styles.statIcon}>👤</div>
<div className={styles.statContent}>
<div className={styles.statValueSmall}>
{instance.userRoles.map((role, idx) => (
<div key={idx}>{role}</div>
))}
</div>
<div className={styles.statLabel}>
{instance.userRoles.length === 1 ? 'Deine Rolle' : 'Deine Rollen'}
</div>
</div>
</div>
) : null}
</div>
{/* Instanz-Infos */}
<div className={styles.infoSection}>
<h3>Instanz-Details</h3>
<div className={styles.infoGrid}>
<div className={styles.infoItem}>
<span className={styles.infoLabel}>Instanz:</span>
<span className={styles.infoValue}>{instance?.instanceLabel}</span>
</div>
<div className={styles.infoItem}>
<span className={styles.infoLabel}>Mandant:</span>
<span className={styles.infoValue}>{instance?.mandateName}</span>
</div>
</div>
</div>
</div>
);
};
export default RealEstateDashboardView;

View file

@ -0,0 +1,32 @@
/**
* RealEstateInstanceRolesPlaceholder
*
* Platzhalter für die View "Rollen & Rechte" bei Real-Estate-Instanzen.
* Zeigt einen Hinweis und Link zur Administration (Feature-Instanz Benutzer / Feature-Rollen),
* bis ein generisches Instance-Roles-UI verfügbar ist.
*/
import React from 'react';
import { Link } from 'react-router-dom';
import styles from '../trustee/TrusteeViews.module.css';
export const RealEstateInstanceRolesPlaceholder: React.FC = () => {
return (
<div className={styles.infoSection} style={{ maxWidth: 560 }}>
<h3>Rollen & Rechte</h3>
<p style={{ margin: '0 0 1rem', color: 'var(--text-secondary, #666)', fontSize: '0.9375rem', lineHeight: 1.5 }}>
Die Verwaltung von Rollen und Benutzern für diese Instanz erfolgt in der Administration.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<Link to="/admin/feature-users" className={styles.primaryButton} style={{ display: 'inline-block', textAlign: 'center', textDecoration: 'none' }}>
Feature-Instanz Benutzer
</Link>
<Link to="/admin/feature-roles" className={styles.secondaryButton} style={{ display: 'inline-block', textAlign: 'center', textDecoration: 'none' }}>
Feature-Rollen
</Link>
</div>
</div>
);
};
export default RealEstateInstanceRolesPlaceholder;

View file

@ -0,0 +1,266 @@
/**
* RealEstateParcelsView
*
* Parzellen-Verwaltung für eine Real Estate/PEK-Instanz.
* Verwendet FormGeneratorTable analog zu TrusteeDocumentsView.
*/
import React, { useState, useMemo, useEffect } from 'react';
import {
useRealEstateParcels,
useRealEstateParcelOperations,
type RealEstateParcel,
} from '../../../hooks/useRealEstate';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
import { FaSync, FaMapMarkerAlt } from 'react-icons/fa';
import styles from '../../admin/Admin.module.css';
export const RealEstateParcelsView: React.FC = () => {
const instanceId = useInstanceId();
const {
items: parcels,
attributes,
permissions,
pagination,
loading,
error,
refetch,
fetchById,
updateOptimistically,
removeOptimistically,
} = useRealEstateParcels();
const {
handleDelete,
handleCreate,
handleUpdate,
deletingItems,
} = useRealEstateParcelOperations();
const [editingParcel, setEditingParcel] = useState<RealEstateParcel | null>(null);
const [isCreateMode, setIsCreateMode] = useState(false);
useEffect(() => {
if (instanceId) {
refetch();
}
}, [instanceId, refetch]);
const columns = useMemo(() => {
return (attributes || []).map(attr => ({
key: attr.name,
label: attr.label || attr.name,
type: attr.type as 'string' | 'number' | 'date' | 'boolean',
sortable: attr.sortable !== false,
filterable: attr.filterable !== false,
searchable: attr.searchable !== false,
width: attr.width || 150,
minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400,
}));
}, [attributes]);
const canCreate = permissions?.create !== 'n';
const canUpdate = permissions?.update !== 'n';
const canDelete = permissions?.delete !== 'n';
const handleEditClick = async (parcel: RealEstateParcel) => {
const full = await fetchById(parcel.id);
if (full) {
setEditingParcel(full);
setIsCreateMode(false);
}
};
const handleCreateClick = () => {
setEditingParcel(null);
setIsCreateMode(true);
};
const handleFormSubmit = async (data: Partial<RealEstateParcel>) => {
if (isCreateMode) {
const result = await handleCreate(data);
if (result.success) {
setIsCreateMode(false);
refetch();
}
} else if (editingParcel) {
const result = await handleUpdate(editingParcel.id, data);
if (result.success) {
setEditingParcel(null);
refetch();
}
}
};
const handleDeleteParcel = async (parcel: RealEstateParcel) => {
removeOptimistically(parcel.id);
const success = await handleDelete(parcel.id);
if (!success) {
refetch();
}
};
const handleCloseModal = () => {
setEditingParcel(null);
setIsCreateMode(false);
};
const formAttributes = useMemo(() => {
const excluded = ['id', 'mandateId', 'instanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy'];
return (attributes || []).filter(attr => !excluded.includes(attr.name));
}, [attributes]);
const handleInlineUpdate = async (
itemId: string,
updateData: Partial<RealEstateParcel>,
row: RealEstateParcel
) => {
updateOptimistically(itemId, updateData);
const result = await handleUpdate(itemId, { ...row, ...updateData });
if (!result.success) {
refetch();
}
};
if (error) {
return (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler beim Laden der Parzellen: {error}</p>
<button className={styles.secondaryButton} onClick={() => refetch()}>
<FaSync /> Erneut versuchen
</button>
</div>
</div>
);
}
return (
<div className={styles.adminPage}>
<div className={styles.pageHeader}>
<div>
<p className={styles.pageSubtitle}>Parzellen verwalten</p>
</div>
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => refetch()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
{canCreate && (
<button className={styles.primaryButton} onClick={handleCreateClick}>
+ Neue Parzelle
</button>
)}
</div>
</div>
<div className={styles.tableContainer}>
{loading && (!parcels || parcels.length === 0) ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Parzellen...</span>
</div>
) : !parcels || parcels.length === 0 ? (
<div className={styles.emptyState}>
<FaMapMarkerAlt className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Parzellen vorhanden</h3>
<p className={styles.emptyDescription}>
Erstellen Sie eine neue Parzelle, um zu beginnen.
</p>
{canCreate && (
<button className={styles.primaryButton} onClick={handleCreateClick}>
+ Neue Parzelle
</button>
)}
</div>
) : (
<FormGeneratorTable
data={parcels}
columns={columns}
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
actionButtons={[
...(canUpdate
? [
{
type: 'edit' as const,
onAction: handleEditClick,
title: 'Bearbeiten',
},
]
: []),
...(canDelete
? [
{
type: 'delete' as const,
title: 'Löschen',
loading: (row: RealEstateParcel) => deletingItems.has(row.id),
},
]
: []),
]}
onDelete={handleDeleteParcel}
hookData={{
refetch,
permissions,
pagination,
handleDelete,
handleInlineUpdate,
updateOptimistically,
}}
emptyMessage="Keine Parzellen gefunden"
/>
)}
</div>
{(editingParcel || isCreateMode) && (
<div className={styles.modalOverlay} onClick={handleCloseModal}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>
{isCreateMode ? 'Neue Parzelle' : 'Parzelle bearbeiten'}
</h2>
<button className={styles.modalClose} onClick={handleCloseModal}>
</button>
</div>
<div className={styles.modalContent}>
{formAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
</div>
) : (
<FormGeneratorForm
attributes={formAttributes}
data={editingParcel || {}}
mode={isCreateMode ? 'create' : 'edit'}
onSubmit={handleFormSubmit}
onCancel={handleCloseModal}
submitButtonText={isCreateMode ? 'Erstellen' : 'Speichern'}
cancelButtonText="Abbrechen"
instanceId={instanceId}
/>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default RealEstateParcelsView;

View file

@ -0,0 +1,113 @@
/**
* RealEstatePekView
*
* PEK-UI für eine Real-Estate-Instanz: Karte, Adresseingabe, optional Command-Eingabe und Nachrichten.
* Wird als Dashboard-View gerendert, wenn der Benutzer auf "PEK" in der Sidebar klickt.
*/
import React from 'react';
import { IoMdSend } from 'react-icons/io';
import { PekProvider, usePekContext } from '../../../contexts/PekContext';
import { Button, TextField } from '../../../components/UiComponents';
import PekLocationInput from './pek/PekLocationInput';
import PekMapView from './pek/PekMapView';
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from '../trustee/TrusteeViews.module.css';
function RealEstatePekViewContent() {
const {
commandInput,
setCommandInput,
processCommand,
isProcessingCommand,
commandResults
} = usePekContext();
const { t } = useLanguage();
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (commandInput.trim()) {
processCommand(commandInput.trim());
}
};
return (
<div className={styles.dashboardView}>
<p className={styles.muted} style={{ marginBottom: '1rem' }}>
{t('projects.description_text')}
</p>
<PekLocationInput />
<PekMapView />
{/* Optional: Command input and results */}
<section style={{ marginTop: '2rem' }}>
<form onSubmit={onSubmit} className={styles.form} style={{ maxWidth: 600 }}>
<div className={styles.formField}>
<label htmlFor="pek-command">
{t('projects.command.placeholder')}
</label>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-end' }}>
<div style={{ flex: 1 }}>
<TextField
id="pek-command"
value={commandInput}
onChange={setCommandInput}
placeholder={t('projects.command.placeholder')}
disabled={isProcessingCommand}
size="md"
type="text"
name="command"
/>
</div>
<Button
type="submit"
variant="primary"
size="md"
icon={IoMdSend}
disabled={!commandInput.trim() || isProcessingCommand}
loading={isProcessingCommand}
>
Senden
</Button>
</div>
</div>
</form>
{commandResults.length > 0 && (
<div style={{ marginTop: '1rem', maxWidth: 800 }}>
<h3 style={{ fontSize: '1rem', marginBottom: '0.5rem' }}>Antworten</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{commandResults.map((msg: any) => (
<div
key={msg.id}
style={{
padding: '0.75rem 1rem',
background: 'var(--surface-color, #f8f9fa)',
border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 8,
fontSize: '0.875rem',
whiteSpace: 'pre-wrap'
}}
>
<strong>{msg.role === 'user' ? 'Sie' : 'Assistent'}:</strong>{' '}
{typeof msg.message === 'string' ? msg.message : JSON.stringify(msg.message)}
</div>
))}
</div>
</div>
)}
</section>
</div>
);
}
export const RealEstatePekView: React.FC = () => {
return (
<PekProvider>
<RealEstatePekViewContent />
</PekProvider>
);
};
export default RealEstatePekView;

View file

@ -0,0 +1,223 @@
/**
* RealEstateProjectsView
*
* Projekt-Verwaltung für eine Real Estate/PEK-Instanz.
* Verwendet FormGeneratorTable analog zu TrusteeDocumentsView.
*/
import React, { useState, useMemo, useEffect } from 'react';
import {
useRealEstateProjects,
useRealEstateProjectOperations,
type RealEstateProject,
} from '../../../hooks/useRealEstate';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
import { FaSync, FaBuilding } from 'react-icons/fa';
import styles from '../../admin/Admin.module.css';
export const RealEstateProjectsView: React.FC = () => {
const instanceId = useInstanceId();
const {
items: projects,
attributes,
permissions,
pagination,
loading,
error,
refetch,
fetchById,
updateOptimistically,
removeOptimistically,
} = useRealEstateProjects();
const {
handleDelete,
handleCreate,
handleUpdate,
deletingItems,
} = useRealEstateProjectOperations();
const [editingProject, setEditingProject] = useState<RealEstateProject | null>(null);
const [isCreateMode, setIsCreateMode] = useState(false);
useEffect(() => {
if (instanceId) refetch();
}, [instanceId, refetch]);
const columns = useMemo(() => {
return (attributes || []).map(attr => ({
key: attr.name,
label: attr.label || attr.name,
type: (attr.type || 'string') as 'string' | 'number' | 'date' | 'boolean',
sortable: attr.sortable !== false,
filterable: attr.filterable !== false,
searchable: attr.searchable !== false,
width: attr.width || 150,
minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400,
}));
}, [attributes]);
const canCreate = permissions?.create !== 'n';
const canUpdate = permissions?.update !== 'n';
const canDelete = permissions?.delete !== 'n';
const handleEditClick = async (project: RealEstateProject) => {
const full = await fetchById(project.id);
if (full) {
setEditingProject(full);
setIsCreateMode(false);
}
};
const handleCreateClick = () => {
setEditingProject(null);
setIsCreateMode(true);
};
const handleFormSubmit = async (data: Partial<RealEstateProject>) => {
if (isCreateMode) {
const result = await handleCreate(data);
if (result.success) {
setIsCreateMode(false);
refetch();
}
} else if (editingProject) {
const result = await handleUpdate(editingProject.id, data);
if (result.success) {
setEditingProject(null);
refetch();
}
}
};
const handleDeleteProject = async (project: RealEstateProject) => {
removeOptimistically(project.id);
const success = await handleDelete(project.id);
if (!success) refetch();
};
const handleCloseModal = () => {
setEditingProject(null);
setIsCreateMode(false);
};
const formAttributes = useMemo(() => {
const excluded = ['id', 'mandateId', 'instanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy'];
return (attributes || []).filter(attr => !excluded.includes(attr.name));
}, [attributes]);
const handleInlineUpdate = async (itemId: string, updateData: Partial<RealEstateProject>, row: RealEstateProject) => {
updateOptimistically(itemId, updateData);
const result = await handleUpdate(itemId, { ...row, ...updateData });
if (!result.success) refetch();
};
if (error) {
return (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler beim Laden der Projekte: {error}</p>
<button className={styles.secondaryButton} onClick={() => refetch()}>
<FaSync /> Erneut versuchen
</button>
</div>
</div>
);
}
return (
<div className={styles.adminPage}>
<div className={styles.pageHeader}>
<div>
<p className={styles.pageSubtitle}>Projekte verwalten</p>
</div>
<div className={styles.headerActions}>
<button className={styles.secondaryButton} onClick={() => refetch()} disabled={loading}>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
{canCreate && (
<button className={styles.primaryButton} onClick={handleCreateClick}>
+ Neues Projekt
</button>
)}
</div>
</div>
<div className={styles.tableContainer}>
{loading && (!projects || projects.length === 0) ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Projekte...</span>
</div>
) : !projects || projects.length === 0 ? (
<div className={styles.emptyState}>
<FaBuilding className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Projekte vorhanden</h3>
<p className={styles.emptyDescription}>Erstellen Sie ein neues Projekt, um zu beginnen.</p>
{canCreate && (
<button className={styles.primaryButton} onClick={handleCreateClick}>
+ Neues Projekt
</button>
)}
</div>
) : (
<FormGeneratorTable
data={projects}
columns={columns}
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
actionButtons={[
...(canUpdate ? [{ type: 'edit' as const, onAction: handleEditClick, title: 'Bearbeiten' }] : []),
...(canDelete ? [{ type: 'delete' as const, title: 'Löschen', loading: (row: RealEstateProject) => deletingItems.has(row.id) }] : []),
]}
onDelete={handleDeleteProject}
hookData={{ refetch, permissions, pagination, handleDelete, handleInlineUpdate, updateOptimistically }}
emptyMessage="Keine Projekte gefunden"
/>
)}
</div>
{(editingProject || isCreateMode) && (
<div className={styles.modalOverlay} onClick={handleCloseModal}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{isCreateMode ? 'Neues Projekt' : 'Projekt bearbeiten'}</h2>
<button className={styles.modalClose} onClick={handleCloseModal}></button>
</div>
<div className={styles.modalContent}>
{formAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
</div>
) : (
<FormGeneratorForm
attributes={formAttributes}
data={editingProject || {}}
mode={isCreateMode ? 'create' : 'edit'}
onSubmit={handleFormSubmit}
onCancel={handleCloseModal}
submitButtonText={isCreateMode ? 'Erstellen' : 'Speichern'}
cancelButtonText="Abbrechen"
instanceId={instanceId}
/>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default RealEstateProjectsView;

View file

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

View file

@ -0,0 +1,63 @@
.locationInputContainer {
width: 100%;
margin-bottom: 1.5rem;
}
.fieldsRow {
display: flex;
gap: 1rem;
align-items: flex-end;
}
.fieldWrapper {
flex: 1;
}
.buttonsWrapper {
display: flex;
flex-direction: row;
gap: 0.5rem;
min-width: 150px;
}
.searchButton {
white-space: nowrap;
}
.locationButton {
white-space: nowrap;
}
@media (max-width: 1024px) {
.fieldsRow {
flex-wrap: wrap;
}
.buttonsWrapper {
width: 100%;
}
.fieldWrapper {
min-width: calc(50% - 0.5rem);
}
}
@media (max-width: 768px) {
.fieldsRow {
flex-direction: column;
}
.fieldWrapper {
width: 100%;
min-width: 100%;
}
.buttonsWrapper {
width: 100%;
}
.searchButton,
.locationButton {
flex: 1;
}
}

View file

@ -0,0 +1,80 @@
import React from 'react';
import { TextField, Button } from '../../../../components/UiComponents';
import { FaLocationArrow } from 'react-icons/fa';
import { IoMdSend } from 'react-icons/io';
import { usePekContext } from '../../../../contexts/PekContext';
import styles from './PekLocationInput.module.css';
const PekLocationInput: React.FC = () => {
const {
adresse,
setAdresse,
buildLocationString,
useCurrentLocation,
isGettingLocation,
searchParcel,
isSearchingParcel
} = usePekContext();
const handleSearch = async () => {
const locationString = buildLocationString();
if (locationString.trim()) {
await searchParcel(locationString.trim(), true);
}
};
const handleUseCurrentLocation = async () => {
await useCurrentLocation();
};
return (
<div className={styles.locationInputContainer}>
<div className={styles.fieldsRow}>
<div className={styles.fieldWrapper}>
<TextField
value={adresse}
onChange={setAdresse}
placeholder="z.B. Bundesplatz 3"
label="Adresse oder Parzelle"
disabled={isGettingLocation || isSearchingParcel}
size="md"
type="text"
name="adresse"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSearch();
}
}}
/>
</div>
<div className={styles.buttonsWrapper}>
<Button
variant="primary"
size="md"
icon={IoMdSend}
onClick={handleSearch}
disabled={!buildLocationString().trim() || isGettingLocation || isSearchingParcel}
loading={isSearchingParcel}
className={styles.searchButton}
>
Suchen
</Button>
<Button
variant="secondary"
size="md"
icon={FaLocationArrow}
onClick={handleUseCurrentLocation}
disabled={isGettingLocation || isSearchingParcel}
loading={isGettingLocation}
className={styles.locationButton}
>
Meine Position
</Button>
</div>
</div>
</div>
);
};
export default PekLocationInput;

View file

@ -0,0 +1,58 @@
import React from 'react';
import { MapView, ParcelInfoPanel } from '../../../../components/UiComponents';
import { usePekContext } from '../../../../contexts/PekContext';
const PekMapView: React.FC = () => {
const {
mapCenter,
mapZoomBounds,
parcelGeometries,
handleMapClick,
handleParcelClick,
selectedParcels,
removeParcel,
isPanelOpen,
setIsPanelOpen
} = usePekContext();
// Aggregate all adjacent parcels from all selected parcels
const allAdjacentParcels = React.useMemo(() => {
const adjacentSet = new Map<string, any>();
selectedParcels.forEach((parcel) => {
if (parcel.adjacent_parcels) {
parcel.adjacent_parcels.forEach((adj: { id: string }) => {
if (!adjacentSet.has(adj.id)) {
adjacentSet.set(adj.id, adj);
}
});
}
});
return Array.from(adjacentSet.values());
}, [selectedParcels]);
return (
<>
<div style={{ marginBottom: '1.5rem' }}>
<MapView
parcels={parcelGeometries}
center={mapCenter || undefined}
zoomBounds={mapZoomBounds || undefined}
onMapClick={handleMapClick}
onParcelClick={handleParcelClick}
height="600px"
emptyMessage="Klicken Sie auf die Karte, um einen Standort auszuwählen, oder suchen Sie nach einer Adresse oben."
/>
</div>
<ParcelInfoPanel
isOpen={isPanelOpen}
onClose={() => setIsPanelOpen(false)}
parcels={selectedParcels}
onRemoveParcel={removeParcel}
adjacentParcels={allAdjacentParcels}
/>
</>
);
};
export default PekMapView;