Merge pull request #8 from valueonag/feat/auxiliaries2

Feat/auxiliaries2
This commit is contained in:
Patrick Motsch 2026-02-13 12:41:06 +01:00 committed by GitHub
commit 3ee156c5f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 6047 additions and 372 deletions

View file

@ -1,8 +1,8 @@
/**
* App.tsx
*
*
* Haupt-App-Komponente mit Multi-Tenant Router-Setup.
*
*
* URL-Struktur:
* - / Dashboard/Übersicht
* - /settings Benutzer-Einstellungen
@ -30,25 +30,15 @@ import { ProtectedRoute } from './providers/auth/ProtectedRoute';
import { LanguageProvider } from './providers/language/LanguageContext';
import { ToastProvider } from './contexts/ToastContext';
import { WorkflowSelectionProvider } from './contexts/WorkflowSelectionContext';
import { FileProvider } from './contexts/FileContext';
// Layouts
import { MainLayout } from './layouts/MainLayout';
import { FeatureLayout } from './layouts/FeatureLayout';
// Pages
import { DashboardPage } from './pages/Dashboard';
import { SettingsPage } from './pages/Settings';
import { GDPRPage } from './pages/GDPR';
import { FeatureViewPage } from './pages/FeatureView';
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminAutomationEventsPage } from './pages/admin';
// Basedata Pages (global)
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';
// Billing Pages
import { BillingDataView, BillingAdmin } from './pages/billing';
function App() {
// Load saved theme preference and set app name on app mount
useEffect(() => {
@ -93,9 +83,7 @@ function App() {
{/* ================================================== */}
<Route path="/" element={
<ProtectedRoute>
<FileProvider>
<MainLayout />
</FileProvider>
</ProtectedRoute>
}>
{/* Dashboard (Root) */}
@ -105,6 +93,15 @@ function App() {
<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) */}
{/* ============================================== */}
@ -114,13 +111,10 @@ function App() {
<Route path="connections" element={<ConnectionsPage />} />
</Route>
{/* ============================================== */}
{/* BILLING ROUTES */}
{/* ============================================== */}
<Route path="billing">
<Route index element={<Navigate to="/billing/transactions" replace />} />
<Route path="transactions" element={<BillingDataView />} />
</Route>
{/* Legacy top-level routes redirect to dashboard (migrated to feature-instance routes) */}
<Route path="chatbot" element={<Navigate to="/" replace />} />
<Route path="pek" element={<Navigate to="/" replace />} />
<Route path="speech" element={<Navigate to="/" replace />} />
{/* ============================================== */}
{/* FEATURE-INSTANZ ROUTES */}
@ -168,8 +162,6 @@ function App() {
{/* ADMIN ROUTES (nur SysAdmin) */}
{/* ============================================== */}
<Route path="admin">
<Route index element={<Navigate to="/admin/access" replace />} />
<Route path="access" element={<AccessManagementHub />} />
<Route path="mandates" element={<AdminMandatesPage />} />
<Route path="users" element={<AdminUsersPage />} />
<Route path="user-mandates" element={<AdminUserMandatesPage />} />
@ -180,8 +172,6 @@ function App() {
<Route path="mandate-roles" element={<AdminMandateRolesPage />} />
<Route path="mandate-role-permissions" element={<AdminMandateRolePermissionsPage />} />
<Route path="user-access-overview" element={<AdminUserAccessOverviewPage />} />
<Route path="billing" element={<BillingAdmin />} />
<Route path="automation-events" element={<AdminAutomationEventsPage />} />
</Route>
</Route>

View file

@ -0,0 +1,344 @@
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, LoadingRenderer } 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');
}
};
// PDF load is handled by the PdfRenderer's onError callback;
// successful load is implicit when no error occurs.
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}
/>
</div>
</div>
);
}
// Show error only if we're not showing PDF anyway
if (error && !showPdfAnyway) {
return (
<div className={styles.errorContainer}>
<div className={styles.errorIcon}></div>
<p>{error}</p>
<div style={{ display: 'flex', gap: '1rem', marginTop: '1rem', flexWrap: 'wrap' }}>
<button
onClick={() => {
setError(null);
setWarning(null);
setIsLoading(true);
setHasLoaded(false);
setShowPdfAnyway(false);
setUsePdfJs(false); // Reset to iframe
}}
className={styles.retryButton}
>
{t('common.retry', 'Retry')}
</button>
<button
onClick={handleOpenInNewTab}
className={styles.retryButton}
style={{
background: 'var(--color-primary, #3b82f6)',
fontSize: '0.875rem',
padding: '0.625rem 1.25rem',
fontWeight: '500'
}}
>
In neuem Tab öffnen
</button>
<button
onClick={handleDownload}
className={styles.retryButton}
style={{
background: 'var(--color-success, #10b981)',
fontSize: '0.875rem',
padding: '0.625rem 1.25rem',
fontWeight: '500'
}}
>
Download File
</button>
</div>
</div>
);
}
if (isLoading && !hasLoaded && !showPdfAnyway) {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{warning && (
<div style={{
padding: '1rem',
background: 'var(--color-warning-bg, #fef3c7)',
borderBottom: '1px solid var(--color-border, #e5e7eb)',
color: 'var(--color-warning-text, #92400e)',
fontSize: '0.875rem',
marginBottom: '1rem'
}}>
{warning}
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.75rem', flexWrap: 'wrap' }}>
<button
onClick={handleOpenInNewTab}
className={styles.retryButton}
style={{
background: 'var(--color-primary, #3b82f6)',
fontSize: '0.875rem',
padding: '0.625rem 1.25rem',
fontWeight: '500'
}}
>
In neuem Tab öffnen
</button>
<button
onClick={handleDownload}
className={styles.retryButton}
style={{
background: 'var(--color-success, #10b981)',
fontSize: '0.875rem',
padding: '0.625rem 1.25rem',
fontWeight: '500'
}}
>
Download
</button>
</div>
</div>
)}
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<LoadingRenderer />
</div>
</div>
);
}
// For other file types, show unsupported message
if (mimeType !== 'application/pdf') {
return (
<div className={styles.unsupportedContainer}>
<div className={styles.unsupportedIcon}>📄</div>
<div className={styles.fileName}>{fileName}</div>
<p>Preview not supported for this file type. Please download the file to view it.</p>
<button onClick={handleDownload} className={styles.retryButton}>
Download File
</button>
</div>
);
}
return null;
};
return (
<Popup
isOpen={isOpen}
onClose={onClose}
title={`${t('files.preview.title', 'Content Preview')}: ${fileName}`}
size="fullscreen"
className={styles.contentPreviewPopup}
actions={actions}
>
<div className={styles.previewContainer}>
{renderPreview()}
</div>
</Popup>
);
}
export default UrlContentPreview;

