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