feat(realestate): PEK map and address UI, realestate views, feature-instance routes
This commit is contained in:
parent
1f87e00339
commit
845094a40a
34 changed files with 6928 additions and 14 deletions
123
src/App.tsx
123
src/App.tsx
|
|
@ -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 { useEffect } from 'react';
|
||||||
|
|
||||||
// Import global CSS reset first
|
// Import global CSS reset first
|
||||||
|
|
@ -14,7 +27,17 @@ import { ProtectedRoute } from './providers/auth/ProtectedRoute';
|
||||||
import { LanguageProvider } from './providers/language/LanguageContext';
|
import { LanguageProvider } from './providers/language/LanguageContext';
|
||||||
import { WorkflowSelectionProvider } from './contexts/WorkflowSelectionContext';
|
import { WorkflowSelectionProvider } from './contexts/WorkflowSelectionContext';
|
||||||
import { FileProvider } from './contexts/FileContext';
|
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() {
|
function App() {
|
||||||
// Load saved theme preference and set app name on app mount
|
// Load saved theme preference and set app name on app mount
|
||||||
|
|
@ -52,22 +75,94 @@ function App() {
|
||||||
{/* PROTECTED ROUTE - requires authentication */}
|
{/* PROTECTED ROUTE - requires authentication */}
|
||||||
<Route path="/" element={
|
<Route path="/" element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<FileProvider>
|
<MainLayout />
|
||||||
<WorkflowSelectionProvider>
|
|
||||||
<Home />
|
|
||||||
</WorkflowSelectionProvider>
|
|
||||||
</FileProvider>
|
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
}>
|
||||||
|
{/* Dashboard (Root) */}
|
||||||
|
<Route index element={<DashboardPage />} />
|
||||||
|
|
||||||
{/* Catch-all redirect to home */}
|
{/* 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 Dashboard */}
|
||||||
|
{/* ================================================== */}
|
||||||
<Route path="*" element={
|
<Route path="*" element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<FileProvider>
|
<MainLayout />
|
||||||
<WorkflowSelectionProvider>
|
|
||||||
<Home />
|
|
||||||
</WorkflowSelectionProvider>
|
|
||||||
</FileProvider>
|
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
|
||||||
313
src/api/realEstateApi.ts
Normal file
313
src/api/realEstateApi.ts
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
import api from '../api';
|
||||||
|
import type { ApiRequestOptions } from '../hooks/useApi';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES & INTERFACES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface AddressSuggestion {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
coordinates?: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Real Estate Project (Projekt). Backend-driven CRUD uses instanceId. */
|
||||||
|
export interface RealEstateProject {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
statusProzess?: string;
|
||||||
|
mandateId?: string;
|
||||||
|
featureInstanceId?: string;
|
||||||
|
perimeter?: any;
|
||||||
|
parzellen?: RealEstateParcel[];
|
||||||
|
_createdAt?: number;
|
||||||
|
_modifiedAt?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Real Estate Parcel (Parzelle). */
|
||||||
|
export interface RealEstateParcel {
|
||||||
|
id: string;
|
||||||
|
label?: string;
|
||||||
|
mandateId?: string;
|
||||||
|
featureInstanceId?: string;
|
||||||
|
strasseNr?: string;
|
||||||
|
plz?: string;
|
||||||
|
perimeter?: any;
|
||||||
|
bauzone?: string;
|
||||||
|
_createdAt?: number;
|
||||||
|
_modifiedAt?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationParams {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||||
|
filters?: Record<string, any>;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
items: T[];
|
||||||
|
pagination?: {
|
||||||
|
currentPage: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
sort?: Array<{ field: string; direction: string }>;
|
||||||
|
filters?: Record<string, any>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HELPER FUNCTIONS (instanceId-based CRUD)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function _getRealEstateBaseUrl(instanceId: string): string {
|
||||||
|
return `/api/realestate/${instanceId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildPaginationParams(params?: PaginationParams): Record<string, string | number | boolean> {
|
||||||
|
if (!params) return {};
|
||||||
|
const paginationObj: Record<string, unknown> = {};
|
||||||
|
if (params.page !== undefined) paginationObj.page = params.page;
|
||||||
|
if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
|
||||||
|
if (params.sort) paginationObj.sort = params.sort;
|
||||||
|
if (params.filters) paginationObj.filters = params.filters;
|
||||||
|
if (params.search) paginationObj.search = params.search;
|
||||||
|
if (Object.keys(paginationObj).length === 0) return {};
|
||||||
|
return { pagination: JSON.stringify(paginationObj) } as Record<string, string | number | boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PROJECTS CRUD (instanceId-based)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function fetchProjects(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
params?: PaginationParams
|
||||||
|
): Promise<PaginatedResponse<RealEstateProject>> {
|
||||||
|
return await request({
|
||||||
|
url: `${_getRealEstateBaseUrl(instanceId)}/projects`,
|
||||||
|
method: 'get',
|
||||||
|
params: _buildPaginationParams(params)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProjectById(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
id: string
|
||||||
|
): Promise<RealEstateProject | null> {
|
||||||
|
try {
|
||||||
|
return await request({
|
||||||
|
url: `${_getRealEstateBaseUrl(instanceId)}/projects/${id}`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProject(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
data: Partial<RealEstateProject>
|
||||||
|
): Promise<RealEstateProject> {
|
||||||
|
return await request({
|
||||||
|
url: `${_getRealEstateBaseUrl(instanceId)}/projects`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProject(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
id: string,
|
||||||
|
data: Partial<RealEstateProject>
|
||||||
|
): Promise<RealEstateProject> {
|
||||||
|
return await request({
|
||||||
|
url: `${_getRealEstateBaseUrl(instanceId)}/projects/${id}`,
|
||||||
|
method: 'put',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProject(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
id: string
|
||||||
|
): Promise<void> {
|
||||||
|
await request({
|
||||||
|
url: `${_getRealEstateBaseUrl(instanceId)}/projects/${id}`,
|
||||||
|
method: 'delete'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PARCELS CRUD (instanceId-based)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function fetchParcels(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
params?: PaginationParams
|
||||||
|
): Promise<PaginatedResponse<RealEstateParcel>> {
|
||||||
|
return await request({
|
||||||
|
url: `${_getRealEstateBaseUrl(instanceId)}/parcels`,
|
||||||
|
method: 'get',
|
||||||
|
params: _buildPaginationParams(params)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchParcelById(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
id: string
|
||||||
|
): Promise<RealEstateParcel | null> {
|
||||||
|
try {
|
||||||
|
return await request({
|
||||||
|
url: `${_getRealEstateBaseUrl(instanceId)}/parcels/${id}`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createParcel(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
data: Partial<RealEstateParcel>
|
||||||
|
): Promise<RealEstateParcel> {
|
||||||
|
return await request({
|
||||||
|
url: `${_getRealEstateBaseUrl(instanceId)}/parcels`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateParcel(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
id: string,
|
||||||
|
data: Partial<RealEstateParcel>
|
||||||
|
): Promise<RealEstateParcel> {
|
||||||
|
return await request({
|
||||||
|
url: `${_getRealEstateBaseUrl(instanceId)}/parcels/${id}`,
|
||||||
|
method: 'put',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteParcel(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
id: string
|
||||||
|
): Promise<void> {
|
||||||
|
await request({
|
||||||
|
url: `${_getRealEstateBaseUrl(instanceId)}/parcels/${id}`,
|
||||||
|
method: 'delete'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ADDRESS AUTOCOMPLETE (legacy, no instanceId)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get address autocomplete suggestions for Swiss addresses
|
||||||
|
* Endpoint: GET /api/realestate/address/autocomplete
|
||||||
|
*
|
||||||
|
* @param query - Search text (minimum 2 characters)
|
||||||
|
* @param limit - Maximum number of results (default: 10, max: 20)
|
||||||
|
* @returns Array of address suggestions
|
||||||
|
*/
|
||||||
|
export async function autocompleteAddress(
|
||||||
|
query: string,
|
||||||
|
limit: number = 10
|
||||||
|
): Promise<AddressSuggestion[]> {
|
||||||
|
if (query.length < 2) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const trimmedQuery = query.trim();
|
||||||
|
const requestParams = {
|
||||||
|
query: trimmedQuery,
|
||||||
|
limit: Math.min(Math.max(limit, 1), 20) // Clamp between 1 and 20
|
||||||
|
};
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.log('🔍 [AddressAutocomplete] Requesting suggestions:', {
|
||||||
|
query: trimmedQuery,
|
||||||
|
limit: requestParams.limit,
|
||||||
|
url: '/api/realestate/address/autocomplete'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await api.get<AddressSuggestion[]>('/api/realestate/address/autocomplete', {
|
||||||
|
params: requestParams
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = response.data || [];
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.log('✅ [AddressAutocomplete] Received suggestions:', {
|
||||||
|
count: results.length,
|
||||||
|
results: results.slice(0, 3) // Log first 3 for debugging
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch (error: any) {
|
||||||
|
// Detailed error logging
|
||||||
|
const errorDetails: any = {
|
||||||
|
message: error?.message || 'Unknown error',
|
||||||
|
query: query.trim(),
|
||||||
|
limit: limit
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error?.response) {
|
||||||
|
// HTTP error response
|
||||||
|
errorDetails.status = error.response.status;
|
||||||
|
errorDetails.statusText = error.response.statusText;
|
||||||
|
errorDetails.data = error.response.data;
|
||||||
|
errorDetails.headers = error.response.headers;
|
||||||
|
|
||||||
|
console.error('❌ [AddressAutocomplete] API Error Response:', {
|
||||||
|
status: errorDetails.status,
|
||||||
|
statusText: errorDetails.statusText,
|
||||||
|
detail: errorDetails.data?.detail || errorDetails.data,
|
||||||
|
url: error.config?.url,
|
||||||
|
method: error.config?.method
|
||||||
|
});
|
||||||
|
} else if (error?.request) {
|
||||||
|
// Request made but no response received
|
||||||
|
errorDetails.requestError = true;
|
||||||
|
console.error('❌ [AddressAutocomplete] Network Error - No response received:', {
|
||||||
|
message: error.message,
|
||||||
|
url: error.config?.url
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Error setting up request
|
||||||
|
console.error('❌ [AddressAutocomplete] Request Setup Error:', errorDetails);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log full error in dev mode
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.error('❌ [AddressAutocomplete] Full error object:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return empty array on error to allow graceful degradation
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
348
src/components/ContentPreview/UrlContentPreview.tsx
Normal file
348
src/components/ContentPreview/UrlContentPreview.tsx
Normal 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;
|
||||||
238
src/components/ContentPreview/renderers/PdfJsRenderer.tsx
Normal file
238
src/components/ContentPreview/renderers/PdfJsRenderer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
240
src/components/Navigation/MandateNavigation.tsx
Normal file
240
src/components/Navigation/MandateNavigation.tsx
Normal 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;
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
2
src/components/UiComponents/AddressAutocomplete/index.ts
Normal file
2
src/components/UiComponents/AddressAutocomplete/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { default } from './AddressAutocomplete';
|
||||||
|
export type { AddressSuggestion } from '../../../api/realEstateApi';
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
187
src/components/UiComponents/OerebSection/OerebSection.module.css
Normal file
187
src/components/UiComponents/OerebSection/OerebSection.module.css
Normal 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);
|
||||||
|
}
|
||||||
133
src/components/UiComponents/OerebSection/OerebSection.tsx
Normal file
133
src/components/UiComponents/OerebSection/OerebSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
10
src/core/PageManager/data/pages/realestate/index.ts
Normal file
10
src/core/PageManager/data/pages/realestate/index.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { GenericPageData } from '../../../pageInterface';
|
||||||
|
import { realEstateProjectsPageData } from './projects';
|
||||||
|
import { realEstateParcelsPageData } from './parcels';
|
||||||
|
|
||||||
|
export { realEstateProjectsPageData, realEstateParcelsPageData };
|
||||||
|
|
||||||
|
export const realEstatePages: GenericPageData[] = [
|
||||||
|
realEstateProjectsPageData,
|
||||||
|
realEstateParcelsPageData,
|
||||||
|
];
|
||||||
147
src/core/PageManager/data/pages/realestate/parcels.ts
Normal file
147
src/core/PageManager/data/pages/realestate/parcels.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { GenericPageData } from '../../../pageInterface';
|
||||||
|
import { FaMapMarkerAlt, FaPlus } from 'react-icons/fa';
|
||||||
|
import { useRealEstateParcels, useRealEstateParcelOperations } from '../../../../../hooks/useRealEstate';
|
||||||
|
|
||||||
|
const attributesToColumns = (attributes: any[]) => {
|
||||||
|
return attributes.map(attr => {
|
||||||
|
const isDateField = attr.type === 'date' || attr.type === 'timestamp' ||
|
||||||
|
/(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name);
|
||||||
|
return {
|
||||||
|
key: attr.name,
|
||||||
|
label: attr.label || attr.name,
|
||||||
|
type: attr.type || 'string',
|
||||||
|
width: attr.width || 200,
|
||||||
|
minWidth: attr.minWidth || 100,
|
||||||
|
maxWidth: attr.maxWidth || 400,
|
||||||
|
sortable: attr.sortable !== false,
|
||||||
|
filterable: isDateField ? false : (attr.filterable !== false),
|
||||||
|
searchable: attr.searchable !== false,
|
||||||
|
filterOptions: attr.filterOptions,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createParcelsHook = () => {
|
||||||
|
return () => {
|
||||||
|
const {
|
||||||
|
items: parcels,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
removeOptimistically,
|
||||||
|
updateOptimistically,
|
||||||
|
attributes,
|
||||||
|
permissions,
|
||||||
|
fetchById,
|
||||||
|
generateEditFieldsFromAttributes,
|
||||||
|
generateCreateFieldsFromAttributes,
|
||||||
|
ensureAttributesLoaded,
|
||||||
|
} = useRealEstateParcels();
|
||||||
|
const {
|
||||||
|
handleDelete,
|
||||||
|
handleCreate,
|
||||||
|
handleUpdate,
|
||||||
|
deletingItems,
|
||||||
|
creatingItem,
|
||||||
|
deleteError,
|
||||||
|
createError,
|
||||||
|
updateError,
|
||||||
|
} = useRealEstateParcelOperations();
|
||||||
|
|
||||||
|
const generatedColumns = attributes && attributes.length > 0
|
||||||
|
? attributesToColumns(attributes)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const wrappedHandleCreate = useCallback(async (formData: any) => {
|
||||||
|
return await handleCreate(formData);
|
||||||
|
}, [handleCreate]);
|
||||||
|
|
||||||
|
const handleDeleteSingle = useCallback(async (item: any) => {
|
||||||
|
const success = await handleDelete(item.id);
|
||||||
|
if (success) refetch();
|
||||||
|
}, [handleDelete, refetch]);
|
||||||
|
|
||||||
|
const handleDeleteMultiple = useCallback(async (selectedItems: any[]) => {
|
||||||
|
const ids = selectedItems.map(item => item.id);
|
||||||
|
const results = await Promise.all(ids.map(id => handleDelete(id)));
|
||||||
|
if (results.every(Boolean)) refetch();
|
||||||
|
}, [handleDelete, refetch]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: parcels,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
removeOptimistically,
|
||||||
|
updateOptimistically,
|
||||||
|
handleDelete,
|
||||||
|
handleDeleteMultiple,
|
||||||
|
handleCreate: wrappedHandleCreate,
|
||||||
|
handleUpdate,
|
||||||
|
onDelete: handleDeleteSingle,
|
||||||
|
onDeleteMultiple: handleDeleteMultiple,
|
||||||
|
deletingItems,
|
||||||
|
creatingItem,
|
||||||
|
deleteError,
|
||||||
|
createError,
|
||||||
|
updateError,
|
||||||
|
attributes,
|
||||||
|
permissions,
|
||||||
|
columns: generatedColumns,
|
||||||
|
fetchById,
|
||||||
|
generateEditFieldsFromAttributes,
|
||||||
|
generateCreateFieldsFromAttributes,
|
||||||
|
ensureAttributesLoaded,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const realEstateParcelsPageData: GenericPageData = {
|
||||||
|
id: 'realestate-parcels',
|
||||||
|
path: 'realestate/parcels',
|
||||||
|
name: 'realestate.parcels.title',
|
||||||
|
description: 'realestate.parcels.description',
|
||||||
|
parentPath: 'start.realestate',
|
||||||
|
icon: FaMapMarkerAlt,
|
||||||
|
title: 'realestate.parcels.title',
|
||||||
|
subtitle: 'realestate.parcels.subtitle',
|
||||||
|
headerButtons: [
|
||||||
|
{
|
||||||
|
id: 'new-parcel',
|
||||||
|
label: 'realestate.parcels.new_button',
|
||||||
|
icon: FaPlus,
|
||||||
|
variant: 'primary',
|
||||||
|
formConfig: {
|
||||||
|
fields: [
|
||||||
|
{ key: 'label', label: 'realestate.parcels.field.label', type: 'string', required: true },
|
||||||
|
{ key: 'strasseNr', label: 'realestate.parcels.field.strasseNr', type: 'string' },
|
||||||
|
{ key: 'plz', label: 'realestate.parcels.field.plz', type: 'string' },
|
||||||
|
],
|
||||||
|
popupTitle: 'realestate.parcels.modal.create.title',
|
||||||
|
popupSize: 'medium',
|
||||||
|
createOperationName: 'handleCreate',
|
||||||
|
successMessage: 'realestate.parcels.create.success',
|
||||||
|
errorMessage: 'realestate.parcels.create.error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
id: 'parcels-table',
|
||||||
|
type: 'table',
|
||||||
|
tableConfig: {
|
||||||
|
hookFactory: createParcelsHook,
|
||||||
|
actionButtons: [
|
||||||
|
{ type: 'edit', operationName: 'handleUpdate', fetchItemFunctionName: 'fetchById' },
|
||||||
|
{ type: 'delete', operationName: 'handleDelete' },
|
||||||
|
],
|
||||||
|
className: 'realestate-parcels-table',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
moduleEnabled: true,
|
||||||
|
onActivate: () => { if (import.meta.env.DEV) console.log('RealEstate Parcels activated'); },
|
||||||
|
onLoad: () => { if (import.meta.env.DEV) console.log('RealEstate Parcels loaded'); },
|
||||||
|
onUnload: () => { if (import.meta.env.DEV) console.log('RealEstate Parcels unloaded'); },
|
||||||
|
};
|
||||||
146
src/core/PageManager/data/pages/realestate/projects.ts
Normal file
146
src/core/PageManager/data/pages/realestate/projects.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { GenericPageData } from '../../../pageInterface';
|
||||||
|
import { FaBuilding, FaPlus } from 'react-icons/fa';
|
||||||
|
import { useRealEstateProjects, useRealEstateProjectOperations } from '../../../../../hooks/useRealEstate';
|
||||||
|
|
||||||
|
const attributesToColumns = (attributes: any[]) => {
|
||||||
|
return attributes.map(attr => {
|
||||||
|
const isDateField = attr.type === 'date' || attr.type === 'timestamp' ||
|
||||||
|
/(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name);
|
||||||
|
return {
|
||||||
|
key: attr.name,
|
||||||
|
label: attr.label || attr.name,
|
||||||
|
type: attr.type || 'string',
|
||||||
|
width: attr.width || 200,
|
||||||
|
minWidth: attr.minWidth || 100,
|
||||||
|
maxWidth: attr.maxWidth || 400,
|
||||||
|
sortable: attr.sortable !== false,
|
||||||
|
filterable: isDateField ? false : (attr.filterable !== false),
|
||||||
|
searchable: attr.searchable !== false,
|
||||||
|
filterOptions: attr.filterOptions,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createProjectsHook = () => {
|
||||||
|
return () => {
|
||||||
|
const {
|
||||||
|
items: projects,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
removeOptimistically,
|
||||||
|
updateOptimistically,
|
||||||
|
attributes,
|
||||||
|
permissions,
|
||||||
|
fetchById,
|
||||||
|
generateEditFieldsFromAttributes,
|
||||||
|
generateCreateFieldsFromAttributes,
|
||||||
|
ensureAttributesLoaded,
|
||||||
|
} = useRealEstateProjects();
|
||||||
|
const {
|
||||||
|
handleDelete,
|
||||||
|
handleCreate,
|
||||||
|
handleUpdate,
|
||||||
|
deletingItems,
|
||||||
|
creatingItem,
|
||||||
|
deleteError,
|
||||||
|
createError,
|
||||||
|
updateError,
|
||||||
|
} = useRealEstateProjectOperations();
|
||||||
|
|
||||||
|
const generatedColumns = attributes && attributes.length > 0
|
||||||
|
? attributesToColumns(attributes)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const wrappedHandleCreate = useCallback(async (formData: any) => {
|
||||||
|
return await handleCreate(formData);
|
||||||
|
}, [handleCreate]);
|
||||||
|
|
||||||
|
const handleDeleteSingle = useCallback(async (item: any) => {
|
||||||
|
const success = await handleDelete(item.id);
|
||||||
|
if (success) refetch();
|
||||||
|
}, [handleDelete, refetch]);
|
||||||
|
|
||||||
|
const handleDeleteMultiple = useCallback(async (selectedItems: any[]) => {
|
||||||
|
const ids = selectedItems.map(item => item.id);
|
||||||
|
const results = await Promise.all(ids.map(id => handleDelete(id)));
|
||||||
|
if (results.every(Boolean)) refetch();
|
||||||
|
}, [handleDelete, refetch]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: projects,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
removeOptimistically,
|
||||||
|
updateOptimistically,
|
||||||
|
handleDelete,
|
||||||
|
handleDeleteMultiple,
|
||||||
|
handleCreate: wrappedHandleCreate,
|
||||||
|
handleUpdate,
|
||||||
|
onDelete: handleDeleteSingle,
|
||||||
|
onDeleteMultiple: handleDeleteMultiple,
|
||||||
|
deletingItems,
|
||||||
|
creatingItem,
|
||||||
|
deleteError,
|
||||||
|
createError,
|
||||||
|
updateError,
|
||||||
|
attributes,
|
||||||
|
permissions,
|
||||||
|
columns: generatedColumns,
|
||||||
|
fetchById,
|
||||||
|
generateEditFieldsFromAttributes,
|
||||||
|
generateCreateFieldsFromAttributes,
|
||||||
|
ensureAttributesLoaded,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const realEstateProjectsPageData: GenericPageData = {
|
||||||
|
id: 'realestate-projects',
|
||||||
|
path: 'realestate/projects',
|
||||||
|
name: 'realestate.projects.title',
|
||||||
|
description: 'realestate.projects.description',
|
||||||
|
parentPath: 'start.realestate',
|
||||||
|
icon: FaBuilding,
|
||||||
|
title: 'realestate.projects.title',
|
||||||
|
subtitle: 'realestate.projects.subtitle',
|
||||||
|
headerButtons: [
|
||||||
|
{
|
||||||
|
id: 'new-project',
|
||||||
|
label: 'realestate.projects.new_button',
|
||||||
|
icon: FaPlus,
|
||||||
|
variant: 'primary',
|
||||||
|
formConfig: {
|
||||||
|
fields: [
|
||||||
|
{ key: 'label', label: 'realestate.projects.field.label', type: 'string', required: true },
|
||||||
|
{ key: 'statusProzess', label: 'realestate.projects.field.statusProzess', type: 'string' },
|
||||||
|
],
|
||||||
|
popupTitle: 'realestate.projects.modal.create.title',
|
||||||
|
popupSize: 'medium',
|
||||||
|
createOperationName: 'handleCreate',
|
||||||
|
successMessage: 'realestate.projects.create.success',
|
||||||
|
errorMessage: 'realestate.projects.create.error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
id: 'projects-table',
|
||||||
|
type: 'table',
|
||||||
|
tableConfig: {
|
||||||
|
hookFactory: createProjectsHook,
|
||||||
|
actionButtons: [
|
||||||
|
{ type: 'edit', operationName: 'handleUpdate', fetchItemFunctionName: 'fetchById' },
|
||||||
|
{ type: 'delete', operationName: 'handleDelete' },
|
||||||
|
],
|
||||||
|
className: 'realestate-projects-table',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
moduleEnabled: true,
|
||||||
|
onActivate: () => { if (import.meta.env.DEV) console.log('RealEstate Projects activated'); },
|
||||||
|
onLoad: () => { if (import.meta.env.DEV) console.log('RealEstate Projects loaded'); },
|
||||||
|
onUnload: () => { if (import.meta.env.DEV) console.log('RealEstate Projects unloaded'); },
|
||||||
|
};
|
||||||
241
src/core/PageManager/data/pages/trustee/position-documents.ts
Normal file
241
src/core/PageManager/data/pages/trustee/position-documents.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { GenericPageData } from '../../../pageInterface';
|
||||||
|
import { FaLink, FaPlus } from 'react-icons/fa';
|
||||||
|
import { useTrusteePositionDocuments, useTrusteePositionDocumentOperations } from '../../../../../hooks/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
403
src/hooks/useRealEstate.ts
Normal file
|
|
@ -0,0 +1,403 @@
|
||||||
|
/**
|
||||||
|
* Real Estate Hooks
|
||||||
|
*
|
||||||
|
* Hooks für das Real Estate/PEK-Feature mit Instanz-Kontext.
|
||||||
|
* Die instanceId wird automatisch aus der URL gelesen (Feature-Instanz-Route).
|
||||||
|
* Analog zu useTrustee.ts für backend-driven FormGeneratorTable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useApiRequest } from './useApi';
|
||||||
|
import api from '../api';
|
||||||
|
import { usePermissions, type UserPermissions } from './usePermissions';
|
||||||
|
import { useInstanceId } from './useCurrentInstance';
|
||||||
|
import {
|
||||||
|
type RealEstateProject,
|
||||||
|
type RealEstateParcel,
|
||||||
|
type PaginationParams,
|
||||||
|
fetchProjects as fetchProjectsApi,
|
||||||
|
fetchProjectById as fetchProjectByIdApi,
|
||||||
|
createProject as createProjectApi,
|
||||||
|
updateProject as updateProjectApi,
|
||||||
|
deleteProject as deleteProjectApi,
|
||||||
|
fetchParcels as fetchParcelsApi,
|
||||||
|
fetchParcelById as fetchParcelByIdApi,
|
||||||
|
createParcel as createParcelApi,
|
||||||
|
updateParcel as updateParcelApi,
|
||||||
|
deleteParcel as deleteParcelApi,
|
||||||
|
} from '../api/realEstateApi';
|
||||||
|
|
||||||
|
export type { RealEstateProject, RealEstateParcel, PaginationParams };
|
||||||
|
|
||||||
|
export interface AttributeDefinition {
|
||||||
|
name: string;
|
||||||
|
type: 'text' | 'email' | 'date' | 'checkbox' | 'select' | 'multiselect' | 'number' | 'textarea' | 'timestamp' | 'file';
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
required?: boolean;
|
||||||
|
default?: any;
|
||||||
|
options?: any[] | string;
|
||||||
|
readonly?: boolean;
|
||||||
|
editable?: boolean;
|
||||||
|
visible?: boolean;
|
||||||
|
order?: number;
|
||||||
|
sortable?: boolean;
|
||||||
|
filterable?: boolean;
|
||||||
|
searchable?: boolean;
|
||||||
|
width?: number;
|
||||||
|
minWidth?: number;
|
||||||
|
maxWidth?: number;
|
||||||
|
filterOptions?: string[];
|
||||||
|
dependsOn?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GENERIC REAL ESTATE ENTITY HOOK FACTORY
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface RealEstateEntityConfig<T> {
|
||||||
|
entityName: string;
|
||||||
|
fetchAll: (request: any, instanceId: string, params?: PaginationParams) => Promise<any>;
|
||||||
|
fetchById: (request: any, instanceId: string, id: string) => Promise<T | null>;
|
||||||
|
create: (request: any, instanceId: string, data: Partial<T>) => Promise<T>;
|
||||||
|
update: (request: any, instanceId: string, id: string, data: Partial<T>) => Promise<T>;
|
||||||
|
deleteItem: (request: any, instanceId: string, id: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _createRealEstateEntityHook<T extends { id: string }>(config: RealEstateEntityConfig<T>) {
|
||||||
|
return function useRealEstateEntity() {
|
||||||
|
const instanceId = useInstanceId();
|
||||||
|
const [items, setItems] = useState<T[]>([]);
|
||||||
|
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
|
||||||
|
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
|
||||||
|
const [pagination, setPagination] = useState<{
|
||||||
|
currentPage: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
} | null>(null);
|
||||||
|
const { request, isLoading: loading, error } = useApiRequest<null, T[]>();
|
||||||
|
const { checkPermission } = usePermissions();
|
||||||
|
|
||||||
|
const fetchAttributes = useCallback(async () => {
|
||||||
|
if (!instanceId) return [];
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/api/realestate/${instanceId}/attributes/${config.entityName}`);
|
||||||
|
let attrs: AttributeDefinition[] = [];
|
||||||
|
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
|
||||||
|
attrs = response.data.attributes;
|
||||||
|
} else if (Array.isArray(response.data)) {
|
||||||
|
attrs = response.data;
|
||||||
|
}
|
||||||
|
setAttributes(attrs);
|
||||||
|
return attrs;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`Error fetching ${config.entityName} attributes:`, err);
|
||||||
|
setAttributes([]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
const fetchPermissions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const objectKey = `data.feature.realestate.${config.entityName}`;
|
||||||
|
const perms = await checkPermission('DATA', objectKey);
|
||||||
|
setPermissions(perms);
|
||||||
|
return perms;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`Error fetching ${config.entityName} permissions:`, err);
|
||||||
|
const defaultPerms: UserPermissions = {
|
||||||
|
view: false,
|
||||||
|
read: 'n',
|
||||||
|
create: 'n',
|
||||||
|
update: 'n',
|
||||||
|
delete: 'n',
|
||||||
|
};
|
||||||
|
setPermissions(defaultPerms);
|
||||||
|
return defaultPerms;
|
||||||
|
}
|
||||||
|
}, [checkPermission]);
|
||||||
|
|
||||||
|
const fetchItems = useCallback(async (params?: PaginationParams) => {
|
||||||
|
if (!instanceId) {
|
||||||
|
setItems([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await config.fetchAll(request, instanceId, params);
|
||||||
|
if (data && typeof data === 'object' && 'items' in data) {
|
||||||
|
const fetchedItems = Array.isArray(data.items) ? data.items : [];
|
||||||
|
setItems(fetchedItems);
|
||||||
|
if (data.pagination) {
|
||||||
|
setPagination(data.pagination);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const fetchedItems = Array.isArray(data) ? data : [];
|
||||||
|
setItems(fetchedItems);
|
||||||
|
setPagination(null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setItems([]);
|
||||||
|
setPagination(null);
|
||||||
|
}
|
||||||
|
}, [request, instanceId]);
|
||||||
|
|
||||||
|
const removeOptimistically = (itemId: string) => {
|
||||||
|
setItems(prev => prev.filter(item => item.id !== itemId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateOptimistically = (itemId: string, updateData: Partial<T>) => {
|
||||||
|
setItems(prev =>
|
||||||
|
prev.map(item =>
|
||||||
|
item.id === itemId ? { ...item, ...updateData } : item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchById = useCallback(async (itemId: string): Promise<T | null> => {
|
||||||
|
if (!instanceId) return null;
|
||||||
|
return await config.fetchById(request, instanceId, itemId);
|
||||||
|
}, [request, instanceId]);
|
||||||
|
|
||||||
|
const generateEditFieldsFromAttributes = useCallback(() => {
|
||||||
|
if (!attributes || attributes.length === 0) return [];
|
||||||
|
return attributes
|
||||||
|
.filter(attr => {
|
||||||
|
if (attr.readonly === true || attr.editable === false) return false;
|
||||||
|
if (attr.name === 'id') return false;
|
||||||
|
const nonEditable = ['_createdBy', '_createdAt', '_modifiedBy', '_modifiedAt'];
|
||||||
|
return !nonEditable.includes(attr.name);
|
||||||
|
})
|
||||||
|
.map(attr => {
|
||||||
|
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' | 'number' = 'string';
|
||||||
|
let options: Array<{ value: string | number; label: string }> | undefined;
|
||||||
|
let optionsReference: string | undefined;
|
||||||
|
if (attr.type === 'checkbox') fieldType = 'boolean';
|
||||||
|
else if (attr.type === 'email') fieldType = 'email';
|
||||||
|
else if (attr.type === 'date') fieldType = 'date';
|
||||||
|
else if (attr.type === 'number') fieldType = 'number';
|
||||||
|
else if (attr.type === 'select') {
|
||||||
|
fieldType = 'enum';
|
||||||
|
if (Array.isArray(attr.options)) {
|
||||||
|
options = (attr.options as any[]).map((opt: any) => ({
|
||||||
|
value: opt.value,
|
||||||
|
label: typeof opt.label === 'string' ? opt.label : opt.label?.en || String(opt.value),
|
||||||
|
}));
|
||||||
|
} else if (typeof attr.options === 'string') optionsReference = attr.options;
|
||||||
|
} else if (attr.type === 'multiselect') {
|
||||||
|
fieldType = 'multiselect';
|
||||||
|
if (Array.isArray(attr.options)) {
|
||||||
|
options = (attr.options as any[]).map((opt: any) => ({
|
||||||
|
value: opt.value,
|
||||||
|
label: typeof opt.label === 'string' ? opt.label : opt.label?.en || String(opt.value),
|
||||||
|
}));
|
||||||
|
} else if (typeof attr.options === 'string') optionsReference = attr.options;
|
||||||
|
} else if (attr.type === 'textarea') fieldType = 'textarea';
|
||||||
|
else if (attr.type === 'timestamp') fieldType = 'readonly';
|
||||||
|
return {
|
||||||
|
key: attr.name,
|
||||||
|
label: attr.label || attr.name,
|
||||||
|
type: fieldType,
|
||||||
|
editable: attr.editable !== false && attr.readonly !== true,
|
||||||
|
required: attr.required === true,
|
||||||
|
options,
|
||||||
|
optionsReference,
|
||||||
|
dependsOn: attr.dependsOn,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [attributes]);
|
||||||
|
|
||||||
|
const generateCreateFieldsFromAttributes = useCallback(() => {
|
||||||
|
if (!attributes || attributes.length === 0) return [];
|
||||||
|
return attributes
|
||||||
|
.filter(attr => !['_createdBy', '_createdAt', '_modifiedBy', '_modifiedAt', 'mandateId', 'featureInstanceId'].includes(attr.name))
|
||||||
|
.map(attr => {
|
||||||
|
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' | 'number' = 'string';
|
||||||
|
let options: Array<{ value: string | number; label: string }> | undefined;
|
||||||
|
let optionsReference: string | undefined;
|
||||||
|
if (attr.type === 'checkbox') fieldType = 'boolean';
|
||||||
|
else if (attr.type === 'email') fieldType = 'email';
|
||||||
|
else if (attr.type === 'date') fieldType = 'date';
|
||||||
|
else if (attr.type === 'number') fieldType = 'number';
|
||||||
|
else if (attr.type === 'select') {
|
||||||
|
fieldType = 'enum';
|
||||||
|
if (Array.isArray(attr.options)) {
|
||||||
|
options = (attr.options as any[]).map((opt: any) => ({
|
||||||
|
value: opt.value,
|
||||||
|
label: typeof opt.label === 'string' ? opt.label : opt.label?.en || String(opt.value),
|
||||||
|
}));
|
||||||
|
} else if (typeof attr.options === 'string') optionsReference = attr.options;
|
||||||
|
} else if (attr.type === 'multiselect') {
|
||||||
|
fieldType = 'multiselect';
|
||||||
|
if (Array.isArray(attr.options)) {
|
||||||
|
options = (attr.options as any[]).map((opt: any) => ({
|
||||||
|
value: opt.value,
|
||||||
|
label: typeof opt.label === 'string' ? opt.label : opt.label?.en || String(opt.value),
|
||||||
|
}));
|
||||||
|
} else if (typeof attr.options === 'string') optionsReference = attr.options;
|
||||||
|
} else if (attr.type === 'textarea') fieldType = 'textarea';
|
||||||
|
else if (attr.type === 'timestamp') fieldType = 'readonly';
|
||||||
|
return {
|
||||||
|
key: attr.name,
|
||||||
|
label: attr.label || attr.name,
|
||||||
|
type: fieldType,
|
||||||
|
editable: true,
|
||||||
|
required: attr.required === true,
|
||||||
|
options,
|
||||||
|
optionsReference,
|
||||||
|
dependsOn: attr.dependsOn,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [attributes]);
|
||||||
|
|
||||||
|
const ensureAttributesLoaded = useCallback(async () => {
|
||||||
|
if (attributes && attributes.length > 0) return attributes;
|
||||||
|
return await fetchAttributes();
|
||||||
|
}, [attributes, fetchAttributes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (instanceId) {
|
||||||
|
fetchAttributes();
|
||||||
|
fetchPermissions();
|
||||||
|
fetchItems();
|
||||||
|
}
|
||||||
|
}, [instanceId, fetchAttributes, fetchPermissions, fetchItems]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch: fetchItems,
|
||||||
|
removeOptimistically,
|
||||||
|
updateOptimistically,
|
||||||
|
attributes,
|
||||||
|
permissions,
|
||||||
|
pagination,
|
||||||
|
fetchById,
|
||||||
|
generateEditFieldsFromAttributes,
|
||||||
|
generateCreateFieldsFromAttributes,
|
||||||
|
ensureAttributesLoaded,
|
||||||
|
instanceId,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _createRealEstateOperationsHook<T extends { id: string }>(config: RealEstateEntityConfig<T>) {
|
||||||
|
return function useRealEstateEntityOperations() {
|
||||||
|
const instanceId = useInstanceId();
|
||||||
|
const [deletingItems, setDeletingItems] = useState<Set<string>>(new Set());
|
||||||
|
const [creatingItem, setCreatingItem] = useState(false);
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async (itemId: string) => {
|
||||||
|
if (!instanceId) {
|
||||||
|
setDeleteError('No instance context');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setDeleteError(null);
|
||||||
|
setDeletingItems(prev => new Set(prev).add(itemId));
|
||||||
|
try {
|
||||||
|
await config.deleteItem(request, instanceId, itemId);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
setDeleteError(err.message);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setDeletingItems(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(itemId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [request, instanceId]);
|
||||||
|
|
||||||
|
const handleCreate = useCallback(async (itemData: Partial<T>) => {
|
||||||
|
if (!instanceId) {
|
||||||
|
setCreateError('No instance context');
|
||||||
|
return { success: false, error: 'No instance context' };
|
||||||
|
}
|
||||||
|
setCreateError(null);
|
||||||
|
setCreatingItem(true);
|
||||||
|
try {
|
||||||
|
const newItem = await config.create(request, instanceId, itemData);
|
||||||
|
return { success: true, data: newItem };
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage = err.response?.data?.detail || err.message;
|
||||||
|
setCreateError(errorMessage);
|
||||||
|
return { success: false, error: errorMessage };
|
||||||
|
} finally {
|
||||||
|
setCreatingItem(false);
|
||||||
|
}
|
||||||
|
}, [request, instanceId]);
|
||||||
|
|
||||||
|
const handleUpdate = useCallback(async (itemId: string, updateData: Partial<T>) => {
|
||||||
|
if (!instanceId) {
|
||||||
|
setUpdateError('No instance context');
|
||||||
|
return { success: false, error: 'No instance context' };
|
||||||
|
}
|
||||||
|
setUpdateError(null);
|
||||||
|
try {
|
||||||
|
const updatedItem = await config.update(request, instanceId, itemId, updateData);
|
||||||
|
return { success: true, data: updatedItem };
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage = err.response?.data?.message || err.message || 'Failed to update';
|
||||||
|
setUpdateError(errorMessage);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
statusCode: err.response?.status,
|
||||||
|
isPermissionError: err.response?.status === 403,
|
||||||
|
isValidationError: err.response?.status === 400,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [request, instanceId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
deletingItems,
|
||||||
|
creatingItem,
|
||||||
|
deleteError,
|
||||||
|
createError,
|
||||||
|
updateError,
|
||||||
|
handleDelete,
|
||||||
|
handleCreate,
|
||||||
|
handleUpdate,
|
||||||
|
instanceId,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PROJECT HOOKS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const projectConfig: RealEstateEntityConfig<RealEstateProject> = {
|
||||||
|
entityName: 'Projekt',
|
||||||
|
fetchAll: fetchProjectsApi,
|
||||||
|
fetchById: fetchProjectByIdApi,
|
||||||
|
create: createProjectApi,
|
||||||
|
update: updateProjectApi,
|
||||||
|
deleteItem: deleteProjectApi,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRealEstateProjects = _createRealEstateEntityHook(projectConfig);
|
||||||
|
export const useRealEstateProjectOperations = _createRealEstateOperationsHook(projectConfig);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PARCEL HOOKS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const parcelConfig: RealEstateEntityConfig<RealEstateParcel> = {
|
||||||
|
entityName: 'Parzelle',
|
||||||
|
fetchAll: fetchParcelsApi,
|
||||||
|
fetchById: fetchParcelByIdApi,
|
||||||
|
create: createParcelApi,
|
||||||
|
update: updateParcelApi,
|
||||||
|
deleteItem: deleteParcelApi,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRealEstateParcels = _createRealEstateEntityHook(parcelConfig);
|
||||||
|
export const useRealEstateParcelOperations = _createRealEstateOperationsHook(parcelConfig);
|
||||||
352
src/hooks/useTrusteeAccess.ts
Normal file
352
src/hooks/useTrusteeAccess.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
362
src/hooks/useTrusteeContracts.ts
Normal file
362
src/hooks/useTrusteeContracts.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
384
src/hooks/useTrusteeDocuments.ts
Normal file
384
src/hooks/useTrusteeDocuments.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
368
src/hooks/useTrusteeOrganisations.ts
Normal file
368
src/hooks/useTrusteeOrganisations.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
302
src/hooks/useTrusteePositionDocuments.ts
Normal file
302
src/hooks/useTrusteePositionDocuments.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
417
src/hooks/useTrusteePositions.ts
Normal file
417
src/hooks/useTrusteePositions.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
368
src/hooks/useTrusteeRoles.ts
Normal file
368
src/hooks/useTrusteeRoles.ts
Normal 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
180
src/pages/FeatureView.tsx
Normal 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;
|
||||||
82
src/pages/views/realestate/RealEstateDashboardView.tsx
Normal file
82
src/pages/views/realestate/RealEstateDashboardView.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
/**
|
||||||
|
* RealEstateDashboardView
|
||||||
|
*
|
||||||
|
* Übersicht/Dashboard für eine Real-Estate-Instanz (PEK).
|
||||||
|
* Zeigt Kennzahlen und Links zu Projekten und Parzellen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||||
|
import { useRealEstateProjects, useRealEstateParcels } from '../../../hooks/useRealEstate';
|
||||||
|
import styles from '../trustee/TrusteeViews.module.css';
|
||||||
|
|
||||||
|
export const RealEstateDashboardView: React.FC = () => {
|
||||||
|
const { instance } = useCurrentInstance();
|
||||||
|
const { items: projects, loading: projectsLoading } = useRealEstateProjects();
|
||||||
|
const { items: parcels, loading: parcelsLoading } = useRealEstateParcels();
|
||||||
|
|
||||||
|
const isLoading = projectsLoading || parcelsLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.dashboardView}>
|
||||||
|
<div className={styles.statsGrid}>
|
||||||
|
{/* Projekte – Link-Karte */}
|
||||||
|
<Link to="projects" className={styles.statCard} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||||
|
<div className={styles.statIcon}>📋</div>
|
||||||
|
<div className={styles.statContent}>
|
||||||
|
<div className={styles.statValue}>
|
||||||
|
{isLoading ? '...' : projects.length}
|
||||||
|
</div>
|
||||||
|
<div className={styles.statLabel}>Projekte</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Parzellen – Link-Karte */}
|
||||||
|
<Link to="parcels" className={styles.statCard} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||||
|
<div className={styles.statIcon}>🗺️</div>
|
||||||
|
<div className={styles.statContent}>
|
||||||
|
<div className={styles.statValue}>
|
||||||
|
{isLoading ? '...' : parcels.length}
|
||||||
|
</div>
|
||||||
|
<div className={styles.statLabel}>Parzellen</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Rollen (optional) */}
|
||||||
|
{instance?.userRoles?.length ? (
|
||||||
|
<div className={styles.statCard}>
|
||||||
|
<div className={styles.statIcon}>👤</div>
|
||||||
|
<div className={styles.statContent}>
|
||||||
|
<div className={styles.statValueSmall}>
|
||||||
|
{instance.userRoles.map((role, idx) => (
|
||||||
|
<div key={idx}>{role}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={styles.statLabel}>
|
||||||
|
{instance.userRoles.length === 1 ? 'Deine Rolle' : 'Deine Rollen'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Instanz-Infos */}
|
||||||
|
<div className={styles.infoSection}>
|
||||||
|
<h3>Instanz-Details</h3>
|
||||||
|
<div className={styles.infoGrid}>
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<span className={styles.infoLabel}>Instanz:</span>
|
||||||
|
<span className={styles.infoValue}>{instance?.instanceLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<span className={styles.infoLabel}>Mandant:</span>
|
||||||
|
<span className={styles.infoValue}>{instance?.mandateName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RealEstateDashboardView;
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
/**
|
||||||
|
* RealEstateInstanceRolesPlaceholder
|
||||||
|
*
|
||||||
|
* Platzhalter für die View "Rollen & Rechte" bei Real-Estate-Instanzen.
|
||||||
|
* Zeigt einen Hinweis und Link zur Administration (Feature-Instanz Benutzer / Feature-Rollen),
|
||||||
|
* bis ein generisches Instance-Roles-UI verfügbar ist.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import styles from '../trustee/TrusteeViews.module.css';
|
||||||
|
|
||||||
|
export const RealEstateInstanceRolesPlaceholder: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className={styles.infoSection} style={{ maxWidth: 560 }}>
|
||||||
|
<h3>Rollen & Rechte</h3>
|
||||||
|
<p style={{ margin: '0 0 1rem', color: 'var(--text-secondary, #666)', fontSize: '0.9375rem', lineHeight: 1.5 }}>
|
||||||
|
Die Verwaltung von Rollen und Benutzern für diese Instanz erfolgt in der Administration.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
|
<Link to="/admin/feature-users" className={styles.primaryButton} style={{ display: 'inline-block', textAlign: 'center', textDecoration: 'none' }}>
|
||||||
|
Feature-Instanz Benutzer
|
||||||
|
</Link>
|
||||||
|
<Link to="/admin/feature-roles" className={styles.secondaryButton} style={{ display: 'inline-block', textAlign: 'center', textDecoration: 'none' }}>
|
||||||
|
Feature-Rollen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RealEstateInstanceRolesPlaceholder;
|
||||||
266
src/pages/views/realestate/RealEstateParcelsView.tsx
Normal file
266
src/pages/views/realestate/RealEstateParcelsView.tsx
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
/**
|
||||||
|
* RealEstateParcelsView
|
||||||
|
*
|
||||||
|
* Parzellen-Verwaltung für eine Real Estate/PEK-Instanz.
|
||||||
|
* Verwendet FormGeneratorTable analog zu TrusteeDocumentsView.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
useRealEstateParcels,
|
||||||
|
useRealEstateParcelOperations,
|
||||||
|
type RealEstateParcel,
|
||||||
|
} from '../../../hooks/useRealEstate';
|
||||||
|
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
|
import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable';
|
||||||
|
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
|
||||||
|
import { FaSync, FaMapMarkerAlt } from 'react-icons/fa';
|
||||||
|
import styles from '../../admin/Admin.module.css';
|
||||||
|
|
||||||
|
export const RealEstateParcelsView: React.FC = () => {
|
||||||
|
const instanceId = useInstanceId();
|
||||||
|
|
||||||
|
const {
|
||||||
|
items: parcels,
|
||||||
|
attributes,
|
||||||
|
permissions,
|
||||||
|
pagination,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
fetchById,
|
||||||
|
updateOptimistically,
|
||||||
|
removeOptimistically,
|
||||||
|
} = useRealEstateParcels();
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleDelete,
|
||||||
|
handleCreate,
|
||||||
|
handleUpdate,
|
||||||
|
deletingItems,
|
||||||
|
} = useRealEstateParcelOperations();
|
||||||
|
|
||||||
|
const [editingParcel, setEditingParcel] = useState<RealEstateParcel | null>(null);
|
||||||
|
const [isCreateMode, setIsCreateMode] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (instanceId) {
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
}, [instanceId, refetch]);
|
||||||
|
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
return (attributes || []).map(attr => ({
|
||||||
|
key: attr.name,
|
||||||
|
label: attr.label || attr.name,
|
||||||
|
type: attr.type as 'string' | 'number' | 'date' | 'boolean',
|
||||||
|
sortable: attr.sortable !== false,
|
||||||
|
filterable: attr.filterable !== false,
|
||||||
|
searchable: attr.searchable !== false,
|
||||||
|
width: attr.width || 150,
|
||||||
|
minWidth: attr.minWidth || 100,
|
||||||
|
maxWidth: attr.maxWidth || 400,
|
||||||
|
}));
|
||||||
|
}, [attributes]);
|
||||||
|
|
||||||
|
const canCreate = permissions?.create !== 'n';
|
||||||
|
const canUpdate = permissions?.update !== 'n';
|
||||||
|
const canDelete = permissions?.delete !== 'n';
|
||||||
|
|
||||||
|
const handleEditClick = async (parcel: RealEstateParcel) => {
|
||||||
|
const full = await fetchById(parcel.id);
|
||||||
|
if (full) {
|
||||||
|
setEditingParcel(full);
|
||||||
|
setIsCreateMode(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateClick = () => {
|
||||||
|
setEditingParcel(null);
|
||||||
|
setIsCreateMode(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormSubmit = async (data: Partial<RealEstateParcel>) => {
|
||||||
|
if (isCreateMode) {
|
||||||
|
const result = await handleCreate(data);
|
||||||
|
if (result.success) {
|
||||||
|
setIsCreateMode(false);
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
} else if (editingParcel) {
|
||||||
|
const result = await handleUpdate(editingParcel.id, data);
|
||||||
|
if (result.success) {
|
||||||
|
setEditingParcel(null);
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteParcel = async (parcel: RealEstateParcel) => {
|
||||||
|
removeOptimistically(parcel.id);
|
||||||
|
const success = await handleDelete(parcel.id);
|
||||||
|
if (!success) {
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setEditingParcel(null);
|
||||||
|
setIsCreateMode(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formAttributes = useMemo(() => {
|
||||||
|
const excluded = ['id', 'mandateId', 'instanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy'];
|
||||||
|
return (attributes || []).filter(attr => !excluded.includes(attr.name));
|
||||||
|
}, [attributes]);
|
||||||
|
|
||||||
|
const handleInlineUpdate = async (
|
||||||
|
itemId: string,
|
||||||
|
updateData: Partial<RealEstateParcel>,
|
||||||
|
row: RealEstateParcel
|
||||||
|
) => {
|
||||||
|
updateOptimistically(itemId, updateData);
|
||||||
|
const result = await handleUpdate(itemId, { ...row, ...updateData });
|
||||||
|
if (!result.success) {
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className={styles.adminPage}>
|
||||||
|
<div className={styles.errorContainer}>
|
||||||
|
<span className={styles.errorIcon}>⚠️</span>
|
||||||
|
<p className={styles.errorMessage}>Fehler beim Laden der Parzellen: {error}</p>
|
||||||
|
<button className={styles.secondaryButton} onClick={() => refetch()}>
|
||||||
|
<FaSync /> Erneut versuchen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.adminPage}>
|
||||||
|
<div className={styles.pageHeader}>
|
||||||
|
<div>
|
||||||
|
<p className={styles.pageSubtitle}>Parzellen verwalten</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.headerActions}>
|
||||||
|
<button
|
||||||
|
className={styles.secondaryButton}
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
|
||||||
|
</button>
|
||||||
|
{canCreate && (
|
||||||
|
<button className={styles.primaryButton} onClick={handleCreateClick}>
|
||||||
|
+ Neue Parzelle
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.tableContainer}>
|
||||||
|
{loading && (!parcels || parcels.length === 0) ? (
|
||||||
|
<div className={styles.loadingContainer}>
|
||||||
|
<div className={styles.spinner} />
|
||||||
|
<span>Lade Parzellen...</span>
|
||||||
|
</div>
|
||||||
|
) : !parcels || parcels.length === 0 ? (
|
||||||
|
<div className={styles.emptyState}>
|
||||||
|
<FaMapMarkerAlt className={styles.emptyIcon} />
|
||||||
|
<h3 className={styles.emptyTitle}>Keine Parzellen vorhanden</h3>
|
||||||
|
<p className={styles.emptyDescription}>
|
||||||
|
Erstellen Sie eine neue Parzelle, um zu beginnen.
|
||||||
|
</p>
|
||||||
|
{canCreate && (
|
||||||
|
<button className={styles.primaryButton} onClick={handleCreateClick}>
|
||||||
|
+ Neue Parzelle
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<FormGeneratorTable
|
||||||
|
data={parcels}
|
||||||
|
columns={columns}
|
||||||
|
loading={loading}
|
||||||
|
pagination={true}
|
||||||
|
pageSize={25}
|
||||||
|
searchable={true}
|
||||||
|
filterable={true}
|
||||||
|
sortable={true}
|
||||||
|
selectable={false}
|
||||||
|
actionButtons={[
|
||||||
|
...(canUpdate
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: 'edit' as const,
|
||||||
|
onAction: handleEditClick,
|
||||||
|
title: 'Bearbeiten',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(canDelete
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: 'delete' as const,
|
||||||
|
title: 'Löschen',
|
||||||
|
loading: (row: RealEstateParcel) => deletingItems.has(row.id),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]}
|
||||||
|
onDelete={handleDeleteParcel}
|
||||||
|
hookData={{
|
||||||
|
refetch,
|
||||||
|
permissions,
|
||||||
|
pagination,
|
||||||
|
handleDelete,
|
||||||
|
handleInlineUpdate,
|
||||||
|
updateOptimistically,
|
||||||
|
}}
|
||||||
|
emptyMessage="Keine Parzellen gefunden"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(editingParcel || isCreateMode) && (
|
||||||
|
<div className={styles.modalOverlay} onClick={handleCloseModal}>
|
||||||
|
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||||||
|
<div className={styles.modalHeader}>
|
||||||
|
<h2 className={styles.modalTitle}>
|
||||||
|
{isCreateMode ? 'Neue Parzelle' : 'Parzelle bearbeiten'}
|
||||||
|
</h2>
|
||||||
|
<button className={styles.modalClose} onClick={handleCloseModal}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.modalContent}>
|
||||||
|
{formAttributes.length === 0 ? (
|
||||||
|
<div className={styles.loadingContainer}>
|
||||||
|
<div className={styles.spinner} />
|
||||||
|
<span>Lade Formular...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<FormGeneratorForm
|
||||||
|
attributes={formAttributes}
|
||||||
|
data={editingParcel || {}}
|
||||||
|
mode={isCreateMode ? 'create' : 'edit'}
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
onCancel={handleCloseModal}
|
||||||
|
submitButtonText={isCreateMode ? 'Erstellen' : 'Speichern'}
|
||||||
|
cancelButtonText="Abbrechen"
|
||||||
|
instanceId={instanceId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RealEstateParcelsView;
|
||||||
113
src/pages/views/realestate/RealEstatePekView.tsx
Normal file
113
src/pages/views/realestate/RealEstatePekView.tsx
Normal 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;
|
||||||
223
src/pages/views/realestate/RealEstateProjectsView.tsx
Normal file
223
src/pages/views/realestate/RealEstateProjectsView.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
/**
|
||||||
|
* RealEstateProjectsView
|
||||||
|
*
|
||||||
|
* Projekt-Verwaltung für eine Real Estate/PEK-Instanz.
|
||||||
|
* Verwendet FormGeneratorTable analog zu TrusteeDocumentsView.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
useRealEstateProjects,
|
||||||
|
useRealEstateProjectOperations,
|
||||||
|
type RealEstateProject,
|
||||||
|
} from '../../../hooks/useRealEstate';
|
||||||
|
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
|
import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable';
|
||||||
|
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
|
||||||
|
import { FaSync, FaBuilding } from 'react-icons/fa';
|
||||||
|
import styles from '../../admin/Admin.module.css';
|
||||||
|
|
||||||
|
export const RealEstateProjectsView: React.FC = () => {
|
||||||
|
const instanceId = useInstanceId();
|
||||||
|
|
||||||
|
const {
|
||||||
|
items: projects,
|
||||||
|
attributes,
|
||||||
|
permissions,
|
||||||
|
pagination,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
fetchById,
|
||||||
|
updateOptimistically,
|
||||||
|
removeOptimistically,
|
||||||
|
} = useRealEstateProjects();
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleDelete,
|
||||||
|
handleCreate,
|
||||||
|
handleUpdate,
|
||||||
|
deletingItems,
|
||||||
|
} = useRealEstateProjectOperations();
|
||||||
|
|
||||||
|
const [editingProject, setEditingProject] = useState<RealEstateProject | null>(null);
|
||||||
|
const [isCreateMode, setIsCreateMode] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (instanceId) refetch();
|
||||||
|
}, [instanceId, refetch]);
|
||||||
|
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
return (attributes || []).map(attr => ({
|
||||||
|
key: attr.name,
|
||||||
|
label: attr.label || attr.name,
|
||||||
|
type: (attr.type || 'string') as 'string' | 'number' | 'date' | 'boolean',
|
||||||
|
sortable: attr.sortable !== false,
|
||||||
|
filterable: attr.filterable !== false,
|
||||||
|
searchable: attr.searchable !== false,
|
||||||
|
width: attr.width || 150,
|
||||||
|
minWidth: attr.minWidth || 100,
|
||||||
|
maxWidth: attr.maxWidth || 400,
|
||||||
|
}));
|
||||||
|
}, [attributes]);
|
||||||
|
|
||||||
|
const canCreate = permissions?.create !== 'n';
|
||||||
|
const canUpdate = permissions?.update !== 'n';
|
||||||
|
const canDelete = permissions?.delete !== 'n';
|
||||||
|
|
||||||
|
const handleEditClick = async (project: RealEstateProject) => {
|
||||||
|
const full = await fetchById(project.id);
|
||||||
|
if (full) {
|
||||||
|
setEditingProject(full);
|
||||||
|
setIsCreateMode(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateClick = () => {
|
||||||
|
setEditingProject(null);
|
||||||
|
setIsCreateMode(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormSubmit = async (data: Partial<RealEstateProject>) => {
|
||||||
|
if (isCreateMode) {
|
||||||
|
const result = await handleCreate(data);
|
||||||
|
if (result.success) {
|
||||||
|
setIsCreateMode(false);
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
} else if (editingProject) {
|
||||||
|
const result = await handleUpdate(editingProject.id, data);
|
||||||
|
if (result.success) {
|
||||||
|
setEditingProject(null);
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteProject = async (project: RealEstateProject) => {
|
||||||
|
removeOptimistically(project.id);
|
||||||
|
const success = await handleDelete(project.id);
|
||||||
|
if (!success) refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setEditingProject(null);
|
||||||
|
setIsCreateMode(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formAttributes = useMemo(() => {
|
||||||
|
const excluded = ['id', 'mandateId', 'instanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy'];
|
||||||
|
return (attributes || []).filter(attr => !excluded.includes(attr.name));
|
||||||
|
}, [attributes]);
|
||||||
|
|
||||||
|
const handleInlineUpdate = async (itemId: string, updateData: Partial<RealEstateProject>, row: RealEstateProject) => {
|
||||||
|
updateOptimistically(itemId, updateData);
|
||||||
|
const result = await handleUpdate(itemId, { ...row, ...updateData });
|
||||||
|
if (!result.success) refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className={styles.adminPage}>
|
||||||
|
<div className={styles.errorContainer}>
|
||||||
|
<span className={styles.errorIcon}>⚠️</span>
|
||||||
|
<p className={styles.errorMessage}>Fehler beim Laden der Projekte: {error}</p>
|
||||||
|
<button className={styles.secondaryButton} onClick={() => refetch()}>
|
||||||
|
<FaSync /> Erneut versuchen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.adminPage}>
|
||||||
|
<div className={styles.pageHeader}>
|
||||||
|
<div>
|
||||||
|
<p className={styles.pageSubtitle}>Projekte verwalten</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.headerActions}>
|
||||||
|
<button className={styles.secondaryButton} onClick={() => refetch()} disabled={loading}>
|
||||||
|
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
|
||||||
|
</button>
|
||||||
|
{canCreate && (
|
||||||
|
<button className={styles.primaryButton} onClick={handleCreateClick}>
|
||||||
|
+ Neues Projekt
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.tableContainer}>
|
||||||
|
{loading && (!projects || projects.length === 0) ? (
|
||||||
|
<div className={styles.loadingContainer}>
|
||||||
|
<div className={styles.spinner} />
|
||||||
|
<span>Lade Projekte...</span>
|
||||||
|
</div>
|
||||||
|
) : !projects || projects.length === 0 ? (
|
||||||
|
<div className={styles.emptyState}>
|
||||||
|
<FaBuilding className={styles.emptyIcon} />
|
||||||
|
<h3 className={styles.emptyTitle}>Keine Projekte vorhanden</h3>
|
||||||
|
<p className={styles.emptyDescription}>Erstellen Sie ein neues Projekt, um zu beginnen.</p>
|
||||||
|
{canCreate && (
|
||||||
|
<button className={styles.primaryButton} onClick={handleCreateClick}>
|
||||||
|
+ Neues Projekt
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<FormGeneratorTable
|
||||||
|
data={projects}
|
||||||
|
columns={columns}
|
||||||
|
loading={loading}
|
||||||
|
pagination={true}
|
||||||
|
pageSize={25}
|
||||||
|
searchable={true}
|
||||||
|
filterable={true}
|
||||||
|
sortable={true}
|
||||||
|
selectable={false}
|
||||||
|
actionButtons={[
|
||||||
|
...(canUpdate ? [{ type: 'edit' as const, onAction: handleEditClick, title: 'Bearbeiten' }] : []),
|
||||||
|
...(canDelete ? [{ type: 'delete' as const, title: 'Löschen', loading: (row: RealEstateProject) => deletingItems.has(row.id) }] : []),
|
||||||
|
]}
|
||||||
|
onDelete={handleDeleteProject}
|
||||||
|
hookData={{ refetch, permissions, pagination, handleDelete, handleInlineUpdate, updateOptimistically }}
|
||||||
|
emptyMessage="Keine Projekte gefunden"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(editingProject || isCreateMode) && (
|
||||||
|
<div className={styles.modalOverlay} onClick={handleCloseModal}>
|
||||||
|
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||||||
|
<div className={styles.modalHeader}>
|
||||||
|
<h2 className={styles.modalTitle}>{isCreateMode ? 'Neues Projekt' : 'Projekt bearbeiten'}</h2>
|
||||||
|
<button className={styles.modalClose} onClick={handleCloseModal}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.modalContent}>
|
||||||
|
{formAttributes.length === 0 ? (
|
||||||
|
<div className={styles.loadingContainer}>
|
||||||
|
<div className={styles.spinner} />
|
||||||
|
<span>Lade Formular...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<FormGeneratorForm
|
||||||
|
attributes={formAttributes}
|
||||||
|
data={editingProject || {}}
|
||||||
|
mode={isCreateMode ? 'create' : 'edit'}
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
onCancel={handleCloseModal}
|
||||||
|
submitButtonText={isCreateMode ? 'Erstellen' : 'Speichern'}
|
||||||
|
cancelButtonText="Abbrechen"
|
||||||
|
instanceId={instanceId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RealEstateProjectsView;
|
||||||
5
src/pages/views/realestate/index.ts
Normal file
5
src/pages/views/realestate/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export { RealEstateDashboardView } from './RealEstateDashboardView';
|
||||||
|
export { RealEstatePekView } from './RealEstatePekView';
|
||||||
|
export { RealEstateProjectsView } from './RealEstateProjectsView';
|
||||||
|
export { RealEstateParcelsView } from './RealEstateParcelsView';
|
||||||
|
export { RealEstateInstanceRolesPlaceholder } from './RealEstateInstanceRolesPlaceholder';
|
||||||
63
src/pages/views/realestate/pek/PekLocationInput.module.css
Normal file
63
src/pages/views/realestate/pek/PekLocationInput.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/pages/views/realestate/pek/PekLocationInput.tsx
Normal file
80
src/pages/views/realestate/pek/PekLocationInput.tsx
Normal 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;
|
||||||
58
src/pages/views/realestate/pek/PekMapView.tsx
Normal file
58
src/pages/views/realestate/pek/PekMapView.tsx
Normal 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;
|
||||||
Loading…
Reference in a new issue