View file

@ -1,3 +1,5 @@
export { ContentPreview } from './ContentPreview';
export type { ContentPreviewProps } from './ContentPreview';
export { UrlContentPreview } from './UrlContentPreview';
export type { UrlContentPreviewProps } from './UrlContentPreview';

View file

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

View file

@ -8,24 +8,26 @@
* Backend liefert Blocks-Struktur mit Static und Dynamic Blocks.
* UI mappt uiComponent zu Icons via pageRegistry.
*
* TREE STRUCTURE (alles collapsible):
* Meine Sicht
* - Übersicht, Einstellungen, Prompts, Dateien, Verbindungen, Billing
*
* Mandant 1
* - 🎯 Instanz 1 (Feature-Icon + Instanz-Name)
* - 💼 Instanz 2 (Feature-Icon + Instanz-Name)
*
* Administration
* - Users, Mandates, Roles, ...
* 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';
@ -51,20 +53,13 @@ function navigationItemToTreeNode(item: NavigationItem): TreeNodeItem {
}
/**
* Convert a list of NavigationItems into a collapsible TreeNodeItem container.
* Used for grouping static items under "Meine Sicht" and "Administration".
* Convert a StaticBlock to TreeItem (section)
*/
function _staticItemsToTreeNode(
id: string,
label: string,
items: NavigationItem[],
defaultExpanded: boolean = true,
): TreeNodeItem {
function staticBlockToTreeItem(block: StaticBlock): TreeItem {
return {
id,
label,
children: items.map(navigationItemToTreeNode),
defaultExpanded,
type: 'section',
title: block.title,
children: block.items.map(navigationItemToTreeNode),
};
}
@ -80,52 +75,59 @@ function featureViewToTreeNode(view: FeatureView): TreeNodeItem {
}
/**
* Convert a FeatureInstance to TreeNodeItem (with feature icon)
* Instance node gets path to first view so clicking the instance name navigates to dashboard.
* Shows the feature icon next to the instance name for visual distinction.
* Convert a FeatureInstance to TreeNodeItem
*/
function featureInstanceToTreeNode(instance: FeatureInstance, featureUiComponent: string): TreeNodeItem {
const children = instance.views.map(featureViewToTreeNode);
function featureInstanceToTreeNode(instance: FeatureInstance): TreeNodeItem {
return {
id: instance.id,
label: instance.uiLabel,
icon: getPageIcon(featureUiComponent), // Use feature icon for instance
path: instance.views.length > 0 ? instance.views[0].uiPath : undefined,
children,
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
*
* FLAT STRUCTURE: Instances are listed directly under mandate (no feature grouping).
* Each instance shows the feature's icon for visual distinction.
*
* Before: Mandate Feature Instance Views
* Now: Mandate Instance (with feature icon) Views
*/
function navigationMandateToTreeNode(mandate: NavigationMandate): TreeNodeItem | null {
if (mandate.features.length === 0) {
return null;
}
// Flatten: collect all instances from all features directly under mandate
const instanceNodes: TreeNodeItem[] = [];
for (const feature of mandate.features) {
for (const instance of feature.instances) {
instanceNodes.push(featureInstanceToTreeNode(instance, feature.uiComponent));
}
}
const children = mandate.features
.map(mandateFeatureToTreeNode)
.filter((node): node is TreeNodeItem => node !== null);
if (instanceNodes.length === 0) {
if (children.length === 0) {
return null;
}
return {
id: mandate.id,
label: mandate.uiLabel,
children: instanceNodes,
children,
defaultExpanded: true,
};
}
@ -172,49 +174,40 @@ export const MandateNavigation: React.FC = () => {
const { blocks, loading } = useNavigation('de');
// Build navigation items from blocks
// Groups static items into collapsible containers:
// - "Meine Sicht": all non-admin static items (Übersicht, Einstellungen, Prompts, etc.)
// - "Administration": all admin static items
// - Dynamic block (mandates) renders between them
const navigationItems: TreeItem[] = useMemo(() => {
const items: TreeItem[] = [];
// Collect static items by category
const meineSichtItems: NavigationItem[] = [];
let adminItems: NavigationItem[] = [];
// Process blocks in order (already sorted by backend)
for (const block of blocks) {
if (block.type === 'static') {
if (block.id === 'admin') {
adminItems = [...block.items];
} else if (block.items.length > 0) {
meineSichtItems.push(...block.items);
// 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));
}
}
}
// "Meine Sicht" - collapsible container for user-facing pages
if (meineSichtItems.length > 0) {
items.push(_staticItemsToTreeNode('meine-sicht', 'Meine Sicht', meineSichtItems, true));
}
// Dynamic block: mandates with feature instances
for (const block of blocks) {
if (block.type === 'dynamic') {
} 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) {
if (items.length > 0) items.push({ type: 'separator' });
items.push(...mandateNodes);
}
// Add separator after dynamic block (before next static blocks)
items.push({ type: 'separator' });
}
}
// "Administration" - collapsible container for admin pages
if (adminItems.length > 0) {
if (items.length > 0) items.push({ type: 'separator' });
items.push(_staticItemsToTreeNode('administration', 'Administration', adminItems, false));
// Remove trailing separator if present
while (items.length > 0 && (items[items.length - 1] as TreeItem & { type?: string }).type === 'separator') {
items.pop();
}
return items;
}, [blocks]);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
export { OerebSection } from './OerebSection';

View file

@ -125,6 +125,24 @@
color: var(--color-text-secondary, #6b7280);
}
.documentsSection {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 2px solid var(--color-primary, #3b82f6);
background-color: var(--color-bg-secondary, #f9fafb);
padding: 1.5rem;
border-radius: 8px;
margin-left: -1.5rem;
margin-right: -1.5rem;
}
.documentsSectionTitle {
margin: 0 0 1rem 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--color-text, #111827);
}
.infoGrid {
display: flex;
flex-direction: column;
@ -241,6 +259,670 @@
overflow-y: auto;
}
.documentsList {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.documentLink {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
background-color: var(--color-bg, #ffffff);
border: 2px solid var(--color-primary, #3b82f6);
border-radius: 8px;
cursor: pointer;
text-align: left;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.documentLink:hover {
background-color: var(--color-primary-light, #eff6ff);
border-color: var(--color-primary-dark, #2563eb);
transform: translateY(-2px);
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.2);
}
.documentLink:active {
transform: translateY(0);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.documentLabel {
font-weight: 600;
color: var(--color-primary, #3b82f6);
font-size: 1rem;
word-break: break-word;
}
.documentLink:hover .documentLabel {
color: var(--color-primary-dark, #2563eb);
}
.documentType {
font-size: 0.8rem;
color: var(--color-text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 500;
}
/* BZO Information Styles */
.bzoButtonContainer {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.5rem;
}
.bzoButton {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background-color: var(--color-primary, #3b82f6);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s ease;
width: fit-content;
}
.bzoButton:hover:not(:disabled) {
background-color: var(--color-primary-dark, #2563eb);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3);
}
.bzoButton:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.errorMessage {
color: var(--color-error, #ef4444);
font-size: 0.875rem;
padding: 0.5rem;
background-color: var(--color-error-light, #fee2e2);
border-radius: 4px;
border: 1px solid var(--color-error, #ef4444);
}
.bzoSection {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 2px solid var(--color-primary, #3b82f6);
background-color: var(--color-bg-secondary, #f9fafb);
padding: 1.5rem;
border-radius: 8px;
margin-left: -1.5rem;
margin-right: -1.5rem;
}
.bzoHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.bzoSectionTitle {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--color-text, #111827);
}
.toggleButton {
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
color: var(--color-text-secondary, #6b7280);
padding: 0.25rem 0.5rem;
border-radius: 4px;
transition: background-color 0.2s;
}
.toggleButton:hover {
background-color: var(--color-hover, #f3f4f6);
}
.bzoContent {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.bzoSubSection {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.bzoSubTitle {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--color-text, #111827);
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.bzoSummary {
padding: 1rem;
background-color: var(--color-bg, #ffffff);
border-left: 4px solid var(--color-primary, #3b82f6);
border-radius: 4px;
color: var(--color-text, #111827);
}
/* Markdown Styles for BZO Content */
.bzoMarkdown {
line-height: 1.6;
color: var(--color-text, #111827);
}
.bzoMarkdownH1,
.bzoMarkdownH2,
.bzoMarkdownH3,
.bzoMarkdownH4,
.bzoMarkdownH5,
.bzoMarkdownH6 {
margin-top: 1.5rem;
margin-bottom: 0.75rem;
font-weight: 600;
color: var(--color-text, #111827);
line-height: 1.3;
}
.bzoMarkdownH1 {
font-size: 1.5rem;
border-bottom: 2px solid var(--color-border, #e5e7eb);
padding-bottom: 0.5rem;
}
.bzoMarkdownH2 {
font-size: 1.25rem;
border-bottom: 1px solid var(--color-border, #e5e7eb);
padding-bottom: 0.25rem;
}
.bzoMarkdownH3 {
font-size: 1.1rem;
}
.bzoMarkdownH4 {
font-size: 1rem;
}
.bzoMarkdownH5 {
font-size: 0.95rem;
}
.bzoMarkdownH6 {
font-size: 0.9rem;
}
.bzoMarkdownP {
margin: 0.75rem 0;
line-height: 1.6;
}
.bzoMarkdownP:first-child {
margin-top: 0;
}
.bzoMarkdownP:last-child {
margin-bottom: 0;
}
.bzoMarkdownUl,
.bzoMarkdownOl {
margin: 0.75rem 0;
padding-left: 1.5rem;
}
.bzoMarkdownLi {
margin: 0.5rem 0;
line-height: 1.6;
}
.bzoMarkdownUl .bzoMarkdownLi {
list-style-type: disc;
}
.bzoMarkdownOl .bzoMarkdownLi {
list-style-type: decimal;
}
.bzoMarkdownTableWrapper {
overflow-x: auto;
margin: 1rem 0;
}
.bzoMarkdownTable {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
overflow: hidden;
}
.bzoMarkdownThead {
background-color: var(--color-bg-secondary, #f9fafb);
}
.bzoMarkdownTh {
padding: 0.75rem;
text-align: left;
font-weight: 600;
border-bottom: 2px solid var(--color-border, #e5e7eb);
color: var(--color-text, #111827);
}
.bzoMarkdownTd {
padding: 0.75rem;
border-bottom: 1px solid var(--color-border, #e5e7eb);
color: var(--color-text, #111827);
}
.bzoMarkdownTr:last-child .bzoMarkdownTd {
border-bottom: none;
}
.bzoMarkdownTr:hover {
background-color: var(--color-hover, #f3f4f6);
}
.bzoMarkdownCodeInline {
background-color: var(--color-bg-secondary, #f9fafb);
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
color: var(--color-primary, #3b82f6);
border: 1px solid var(--color-border, #e5e7eb);
}
.bzoMarkdownPre {
background-color: var(--color-bg-secondary, #f9fafb);
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
border: 1px solid var(--color-border, #e5e7eb);
margin: 1rem 0;
}
.bzoMarkdownCodeBlock {
font-family: 'Courier New', monospace;
font-size: 0.9em;
color: var(--color-text, #111827);
}
.bzoMarkdownBlockquote {
margin: 1rem 0;
padding: 0.75rem 1rem;
border-left: 4px solid var(--color-primary, #3b82f6);
background-color: var(--color-bg-secondary, #f9fafb);
border-radius: 4px;
color: var(--color-text-secondary, #6b7280);
font-style: italic;
}
.bzoMarkdownStrong {
font-weight: 600;
color: var(--color-text, #111827);
}
.bzoMarkdownEm {
font-style: italic;
}
.bzoMarkdownLink {
color: var(--color-primary, #3b82f6);
text-decoration: none;
font-weight: 500;
transition: color 0.2s;
}
.bzoMarkdownLink:hover {
color: var(--color-primary-dark, #2563eb);
text-decoration: underline;
}
.bzoMarkdownHr {
margin: 1.5rem 0;
border: none;
border-top: 1px solid var(--color-border, #e5e7eb);
}
.bzoInfoGrid {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.bzoInfoItem {
display: flex;
gap: 0.5rem;
}
.bzoLabel {
font-weight: 500;
color: var(--color-text-secondary, #6b7280);
min-width: 80px;
}
.bzoValue {
color: var(--color-text, #111827);
}
.bzoZonesList {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.bzoZoneCard {
padding: 1rem;
background-color: var(--color-bg, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
transition: box-shadow 0.2s;
}
.bzoZoneCard:hover {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.bzoZoneHeader {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.bzoZoneCode {
font-weight: 700;
font-size: 1.1rem;
color: var(--color-primary, #3b82f6);
}
.bzoZoneName {
font-weight: 600;
color: var(--color-text, #111827);
}
.bzoZoneDetails {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.bzoZoneDetailItem {
display: flex;
gap: 0.5rem;
}
.bzoDetailLabel {
font-weight: 500;
color: var(--color-text-secondary, #6b7280);
min-width: 140px;
font-size: 0.875rem;
}
.bzoDetailValue {
color: var(--color-text, #111827);
font-size: 0.875rem;
}
.bzoRulesList {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.bzoRuleCard {
padding: 1rem;
background-color: var(--color-bg, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-left: 4px solid var(--color-primary, #3b82f6);
border-radius: 6px;
transition: box-shadow 0.2s;
}
.bzoRuleCard:hover {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.bzoRuleHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.bzoRuleType {
font-weight: 600;
color: var(--color-text, #111827);
text-transform: capitalize;
font-size: 0.95rem;
}
.bzoConfidence {
font-size: 0.75rem;
color: var(--color-text-secondary, #6b7280);
background-color: var(--color-bg-secondary, #f9fafb);
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.bzoRuleValue {
margin-bottom: 0.5rem;
}
.bzoRuleNumeric {
font-size: 1.1rem;
font-weight: 600;
color: var(--color-primary, #3b82f6);
}
.bzoRuleText {
color: var(--color-text, #111827);
font-weight: 500;
}
.bzoRuleSnippet {
margin-top: 0.5rem;
padding: 0.5rem;
background-color: var(--color-bg-secondary, #f9fafb);
border-radius: 4px;
font-style: italic;
color: var(--color-text-secondary, #6b7280);
font-size: 0.875rem;
}
.bzoRuleZone,
.bzoRulePage {
font-size: 0.75rem;
color: var(--color-text-secondary, #6b7280);
margin-top: 0.25rem;
}
.bzoRuleMeta {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--color-border, #e5e7eb);
font-size: 0.75rem;
color: var(--color-text-secondary, #6b7280);
}
.bzoArticlesList {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.bzoArticleCard {
padding: 1rem;
background-color: var(--color-bg, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
transition: box-shadow 0.2s;
}
.bzoArticleCard:hover {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.bzoArticleHeader {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.bzoArticleLabel {
font-weight: 700;
color: var(--color-primary, #3b82f6);
font-size: 1rem;
}
.bzoArticleTitle {
font-weight: 600;
color: var(--color-text, #111827);
}
.bzoArticleText {
margin-bottom: 0.75rem;
line-height: 1.6;
color: var(--color-text, #111827);
white-space: pre-wrap;
}
.bzoArticleMeta {
display: flex;
gap: 1rem;
font-size: 0.75rem;
color: var(--color-text-secondary, #6b7280);
padding-top: 0.5rem;
border-top: 1px solid var(--color-border, #e5e7eb);
}
.bzoDocumentsList {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.bzoDocumentItem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background-color: var(--color-bg, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
}
.bzoDocumentLabel {
font-weight: 500;
color: var(--color-text, #111827);
}
.bzoDocumentType {
font-size: 0.75rem;
color: var(--color-text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.bzoErrors,
.bzoWarnings {
margin-top: 1rem;
}
.bzoErrorTitle {
margin: 0 0 0.5rem 0;
font-size: 0.95rem;
font-weight: 600;
color: var(--color-error, #ef4444);
}
.bzoWarningTitle {
margin: 0 0 0.5rem 0;
font-size: 0.95rem;
font-weight: 600;
color: #f59e0b;
}
.bzoErrorList,
.bzoWarningList {
margin: 0;
padding-left: 1.5rem;
color: var(--color-text, #111827);
}
.bzoErrorList li {
color: var(--color-error, #ef4444);
margin-bottom: 0.25rem;
}
.bzoWarningList li {
color: #f59e0b;
margin-bottom: 0.25rem;
}
.bzoStats {
display: flex;
gap: 2rem;
flex-wrap: wrap;
}
.bzoStatItem {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.bzoStatLabel {
font-size: 0.875rem;
color: var(--color-text-secondary, #6b7280);
font-weight: 500;
}
.bzoStatValue {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-primary, #3b82f6);
}
@media (max-width: 768px) {
.panel {
width: 100vw;

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { GenericPageData } from '../../../pageInterface';
import { FaLink, FaPlus } from 'react-icons/fa';
import { useTrusteePositionDocuments, useTrusteePositionDocumentOperations } from '../../../../../hooks/useTrustee';
import { useTrusteePositionDocuments, useTrusteePositionDocumentOperations } from '../../../../../hooks/useTrusteePositionDocuments';
// Helper function to convert attribute definitions to column config
const attributesToColumns = (attributes: any[]) => {
@ -28,7 +28,7 @@ const attributesToColumns = (attributes: any[]) => {
const createPositionDocumentsHook = () => {
return () => {
const {
items: positionDocuments,
positionDocuments,
loading,
error,
refetch,
@ -36,15 +36,15 @@ const createPositionDocumentsHook = () => {
attributes,
permissions,
pagination,
fetchById: fetchPositionDocumentById,
fetchPositionDocumentById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
} = useTrusteePositionDocuments();
const {
handleDelete: handlePositionDocumentDelete,
handleCreate: handlePositionDocumentCreate,
deletingItems: deletingPositionDocuments,
creatingItem: creatingPositionDocument,
handlePositionDocumentDelete,
handlePositionDocumentCreate,
deletingPositionDocuments,
creatingPositionDocument,
deleteError,
createError
} = useTrusteePositionDocumentOperations();
@ -70,7 +70,7 @@ const createPositionDocumentsHook = () => {
positionDocumentIds.map(id => handlePositionDocumentDelete(id))
);
const allSuccessful = results.every((result: boolean) => result);
const allSuccessful = results.every(result => result);
if (allSuccessful) {
refetch();
}

View file

@ -0,0 +1,383 @@
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 {
fetchAccess as fetchAccessApi,
fetchAccessById as fetchAccessByIdApi,
createAccess as createAccessApi,
updateAccess as updateAccessApi,
deleteAccess as deleteAccessApi,
type TrusteeAccess,
type PaginationParams
} from '../api/trusteeApi';
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;
}
// Re-export types
export type { TrusteeAccess, PaginationParams };
// Access list hook
export function useTrusteeAccess() {
const instanceId = useInstanceId();
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 () => {
if (!instanceId) return [];
try {
const response = await api.get(`/api/trustee/${instanceId}/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 [];
}
}, [instanceId]);
// Fetch permissions from backend
const fetchPermissions = useCallback(async () => {
try {
const objectKey = 'data.feature.trustee.TrusteeAccess';
const perms = await checkPermission('DATA', objectKey);
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) => {
if (!instanceId) {
setAccessRecords([]);
return;
}
try {
const data = await fetchAccessApi(request, instanceId, 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, instanceId]);
// 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> => {
if (!instanceId) return null;
return await fetchAccessByIdApi(request, instanceId, accessId);
}, [request, instanceId]);
// 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: any) => {
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 data when instanceId is available
useEffect(() => {
if (instanceId) {
fetchAttributes();
fetchPermissions();
fetchAccess();
}
}, [instanceId, fetchAttributes, fetchPermissions, fetchAccess]);
return {
accessRecords,
loading,
error,
refetch: fetchAccess,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
pagination,
fetchAccessById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded,
instanceId
};
}
// Access operations hook
export function useTrusteeAccessOperations() {
const instanceId = useInstanceId();
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) => {
if (!instanceId) {
setDeleteError('No instance context');
return false;
}
setDeleteError(null);
setDeletingAccess(prev => new Set(prev).add(accessId));
try {
await deleteAccessApi(request, instanceId, 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>) => {
if (!instanceId) {
setCreateError('No instance context');
return { success: false, error: 'No instance context' };
}
setCreateError(null);
setCreatingAccess(true);
try {
const newAccess = await createAccessApi(request, instanceId, accessData);
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
) => {
if (!instanceId) {
setUpdateError('No instance context');
return { success: false, error: 'No instance context' };
}
setUpdateError(null);
try {
const updatedAccess = await updateAccessApi(request, instanceId, accessId, updateData);
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,
instanceId
};
}

View file

@ -0,0 +1,391 @@
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 {
fetchContracts as fetchContractsApi,
fetchContractById as fetchContractByIdApi,
createContract as createContractApi,
updateContract as updateContractApi,
deleteContract as deleteContractApi,
type TrusteeContract,
type PaginationParams
} from '../api/trusteeApi';
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;
}
// Re-export types
export type { TrusteeContract, PaginationParams };
// Contracts list hook
export function useTrusteeContracts() {
const instanceId = useInstanceId();
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 () => {
if (!instanceId) return [];
try {
const response = await api.get(`/api/trustee/${instanceId}/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 [];
}
}, [instanceId]);
// Fetch permissions from backend
const fetchPermissions = useCallback(async () => {
try {
const objectKey = 'data.feature.trustee.TrusteeContract';
const perms = await checkPermission('DATA', objectKey);
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) => {
if (!instanceId) {
setContracts([]);
return;
}
try {
const data = await fetchContractsApi(request, instanceId, 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, instanceId]);
// 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> => {
if (!instanceId) return null;
return await fetchContractByIdApi(request, instanceId, contractId);
}, [request, instanceId]);
// 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: any) => {
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 data when instanceId is available
useEffect(() => {
if (instanceId) {
fetchAttributes();
fetchPermissions();
fetchContracts();
}
}, [instanceId, fetchAttributes, fetchPermissions, fetchContracts]);
return {
contracts,
loading,
error,
refetch: fetchContracts,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
pagination,
fetchContractById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded,
instanceId
};
}
// Contract operations hook
export function useTrusteeContractOperations() {
const instanceId = useInstanceId();
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) => {
if (!instanceId) {
setDeleteError('No instance context');
return false;
}
setDeleteError(null);
setDeletingContracts(prev => new Set(prev).add(contractId));
try {
await deleteContractApi(request, instanceId, 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>) => {
if (!instanceId) {
setCreateError('No instance context');
return { success: false, error: 'No instance context' };
}
setCreateError(null);
setCreatingContract(true);
try {
const newContract = await createContractApi(request, instanceId, contractData);
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
) => {
if (!instanceId) {
setUpdateError('No instance context');
return { success: false, error: 'No instance context' };
}
setUpdateError(null);
try {
const updatedContract = await updateContractApi(request, instanceId, contractId, updateData);
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,
instanceId
};
}

View file

@ -0,0 +1,434 @@
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 {
fetchDocuments as fetchDocumentsApi,
fetchDocumentById as fetchDocumentByIdApi,
createDocument as createDocumentApi,
updateDocument as updateDocumentApi,
deleteDocument as deleteDocumentApi,
type TrusteeDocument,
type PaginationParams
} from '../api/trusteeApi';
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;
}
// Re-export types
export type { TrusteeDocument, PaginationParams };
// Documents list hook
export function useTrusteeDocuments() {
const instanceId = useInstanceId();
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 () => {
if (!instanceId) return [];
try {
const response = await api.get(`/api/trustee/${instanceId}/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 [];
}
}, [instanceId]);
// Fetch permissions from backend
const fetchPermissions = useCallback(async () => {
try {
const objectKey = 'data.feature.trustee.TrusteeDocument';
const perms = await checkPermission('DATA', objectKey);
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) => {
if (!instanceId) {
setDocuments([]);
return;
}
try {
const data = await fetchDocumentsApi(request, instanceId, 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, instanceId]);
// 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> => {
if (!instanceId) return null;
return await fetchDocumentByIdApi(request, instanceId, documentId);
}, [request, instanceId]);
// 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: any) => {
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 data when instanceId is available
useEffect(() => {
if (instanceId) {
fetchAttributes();
fetchPermissions();
fetchDocuments();
}
}, [instanceId, fetchAttributes, fetchPermissions, fetchDocuments]);
return {
documents,
loading,
error,
refetch: fetchDocuments,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
pagination,
fetchDocumentById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded,
instanceId
};
}
// Document operations hook
export function useTrusteeDocumentOperations() {
const instanceId = useInstanceId();
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) => {
if (!instanceId) {
setDeleteError('No instance context');
return false;
}
setDeleteError(null);
setDeletingDocuments(prev => new Set(prev).add(documentId));
try {
await deleteDocumentApi(request, instanceId, 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: Partial<TrusteeDocument>) => {
if (!instanceId) {
setCreateError('No instance context');
return { success: false, error: 'No instance context' };
}
setCreateError(null);
setCreatingDocument(true);
try {
const newDocument = await createDocumentApi(request, instanceId, 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
) => {
if (!instanceId) {
setUpdateError('No instance context');
return { success: false, error: 'No instance context' };
}
setUpdateError(null);
try {
const updatedDocument = await updateDocumentApi(request, instanceId, documentId, updateData);
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) => {
if (!instanceId) {
setDownloadError('No instance context');
return false;
}
setDownloadError(null);
setDownloadingDocuments(prev => new Set(prev).add(documentId));
try {
const doc = await fetchDocumentByIdApi(request, instanceId, documentId);
if (!doc || !doc.documentData) {
throw new Error('Document data not found');
}
// Convert base64 to blob
const byteCharacters = atob(doc.documentData);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: doc.documentMimeType || 'application/octet-stream' });
// 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,
instanceId
};
}

View file

@ -0,0 +1,399 @@
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 {
fetchOrganisations as fetchOrganisationsApi,
fetchOrganisationById as fetchOrganisationByIdApi,
createOrganisation as createOrganisationApi,
updateOrganisation as updateOrganisationApi,
deleteOrganisation as deleteOrganisationApi,
type TrusteeOrganisation,
type PaginationParams
} from '../api/trusteeApi';
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;
}
// Re-export types
export type { TrusteeOrganisation, PaginationParams };
// Organisations list hook
export function useTrusteeOrganisations() {
const instanceId = useInstanceId();
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 () => {
if (!instanceId) return [];
try {
const response = await api.get(`/api/trustee/${instanceId}/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 [];
}
}, [instanceId]);
// Fetch permissions from backend
const fetchPermissions = useCallback(async () => {
try {
const objectKey = 'data.feature.trustee.TrusteeOrganisation';
const perms = await checkPermission('DATA', objectKey);
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) => {
if (!instanceId) {
setOrganisations([]);
return;
}
try {
const data = await fetchOrganisationsApi(request, instanceId, 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, instanceId]);
// 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> => {
if (!instanceId) return null;
return await fetchOrganisationByIdApi(request, instanceId, organisationId);
}, [request, instanceId]);
// 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: any) => {
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: any) => {
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 data when instanceId is available
useEffect(() => {
if (instanceId) {
fetchAttributes();
fetchPermissions();
fetchOrganisations();
}
}, [instanceId, fetchAttributes, fetchPermissions, fetchOrganisations]);
return {
organisations,
loading,
error,
refetch: fetchOrganisations,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
pagination,
fetchOrganisationById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded,
instanceId
};
}
// Organisation operations hook
export function useTrusteeOrganisationOperations() {
const instanceId = useInstanceId();
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) => {
if (!instanceId) {
setDeleteError('No instance context');
return false;
}
setDeleteError(null);
setDeletingOrganisations(prev => new Set(prev).add(organisationId));
try {
await deleteOrganisationApi(request, instanceId, 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>) => {
if (!instanceId) {
setCreateError('No instance context');
return { success: false, error: 'No instance context' };
}
setCreateError(null);
setCreatingOrganisation(true);
try {
const newOrganisation = await createOrganisationApi(request, instanceId, organisationData);
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
) => {
if (!instanceId) {
setUpdateError('No instance context');
return { success: false, error: 'No instance context' };
}
setUpdateError(null);
try {
const updatedOrganisation = await updateOrganisationApi(request, instanceId, organisationId, updateData);
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,
instanceId
};
}

View file

@ -0,0 +1,337 @@
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 {
fetchPositionDocuments as fetchPositionDocumentsApi,
fetchPositionDocumentById as fetchPositionDocumentByIdApi,
createPositionDocument as createPositionDocumentApi,
deletePositionDocument as deletePositionDocumentApi,
type TrusteePositionDocument,
type PaginationParams
} from '../api/trusteeApi';
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;
}
// Re-export types
export type { TrusteePositionDocument, PaginationParams };
// Position-Documents list hook
export function useTrusteePositionDocuments() {
const instanceId = useInstanceId();
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 () => {
if (!instanceId) return [];
try {
const response = await api.get(`/api/trustee/${instanceId}/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 [];
}
}, [instanceId]);
// Fetch permissions from backend
const fetchPermissions = useCallback(async () => {
try {
const objectKey = 'data.feature.trustee.TrusteePositionDocument';
const perms = await checkPermission('DATA', objectKey);
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) => {
if (!instanceId) {
setPositionDocuments([]);
return;
}
try {
const data = await fetchPositionDocumentsApi(request, instanceId, 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, instanceId]);
// 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> => {
if (!instanceId) return null;
return await fetchPositionDocumentByIdApi(request, instanceId, positionDocumentId);
}, [request, instanceId]);
// 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: any) => {
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 data when instanceId is available
useEffect(() => {
if (instanceId) {
fetchAttributes();
fetchPermissions();
fetchPositionDocuments();
}
}, [instanceId, fetchAttributes, fetchPermissions, fetchPositionDocuments]);
return {
positionDocuments,
loading,
error,
refetch: fetchPositionDocuments,
removeOptimistically,
attributes,
permissions,
pagination,
fetchPositionDocumentById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded,
instanceId
};
}
// Position-Document operations hook
export function useTrusteePositionDocumentOperations() {
const instanceId = useInstanceId();
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) => {
if (!instanceId) {
setDeleteError('No instance context');
return false;
}
setDeleteError(null);
setDeletingPositionDocuments(prev => new Set(prev).add(positionDocumentId));
try {
await deletePositionDocumentApi(request, instanceId, 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>) => {
if (!instanceId) {
setCreateError('No instance context');
return { success: false, error: 'No instance context' };
}
setCreateError(null);
setCreatingPositionDocument(true);
try {
const newPositionDocument = await createPositionDocumentApi(request, instanceId, positionDocumentData);
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,
instanceId
};
}

View file

@ -0,0 +1,448 @@
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 {
fetchPositions as fetchPositionsApi,
fetchPositionById as fetchPositionByIdApi,
createPosition as createPositionApi,
updatePosition as updatePositionApi,
deletePosition as deletePositionApi,
type TrusteePosition,
type PaginationParams
} from '../api/trusteeApi';
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;
}
// Re-export types
export type { TrusteePosition, PaginationParams };
// Positions list hook
export function useTrusteePositions() {
const instanceId = useInstanceId();
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 () => {
if (!instanceId) return [];
try {
const response = await api.get(`/api/trustee/${instanceId}/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 [];
}
}, [instanceId]);
// Fetch permissions from backend
const fetchPermissions = useCallback(async () => {
try {
const objectKey = 'data.feature.trustee.TrusteePosition';
const perms = await checkPermission('DATA', objectKey);
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) => {
if (!instanceId) {
setPositions([]);
return;
}
try {
const data = await fetchPositionsApi(request, instanceId, 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, instanceId]);
// 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> => {
if (!instanceId) return null;
return await fetchPositionByIdApi(request, instanceId, positionId);
}, [request, instanceId]);
// 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: any) => {
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 data when instanceId is available
useEffect(() => {
if (instanceId) {
fetchAttributes();
fetchPermissions();
fetchPositions();
}
}, [instanceId, fetchAttributes, fetchPermissions, fetchPositions]);
return {
positions,
loading,
error,
refetch: fetchPositions,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
pagination,
fetchPositionById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded,
instanceId
};
}
// Position operations hook
export function useTrusteePositionOperations() {
const instanceId = useInstanceId();
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) => {
if (!instanceId) {
setDeleteError('No instance context');
return false;
}
setDeleteError(null);
setDeletingPositions(prev => new Set(prev).add(positionId));
try {
await deletePositionApi(request, instanceId, 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>) => {
if (!instanceId) {
setCreateError('No instance context');
return { success: false, error: 'No instance context' };
}
setCreateError(null);
setCreatingPosition(true);
try {
const newPosition = await createPositionApi(request, instanceId, positionData);
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
) => {
if (!instanceId) {
setUpdateError('No instance context');
return { success: false, error: 'No instance context' };
}
setUpdateError(null);
try {
const updatedPosition = await updatePositionApi(request, instanceId, positionId, updateData);
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,
instanceId
};
}

View file

@ -0,0 +1,399 @@
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 {
fetchRoles as fetchRolesApi,
fetchRoleById as fetchRoleByIdApi,
createRole as createRoleApi,
updateRole as updateRoleApi,
deleteRole as deleteRoleApi,
type TrusteeRole,
type PaginationParams
} from '../api/trusteeApi';
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;
}
// Re-export types
export type { TrusteeRole, PaginationParams };
// Roles list hook
export function useTrusteeRoles() {
const instanceId = useInstanceId();
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 () => {
if (!instanceId) return [];
try {
const response = await api.get(`/api/trustee/${instanceId}/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 [];
}
}, [instanceId]);
// Fetch permissions from backend
const fetchPermissions = useCallback(async () => {
try {
const objectKey = 'data.feature.trustee.TrusteeRole';
const perms = await checkPermission('DATA', objectKey);
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) => {
if (!instanceId) {
setRoles([]);
return;
}
try {
const data = await fetchRolesApi(request, instanceId, 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, instanceId]);
// 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> => {
if (!instanceId) return null;
return await fetchRoleByIdApi(request, instanceId, roleId);
}, [request, instanceId]);
// 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;
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: any) => {
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]);
// Ensure attributes are loaded
const ensureAttributesLoaded = useCallback(async () => {
if (attributes && attributes.length > 0) {
return attributes;
}
const fetchedAttributes = await fetchAttributes();
return fetchedAttributes;
}, [attributes, fetchAttributes]);
// Fetch data when instanceId is available
useEffect(() => {
if (instanceId) {
fetchAttributes();
fetchPermissions();
fetchRoles();
}
}, [instanceId, fetchAttributes, fetchPermissions, fetchRoles]);
return {
roles,
loading,
error,
refetch: fetchRoles,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
pagination,
fetchRoleById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded,
instanceId
};
}
// Role operations hook
export function useTrusteeRoleOperations() {
const instanceId = useInstanceId();
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) => {
if (!instanceId) {
setDeleteError('No instance context');
return false;
}
setDeleteError(null);
setDeletingRoles(prev => new Set(prev).add(roleId));
try {
await deleteRoleApi(request, instanceId, 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>) => {
if (!instanceId) {
setCreateError('No instance context');
return { success: false, error: 'No instance context' };
}
setCreateError(null);
setCreatingRole(true);
try {
const newRole = await createRoleApi(request, instanceId, roleData);
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
) => {
if (!instanceId) {
setUpdateError('No instance context');
return { success: false, error: 'No instance context' };
}
setUpdateError(null);
try {
const updatedRole = await updateRoleApi(request, instanceId, roleId, updateData);
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,
instanceId
};
}

View file

@ -8,7 +8,7 @@
import React from 'react';
import { useCurrentInstance } from '../hooks/useCurrentInstance';
import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
import useNavigation from '../hooks/useNavigation';
import { getLabel, FEATURE_REGISTRY } from '../types/mandate';
// Trustee Views
// Note: TrusteeOrganisationsView and TrusteeContractsView removed - Feature-Instanz = Organisation
@ -139,13 +139,31 @@ interface FeatureViewPageProps {
}
export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
const { instance, featureCode, mandateId, isValid } = useCurrentInstance();
const { dynamicBlock } = useNavigation();
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 />;
@ -167,17 +185,10 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
return <NotFound />;
}
// View-Label aus Backend-Navigation ermitteln
let viewLabel = view;
if (dynamicBlock) {
const navMandate = dynamicBlock.mandates.find(m => m.id === mandateId);
const navFeature = navMandate?.features.find(f => f.uiComponent.includes(featureCode));
const navInstance = navFeature?.instances.find(i => i.id === instance.id);
const navView = navInstance?.views.find(v => v.uiComponent.includes(view));
if (navView) {
viewLabel = navView.uiLabel;
}
}
// 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}>

View file

@ -185,7 +185,6 @@ export const RealEstateParcelsView: React.FC = () => {
<FormGeneratorTable
data={parcels}
columns={columns}
apiEndpoint={instanceId ? `/api/realestate/${instanceId}/parcels` : undefined}
loading={loading}
pagination={true}
pageSize={25}

View file

@ -169,7 +169,6 @@ export const RealEstateProjectsView: React.FC = () => {
<FormGeneratorTable
data={projects}
columns={columns}
apiEndpoint={instanceId ? `/api/realestate/${instanceId}/projects` : undefined}
loading={loading}
pagination={true}
pageSize={25}

View file

@ -2,4 +2,4 @@ export { RealEstateDashboardView } from './RealEstateDashboardView';
export { RealEstatePekView } from './RealEstatePekView';
export { RealEstateProjectsView } from './RealEstateProjectsView';
export { RealEstateParcelsView } from './RealEstateParcelsView';
export { RealEstateInstanceRolesPlaceholder } from './RealEstateInstanceRolesPlaceholder';
export { RealEstateInstanceRolesPlaceholder } from './RealEstateInstanceRolesPlaceholder';

View file

@ -11,7 +11,11 @@ export const TeamsbotSettingsView: React.FC = () => {
const { instance } = useCurrentInstance();
const instanceId = instance?.id || '';
<<<<<<< feat/auxiliaries2
const [_config, setConfig] = useState<TeamsbotConfig | null>(null);
=======
const [, setConfig] = useState<TeamsbotConfig | null>(null);
>>>>>>> int
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);