Merge branch 'feat/real-estate' into int
This commit is contained in:
commit
00f2158040
25 changed files with 5803 additions and 396 deletions
49
src/App.tsx
49
src/App.tsx
|
|
@ -30,25 +30,15 @@ import { ProtectedRoute } from './providers/auth/ProtectedRoute';
|
||||||
import { LanguageProvider } from './providers/language/LanguageContext';
|
import { LanguageProvider } from './providers/language/LanguageContext';
|
||||||
import { ToastProvider } from './contexts/ToastContext';
|
import { ToastProvider } from './contexts/ToastContext';
|
||||||
import { WorkflowSelectionProvider } from './contexts/WorkflowSelectionContext';
|
import { WorkflowSelectionProvider } from './contexts/WorkflowSelectionContext';
|
||||||
import { FileProvider } from './contexts/FileContext';
|
|
||||||
|
|
||||||
// Layouts
|
|
||||||
import { MainLayout } from './layouts/MainLayout';
|
import { MainLayout } from './layouts/MainLayout';
|
||||||
import { FeatureLayout } from './layouts/FeatureLayout';
|
import { FeatureLayout } from './layouts/FeatureLayout';
|
||||||
|
|
||||||
// Pages
|
|
||||||
import { DashboardPage } from './pages/Dashboard';
|
import { DashboardPage } from './pages/Dashboard';
|
||||||
import { SettingsPage } from './pages/Settings';
|
import { SettingsPage } from './pages/Settings';
|
||||||
import { GDPRPage } from './pages/GDPR';
|
import { GDPRPage } from './pages/GDPR';
|
||||||
import { FeatureViewPage } from './pages/FeatureView';
|
import { FeatureViewPage } from './pages/FeatureView';
|
||||||
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminAutomationEventsPage } from './pages/admin';
|
import { AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolesPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage } from './pages/admin';
|
||||||
|
import { PlaygroundPage, WorkflowsPage, AutomationsPage } from './pages/workflows';
|
||||||
// Basedata Pages (global)
|
|
||||||
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
||||||
|
|
||||||
// Billing Pages
|
|
||||||
import { BillingDataView, BillingAdmin } from './pages/billing';
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
// Load saved theme preference and set app name on app mount
|
// Load saved theme preference and set app name on app mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -93,9 +83,7 @@ function App() {
|
||||||
{/* ================================================== */}
|
{/* ================================================== */}
|
||||||
<Route path="/" element={
|
<Route path="/" element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<FileProvider>
|
|
||||||
<MainLayout />
|
<MainLayout />
|
||||||
</FileProvider>
|
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}>
|
}>
|
||||||
{/* Dashboard (Root) */}
|
{/* Dashboard (Root) */}
|
||||||
|
|
@ -105,6 +93,15 @@ function App() {
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="gdpr" element={<GDPRPage />} />
|
<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) */}
|
{/* BASISDATEN ROUTES (global) */}
|
||||||
{/* ============================================== */}
|
{/* ============================================== */}
|
||||||
|
|
@ -114,13 +111,10 @@ function App() {
|
||||||
<Route path="connections" element={<ConnectionsPage />} />
|
<Route path="connections" element={<ConnectionsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* ============================================== */}
|
{/* Legacy top-level routes – redirect to dashboard (migrated to feature-instance routes) */}
|
||||||
{/* BILLING ROUTES */}
|
<Route path="chatbot" element={<Navigate to="/" replace />} />
|
||||||
{/* ============================================== */}
|
<Route path="pek" element={<Navigate to="/" replace />} />
|
||||||
<Route path="billing">
|
<Route path="speech" element={<Navigate to="/" replace />} />
|
||||||
<Route index element={<Navigate to="/billing/transactions" replace />} />
|
|
||||||
<Route path="transactions" element={<BillingDataView />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
{/* ============================================== */}
|
{/* ============================================== */}
|
||||||
{/* FEATURE-INSTANZ ROUTES */}
|
{/* FEATURE-INSTANZ ROUTES */}
|
||||||
|
|
@ -148,15 +142,6 @@ function App() {
|
||||||
<Route path="projects" element={<FeatureViewPage view="projects" />} />
|
<Route path="projects" element={<FeatureViewPage view="projects" />} />
|
||||||
<Route path="parcels" element={<FeatureViewPage view="parcels" />} />
|
<Route path="parcels" element={<FeatureViewPage view="parcels" />} />
|
||||||
|
|
||||||
{/* Chat Playground Feature Views */}
|
|
||||||
<Route path="playground" element={<FeatureViewPage view="playground" />} />
|
|
||||||
<Route path="workflows" element={<FeatureViewPage view="workflows" />} />
|
|
||||||
|
|
||||||
{/* Automation Feature Views */}
|
|
||||||
<Route path="definitions" element={<FeatureViewPage view="definitions" />} />
|
|
||||||
<Route path="templates" element={<FeatureViewPage view="templates" />} />
|
|
||||||
<Route path="logs" element={<FeatureViewPage view="logs" />} />
|
|
||||||
|
|
||||||
{/* Catch-all für unbekannte Sub-Pfade */}
|
{/* Catch-all für unbekannte Sub-Pfade */}
|
||||||
<Route path="*" element={<FeatureViewPage view="not-found" />} />
|
<Route path="*" element={<FeatureViewPage view="not-found" />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
@ -165,8 +150,6 @@ function App() {
|
||||||
{/* ADMIN ROUTES (nur SysAdmin) */}
|
{/* ADMIN ROUTES (nur SysAdmin) */}
|
||||||
{/* ============================================== */}
|
{/* ============================================== */}
|
||||||
<Route path="admin">
|
<Route path="admin">
|
||||||
<Route index element={<Navigate to="/admin/access" replace />} />
|
|
||||||
<Route path="access" element={<AccessManagementHub />} />
|
|
||||||
<Route path="mandates" element={<AdminMandatesPage />} />
|
<Route path="mandates" element={<AdminMandatesPage />} />
|
||||||
<Route path="users" element={<AdminUsersPage />} />
|
<Route path="users" element={<AdminUsersPage />} />
|
||||||
<Route path="user-mandates" element={<AdminUserMandatesPage />} />
|
<Route path="user-mandates" element={<AdminUserMandatesPage />} />
|
||||||
|
|
@ -177,8 +160,6 @@ function App() {
|
||||||
<Route path="mandate-roles" element={<AdminMandateRolesPage />} />
|
<Route path="mandate-roles" element={<AdminMandateRolesPage />} />
|
||||||
<Route path="mandate-role-permissions" element={<AdminMandateRolePermissionsPage />} />
|
<Route path="mandate-role-permissions" element={<AdminMandateRolePermissionsPage />} />
|
||||||
<Route path="user-access-overview" element={<AdminUserAccessOverviewPage />} />
|
<Route path="user-access-overview" element={<AdminUserAccessOverviewPage />} />
|
||||||
<Route path="billing" element={<BillingAdmin />} />
|
|
||||||
<Route path="automation-events" element={<AdminAutomationEventsPage />} />
|
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|
|
||||||
348
src/components/ContentPreview/UrlContentPreview.tsx
Normal file
348
src/components/ContentPreview/UrlContentPreview.tsx
Normal file
|
|
@ -0,0 +1,348 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { IoIosDownload } from 'react-icons/io';
|
||||||
|
import { Popup, PopupAction } from '../UiComponents/Popup/Popup';
|
||||||
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import { PdfRenderer, PdfJsRenderer, LoadingRenderer, ErrorRenderer } from './renderers';
|
||||||
|
import styles from './ContentPreview.module.css';
|
||||||
|
|
||||||
|
export interface UrlContentPreviewProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
url: string;
|
||||||
|
fileName: string;
|
||||||
|
mimeType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UrlContentPreview({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
url,
|
||||||
|
fileName,
|
||||||
|
mimeType = 'application/pdf'
|
||||||
|
}: UrlContentPreviewProps) {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [hasLoaded, setHasLoaded] = useState(false);
|
||||||
|
const [warning, setWarning] = useState<string | null>(null);
|
||||||
|
const [showPdfAnyway, setShowPdfAnyway] = useState(false);
|
||||||
|
const [usePdfJs, setUsePdfJs] = useState(false);
|
||||||
|
|
||||||
|
// Reset state when modal opens/closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && url) {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setWarning(null);
|
||||||
|
setHasLoaded(false);
|
||||||
|
setShowPdfAnyway(false);
|
||||||
|
setUsePdfJs(false); // Start with iframe
|
||||||
|
} else {
|
||||||
|
setIsLoading(false);
|
||||||
|
setError(null);
|
||||||
|
setWarning(null);
|
||||||
|
setHasLoaded(false);
|
||||||
|
setShowPdfAnyway(false);
|
||||||
|
setUsePdfJs(false);
|
||||||
|
}
|
||||||
|
}, [isOpen, url]);
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
try {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = fileName;
|
||||||
|
link.target = '_blank';
|
||||||
|
link.rel = 'noopener noreferrer';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to download file:', err);
|
||||||
|
// Fallback: open in new tab
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePdfLoad = () => {
|
||||||
|
setIsLoading(false);
|
||||||
|
setHasLoaded(true);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePdfError = () => {
|
||||||
|
// Try PDF.js as fallback instead of showing error immediately
|
||||||
|
if (!usePdfJs) {
|
||||||
|
console.log('Iframe failed, switching to PDF.js fallback');
|
||||||
|
setUsePdfJs(true);
|
||||||
|
setIsLoading(true); // Restart loading with PDF.js
|
||||||
|
setError(null);
|
||||||
|
setWarning(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If PDF.js also fails, show error
|
||||||
|
setIsLoading(false);
|
||||||
|
setError('Failed to load PDF. This might be due to CORS restrictions. You can try downloading the file or opening it in a new tab.');
|
||||||
|
setShowPdfAnyway(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenInNewTab = () => {
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set up progressive timeout for loading (schnellerer Fallback)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && isLoading && !hasLoaded) {
|
||||||
|
// Schnellerer Timeout für externe PDFs: Warning after 3s, Error after 5s
|
||||||
|
const QUICK_TIMEOUT = 5000; // 5 Sekunden
|
||||||
|
const WARNING_TIMEOUT = 3000; // 3 Sekunden Warnung
|
||||||
|
|
||||||
|
const warningTimeout = setTimeout(() => {
|
||||||
|
if (isLoading && !hasLoaded) {
|
||||||
|
setWarning('PDF lädt langsam. Sie können es auch direkt herunterladen oder in einem neuen Tab öffnen.');
|
||||||
|
// Don't set isLoading to false - let it continue
|
||||||
|
}
|
||||||
|
}, WARNING_TIMEOUT);
|
||||||
|
|
||||||
|
const errorTimeout = setTimeout(() => {
|
||||||
|
if (isLoading && !hasLoaded && !usePdfJs) {
|
||||||
|
// Try PDF.js as fallback after 5 seconds
|
||||||
|
console.log('PDF loading timeout, switching to PDF.js fallback');
|
||||||
|
setUsePdfJs(true);
|
||||||
|
setIsLoading(true); // Restart loading with PDF.js
|
||||||
|
setWarning('PDF lädt langsam. Versuche alternative Anzeigemethode...');
|
||||||
|
} else if (isLoading && !hasLoaded && usePdfJs) {
|
||||||
|
// PDF.js also failed, show error
|
||||||
|
setShowPdfAnyway(true);
|
||||||
|
setError('PDF lädt langsam. Bitte verwenden Sie den Download-Button oder öffnen Sie es in einem neuen Tab.');
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, QUICK_TIMEOUT);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(warningTimeout);
|
||||||
|
clearTimeout(errorTimeout);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [isOpen, isLoading, hasLoaded, usePdfJs]);
|
||||||
|
|
||||||
|
// Validate URL
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && url) {
|
||||||
|
try {
|
||||||
|
new URL(url);
|
||||||
|
} catch (e) {
|
||||||
|
setError('Invalid URL');
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOpen, url]);
|
||||||
|
|
||||||
|
// Create action buttons for the popup header
|
||||||
|
const actions: PopupAction[] = [
|
||||||
|
{
|
||||||
|
label: String(''),
|
||||||
|
icon: <IoIosDownload />,
|
||||||
|
onClick: handleDownload,
|
||||||
|
disabled: false,
|
||||||
|
variant: 'success' as const
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const renderPreview = () => {
|
||||||
|
// Show warning but continue loading
|
||||||
|
const showWarning = warning && !error;
|
||||||
|
|
||||||
|
// For PDF files, always try to show PDF (even if there's an error)
|
||||||
|
if (mimeType === 'application/pdf' && (hasLoaded || showPdfAnyway || !error)) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
{showWarning && (
|
||||||
|
<div style={{
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
background: 'var(--color-warning-bg, #fef3c7)',
|
||||||
|
borderBottom: '1px solid var(--color-border, #e5e7eb)',
|
||||||
|
color: 'var(--color-warning-text, #92400e)',
|
||||||
|
fontSize: '0.875rem'
|
||||||
|
}}>
|
||||||
|
⚠️ {warning}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
background: 'var(--color-error-bg, #fee2e2)',
|
||||||
|
borderBottom: '1px solid var(--color-border, #e5e7eb)',
|
||||||
|
color: 'var(--color-error-text, #991b1b)',
|
||||||
|
fontSize: '0.875rem'
|
||||||
|
}}>
|
||||||
|
⚠️ {error}
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleOpenInNewTab}
|
||||||
|
className={styles.retryButton}
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-primary, #3b82f6)',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
padding: '0.5rem 1rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
In neuem Tab öffnen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className={styles.retryButton}
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-success, #10b981)',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
padding: '0.5rem 1rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ flex: 1, position: 'relative' }}>
|
||||||
|
<PdfRenderer
|
||||||
|
previewUrl={url}
|
||||||
|
fileName={fileName}
|
||||||
|
onError={handlePdfError}
|
||||||
|
onLoad={handlePdfLoad}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show error only if we're not showing PDF anyway
|
||||||
|
if (error && !showPdfAnyway) {
|
||||||
|
return (
|
||||||
|
<div className={styles.errorContainer}>
|
||||||
|
<div className={styles.errorIcon}>⚠️</div>
|
||||||
|
<p>{error}</p>
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', marginTop: '1rem', flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setError(null);
|
||||||
|
setWarning(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
setHasLoaded(false);
|
||||||
|
setShowPdfAnyway(false);
|
||||||
|
setUsePdfJs(false); // Reset to iframe
|
||||||
|
}}
|
||||||
|
className={styles.retryButton}
|
||||||
|
>
|
||||||
|
{t('common.retry', 'Retry')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleOpenInNewTab}
|
||||||
|
className={styles.retryButton}
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-primary, #3b82f6)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
padding: '0.625rem 1.25rem',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
In neuem Tab öffnen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className={styles.retryButton}
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-success, #10b981)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
padding: '0.625rem 1.25rem',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Download File
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading && !hasLoaded && !showPdfAnyway) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
{warning && (
|
||||||
|
<div style={{
|
||||||
|
padding: '1rem',
|
||||||
|
background: 'var(--color-warning-bg, #fef3c7)',
|
||||||
|
borderBottom: '1px solid var(--color-border, #e5e7eb)',
|
||||||
|
color: 'var(--color-warning-text, #92400e)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
marginBottom: '1rem'
|
||||||
|
}}>
|
||||||
|
⚠️ {warning}
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.75rem', flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleOpenInNewTab}
|
||||||
|
className={styles.retryButton}
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-primary, #3b82f6)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
padding: '0.625rem 1.25rem',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
In neuem Tab öffnen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className={styles.retryButton}
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-success, #10b981)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
padding: '0.625rem 1.25rem',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<LoadingRenderer />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other file types, show unsupported message
|
||||||
|
if (mimeType !== 'application/pdf') {
|
||||||
|
return (
|
||||||
|
<div className={styles.unsupportedContainer}>
|
||||||
|
<div className={styles.unsupportedIcon}>📄</div>
|
||||||
|
<div className={styles.fileName}>{fileName}</div>
|
||||||
|
<p>Preview not supported for this file type. Please download the file to view it.</p>
|
||||||
|
<button onClick={handleDownload} className={styles.retryButton}>
|
||||||
|
Download File
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popup
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={`${t('files.preview.title', 'Content Preview')}: ${fileName}`}
|
||||||
|
size="fullscreen"
|
||||||
|
className={styles.contentPreviewPopup}
|
||||||
|
actions={actions}
|
||||||
|
>
|
||||||
|
<div className={styles.previewContainer}>
|
||||||
|
{renderPreview()}
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UrlContentPreview;
|
||||||
238
src/components/ContentPreview/renderers/PdfJsRenderer.tsx
Normal file
238
src/components/ContentPreview/renderers/PdfJsRenderer.tsx
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
|
import styles from '../ContentPreview.module.css';
|
||||||
|
|
||||||
|
// Set worker source for PDF.js
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
// Try to use local worker first, fallback to CDN
|
||||||
|
try {
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||||
|
'../../../../node_modules/pdfjs-dist/build/pdf.worker.min.js',
|
||||||
|
import.meta.url
|
||||||
|
).toString();
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback to CDN if local worker not available
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.js`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PdfJsRendererProps {
|
||||||
|
previewUrl: string;
|
||||||
|
fileName: string;
|
||||||
|
onError: () => void;
|
||||||
|
onLoad?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PdfJsRenderer({ previewUrl, fileName, onError, onLoad }: PdfJsRendererProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [numPages, setNumPages] = useState(0);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [scale, setScale] = useState(1.5);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let pdfDoc: pdfjsLib.PDFDocumentProxy | null = null;
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const loadPdf = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Load PDF using fetch (like download)
|
||||||
|
const response = await fetch(previewUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch PDF: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
|
||||||
|
pdfDoc = await loadingTask.promise;
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
setNumPages(pdfDoc.numPages);
|
||||||
|
setIsLoading(false);
|
||||||
|
if (onLoad) {
|
||||||
|
onLoad();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading PDF with PDF.js:', err);
|
||||||
|
if (isMounted) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load PDF');
|
||||||
|
setIsLoading(false);
|
||||||
|
onError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadPdf();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [previewUrl, onLoad, onError]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasRef.current || isLoading || error) return;
|
||||||
|
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const renderPage = async (pageNum: number) => {
|
||||||
|
try {
|
||||||
|
// Load PDF again for rendering (could be optimized with caching)
|
||||||
|
const response = await fetch(previewUrl);
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
|
||||||
|
const pdfDoc = await loadingTask.promise;
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
const page = await pdfDoc.getPage(pageNum);
|
||||||
|
const viewport = page.getViewport({ scale });
|
||||||
|
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
canvas.height = viewport.height;
|
||||||
|
canvas.width = viewport.width;
|
||||||
|
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) return;
|
||||||
|
|
||||||
|
const renderContext = {
|
||||||
|
canvasContext: context,
|
||||||
|
viewport: viewport,
|
||||||
|
};
|
||||||
|
|
||||||
|
await page.render(renderContext).promise;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error rendering PDF page:', err);
|
||||||
|
if (isMounted) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to render PDF page');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
renderPage(currentPage);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [previewUrl, currentPage, scale, isLoading, error]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className={styles.errorContainer}>
|
||||||
|
<div className={styles.errorIcon}>⚠️</div>
|
||||||
|
<p>Fehler beim Laden der PDF: {error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className={styles.loadingContainer}>
|
||||||
|
<div className={styles.spinner}></div>
|
||||||
|
<p>PDF wird geladen...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} style={{ width: '100%', height: '100%', overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{/* Navigation Controls */}
|
||||||
|
{numPages > 1 && (
|
||||||
|
<div style={{
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
background: 'var(--color-background-secondary, #f3f4f6)',
|
||||||
|
borderBottom: '1px solid var(--color-border, #e5e7eb)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '1rem',
|
||||||
|
flexWrap: 'wrap'
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
background: 'var(--color-primary, #3b82f6)',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: currentPage === 1 ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: currentPage === 1 ? 0.5 : 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</button>
|
||||||
|
<span style={{ fontSize: '0.875rem', color: 'var(--color-text, #1f2937)' }}>
|
||||||
|
Seite {currentPage} von {numPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(prev => Math.min(numPages, prev + 1))}
|
||||||
|
disabled={currentPage === numPages}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
background: 'var(--color-primary, #3b82f6)',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: currentPage === numPages ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: currentPage === numPages ? 0.5 : 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</button>
|
||||||
|
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setScale(prev => Math.max(0.5, prev - 0.25))}
|
||||||
|
style={{
|
||||||
|
padding: '0.25rem 0.75rem',
|
||||||
|
background: 'var(--color-background, #ffffff)',
|
||||||
|
border: '1px solid var(--color-border, #e5e7eb)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.75rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
<span style={{ fontSize: '0.75rem', minWidth: '3rem', textAlign: 'center' }}>
|
||||||
|
{Math.round(scale * 100)}%
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setScale(prev => Math.min(3, prev + 0.25))}
|
||||||
|
style={{
|
||||||
|
padding: '0.25rem 0.75rem',
|
||||||
|
background: 'var(--color-background, #ffffff)',
|
||||||
|
border: '1px solid var(--color-border, #e5e7eb)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.75rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PDF Canvas */}
|
||||||
|
<div style={{ flex: 1, display: 'flex', justifyContent: 'center', alignItems: 'flex-start', padding: '1rem', overflow: 'auto' }}>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||||
|
background: 'white'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -8,24 +8,26 @@
|
||||||
* Backend liefert Blocks-Struktur mit Static und Dynamic Blocks.
|
* Backend liefert Blocks-Struktur mit Static und Dynamic Blocks.
|
||||||
* UI mappt uiComponent zu Icons via pageRegistry.
|
* UI mappt uiComponent zu Icons via pageRegistry.
|
||||||
*
|
*
|
||||||
* TREE STRUCTURE (alles collapsible):
|
* Struktur (gemäss Navigation-API-Konzept):
|
||||||
* ▼ Meine Sicht
|
* - SYSTEM (static block, order: 10)
|
||||||
* - Übersicht, Einstellungen, Prompts, Dateien, Verbindungen, Billing
|
* - MEINE FEATURES (dynamic block, order: 15)
|
||||||
* ─────────────
|
* - Mandant 1
|
||||||
* ▼ Mandant 1
|
* - Feature A
|
||||||
* - 🎯 Instanz 1 (Feature-Icon + Instanz-Name)
|
* - Instanz 1 (mit Views)
|
||||||
* - 💼 Instanz 2 (Feature-Icon + Instanz-Name)
|
* - WORKFLOWS (static block, order: 20)
|
||||||
* ─────────────
|
* - BASISDATEN (static block, order: 30)
|
||||||
* ▶ Administration
|
* - MIGRATE TO FEATURES (static block, order: 40)
|
||||||
* - Users, Mandates, Roles, ...
|
* - ADMINISTRATION (static block, order: 200)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { useNavigation } from '../../hooks/useNavigation';
|
import { useNavigation } from '../../hooks/useNavigation';
|
||||||
import type {
|
import type {
|
||||||
|
StaticBlock,
|
||||||
DynamicBlock,
|
DynamicBlock,
|
||||||
NavigationItem,
|
NavigationItem,
|
||||||
NavigationMandate,
|
NavigationMandate,
|
||||||
|
MandateFeature,
|
||||||
FeatureInstance,
|
FeatureInstance,
|
||||||
FeatureView
|
FeatureView
|
||||||
} from '../../hooks/useNavigation';
|
} from '../../hooks/useNavigation';
|
||||||
|
|
@ -51,20 +53,13 @@ function navigationItemToTreeNode(item: NavigationItem): TreeNodeItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a list of NavigationItems into a collapsible TreeNodeItem container.
|
* Convert a StaticBlock to TreeItem (section)
|
||||||
* Used for grouping static items under "Meine Sicht" and "Administration".
|
|
||||||
*/
|
*/
|
||||||
function _staticItemsToTreeNode(
|
function staticBlockToTreeItem(block: StaticBlock): TreeItem {
|
||||||
id: string,
|
|
||||||
label: string,
|
|
||||||
items: NavigationItem[],
|
|
||||||
defaultExpanded: boolean = true,
|
|
||||||
): TreeNodeItem {
|
|
||||||
return {
|
return {
|
||||||
id,
|
type: 'section',
|
||||||
label,
|
title: block.title,
|
||||||
children: items.map(navigationItemToTreeNode),
|
children: block.items.map(navigationItemToTreeNode),
|
||||||
defaultExpanded,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,52 +75,59 @@ function featureViewToTreeNode(view: FeatureView): TreeNodeItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a FeatureInstance to TreeNodeItem (with feature icon)
|
* Convert a FeatureInstance to TreeNodeItem
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
function featureInstanceToTreeNode(instance: FeatureInstance, featureUiComponent: string): TreeNodeItem {
|
function featureInstanceToTreeNode(instance: FeatureInstance): TreeNodeItem {
|
||||||
const children = instance.views.map(featureViewToTreeNode);
|
|
||||||
return {
|
return {
|
||||||
id: instance.id,
|
id: instance.id,
|
||||||
label: instance.uiLabel,
|
label: instance.uiLabel,
|
||||||
icon: getPageIcon(featureUiComponent), // Use feature icon for instance
|
|
||||||
path: instance.views.length > 0 ? instance.views[0].uiPath : undefined,
|
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,
|
defaultExpanded: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a NavigationMandate to TreeNodeItem
|
* 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 {
|
function navigationMandateToTreeNode(mandate: NavigationMandate): TreeNodeItem | null {
|
||||||
if (mandate.features.length === 0) {
|
if (mandate.features.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flatten: collect all instances from all features directly under mandate
|
const children = mandate.features
|
||||||
const instanceNodes: TreeNodeItem[] = [];
|
.map(mandateFeatureToTreeNode)
|
||||||
for (const feature of mandate.features) {
|
.filter((node): node is TreeNodeItem => node !== null);
|
||||||
for (const instance of feature.instances) {
|
|
||||||
instanceNodes.push(featureInstanceToTreeNode(instance, feature.uiComponent));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (instanceNodes.length === 0) {
|
if (children.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: mandate.id,
|
id: mandate.id,
|
||||||
label: mandate.uiLabel,
|
label: mandate.uiLabel,
|
||||||
children: instanceNodes,
|
children,
|
||||||
defaultExpanded: true,
|
defaultExpanded: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -172,47 +174,38 @@ export const MandateNavigation: React.FC = () => {
|
||||||
const { blocks, loading } = useNavigation('de');
|
const { blocks, loading } = useNavigation('de');
|
||||||
|
|
||||||
// Build navigation items from blocks
|
// 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 navigationItems: TreeItem[] = useMemo(() => {
|
||||||
const items: TreeItem[] = [];
|
const items: TreeItem[] = [];
|
||||||
|
|
||||||
// Collect static items by category
|
// Process blocks in order (already sorted by backend)
|
||||||
const meineSichtItems: NavigationItem[] = [];
|
|
||||||
let adminItems: NavigationItem[] = [];
|
|
||||||
|
|
||||||
for (const block of blocks) {
|
for (const block of blocks) {
|
||||||
if (block.type === 'static') {
|
if (block.type === 'static') {
|
||||||
if (block.id === 'admin') {
|
// Static block: system, workflows, basedata, migrate, admin
|
||||||
adminItems = [...block.items];
|
if (block.items.length > 0) {
|
||||||
} else if (block.items.length > 0) {
|
// Add separator before admin block
|
||||||
meineSichtItems.push(...block.items);
|
if (block.id === 'admin') {
|
||||||
|
items.push({ type: 'separator' });
|
||||||
|
}
|
||||||
|
items.push(staticBlockToTreeItem(block));
|
||||||
}
|
}
|
||||||
}
|
} else if (block.type === 'dynamic') {
|
||||||
}
|
// Dynamic block: features/mandates
|
||||||
|
// Add separator before dynamic block
|
||||||
|
items.push({ type: 'separator' });
|
||||||
|
|
||||||
// "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') {
|
|
||||||
const mandateNodes = dynamicBlockToTreeNodes(block);
|
const mandateNodes = dynamicBlockToTreeNodes(block);
|
||||||
if (mandateNodes.length > 0) {
|
if (mandateNodes.length > 0) {
|
||||||
if (items.length > 0) items.push({ type: 'separator' });
|
|
||||||
items.push(...mandateNodes);
|
items.push(...mandateNodes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add separator after dynamic block (before next static blocks)
|
||||||
|
items.push({ type: 'separator' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// "Administration" - collapsible container for admin pages
|
// Remove trailing separator if present
|
||||||
if (adminItems.length > 0) {
|
while (items.length > 0 && (items[items.length - 1] as TreeItem & { type?: string }).type === 'separator') {
|
||||||
if (items.length > 0) items.push({ type: 'separator' });
|
items.pop();
|
||||||
items.push(_staticItemsToTreeNode('administration', 'Administration', adminItems, false));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,211 @@
|
||||||
|
.autocompleteContainer {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestionsWrapper {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
z-index: 1000;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
/* Glassmorphism effect */
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 32px rgba(0, 0, 0, 0.1),
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.2) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme support */
|
||||||
|
[data-theme="dark"] .suggestionsWrapper {
|
||||||
|
background: rgba(30, 30, 30, 0.85);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 32px rgba(0, 0, 0, 0.3),
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestionsList {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestionItem {
|
||||||
|
padding: 12px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
/* Subtle background for better visibility */
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestionItem:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestionItem:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestionItemSelected {
|
||||||
|
background: rgba(59, 130, 246, 0.15) !important;
|
||||||
|
|
||||||
|
/* Glow effect for selected item */
|
||||||
|
box-shadow:
|
||||||
|
0 0 12px rgba(59, 130, 246, 0.4),
|
||||||
|
0 0 24px rgba(59, 130, 246, 0.2),
|
||||||
|
inset 0 0 8px rgba(59, 130, 246, 0.1);
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme adjustments */
|
||||||
|
[data-theme="dark"] .suggestionItem {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .suggestionItem:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .suggestionItemSelected {
|
||||||
|
background: rgba(59, 130, 246, 0.25) !important;
|
||||||
|
box-shadow:
|
||||||
|
0 0 16px rgba(59, 130, 246, 0.5),
|
||||||
|
0 0 32px rgba(59, 130, 246, 0.3),
|
||||||
|
inset 0 0 12px rgba(59, 130, 246, 0.15);
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestionText {
|
||||||
|
display: block;
|
||||||
|
color: var(--color-text, #111827);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .suggestionText {
|
||||||
|
color: var(--color-text, #f9fafb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight {
|
||||||
|
background: rgba(59, 130, 246, 0.2);
|
||||||
|
color: var(--color-primary, #3b82f6);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .highlight {
|
||||||
|
background: rgba(59, 130, 246, 0.3);
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingText,
|
||||||
|
.noResultsText {
|
||||||
|
display: block;
|
||||||
|
padding: 12px 16px;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorText {
|
||||||
|
display: block;
|
||||||
|
padding: 12px 16px;
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .loadingText,
|
||||||
|
[data-theme="dark"] .noResultsText {
|
||||||
|
color: var(--color-text-secondary, #9ca3af);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .errorText {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
.suggestionsList::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestionsList::-webkit-scrollbar-track {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestionsList::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(59, 130, 246, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestionsList::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .suggestionsList::-webkit-scrollbar-track {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .suggestionsList::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(59, 130, 246, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .suggestionsList::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive design */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.suggestionsWrapper {
|
||||||
|
border-radius: 8px;
|
||||||
|
max-height: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestionItem {
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestionText {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation for dropdown appearance */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestionsWrapper {
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,309 @@
|
||||||
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import TextField, { BaseTextFieldProps } from '../TextField/TextField';
|
||||||
|
import { autocompleteAddress, AddressSuggestion } from '../../../api/realEstateApi';
|
||||||
|
import styles from './AddressAutocomplete.module.css';
|
||||||
|
|
||||||
|
interface AddressAutocompleteProps extends BaseTextFieldProps {
|
||||||
|
onSelect?: (suggestion: AddressSuggestion) => void;
|
||||||
|
debounceMs?: number;
|
||||||
|
minQueryLength?: number;
|
||||||
|
maxSuggestions?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({
|
||||||
|
value = '',
|
||||||
|
onChange,
|
||||||
|
onSelect,
|
||||||
|
placeholder,
|
||||||
|
disabled = false,
|
||||||
|
required = false,
|
||||||
|
readonly = false,
|
||||||
|
size = 'md',
|
||||||
|
error,
|
||||||
|
helperText,
|
||||||
|
label,
|
||||||
|
className = '',
|
||||||
|
type = 'text',
|
||||||
|
name,
|
||||||
|
id,
|
||||||
|
onKeyDown,
|
||||||
|
debounceMs = 300,
|
||||||
|
minQueryLength = 2,
|
||||||
|
maxSuggestions = 10,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [suggestions, setSuggestions] = useState<AddressSuggestion[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||||
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
|
const [query, setQuery] = useState(value);
|
||||||
|
const [autocompleteError, setAutocompleteError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const suggestionsRef = useRef<HTMLUListElement>(null);
|
||||||
|
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// Sync query with value prop
|
||||||
|
useEffect(() => {
|
||||||
|
setQuery(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
// Debounced search function
|
||||||
|
const performSearch = useCallback(async (searchQuery: string) => {
|
||||||
|
if (searchQuery.length < minQueryLength) {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.log('🔍 [AddressAutocomplete] Query too short:', {
|
||||||
|
query: searchQuery,
|
||||||
|
length: searchQuery.length,
|
||||||
|
minLength: minQueryLength
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setSuggestions([]);
|
||||||
|
setShowSuggestions(false);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.log('🔍 [AddressAutocomplete] Starting search:', {
|
||||||
|
query: searchQuery,
|
||||||
|
length: searchQuery.length,
|
||||||
|
maxSuggestions: maxSuggestions
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setAutocompleteError(null); // Clear previous errors
|
||||||
|
try {
|
||||||
|
const results = await autocompleteAddress(searchQuery, maxSuggestions);
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.log('✅ [AddressAutocomplete] Search completed:', {
|
||||||
|
query: searchQuery,
|
||||||
|
resultCount: results.length,
|
||||||
|
results: results.slice(0, 3) // Log first 3
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setSuggestions(results);
|
||||||
|
setShowSuggestions(results.length > 0 || true); // Show dropdown even if empty to show "no results" or error
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
setAutocompleteError(null); // Clear any previous errors on success
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('❌ [AddressAutocomplete] Error in performSearch:', err);
|
||||||
|
const errorMessage = err?.response?.data?.detail || err?.message || 'Fehler beim Laden der Adressvorschläge';
|
||||||
|
setAutocompleteError(errorMessage);
|
||||||
|
setSuggestions([]);
|
||||||
|
setShowSuggestions(true); // Show dropdown to display error
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [minQueryLength, maxSuggestions]);
|
||||||
|
|
||||||
|
// Handle input change with debouncing
|
||||||
|
const handleInputChange = useCallback((newValue: string) => {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.log('⌨️ [AddressAutocomplete] Input changed:', {
|
||||||
|
newValue: newValue,
|
||||||
|
length: newValue.length,
|
||||||
|
willSearch: newValue.length >= minQueryLength
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setQuery(newValue);
|
||||||
|
setAutocompleteError(null); // Clear error on new input
|
||||||
|
|
||||||
|
// Update parent component immediately
|
||||||
|
if (onChange) {
|
||||||
|
onChange(newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing timer
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set new timer for debounced search
|
||||||
|
debounceTimerRef.current = setTimeout(() => {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.log('⏱️ [AddressAutocomplete] Debounce timer fired, calling performSearch');
|
||||||
|
}
|
||||||
|
performSearch(newValue);
|
||||||
|
}, debounceMs);
|
||||||
|
}, [onChange, debounceMs, performSearch, minQueryLength]);
|
||||||
|
|
||||||
|
// Handle suggestion selection
|
||||||
|
const handleSelectSuggestion = useCallback((suggestion: AddressSuggestion) => {
|
||||||
|
setQuery(suggestion.value);
|
||||||
|
setShowSuggestions(false);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
|
||||||
|
if (onChange) {
|
||||||
|
onChange(suggestion.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onSelect) {
|
||||||
|
onSelect(suggestion);
|
||||||
|
}
|
||||||
|
}, [onChange, onSelect]);
|
||||||
|
|
||||||
|
// Handle keyboard navigation
|
||||||
|
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
if (!showSuggestions || suggestions.length === 0) {
|
||||||
|
if (onKeyDown) {
|
||||||
|
onKeyDown(e);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex(prev =>
|
||||||
|
prev < suggestions.length - 1 ? prev + 1 : prev
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex(prev => prev > 0 ? prev - 1 : -1);
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSelectSuggestion(suggestions[selectedIndex]);
|
||||||
|
} else if (onKeyDown) {
|
||||||
|
onKeyDown(e);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault();
|
||||||
|
setShowSuggestions(false);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (onKeyDown) {
|
||||||
|
onKeyDown(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [showSuggestions, suggestions, selectedIndex, handleSelectSuggestion, onKeyDown]);
|
||||||
|
|
||||||
|
// Click outside handler
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setShowSuggestions(false);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showSuggestions) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [showSuggestions]);
|
||||||
|
|
||||||
|
// Scroll selected item into view
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedIndex >= 0 && suggestionsRef.current) {
|
||||||
|
const selectedElement = suggestionsRef.current.children[selectedIndex] as HTMLElement;
|
||||||
|
if (selectedElement) {
|
||||||
|
selectedElement.scrollIntoView({
|
||||||
|
block: 'nearest',
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
|
// Highlight matching text in suggestion
|
||||||
|
const highlightText = (text: string, query: string): React.ReactNode => {
|
||||||
|
if (!query || query.length < minQueryLength) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = text.split(new RegExp(`(${query})`, 'gi'));
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{parts.map((part, index) =>
|
||||||
|
part.toLowerCase() === query.toLowerCase() ? (
|
||||||
|
<mark key={index} className={styles.highlight}>{part}</mark>
|
||||||
|
) : (
|
||||||
|
<span key={index}>{part}</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className={`${styles.autocompleteContainer} ${className}`}>
|
||||||
|
<TextField
|
||||||
|
value={query}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
required={required}
|
||||||
|
readonly={readonly}
|
||||||
|
size={size}
|
||||||
|
error={undefined}
|
||||||
|
helperText={helperText}
|
||||||
|
label={label}
|
||||||
|
type={type}
|
||||||
|
name={name}
|
||||||
|
id={id}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showSuggestions && (
|
||||||
|
<div className={styles.suggestionsWrapper}>
|
||||||
|
<ul ref={suggestionsRef} className={styles.suggestionsList}>
|
||||||
|
{isLoading && (
|
||||||
|
<li className={styles.suggestionItem}>
|
||||||
|
<span className={styles.loadingText}>Suche Adressen...</span>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{!isLoading && autocompleteError && (
|
||||||
|
<li className={styles.suggestionItem}>
|
||||||
|
<span className={styles.errorText}>{autocompleteError}</span>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{!isLoading && !autocompleteError && suggestions.length === 0 && query.length >= minQueryLength && (
|
||||||
|
<li className={styles.suggestionItem}>
|
||||||
|
<span className={styles.noResultsText}>Keine Adressen gefunden</span>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{!isLoading && suggestions.map((suggestion, index) => (
|
||||||
|
<li
|
||||||
|
key={`${suggestion.value}-${index}`}
|
||||||
|
className={`${styles.suggestionItem} ${
|
||||||
|
index === selectedIndex ? styles.suggestionItemSelected : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => handleSelectSuggestion(suggestion)}
|
||||||
|
onMouseEnter={() => setSelectedIndex(index)}
|
||||||
|
>
|
||||||
|
<span className={styles.suggestionText}>
|
||||||
|
{highlightText(suggestion.label, query)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddressAutocomplete;
|
||||||
2
src/components/UiComponents/AddressAutocomplete/index.ts
Normal file
2
src/components/UiComponents/AddressAutocomplete/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { default } from './AddressAutocomplete';
|
||||||
|
export type { AddressSuggestion } from '../../../api/realEstateApi';
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
/* Bauvorschriften Section Styles */
|
||||||
|
.bauvorschriftenSection {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bauvorschriftenHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bauvorschriftenHeader:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bauvorschriftenHeader .subSectionTitle {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--color-accent, #10b981);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expandButton {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expandButton:hover {
|
||||||
|
color: var(--color-text, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bauvorschriftenContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bauvorschriftenGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bauvorschriftItem {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: linear-gradient(135deg, rgba(243, 244, 246, 0.8) 0%, rgba(249, 250, 251, 0.8) 100%);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 3px solid var(--color-accent, #10b981);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bauvorschriftItem:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 8px rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bauvorschriftItem .label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bauvorschriftItem .value {
|
||||||
|
display: block;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceLink {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceLinkButton {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: linear-gradient(135deg, var(--color-accent, #10b981) 0%, #059669 100%);
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 4px 6px rgba(16, 185, 129, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceLinkButton:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 12px rgba(16, 185, 129, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bauvorschriftenFooter {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lastUpdated {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.bauvorschriftenGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { FaChevronDown, FaChevronUp, FaFilePdf, FaRuler } from 'react-icons/fa';
|
||||||
|
import styles from './BauvorschriftenSection.module.css';
|
||||||
|
|
||||||
|
export interface BauvorschriftenZone {
|
||||||
|
zonenbezeichnung: string;
|
||||||
|
ausnuetzungsziffer?: number;
|
||||||
|
vollgeschosse?: number;
|
||||||
|
gebaeudelaengeMax?: number;
|
||||||
|
grenzabstand?: number;
|
||||||
|
mehrlaengenzuschlag?: string;
|
||||||
|
hoechstmassMax?: number;
|
||||||
|
fassadenhoehe?: string;
|
||||||
|
quelleUrl?: string;
|
||||||
|
extraktionsDatum?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BauvorschriftenSectionProps {
|
||||||
|
bauvorschriften: BauvorschriftenZone;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BauvorschriftenSection: React.FC<BauvorschriftenSectionProps> = ({ bauvorschriften }) => {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.bauvorschriftenSection}>
|
||||||
|
<div className={styles.bauvorschriftenHeader} onClick={() => setIsExpanded(!isExpanded)}>
|
||||||
|
<h4 className={styles.subSectionTitle}>
|
||||||
|
<FaRuler style={{ marginRight: '8px', display: 'inline' }} />
|
||||||
|
Bauvorschriften - {bauvorschriften.zonenbezeichnung}
|
||||||
|
</h4>
|
||||||
|
<button className={styles.expandButton}>
|
||||||
|
{isExpanded ? <FaChevronUp /> : <FaChevronDown />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className={styles.bauvorschriftenContent}>
|
||||||
|
<div className={styles.bauvorschriftenGrid}>
|
||||||
|
{bauvorschriften.ausnuetzungsziffer !== undefined && bauvorschriften.ausnuetzungsziffer !== null && (
|
||||||
|
<div className={styles.bauvorschriftItem}>
|
||||||
|
<span className={styles.label}>Ausnützungsziffer:</span>
|
||||||
|
<span className={styles.value}>{bauvorschriften.ausnuetzungsziffer}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{bauvorschriften.vollgeschosse !== undefined && bauvorschriften.vollgeschosse !== null && (
|
||||||
|
<div className={styles.bauvorschriftItem}>
|
||||||
|
<span className={styles.label}>Vollgeschosse:</span>
|
||||||
|
<span className={styles.value}>{bauvorschriften.vollgeschosse}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{bauvorschriften.gebaeudelaengeMax !== undefined && bauvorschriften.gebaeudelaengeMax !== null && (
|
||||||
|
<div className={styles.bauvorschriftItem}>
|
||||||
|
<span className={styles.label}>Gebäudelänge max:</span>
|
||||||
|
<span className={styles.value}>{bauvorschriften.gebaeudelaengeMax} m</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{bauvorschriften.grenzabstand !== undefined && bauvorschriften.grenzabstand !== null && (
|
||||||
|
<div className={styles.bauvorschriftItem}>
|
||||||
|
<span className={styles.label}>Grenzabstand:</span>
|
||||||
|
<span className={styles.value}>{bauvorschriften.grenzabstand} m</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{bauvorschriften.mehrlaengenzuschlag && (
|
||||||
|
<div className={styles.bauvorschriftItem}>
|
||||||
|
<span className={styles.label}>Mehrlängenzuschlag:</span>
|
||||||
|
<span className={styles.value}>{bauvorschriften.mehrlaengenzuschlag}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{bauvorschriften.hoechstmassMax !== undefined && bauvorschriften.hoechstmassMax !== null && (
|
||||||
|
<div className={styles.bauvorschriftItem}>
|
||||||
|
<span className={styles.label}>Höchstmass max:</span>
|
||||||
|
<span className={styles.value}>{bauvorschriften.hoechstmassMax} m</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{bauvorschriften.fassadenhoehe && (
|
||||||
|
<div className={styles.bauvorschriftItem}>
|
||||||
|
<span className={styles.label}>Fassadenhöhe:</span>
|
||||||
|
<span className={styles.value}>{bauvorschriften.fassadenhoehe}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bauvorschriften.quelleUrl && bauvorschriften.quelleUrl !== 'config' && (
|
||||||
|
<div className={styles.sourceLink}>
|
||||||
|
<a
|
||||||
|
href={bauvorschriften.quelleUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={styles.sourceLinkButton}
|
||||||
|
>
|
||||||
|
<FaFilePdf style={{ marginRight: '8px' }} />
|
||||||
|
Nutzungsplan öffnen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bauvorschriften.extraktionsDatum && (
|
||||||
|
<div className={styles.bauvorschriftenFooter}>
|
||||||
|
<span className={styles.lastUpdated}>
|
||||||
|
Extrahiert: {new Date(bauvorschriften.extraktionsDatum).toLocaleString('de-CH')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
187
src/components/UiComponents/OerebSection/OerebSection.module.css
Normal file
187
src/components/UiComponents/OerebSection/OerebSection.module.css
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
/* ÖREB Section Styles */
|
||||||
|
.oerebSection {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oerebHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oerebHeader:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oerebHeader .subSectionTitle {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--color-primary, #3b82f6);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
background-color: var(--color-primary, #3b82f6);
|
||||||
|
color: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expandButton {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expandButton:hover {
|
||||||
|
color: var(--color-text, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oerebContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oerebExtractLink {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oerebLink {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: linear-gradient(135deg, var(--color-primary, #3b82f6) 0%, var(--color-primary-dark, #2563eb) 100%);
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.3);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oerebLink:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 12px rgba(59, 130, 246, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oerebLink:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.restrictionsList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restrictionItem {
|
||||||
|
padding: 1rem;
|
||||||
|
background: linear-gradient(135deg, rgba(249, 250, 251, 0.8) 0%, rgba(243, 244, 246, 0.8) 100%);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
border-left: 3px solid var(--color-primary, #3b82f6);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.restrictionHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restrictionTheme {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text, #111827);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restrictionStatus {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background: linear-gradient(135deg, rgba(209, 250, 229, 0.8) 0%, rgba(167, 243, 208, 0.8) 100%);
|
||||||
|
color: var(--color-success-dark, #065f46);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.restrictionType {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restrictionInfo {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text, #111827);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restrictionDocuments {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentLink {
|
||||||
|
color: var(--color-primary, #3b82f6);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentLink:hover {
|
||||||
|
color: var(--color-primary-dark, #2563eb);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noRestrictions {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oerebFooter {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lastUpdated {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
}
|
||||||
133
src/components/UiComponents/OerebSection/OerebSection.tsx
Normal file
133
src/components/UiComponents/OerebSection/OerebSection.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { FaChevronDown, FaChevronUp, FaFilePdf, FaInfoCircle } from 'react-icons/fa';
|
||||||
|
import { UrlContentPreview } from '../../ContentPreview';
|
||||||
|
import styles from './OerebSection.module.css';
|
||||||
|
|
||||||
|
export interface OerebData {
|
||||||
|
extract_url?: string;
|
||||||
|
restrictions?: Array<{
|
||||||
|
theme: string;
|
||||||
|
type?: string;
|
||||||
|
law_status?: string;
|
||||||
|
information?: string;
|
||||||
|
documents?: Array<{ reference: string }>;
|
||||||
|
}>;
|
||||||
|
last_updated?: string;
|
||||||
|
canton?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OerebSectionProps {
|
||||||
|
oereb: OerebData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OerebSection: React.FC<OerebSectionProps> = ({ oereb }) => {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||||
|
const restrictions = oereb.restrictions || [];
|
||||||
|
|
||||||
|
if (restrictions.length === 0 && !oereb.extract_url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.oerebSection}>
|
||||||
|
<div className={styles.oerebHeader} onClick={() => setIsExpanded(!isExpanded)}>
|
||||||
|
<h4 className={styles.subSectionTitle}>
|
||||||
|
<FaInfoCircle style={{ marginRight: '8px', display: 'inline' }} />
|
||||||
|
ÖREB-Kataster
|
||||||
|
{restrictions.length > 0 && (
|
||||||
|
<span className={styles.badge}>({restrictions.length})</span>
|
||||||
|
)}
|
||||||
|
</h4>
|
||||||
|
<button className={styles.expandButton}>
|
||||||
|
{isExpanded ? <FaChevronUp /> : <FaChevronDown />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className={styles.oerebContent}>
|
||||||
|
{oereb.extract_url && (
|
||||||
|
<div className={styles.oerebExtractLink}>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsPreviewOpen(true)}
|
||||||
|
className={styles.oerebLink}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<FaFilePdf style={{ marginRight: '8px' }} />
|
||||||
|
Vollständigen ÖREB-Auszug öffnen (PDF)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{restrictions.length > 0 ? (
|
||||||
|
<div className={styles.restrictionsList}>
|
||||||
|
{restrictions.map((restriction, index) => (
|
||||||
|
<div key={index} className={styles.restrictionItem}>
|
||||||
|
<div className={styles.restrictionHeader}>
|
||||||
|
<span className={styles.restrictionTheme}>{restriction.theme}</span>
|
||||||
|
{restriction.law_status && (
|
||||||
|
<span className={styles.restrictionStatus}>
|
||||||
|
{restriction.law_status === 'inKraft' || restriction.law_status === 'inForce'
|
||||||
|
? 'In Kraft'
|
||||||
|
: restriction.law_status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{restriction.type && (
|
||||||
|
<div className={styles.restrictionType}>
|
||||||
|
<span className={styles.label}>Typ:</span>
|
||||||
|
<span className={styles.value}>{restriction.type}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{restriction.information && (
|
||||||
|
<div className={styles.restrictionInfo}>
|
||||||
|
<span className={styles.value}>{restriction.information}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{restriction.documents && restriction.documents.length > 0 && (
|
||||||
|
<div className={styles.restrictionDocuments}>
|
||||||
|
<span className={styles.label}>Dokumente:</span>
|
||||||
|
{restriction.documents.map((doc, docIndex) => (
|
||||||
|
<a
|
||||||
|
key={docIndex}
|
||||||
|
href={doc.reference}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={styles.documentLink}
|
||||||
|
>
|
||||||
|
Dokument {docIndex + 1}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.noRestrictions}>
|
||||||
|
Keine öffentlich-rechtlichen Beschränkungen gefunden.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{oereb.last_updated && (
|
||||||
|
<div className={styles.oerebFooter}>
|
||||||
|
<span className={styles.lastUpdated}>
|
||||||
|
Aktualisiert: {new Date(oereb.last_updated).toLocaleString('de-CH')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{oereb.extract_url && (
|
||||||
|
<UrlContentPreview
|
||||||
|
isOpen={isPreviewOpen}
|
||||||
|
onClose={() => setIsPreviewOpen(false)}
|
||||||
|
url={oereb.extract_url}
|
||||||
|
fileName="ÖREB-Auszug.pdf"
|
||||||
|
mimeType="application/pdf"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -125,6 +125,24 @@
|
||||||
color: var(--color-text-secondary, #6b7280);
|
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 {
|
.infoGrid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -241,6 +259,670 @@
|
||||||
overflow-y: auto;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.panel {
|
.panel {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,7 @@
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { GenericPageData } from '../../../pageInterface';
|
import { GenericPageData } from '../../../pageInterface';
|
||||||
import { FaLink, FaPlus } from 'react-icons/fa';
|
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
|
// Helper function to convert attribute definitions to column config
|
||||||
const attributesToColumns = (attributes: any[]) => {
|
const attributesToColumns = (attributes: any[]) => {
|
||||||
|
|
@ -28,7 +28,7 @@ const attributesToColumns = (attributes: any[]) => {
|
||||||
const createPositionDocumentsHook = () => {
|
const createPositionDocumentsHook = () => {
|
||||||
return () => {
|
return () => {
|
||||||
const {
|
const {
|
||||||
items: positionDocuments,
|
positionDocuments,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
refetch,
|
refetch,
|
||||||
|
|
@ -36,15 +36,15 @@ const createPositionDocumentsHook = () => {
|
||||||
attributes,
|
attributes,
|
||||||
permissions,
|
permissions,
|
||||||
pagination,
|
pagination,
|
||||||
fetchById: fetchPositionDocumentById,
|
fetchPositionDocumentById,
|
||||||
generateEditFieldsFromAttributes,
|
generateEditFieldsFromAttributes,
|
||||||
ensureAttributesLoaded
|
ensureAttributesLoaded
|
||||||
} = useTrusteePositionDocuments();
|
} = useTrusteePositionDocuments();
|
||||||
const {
|
const {
|
||||||
handleDelete: handlePositionDocumentDelete,
|
handlePositionDocumentDelete,
|
||||||
handleCreate: handlePositionDocumentCreate,
|
handlePositionDocumentCreate,
|
||||||
deletingItems: deletingPositionDocuments,
|
deletingPositionDocuments,
|
||||||
creatingItem: creatingPositionDocument,
|
creatingPositionDocument,
|
||||||
deleteError,
|
deleteError,
|
||||||
createError
|
createError
|
||||||
} = useTrusteePositionDocumentOperations();
|
} = useTrusteePositionDocumentOperations();
|
||||||
|
|
@ -70,7 +70,7 @@ const createPositionDocumentsHook = () => {
|
||||||
positionDocumentIds.map(id => handlePositionDocumentDelete(id))
|
positionDocumentIds.map(id => handlePositionDocumentDelete(id))
|
||||||
);
|
);
|
||||||
|
|
||||||
const allSuccessful = results.every((result: boolean) => result);
|
const allSuccessful = results.every(result => result);
|
||||||
if (allSuccessful) {
|
if (allSuccessful) {
|
||||||
refetch();
|
refetch();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
352
src/hooks/useTrusteeAccess.ts
Normal file
352
src/hooks/useTrusteeAccess.ts
Normal file
|
|
@ -0,0 +1,352 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useApiRequest } from './useApi';
|
||||||
|
import { getUserDataCache } from '../utils/userCache';
|
||||||
|
import api from '../api';
|
||||||
|
import { usePermissions, type UserPermissions } from './usePermissions';
|
||||||
|
import {
|
||||||
|
fetchAccess as fetchAccessApi,
|
||||||
|
fetchAccessById as fetchAccessByIdApi,
|
||||||
|
createAccess as createAccessApi,
|
||||||
|
updateAccess as updateAccessApi,
|
||||||
|
deleteAccess as deleteAccessApi,
|
||||||
|
type TrusteeAccess,
|
||||||
|
type AttributeDefinition,
|
||||||
|
type PaginationParams
|
||||||
|
} from '../api/trusteeApi';
|
||||||
|
|
||||||
|
// Re-export types
|
||||||
|
export type { TrusteeAccess, AttributeDefinition, PaginationParams };
|
||||||
|
|
||||||
|
// Access list hook
|
||||||
|
export function useTrusteeAccess() {
|
||||||
|
const [accessRecords, setAccessRecords] = useState<TrusteeAccess[]>([]);
|
||||||
|
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
|
||||||
|
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
|
||||||
|
const [pagination, setPagination] = useState<{
|
||||||
|
currentPage: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
} | null>(null);
|
||||||
|
const { request, isLoading: loading, error } = useApiRequest<null, TrusteeAccess[]>();
|
||||||
|
const { checkPermission } = usePermissions();
|
||||||
|
|
||||||
|
// Fetch attributes from backend
|
||||||
|
const fetchAttributes = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/api/attributes/TrusteeAccess');
|
||||||
|
|
||||||
|
let attrs: AttributeDefinition[] = [];
|
||||||
|
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
|
||||||
|
attrs = response.data.attributes;
|
||||||
|
} else if (Array.isArray(response.data)) {
|
||||||
|
attrs = response.data;
|
||||||
|
} else if (response.data && typeof response.data === 'object') {
|
||||||
|
const keys = Object.keys(response.data);
|
||||||
|
for (const key of keys) {
|
||||||
|
if (Array.isArray(response.data[key])) {
|
||||||
|
attrs = response.data[key];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttributes(attrs);
|
||||||
|
return attrs;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching attributes:', error);
|
||||||
|
setAttributes([]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch permissions from backend
|
||||||
|
const fetchPermissions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const perms = await checkPermission('DATA', 'trustee.access');
|
||||||
|
setPermissions(perms);
|
||||||
|
return perms;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching permissions:', error);
|
||||||
|
const defaultPerms: UserPermissions = {
|
||||||
|
view: false,
|
||||||
|
read: 'n',
|
||||||
|
create: 'n',
|
||||||
|
update: 'n',
|
||||||
|
delete: 'n',
|
||||||
|
};
|
||||||
|
setPermissions(defaultPerms);
|
||||||
|
return defaultPerms;
|
||||||
|
}
|
||||||
|
}, [checkPermission]);
|
||||||
|
|
||||||
|
const fetchAccess = useCallback(async (params?: PaginationParams) => {
|
||||||
|
try {
|
||||||
|
const data = await fetchAccessApi(request, params);
|
||||||
|
|
||||||
|
if (data && typeof data === 'object' && 'items' in data) {
|
||||||
|
const items = Array.isArray(data.items) ? data.items : [];
|
||||||
|
setAccessRecords(items);
|
||||||
|
if (data.pagination) {
|
||||||
|
setPagination(data.pagination);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const items = Array.isArray(data) ? data : [];
|
||||||
|
setAccessRecords(items);
|
||||||
|
setPagination(null);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
setAccessRecords([]);
|
||||||
|
setPagination(null);
|
||||||
|
}
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
// Optimistically remove an access record
|
||||||
|
const removeOptimistically = (accessId: string) => {
|
||||||
|
setAccessRecords(prevAccess => prevAccess.filter(acc => acc.id !== accessId));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optimistically update an access record
|
||||||
|
const updateOptimistically = (accessId: string, updateData: Partial<TrusteeAccess>) => {
|
||||||
|
setAccessRecords(prevAccess =>
|
||||||
|
prevAccess.map(acc =>
|
||||||
|
acc.id === accessId
|
||||||
|
? { ...acc, ...updateData }
|
||||||
|
: acc
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch a single access record by ID
|
||||||
|
const fetchAccessById = useCallback(async (accessId: string): Promise<TrusteeAccess | null> => {
|
||||||
|
return await fetchAccessByIdApi(request, accessId);
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
// Generate edit fields from attributes dynamically
|
||||||
|
const generateEditFieldsFromAttributes = useCallback((): Array<{
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly';
|
||||||
|
editable?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
validator?: (value: any) => string | null;
|
||||||
|
options?: Array<{ value: string | number; label: string }>;
|
||||||
|
optionsReference?: string;
|
||||||
|
dependsOn?: string;
|
||||||
|
}> => {
|
||||||
|
if (!attributes || attributes.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const editableFields = attributes
|
||||||
|
.filter(attr => {
|
||||||
|
if (attr.readonly === true || attr.editable === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
|
||||||
|
return !nonEditableFields.includes(attr.name);
|
||||||
|
})
|
||||||
|
.map(attr => {
|
||||||
|
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string';
|
||||||
|
let options: Array<{ value: string | number; label: string }> | undefined = undefined;
|
||||||
|
let optionsReference: string | undefined = undefined;
|
||||||
|
let dependsOn: string | undefined = undefined;
|
||||||
|
|
||||||
|
if (attr.type === 'checkbox') {
|
||||||
|
fieldType = 'boolean';
|
||||||
|
} else if (attr.type === 'email') {
|
||||||
|
fieldType = 'email';
|
||||||
|
} else if (attr.type === 'date') {
|
||||||
|
fieldType = 'date';
|
||||||
|
} else if (attr.type === 'select') {
|
||||||
|
fieldType = 'enum';
|
||||||
|
if (Array.isArray(attr.options)) {
|
||||||
|
options = attr.options.map(opt => {
|
||||||
|
const labelValue = typeof opt.label === 'string'
|
||||||
|
? opt.label
|
||||||
|
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
|
||||||
|
return {
|
||||||
|
value: opt.value,
|
||||||
|
label: labelValue
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else if (typeof attr.options === 'string') {
|
||||||
|
optionsReference = attr.options;
|
||||||
|
}
|
||||||
|
} else if (attr.type === 'textarea') {
|
||||||
|
fieldType = 'textarea';
|
||||||
|
}
|
||||||
|
|
||||||
|
// contractId dropdown depends on organisationId
|
||||||
|
if (attr.name === 'contractId') {
|
||||||
|
dependsOn = 'organisationId';
|
||||||
|
}
|
||||||
|
|
||||||
|
let required = attr.required === true;
|
||||||
|
let validator: ((value: any) => string | null) | undefined = undefined;
|
||||||
|
|
||||||
|
// contractId is optional
|
||||||
|
if (attr.name === 'contractId') {
|
||||||
|
required = false;
|
||||||
|
} else if (attr.name === 'organisationId' || attr.name === 'roleId' || attr.name === 'userId') {
|
||||||
|
required = true;
|
||||||
|
validator = (value: any) => {
|
||||||
|
if (!value || (typeof value === 'string' && value.trim() === '')) {
|
||||||
|
return `${attr.label || attr.name} is required`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: attr.name,
|
||||||
|
label: attr.label || attr.name,
|
||||||
|
type: fieldType,
|
||||||
|
editable: attr.editable !== false && attr.readonly !== true,
|
||||||
|
required,
|
||||||
|
validator,
|
||||||
|
options,
|
||||||
|
optionsReference,
|
||||||
|
dependsOn
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return editableFields;
|
||||||
|
}, [attributes]);
|
||||||
|
|
||||||
|
// Ensure attributes are loaded
|
||||||
|
const ensureAttributesLoaded = useCallback(async () => {
|
||||||
|
if (attributes && attributes.length > 0) {
|
||||||
|
return attributes;
|
||||||
|
}
|
||||||
|
const fetchedAttributes = await fetchAttributes();
|
||||||
|
return fetchedAttributes;
|
||||||
|
}, [attributes, fetchAttributes]);
|
||||||
|
|
||||||
|
// Fetch attributes and permissions on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAttributes();
|
||||||
|
fetchPermissions();
|
||||||
|
}, [fetchAttributes, fetchPermissions]);
|
||||||
|
|
||||||
|
// Initial fetch
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAccess();
|
||||||
|
}, [fetchAccess]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessRecords,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch: fetchAccess,
|
||||||
|
removeOptimistically,
|
||||||
|
updateOptimistically,
|
||||||
|
attributes,
|
||||||
|
permissions,
|
||||||
|
pagination,
|
||||||
|
fetchAccessById,
|
||||||
|
generateEditFieldsFromAttributes,
|
||||||
|
ensureAttributesLoaded
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Access operations hook
|
||||||
|
export function useTrusteeAccessOperations() {
|
||||||
|
const [deletingAccess, setDeletingAccess] = useState<Set<string>>(new Set());
|
||||||
|
const [creatingAccess, setCreatingAccess] = useState(false);
|
||||||
|
const { request, isLoading } = useApiRequest();
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleAccessDelete = async (accessId: string) => {
|
||||||
|
setDeleteError(null);
|
||||||
|
setDeletingAccess(prev => new Set(prev).add(accessId));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteAccessApi(request, accessId);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
setDeleteError(error.message);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setDeletingAccess(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(accessId);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAccessCreate = async (accessData: Partial<TrusteeAccess>) => {
|
||||||
|
setCreateError(null);
|
||||||
|
setCreatingAccess(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentUserData = getUserDataCache();
|
||||||
|
const mandateId = currentUserData?.mandateId || '';
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
...accessData,
|
||||||
|
mandate: mandateId
|
||||||
|
};
|
||||||
|
|
||||||
|
const newAccess = await createAccessApi(request, requestBody);
|
||||||
|
|
||||||
|
return { success: true, accessData: newAccess };
|
||||||
|
} catch (error: any) {
|
||||||
|
setCreateError(error.message);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
} finally {
|
||||||
|
setCreatingAccess(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAccessUpdate = async (
|
||||||
|
accessId: string,
|
||||||
|
updateData: Partial<TrusteeAccess>,
|
||||||
|
_originalData?: any
|
||||||
|
) => {
|
||||||
|
setUpdateError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentUserData = getUserDataCache();
|
||||||
|
const mandateId = currentUserData?.mandateId || '';
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
...updateData,
|
||||||
|
mandate: mandateId
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedAccess = await updateAccessApi(request, accessId, requestBody);
|
||||||
|
|
||||||
|
return { success: true, accessData: updatedAccess };
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage = error.response?.data?.message || error.message || 'Failed to update access';
|
||||||
|
const statusCode = error.response?.status;
|
||||||
|
|
||||||
|
setUpdateError(errorMessage);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
statusCode,
|
||||||
|
isPermissionError: statusCode === 403,
|
||||||
|
isValidationError: statusCode === 400
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
deletingAccess,
|
||||||
|
creatingAccess,
|
||||||
|
deleteError,
|
||||||
|
createError,
|
||||||
|
updateError,
|
||||||
|
handleAccessDelete,
|
||||||
|
handleAccessCreate,
|
||||||
|
handleAccessUpdate,
|
||||||
|
isLoading
|
||||||
|
};
|
||||||
|
}
|
||||||
362
src/hooks/useTrusteeContracts.ts
Normal file
362
src/hooks/useTrusteeContracts.ts
Normal file
|
|
@ -0,0 +1,362 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useApiRequest } from './useApi';
|
||||||
|
import { getUserDataCache } from '../utils/userCache';
|
||||||
|
import api from '../api';
|
||||||
|
import { usePermissions, type UserPermissions } from './usePermissions';
|
||||||
|
import {
|
||||||
|
fetchContracts as fetchContractsApi,
|
||||||
|
fetchContractById as fetchContractByIdApi,
|
||||||
|
createContract as createContractApi,
|
||||||
|
updateContract as updateContractApi,
|
||||||
|
deleteContract as deleteContractApi,
|
||||||
|
type TrusteeContract,
|
||||||
|
type AttributeDefinition,
|
||||||
|
type PaginationParams
|
||||||
|
} from '../api/trusteeApi';
|
||||||
|
|
||||||
|
// Re-export types
|
||||||
|
export type { TrusteeContract, AttributeDefinition, PaginationParams };
|
||||||
|
|
||||||
|
// Contracts list hook
|
||||||
|
export function useTrusteeContracts() {
|
||||||
|
const [contracts, setContracts] = useState<TrusteeContract[]>([]);
|
||||||
|
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
|
||||||
|
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
|
||||||
|
const [pagination, setPagination] = useState<{
|
||||||
|
currentPage: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
} | null>(null);
|
||||||
|
const { request, isLoading: loading, error } = useApiRequest<null, TrusteeContract[]>();
|
||||||
|
const { checkPermission } = usePermissions();
|
||||||
|
|
||||||
|
// Fetch attributes from backend
|
||||||
|
const fetchAttributes = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/api/attributes/TrusteeContract');
|
||||||
|
|
||||||
|
let attrs: AttributeDefinition[] = [];
|
||||||
|
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
|
||||||
|
attrs = response.data.attributes;
|
||||||
|
} else if (Array.isArray(response.data)) {
|
||||||
|
attrs = response.data;
|
||||||
|
} else if (response.data && typeof response.data === 'object') {
|
||||||
|
const keys = Object.keys(response.data);
|
||||||
|
for (const key of keys) {
|
||||||
|
if (Array.isArray(response.data[key])) {
|
||||||
|
attrs = response.data[key];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttributes(attrs);
|
||||||
|
return attrs;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching attributes:', error);
|
||||||
|
setAttributes([]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch permissions from backend
|
||||||
|
const fetchPermissions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const perms = await checkPermission('DATA', 'trustee.contract');
|
||||||
|
setPermissions(perms);
|
||||||
|
return perms;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching permissions:', error);
|
||||||
|
const defaultPerms: UserPermissions = {
|
||||||
|
view: false,
|
||||||
|
read: 'n',
|
||||||
|
create: 'n',
|
||||||
|
update: 'n',
|
||||||
|
delete: 'n',
|
||||||
|
};
|
||||||
|
setPermissions(defaultPerms);
|
||||||
|
return defaultPerms;
|
||||||
|
}
|
||||||
|
}, [checkPermission]);
|
||||||
|
|
||||||
|
const fetchContracts = useCallback(async (params?: PaginationParams) => {
|
||||||
|
try {
|
||||||
|
const data = await fetchContractsApi(request, params);
|
||||||
|
|
||||||
|
if (data && typeof data === 'object' && 'items' in data) {
|
||||||
|
const items = Array.isArray(data.items) ? data.items : [];
|
||||||
|
setContracts(items);
|
||||||
|
if (data.pagination) {
|
||||||
|
setPagination(data.pagination);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const items = Array.isArray(data) ? data : [];
|
||||||
|
setContracts(items);
|
||||||
|
setPagination(null);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
setContracts([]);
|
||||||
|
setPagination(null);
|
||||||
|
}
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
// Optimistically remove a contract
|
||||||
|
const removeOptimistically = (contractId: string) => {
|
||||||
|
setContracts(prevContracts => prevContracts.filter(contract => contract.id !== contractId));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optimistically update a contract
|
||||||
|
const updateOptimistically = (contractId: string, updateData: Partial<TrusteeContract>) => {
|
||||||
|
setContracts(prevContracts =>
|
||||||
|
prevContracts.map(contract =>
|
||||||
|
contract.id === contractId
|
||||||
|
? { ...contract, ...updateData }
|
||||||
|
: contract
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch a single contract by ID
|
||||||
|
const fetchContractById = useCallback(async (contractId: string): Promise<TrusteeContract | null> => {
|
||||||
|
return await fetchContractByIdApi(request, contractId);
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
// Generate edit fields from attributes dynamically
|
||||||
|
const generateEditFieldsFromAttributes = useCallback((): Array<{
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly';
|
||||||
|
editable?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
validator?: (value: any) => string | null;
|
||||||
|
options?: Array<{ value: string | number; label: string }>;
|
||||||
|
optionsReference?: string;
|
||||||
|
readonlyCondition?: (formData: any) => boolean;
|
||||||
|
}> => {
|
||||||
|
if (!attributes || attributes.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const editableFields = attributes
|
||||||
|
.filter(attr => {
|
||||||
|
if (attr.readonly === true || attr.editable === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
|
||||||
|
return !nonEditableFields.includes(attr.name);
|
||||||
|
})
|
||||||
|
.map(attr => {
|
||||||
|
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string';
|
||||||
|
let options: Array<{ value: string | number; label: string }> | undefined = undefined;
|
||||||
|
let optionsReference: string | undefined = undefined;
|
||||||
|
let readonlyCondition: ((formData: any) => boolean) | undefined = undefined;
|
||||||
|
|
||||||
|
if (attr.type === 'checkbox') {
|
||||||
|
fieldType = 'boolean';
|
||||||
|
} else if (attr.type === 'email') {
|
||||||
|
fieldType = 'email';
|
||||||
|
} else if (attr.type === 'date') {
|
||||||
|
fieldType = 'date';
|
||||||
|
} else if (attr.type === 'select') {
|
||||||
|
fieldType = 'enum';
|
||||||
|
if (Array.isArray(attr.options)) {
|
||||||
|
options = attr.options.map(opt => {
|
||||||
|
const labelValue = typeof opt.label === 'string'
|
||||||
|
? opt.label
|
||||||
|
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
|
||||||
|
return {
|
||||||
|
value: opt.value,
|
||||||
|
label: labelValue
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else if (typeof attr.options === 'string') {
|
||||||
|
optionsReference = attr.options;
|
||||||
|
}
|
||||||
|
} else if (attr.type === 'textarea') {
|
||||||
|
fieldType = 'textarea';
|
||||||
|
}
|
||||||
|
|
||||||
|
// IMPORTANT: organisationId is immutable after creation
|
||||||
|
// It's readonly when id is present (non-blank)
|
||||||
|
if (attr.name === 'organisationId') {
|
||||||
|
readonlyCondition = (formData: any) => {
|
||||||
|
return formData && formData.id && formData.id !== '';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let required = attr.required === true;
|
||||||
|
let validator: ((value: any) => string | null) | undefined = undefined;
|
||||||
|
|
||||||
|
if (attr.name === 'organisationId') {
|
||||||
|
required = true;
|
||||||
|
validator = (value: any) => {
|
||||||
|
if (!value || (typeof value === 'string' && value.trim() === '')) {
|
||||||
|
return 'Organisation is required';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
} else if (attr.name === 'label') {
|
||||||
|
required = true;
|
||||||
|
validator = (value: string) => {
|
||||||
|
if (!value || value.trim() === '') {
|
||||||
|
return 'Label cannot be empty';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: attr.name,
|
||||||
|
label: attr.label || attr.name,
|
||||||
|
type: fieldType,
|
||||||
|
editable: attr.editable !== false && attr.readonly !== true,
|
||||||
|
required,
|
||||||
|
validator,
|
||||||
|
options,
|
||||||
|
optionsReference,
|
||||||
|
readonlyCondition
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return editableFields;
|
||||||
|
}, [attributes]);
|
||||||
|
|
||||||
|
// Ensure attributes are loaded
|
||||||
|
const ensureAttributesLoaded = useCallback(async () => {
|
||||||
|
if (attributes && attributes.length > 0) {
|
||||||
|
return attributes;
|
||||||
|
}
|
||||||
|
const fetchedAttributes = await fetchAttributes();
|
||||||
|
return fetchedAttributes;
|
||||||
|
}, [attributes, fetchAttributes]);
|
||||||
|
|
||||||
|
// Fetch attributes and permissions on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAttributes();
|
||||||
|
fetchPermissions();
|
||||||
|
}, [fetchAttributes, fetchPermissions]);
|
||||||
|
|
||||||
|
// Initial fetch
|
||||||
|
useEffect(() => {
|
||||||
|
fetchContracts();
|
||||||
|
}, [fetchContracts]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
contracts,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch: fetchContracts,
|
||||||
|
removeOptimistically,
|
||||||
|
updateOptimistically,
|
||||||
|
attributes,
|
||||||
|
permissions,
|
||||||
|
pagination,
|
||||||
|
fetchContractById,
|
||||||
|
generateEditFieldsFromAttributes,
|
||||||
|
ensureAttributesLoaded
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contract operations hook
|
||||||
|
export function useTrusteeContractOperations() {
|
||||||
|
const [deletingContracts, setDeletingContracts] = useState<Set<string>>(new Set());
|
||||||
|
const [creatingContract, setCreatingContract] = useState(false);
|
||||||
|
const { request, isLoading } = useApiRequest();
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleContractDelete = async (contractId: string) => {
|
||||||
|
setDeleteError(null);
|
||||||
|
setDeletingContracts(prev => new Set(prev).add(contractId));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteContractApi(request, contractId);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
setDeleteError(error.message);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setDeletingContracts(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(contractId);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContractCreate = async (contractData: Partial<TrusteeContract>) => {
|
||||||
|
setCreateError(null);
|
||||||
|
setCreatingContract(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentUserData = getUserDataCache();
|
||||||
|
const mandateId = currentUserData?.mandateId || '';
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
...contractData,
|
||||||
|
mandate: mandateId
|
||||||
|
};
|
||||||
|
|
||||||
|
const newContract = await createContractApi(request, requestBody);
|
||||||
|
|
||||||
|
return { success: true, contractData: newContract };
|
||||||
|
} catch (error: any) {
|
||||||
|
setCreateError(error.message);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
} finally {
|
||||||
|
setCreatingContract(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContractUpdate = async (
|
||||||
|
contractId: string,
|
||||||
|
updateData: Partial<TrusteeContract>,
|
||||||
|
_originalData?: any
|
||||||
|
) => {
|
||||||
|
setUpdateError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentUserData = getUserDataCache();
|
||||||
|
const mandateId = currentUserData?.mandateId || '';
|
||||||
|
|
||||||
|
// Note: organisationId should NOT be included in update if immutable
|
||||||
|
// Backend will reject if organisationId is changed
|
||||||
|
const requestBody = {
|
||||||
|
...updateData,
|
||||||
|
mandate: mandateId
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedContract = await updateContractApi(request, contractId, requestBody);
|
||||||
|
|
||||||
|
return { success: true, contractData: updatedContract };
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage = error.response?.data?.message || error.message || 'Failed to update contract';
|
||||||
|
const statusCode = error.response?.status;
|
||||||
|
|
||||||
|
setUpdateError(errorMessage);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
statusCode,
|
||||||
|
isPermissionError: statusCode === 403,
|
||||||
|
isValidationError: statusCode === 400
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
deletingContracts,
|
||||||
|
creatingContract,
|
||||||
|
deleteError,
|
||||||
|
createError,
|
||||||
|
updateError,
|
||||||
|
handleContractDelete,
|
||||||
|
handleContractCreate,
|
||||||
|
handleContractUpdate,
|
||||||
|
isLoading
|
||||||
|
};
|
||||||
|
}
|
||||||
384
src/hooks/useTrusteeDocuments.ts
Normal file
384
src/hooks/useTrusteeDocuments.ts
Normal file
|
|
@ -0,0 +1,384 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useApiRequest } from './useApi';
|
||||||
|
import { getUserDataCache } from '../utils/userCache';
|
||||||
|
import api from '../api';
|
||||||
|
import { usePermissions, type UserPermissions } from './usePermissions';
|
||||||
|
import {
|
||||||
|
fetchDocuments as fetchDocumentsApi,
|
||||||
|
fetchDocumentById as fetchDocumentByIdApi,
|
||||||
|
createDocument as createDocumentApi,
|
||||||
|
updateDocument as updateDocumentApi,
|
||||||
|
deleteDocument as deleteDocumentApi,
|
||||||
|
downloadDocumentData as downloadDocumentDataApi,
|
||||||
|
type TrusteeDocument,
|
||||||
|
type AttributeDefinition,
|
||||||
|
type PaginationParams
|
||||||
|
} from '../api/trusteeApi';
|
||||||
|
|
||||||
|
// Re-export types
|
||||||
|
export type { TrusteeDocument, AttributeDefinition, PaginationParams };
|
||||||
|
|
||||||
|
// Documents list hook
|
||||||
|
export function useTrusteeDocuments() {
|
||||||
|
const [documents, setDocuments] = useState<TrusteeDocument[]>([]);
|
||||||
|
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
|
||||||
|
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
|
||||||
|
const [pagination, setPagination] = useState<{
|
||||||
|
currentPage: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
} | null>(null);
|
||||||
|
const { request, isLoading: loading, error } = useApiRequest<null, TrusteeDocument[]>();
|
||||||
|
const { checkPermission } = usePermissions();
|
||||||
|
|
||||||
|
// Fetch attributes from backend
|
||||||
|
const fetchAttributes = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/api/attributes/TrusteeDocument');
|
||||||
|
|
||||||
|
let attrs: AttributeDefinition[] = [];
|
||||||
|
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
|
||||||
|
attrs = response.data.attributes;
|
||||||
|
} else if (Array.isArray(response.data)) {
|
||||||
|
attrs = response.data;
|
||||||
|
} else if (response.data && typeof response.data === 'object') {
|
||||||
|
const keys = Object.keys(response.data);
|
||||||
|
for (const key of keys) {
|
||||||
|
if (Array.isArray(response.data[key])) {
|
||||||
|
attrs = response.data[key];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttributes(attrs);
|
||||||
|
return attrs;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching attributes:', error);
|
||||||
|
setAttributes([]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch permissions from backend
|
||||||
|
const fetchPermissions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const perms = await checkPermission('DATA', 'trustee.document');
|
||||||
|
setPermissions(perms);
|
||||||
|
return perms;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching permissions:', error);
|
||||||
|
const defaultPerms: UserPermissions = {
|
||||||
|
view: false,
|
||||||
|
read: 'n',
|
||||||
|
create: 'n',
|
||||||
|
update: 'n',
|
||||||
|
delete: 'n',
|
||||||
|
};
|
||||||
|
setPermissions(defaultPerms);
|
||||||
|
return defaultPerms;
|
||||||
|
}
|
||||||
|
}, [checkPermission]);
|
||||||
|
|
||||||
|
const fetchDocuments = useCallback(async (params?: PaginationParams) => {
|
||||||
|
try {
|
||||||
|
const data = await fetchDocumentsApi(request, params);
|
||||||
|
|
||||||
|
if (data && typeof data === 'object' && 'items' in data) {
|
||||||
|
const items = Array.isArray(data.items) ? data.items : [];
|
||||||
|
setDocuments(items);
|
||||||
|
if (data.pagination) {
|
||||||
|
setPagination(data.pagination);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const items = Array.isArray(data) ? data : [];
|
||||||
|
setDocuments(items);
|
||||||
|
setPagination(null);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
setDocuments([]);
|
||||||
|
setPagination(null);
|
||||||
|
}
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
// Optimistically remove a document
|
||||||
|
const removeOptimistically = (documentId: string) => {
|
||||||
|
setDocuments(prevDocs => prevDocs.filter(doc => doc.id !== documentId));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optimistically update a document
|
||||||
|
const updateOptimistically = (documentId: string, updateData: Partial<TrusteeDocument>) => {
|
||||||
|
setDocuments(prevDocs =>
|
||||||
|
prevDocs.map(doc =>
|
||||||
|
doc.id === documentId
|
||||||
|
? { ...doc, ...updateData }
|
||||||
|
: doc
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch a single document by ID
|
||||||
|
const fetchDocumentById = useCallback(async (documentId: string): Promise<TrusteeDocument | null> => {
|
||||||
|
return await fetchDocumentByIdApi(request, documentId);
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
// Generate edit fields from attributes dynamically
|
||||||
|
const generateEditFieldsFromAttributes = useCallback((): Array<{
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly';
|
||||||
|
editable?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
validator?: (value: any) => string | null;
|
||||||
|
options?: Array<{ value: string | number; label: string }>;
|
||||||
|
optionsReference?: string;
|
||||||
|
dependsOn?: string;
|
||||||
|
}> => {
|
||||||
|
if (!attributes || attributes.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const editableFields = attributes
|
||||||
|
.filter(attr => {
|
||||||
|
if (attr.readonly === true || attr.editable === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// documentData is handled separately (binary upload)
|
||||||
|
const nonEditableFields = ['id', 'documentData', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
|
||||||
|
return !nonEditableFields.includes(attr.name);
|
||||||
|
})
|
||||||
|
.map(attr => {
|
||||||
|
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string';
|
||||||
|
let options: Array<{ value: string | number; label: string }> | undefined = undefined;
|
||||||
|
let optionsReference: string | undefined = undefined;
|
||||||
|
let dependsOn: string | undefined = undefined;
|
||||||
|
|
||||||
|
if (attr.type === 'checkbox') {
|
||||||
|
fieldType = 'boolean';
|
||||||
|
} else if (attr.type === 'email') {
|
||||||
|
fieldType = 'email';
|
||||||
|
} else if (attr.type === 'date') {
|
||||||
|
fieldType = 'date';
|
||||||
|
} else if (attr.type === 'select') {
|
||||||
|
fieldType = 'enum';
|
||||||
|
if (Array.isArray(attr.options)) {
|
||||||
|
options = attr.options.map(opt => {
|
||||||
|
const labelValue = typeof opt.label === 'string'
|
||||||
|
? opt.label
|
||||||
|
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
|
||||||
|
return {
|
||||||
|
value: opt.value,
|
||||||
|
label: labelValue
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else if (typeof attr.options === 'string') {
|
||||||
|
optionsReference = attr.options;
|
||||||
|
}
|
||||||
|
} else if (attr.type === 'textarea') {
|
||||||
|
fieldType = 'textarea';
|
||||||
|
}
|
||||||
|
|
||||||
|
// contractId depends on organisationId
|
||||||
|
if (attr.name === 'contractId') {
|
||||||
|
dependsOn = 'organisationId';
|
||||||
|
}
|
||||||
|
|
||||||
|
let required = attr.required === true;
|
||||||
|
let validator: ((value: any) => string | null) | undefined = undefined;
|
||||||
|
|
||||||
|
if (attr.name === 'organisationId' || attr.name === 'contractId' || attr.name === 'documentName') {
|
||||||
|
required = true;
|
||||||
|
validator = (value: any) => {
|
||||||
|
if (!value || (typeof value === 'string' && value.trim() === '')) {
|
||||||
|
return `${attr.label || attr.name} is required`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: attr.name,
|
||||||
|
label: attr.label || attr.name,
|
||||||
|
type: fieldType,
|
||||||
|
editable: attr.editable !== false && attr.readonly !== true,
|
||||||
|
required,
|
||||||
|
validator,
|
||||||
|
options,
|
||||||
|
optionsReference,
|
||||||
|
dependsOn
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return editableFields;
|
||||||
|
}, [attributes]);
|
||||||
|
|
||||||
|
// Ensure attributes are loaded
|
||||||
|
const ensureAttributesLoaded = useCallback(async () => {
|
||||||
|
if (attributes && attributes.length > 0) {
|
||||||
|
return attributes;
|
||||||
|
}
|
||||||
|
const fetchedAttributes = await fetchAttributes();
|
||||||
|
return fetchedAttributes;
|
||||||
|
}, [attributes, fetchAttributes]);
|
||||||
|
|
||||||
|
// Fetch attributes and permissions on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAttributes();
|
||||||
|
fetchPermissions();
|
||||||
|
}, [fetchAttributes, fetchPermissions]);
|
||||||
|
|
||||||
|
// Initial fetch
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDocuments();
|
||||||
|
}, [fetchDocuments]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
documents,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch: fetchDocuments,
|
||||||
|
removeOptimistically,
|
||||||
|
updateOptimistically,
|
||||||
|
attributes,
|
||||||
|
permissions,
|
||||||
|
pagination,
|
||||||
|
fetchDocumentById,
|
||||||
|
generateEditFieldsFromAttributes,
|
||||||
|
ensureAttributesLoaded
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Document operations hook
|
||||||
|
export function useTrusteeDocumentOperations() {
|
||||||
|
const [deletingDocuments, setDeletingDocuments] = useState<Set<string>>(new Set());
|
||||||
|
const [creatingDocument, setCreatingDocument] = useState(false);
|
||||||
|
const [downloadingDocuments, setDownloadingDocuments] = useState<Set<string>>(new Set());
|
||||||
|
const { request, isLoading } = useApiRequest();
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||||
|
const [downloadError, setDownloadError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleDocumentDelete = async (documentId: string) => {
|
||||||
|
setDeleteError(null);
|
||||||
|
setDeletingDocuments(prev => new Set(prev).add(documentId));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteDocumentApi(request, documentId);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
setDeleteError(error.message);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setDeletingDocuments(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(documentId);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDocumentCreate = async (documentData: FormData) => {
|
||||||
|
setCreateError(null);
|
||||||
|
setCreatingDocument(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentUserData = getUserDataCache();
|
||||||
|
const mandateId = currentUserData?.mandateId || '';
|
||||||
|
|
||||||
|
documentData.append('mandate', mandateId);
|
||||||
|
|
||||||
|
const newDocument = await createDocumentApi(request, documentData);
|
||||||
|
|
||||||
|
return { success: true, documentData: newDocument };
|
||||||
|
} catch (error: any) {
|
||||||
|
setCreateError(error.message);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
} finally {
|
||||||
|
setCreatingDocument(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDocumentUpdate = async (
|
||||||
|
documentId: string,
|
||||||
|
updateData: Partial<TrusteeDocument>,
|
||||||
|
_originalData?: any
|
||||||
|
) => {
|
||||||
|
setUpdateError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentUserData = getUserDataCache();
|
||||||
|
const mandateId = currentUserData?.mandateId || '';
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
...updateData,
|
||||||
|
mandate: mandateId
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedDocument = await updateDocumentApi(request, documentId, requestBody);
|
||||||
|
|
||||||
|
return { success: true, documentData: updatedDocument };
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage = error.response?.data?.message || error.message || 'Failed to update document';
|
||||||
|
const statusCode = error.response?.status;
|
||||||
|
|
||||||
|
setUpdateError(errorMessage);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
statusCode,
|
||||||
|
isPermissionError: statusCode === 403,
|
||||||
|
isValidationError: statusCode === 400
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDocumentDownload = async (documentId: string, documentName: string) => {
|
||||||
|
setDownloadError(null);
|
||||||
|
setDownloadingDocuments(prev => new Set(prev).add(documentId));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blob = await downloadDocumentDataApi(request, documentId);
|
||||||
|
|
||||||
|
// Create download link
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = documentName || `document-${documentId}`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage = error.message || 'Failed to download document';
|
||||||
|
setDownloadError(errorMessage);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setDownloadingDocuments(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(documentId);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
deletingDocuments,
|
||||||
|
creatingDocument,
|
||||||
|
downloadingDocuments,
|
||||||
|
deleteError,
|
||||||
|
createError,
|
||||||
|
updateError,
|
||||||
|
downloadError,
|
||||||
|
handleDocumentDelete,
|
||||||
|
handleDocumentCreate,
|
||||||
|
handleDocumentUpdate,
|
||||||
|
handleDocumentDownload,
|
||||||
|
isLoading
|
||||||
|
};
|
||||||
|
}
|
||||||
368
src/hooks/useTrusteeOrganisations.ts
Normal file
368
src/hooks/useTrusteeOrganisations.ts
Normal file
|
|
@ -0,0 +1,368 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useApiRequest } from './useApi';
|
||||||
|
import { getUserDataCache } from '../utils/userCache';
|
||||||
|
import api from '../api';
|
||||||
|
import { usePermissions, type UserPermissions } from './usePermissions';
|
||||||
|
import {
|
||||||
|
fetchOrganisations as fetchOrganisationsApi,
|
||||||
|
fetchOrganisationById as fetchOrganisationByIdApi,
|
||||||
|
createOrganisation as createOrganisationApi,
|
||||||
|
updateOrganisation as updateOrganisationApi,
|
||||||
|
deleteOrganisation as deleteOrganisationApi,
|
||||||
|
type TrusteeOrganisation,
|
||||||
|
type AttributeDefinition,
|
||||||
|
type PaginationParams
|
||||||
|
} from '../api/trusteeApi';
|
||||||
|
|
||||||
|
// Re-export types
|
||||||
|
export type { TrusteeOrganisation, AttributeDefinition, PaginationParams };
|
||||||
|
|
||||||
|
// Organisations list hook
|
||||||
|
export function useTrusteeOrganisations() {
|
||||||
|
const [organisations, setOrganisations] = useState<TrusteeOrganisation[]>([]);
|
||||||
|
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
|
||||||
|
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
|
||||||
|
const [pagination, setPagination] = useState<{
|
||||||
|
currentPage: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
} | null>(null);
|
||||||
|
const { request, isLoading: loading, error } = useApiRequest<null, TrusteeOrganisation[]>();
|
||||||
|
const { checkPermission } = usePermissions();
|
||||||
|
|
||||||
|
// Fetch attributes from backend
|
||||||
|
const fetchAttributes = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/api/attributes/TrusteeOrganisation');
|
||||||
|
|
||||||
|
let attrs: AttributeDefinition[] = [];
|
||||||
|
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
|
||||||
|
attrs = response.data.attributes;
|
||||||
|
} else if (Array.isArray(response.data)) {
|
||||||
|
attrs = response.data;
|
||||||
|
} else if (response.data && typeof response.data === 'object') {
|
||||||
|
const keys = Object.keys(response.data);
|
||||||
|
for (const key of keys) {
|
||||||
|
if (Array.isArray(response.data[key])) {
|
||||||
|
attrs = response.data[key];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttributes(attrs);
|
||||||
|
return attrs;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching attributes:', error);
|
||||||
|
setAttributes([]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch permissions from backend
|
||||||
|
const fetchPermissions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const perms = await checkPermission('DATA', 'trustee.organisation');
|
||||||
|
setPermissions(perms);
|
||||||
|
return perms;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching permissions:', error);
|
||||||
|
const defaultPerms: UserPermissions = {
|
||||||
|
view: false,
|
||||||
|
read: 'n',
|
||||||
|
create: 'n',
|
||||||
|
update: 'n',
|
||||||
|
delete: 'n',
|
||||||
|
};
|
||||||
|
setPermissions(defaultPerms);
|
||||||
|
return defaultPerms;
|
||||||
|
}
|
||||||
|
}, [checkPermission]);
|
||||||
|
|
||||||
|
const fetchOrganisations = useCallback(async (params?: PaginationParams) => {
|
||||||
|
try {
|
||||||
|
const data = await fetchOrganisationsApi(request, params);
|
||||||
|
|
||||||
|
if (data && typeof data === 'object' && 'items' in data) {
|
||||||
|
const items = Array.isArray(data.items) ? data.items : [];
|
||||||
|
setOrganisations(items);
|
||||||
|
if (data.pagination) {
|
||||||
|
setPagination(data.pagination);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const items = Array.isArray(data) ? data : [];
|
||||||
|
setOrganisations(items);
|
||||||
|
setPagination(null);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
setOrganisations([]);
|
||||||
|
setPagination(null);
|
||||||
|
}
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
// Optimistically remove an organisation
|
||||||
|
const removeOptimistically = (organisationId: string) => {
|
||||||
|
setOrganisations(prevOrgs => prevOrgs.filter(org => org.id !== organisationId));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optimistically update an organisation
|
||||||
|
const updateOptimistically = (organisationId: string, updateData: Partial<TrusteeOrganisation>) => {
|
||||||
|
setOrganisations(prevOrgs =>
|
||||||
|
prevOrgs.map(org =>
|
||||||
|
org.id === organisationId
|
||||||
|
? { ...org, ...updateData }
|
||||||
|
: org
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch a single organisation by ID
|
||||||
|
const fetchOrganisationById = useCallback(async (organisationId: string): Promise<TrusteeOrganisation | null> => {
|
||||||
|
return await fetchOrganisationByIdApi(request, organisationId);
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
// Generate edit fields from attributes dynamically
|
||||||
|
const generateEditFieldsFromAttributes = useCallback((): Array<{
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly';
|
||||||
|
editable?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
validator?: (value: any) => string | null;
|
||||||
|
options?: Array<{ value: string | number; label: string }>;
|
||||||
|
optionsReference?: string;
|
||||||
|
}> => {
|
||||||
|
if (!attributes || attributes.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const editableFields = attributes
|
||||||
|
.filter(attr => {
|
||||||
|
if (attr.readonly === true || attr.editable === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const nonEditableFields = ['mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
|
||||||
|
return !nonEditableFields.includes(attr.name);
|
||||||
|
})
|
||||||
|
.map(attr => {
|
||||||
|
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string';
|
||||||
|
let options: Array<{ value: string | number; label: string }> | undefined = undefined;
|
||||||
|
let optionsReference: string | undefined = undefined;
|
||||||
|
|
||||||
|
if (attr.type === 'checkbox') {
|
||||||
|
fieldType = 'boolean';
|
||||||
|
} else if (attr.type === 'email') {
|
||||||
|
fieldType = 'email';
|
||||||
|
} else if (attr.type === 'date') {
|
||||||
|
fieldType = 'date';
|
||||||
|
} else if (attr.type === 'select') {
|
||||||
|
fieldType = 'enum';
|
||||||
|
if (Array.isArray(attr.options)) {
|
||||||
|
options = attr.options.map(opt => {
|
||||||
|
const labelValue = typeof opt.label === 'string'
|
||||||
|
? opt.label
|
||||||
|
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
|
||||||
|
return {
|
||||||
|
value: opt.value,
|
||||||
|
label: labelValue
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else if (typeof attr.options === 'string') {
|
||||||
|
optionsReference = attr.options;
|
||||||
|
}
|
||||||
|
} else if (attr.type === 'multiselect') {
|
||||||
|
fieldType = 'multiselect';
|
||||||
|
if (Array.isArray(attr.options)) {
|
||||||
|
options = attr.options.map(opt => {
|
||||||
|
const labelValue = typeof opt.label === 'string'
|
||||||
|
? opt.label
|
||||||
|
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
|
||||||
|
return {
|
||||||
|
value: opt.value,
|
||||||
|
label: labelValue
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else if (typeof attr.options === 'string') {
|
||||||
|
optionsReference = attr.options;
|
||||||
|
}
|
||||||
|
} else if (attr.type === 'textarea') {
|
||||||
|
fieldType = 'textarea';
|
||||||
|
}
|
||||||
|
|
||||||
|
let required = attr.required === true;
|
||||||
|
let validator: ((value: any) => string | null) | undefined = undefined;
|
||||||
|
|
||||||
|
// Special validation for 'id' field (alphanumeric + dash/underscore, 3-50 chars)
|
||||||
|
if (attr.name === 'id') {
|
||||||
|
required = true;
|
||||||
|
validator = (value: string) => {
|
||||||
|
if (!value || value.trim() === '') {
|
||||||
|
return 'Organisation ID cannot be empty';
|
||||||
|
}
|
||||||
|
if (!/^[a-zA-Z0-9_-]{3,50}$/.test(value)) {
|
||||||
|
return 'ID must be 3-50 characters long (alphanumeric, dash, underscore only)';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
} else if (attr.name === 'label') {
|
||||||
|
required = true;
|
||||||
|
validator = (value: string) => {
|
||||||
|
if (!value || value.trim() === '') {
|
||||||
|
return 'Label cannot be empty';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: attr.name,
|
||||||
|
label: attr.label || attr.name,
|
||||||
|
type: fieldType,
|
||||||
|
editable: attr.editable !== false && attr.readonly !== true,
|
||||||
|
required,
|
||||||
|
validator,
|
||||||
|
options,
|
||||||
|
optionsReference
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return editableFields;
|
||||||
|
}, [attributes]);
|
||||||
|
|
||||||
|
// Ensure attributes are loaded
|
||||||
|
const ensureAttributesLoaded = useCallback(async () => {
|
||||||
|
if (attributes && attributes.length > 0) {
|
||||||
|
return attributes;
|
||||||
|
}
|
||||||
|
const fetchedAttributes = await fetchAttributes();
|
||||||
|
return fetchedAttributes;
|
||||||
|
}, [attributes, fetchAttributes]);
|
||||||
|
|
||||||
|
// Fetch attributes and permissions on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAttributes();
|
||||||
|
fetchPermissions();
|
||||||
|
}, [fetchAttributes, fetchPermissions]);
|
||||||
|
|
||||||
|
// Initial fetch
|
||||||
|
useEffect(() => {
|
||||||
|
fetchOrganisations();
|
||||||
|
}, [fetchOrganisations]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
organisations,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch: fetchOrganisations,
|
||||||
|
removeOptimistically,
|
||||||
|
updateOptimistically,
|
||||||
|
attributes,
|
||||||
|
permissions,
|
||||||
|
pagination,
|
||||||
|
fetchOrganisationById,
|
||||||
|
generateEditFieldsFromAttributes,
|
||||||
|
ensureAttributesLoaded
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Organisation operations hook
|
||||||
|
export function useTrusteeOrganisationOperations() {
|
||||||
|
const [deletingOrganisations, setDeletingOrganisations] = useState<Set<string>>(new Set());
|
||||||
|
const [creatingOrganisation, setCreatingOrganisation] = useState(false);
|
||||||
|
const { request, isLoading } = useApiRequest();
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleOrganisationDelete = async (organisationId: string) => {
|
||||||
|
setDeleteError(null);
|
||||||
|
setDeletingOrganisations(prev => new Set(prev).add(organisationId));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteOrganisationApi(request, organisationId);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
setDeleteError(error.message);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setDeletingOrganisations(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(organisationId);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOrganisationCreate = async (organisationData: Partial<TrusteeOrganisation>) => {
|
||||||
|
setCreateError(null);
|
||||||
|
setCreatingOrganisation(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentUserData = getUserDataCache();
|
||||||
|
const mandateId = currentUserData?.mandateId || '';
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
...organisationData,
|
||||||
|
mandate: mandateId
|
||||||
|
};
|
||||||
|
|
||||||
|
const newOrganisation = await createOrganisationApi(request, requestBody);
|
||||||
|
|
||||||
|
return { success: true, organisationData: newOrganisation };
|
||||||
|
} catch (error: any) {
|
||||||
|
setCreateError(error.message);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
} finally {
|
||||||
|
setCreatingOrganisation(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOrganisationUpdate = async (
|
||||||
|
organisationId: string,
|
||||||
|
updateData: Partial<TrusteeOrganisation>,
|
||||||
|
_originalData?: any
|
||||||
|
) => {
|
||||||
|
setUpdateError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentUserData = getUserDataCache();
|
||||||
|
const mandateId = currentUserData?.mandateId || '';
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
...updateData,
|
||||||
|
mandate: mandateId
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedOrganisation = await updateOrganisationApi(request, organisationId, requestBody);
|
||||||
|
|
||||||
|
return { success: true, organisationData: updatedOrganisation };
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage = error.response?.data?.message || error.message || 'Failed to update organisation';
|
||||||
|
const statusCode = error.response?.status;
|
||||||
|
|
||||||
|
setUpdateError(errorMessage);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
statusCode,
|
||||||
|
isPermissionError: statusCode === 403,
|
||||||
|
isValidationError: statusCode === 400
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
deletingOrganisations,
|
||||||
|
creatingOrganisation,
|
||||||
|
deleteError,
|
||||||
|
createError,
|
||||||
|
updateError,
|
||||||
|
handleOrganisationDelete,
|
||||||
|
handleOrganisationCreate,
|
||||||
|
handleOrganisationUpdate,
|
||||||
|
isLoading
|
||||||
|
};
|
||||||
|
}
|
||||||
302
src/hooks/useTrusteePositionDocuments.ts
Normal file
302
src/hooks/useTrusteePositionDocuments.ts
Normal file
|
|
@ -0,0 +1,302 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useApiRequest } from './useApi';
|
||||||
|
import { getUserDataCache } from '../utils/userCache';
|
||||||
|
import api from '../api';
|
||||||
|
import { usePermissions, type UserPermissions } from './usePermissions';
|
||||||
|
import {
|
||||||
|
fetchPositionDocuments as fetchPositionDocumentsApi,
|
||||||
|
fetchPositionDocumentById as fetchPositionDocumentByIdApi,
|
||||||
|
createPositionDocument as createPositionDocumentApi,
|
||||||
|
deletePositionDocument as deletePositionDocumentApi,
|
||||||
|
type TrusteePositionDocument,
|
||||||
|
type AttributeDefinition,
|
||||||
|
type PaginationParams
|
||||||
|
} from '../api/trusteeApi';
|
||||||
|
|
||||||
|
// Re-export types
|
||||||
|
export type { TrusteePositionDocument, AttributeDefinition, PaginationParams };
|
||||||
|
|
||||||
|
// Position-Documents list hook
|
||||||
|
export function useTrusteePositionDocuments() {
|
||||||
|
const [positionDocuments, setPositionDocuments] = useState<TrusteePositionDocument[]>([]);
|
||||||
|
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
|
||||||
|
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
|
||||||
|
const [pagination, setPagination] = useState<{
|
||||||
|
currentPage: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
} | null>(null);
|
||||||
|
const { request, isLoading: loading, error } = useApiRequest<null, TrusteePositionDocument[]>();
|
||||||
|
const { checkPermission } = usePermissions();
|
||||||
|
|
||||||
|
// Fetch attributes from backend
|
||||||
|
const fetchAttributes = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/api/attributes/TrusteePositionDocument');
|
||||||
|
|
||||||
|
let attrs: AttributeDefinition[] = [];
|
||||||
|
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
|
||||||
|
attrs = response.data.attributes;
|
||||||
|
} else if (Array.isArray(response.data)) {
|
||||||
|
attrs = response.data;
|
||||||
|
} else if (response.data && typeof response.data === 'object') {
|
||||||
|
const keys = Object.keys(response.data);
|
||||||
|
for (const key of keys) {
|
||||||
|
if (Array.isArray(response.data[key])) {
|
||||||
|
attrs = response.data[key];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttributes(attrs);
|
||||||
|
return attrs;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching attributes:', error);
|
||||||
|
setAttributes([]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch permissions from backend
|
||||||
|
const fetchPermissions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const perms = await checkPermission('DATA', 'trustee.xpositiondocument');
|
||||||
|
setPermissions(perms);
|
||||||
|
return perms;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching permissions:', error);
|
||||||
|
const defaultPerms: UserPermissions = {
|
||||||
|
view: false,
|
||||||
|
read: 'n',
|
||||||
|
create: 'n',
|
||||||
|
update: 'n',
|
||||||
|
delete: 'n',
|
||||||
|
};
|
||||||
|
setPermissions(defaultPerms);
|
||||||
|
return defaultPerms;
|
||||||
|
}
|
||||||
|
}, [checkPermission]);
|
||||||
|
|
||||||
|
const fetchPositionDocuments = useCallback(async (params?: PaginationParams) => {
|
||||||
|
try {
|
||||||
|
const data = await fetchPositionDocumentsApi(request, params);
|
||||||
|
|
||||||
|
if (data && typeof data === 'object' && 'items' in data) {
|
||||||
|
const items = Array.isArray(data.items) ? data.items : [];
|
||||||
|
setPositionDocuments(items);
|
||||||
|
if (data.pagination) {
|
||||||
|
setPagination(data.pagination);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const items = Array.isArray(data) ? data : [];
|
||||||
|
setPositionDocuments(items);
|
||||||
|
setPagination(null);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
setPositionDocuments([]);
|
||||||
|
setPagination(null);
|
||||||
|
}
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
// Optimistically remove a position-document link
|
||||||
|
const removeOptimistically = (positionDocumentId: string) => {
|
||||||
|
setPositionDocuments(prevPD => prevPD.filter(pd => pd.id !== positionDocumentId));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch a single position-document by ID
|
||||||
|
const fetchPositionDocumentById = useCallback(async (positionDocumentId: string): Promise<TrusteePositionDocument | null> => {
|
||||||
|
return await fetchPositionDocumentByIdApi(request, positionDocumentId);
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
// Generate edit fields from attributes dynamically
|
||||||
|
const generateEditFieldsFromAttributes = useCallback((): Array<{
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly';
|
||||||
|
editable?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
validator?: (value: any) => string | null;
|
||||||
|
options?: Array<{ value: string | number; label: string }>;
|
||||||
|
optionsReference?: string;
|
||||||
|
dependsOn?: string;
|
||||||
|
}> => {
|
||||||
|
if (!attributes || attributes.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const editableFields = attributes
|
||||||
|
.filter(attr => {
|
||||||
|
if (attr.readonly === true || attr.editable === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
|
||||||
|
return !nonEditableFields.includes(attr.name);
|
||||||
|
})
|
||||||
|
.map(attr => {
|
||||||
|
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string';
|
||||||
|
let options: Array<{ value: string | number; label: string }> | undefined = undefined;
|
||||||
|
let optionsReference: string | undefined = undefined;
|
||||||
|
let dependsOn: string | undefined = undefined;
|
||||||
|
|
||||||
|
if (attr.type === 'checkbox') {
|
||||||
|
fieldType = 'boolean';
|
||||||
|
} else if (attr.type === 'email') {
|
||||||
|
fieldType = 'email';
|
||||||
|
} else if (attr.type === 'date') {
|
||||||
|
fieldType = 'date';
|
||||||
|
} else if (attr.type === 'select') {
|
||||||
|
fieldType = 'enum';
|
||||||
|
if (Array.isArray(attr.options)) {
|
||||||
|
options = attr.options.map(opt => {
|
||||||
|
const labelValue = typeof opt.label === 'string'
|
||||||
|
? opt.label
|
||||||
|
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
|
||||||
|
return {
|
||||||
|
value: opt.value,
|
||||||
|
label: labelValue
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else if (typeof attr.options === 'string') {
|
||||||
|
optionsReference = attr.options;
|
||||||
|
}
|
||||||
|
} else if (attr.type === 'textarea') {
|
||||||
|
fieldType = 'textarea';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dependency chain: contractId depends on organisationId
|
||||||
|
// positionId and documentId depend on contractId
|
||||||
|
if (attr.name === 'contractId') {
|
||||||
|
dependsOn = 'organisationId';
|
||||||
|
} else if (attr.name === 'positionId' || attr.name === 'documentId') {
|
||||||
|
dependsOn = 'contractId';
|
||||||
|
}
|
||||||
|
|
||||||
|
let required = attr.required === true;
|
||||||
|
let validator: ((value: any) => string | null) | undefined = undefined;
|
||||||
|
|
||||||
|
if (attr.name === 'organisationId' || attr.name === 'contractId' ||
|
||||||
|
attr.name === 'positionId' || attr.name === 'documentId') {
|
||||||
|
required = true;
|
||||||
|
validator = (value: any) => {
|
||||||
|
if (!value || (typeof value === 'string' && value.trim() === '')) {
|
||||||
|
return `${attr.label || attr.name} is required`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: attr.name,
|
||||||
|
label: attr.label || attr.name,
|
||||||
|
type: fieldType,
|
||||||
|
editable: attr.editable !== false && attr.readonly !== true,
|
||||||
|
required,
|
||||||
|
validator,
|
||||||
|
options,
|
||||||
|
optionsReference,
|
||||||
|
dependsOn
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return editableFields;
|
||||||
|
}, [attributes]);
|
||||||
|
|
||||||
|
// Ensure attributes are loaded
|
||||||
|
const ensureAttributesLoaded = useCallback(async () => {
|
||||||
|
if (attributes && attributes.length > 0) {
|
||||||
|
return attributes;
|
||||||
|
}
|
||||||
|
const fetchedAttributes = await fetchAttributes();
|
||||||
|
return fetchedAttributes;
|
||||||
|
}, [attributes, fetchAttributes]);
|
||||||
|
|
||||||
|
// Fetch attributes and permissions on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAttributes();
|
||||||
|
fetchPermissions();
|
||||||
|
}, [fetchAttributes, fetchPermissions]);
|
||||||
|
|
||||||
|
// Initial fetch
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPositionDocuments();
|
||||||
|
}, [fetchPositionDocuments]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
positionDocuments,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch: fetchPositionDocuments,
|
||||||
|
removeOptimistically,
|
||||||
|
attributes,
|
||||||
|
permissions,
|
||||||
|
pagination,
|
||||||
|
fetchPositionDocumentById,
|
||||||
|
generateEditFieldsFromAttributes,
|
||||||
|
ensureAttributesLoaded
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position-Document operations hook
|
||||||
|
export function useTrusteePositionDocumentOperations() {
|
||||||
|
const [deletingPositionDocuments, setDeletingPositionDocuments] = useState<Set<string>>(new Set());
|
||||||
|
const [creatingPositionDocument, setCreatingPositionDocument] = useState(false);
|
||||||
|
const { request, isLoading } = useApiRequest();
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handlePositionDocumentDelete = async (positionDocumentId: string) => {
|
||||||
|
setDeleteError(null);
|
||||||
|
setDeletingPositionDocuments(prev => new Set(prev).add(positionDocumentId));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deletePositionDocumentApi(request, positionDocumentId);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
setDeleteError(error.message);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setDeletingPositionDocuments(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(positionDocumentId);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePositionDocumentCreate = async (positionDocumentData: Partial<TrusteePositionDocument>) => {
|
||||||
|
setCreateError(null);
|
||||||
|
setCreatingPositionDocument(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentUserData = getUserDataCache();
|
||||||
|
const mandateId = currentUserData?.mandateId || '';
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
...positionDocumentData,
|
||||||
|
mandate: mandateId
|
||||||
|
};
|
||||||
|
|
||||||
|
const newPositionDocument = await createPositionDocumentApi(request, requestBody);
|
||||||
|
|
||||||
|
return { success: true, positionDocumentData: newPositionDocument };
|
||||||
|
} catch (error: any) {
|
||||||
|
setCreateError(error.message);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
} finally {
|
||||||
|
setCreatingPositionDocument(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
deletingPositionDocuments,
|
||||||
|
creatingPositionDocument,
|
||||||
|
deleteError,
|
||||||
|
createError,
|
||||||
|
handlePositionDocumentDelete,
|
||||||
|
handlePositionDocumentCreate,
|
||||||
|
isLoading
|
||||||
|
};
|
||||||
|
}
|
||||||
417
src/hooks/useTrusteePositions.ts
Normal file
417
src/hooks/useTrusteePositions.ts
Normal file
|
|
@ -0,0 +1,417 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useApiRequest } from './useApi';
|
||||||
|
import { getUserDataCache } from '../utils/userCache';
|
||||||
|
import api from '../api';
|
||||||
|
import { usePermissions, type UserPermissions } from './usePermissions';
|
||||||
|
import {
|
||||||
|
fetchPositions as fetchPositionsApi,
|
||||||
|
fetchPositionById as fetchPositionByIdApi,
|
||||||
|
createPosition as createPositionApi,
|
||||||
|
updatePosition as updatePositionApi,
|
||||||
|
deletePosition as deletePositionApi,
|
||||||
|
type TrusteePosition,
|
||||||
|
type AttributeDefinition,
|
||||||
|
type PaginationParams
|
||||||
|
} from '../api/trusteeApi';
|
||||||
|
|
||||||
|
// Re-export types
|
||||||
|
export type { TrusteePosition, AttributeDefinition, PaginationParams };
|
||||||
|
|
||||||
|
// Positions list hook
|
||||||
|
export function useTrusteePositions() {
|
||||||
|
const [positions, setPositions] = useState<TrusteePosition[]>([]);
|
||||||
|
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
|
||||||
|
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
|
||||||
|
const [pagination, setPagination] = useState<{
|
||||||
|
currentPage: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
} | null>(null);
|
||||||
|
const { request, isLoading: loading, error } = useApiRequest<null, TrusteePosition[]>();
|
||||||
|
const { checkPermission } = usePermissions();
|
||||||
|
|
||||||
|
// Fetch attributes from backend
|
||||||
|
const fetchAttributes = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/api/attributes/TrusteePosition');
|
||||||
|
|
||||||
|
let attrs: AttributeDefinition[] = [];
|
||||||
|
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
|
||||||
|
attrs = response.data.attributes;
|
||||||
|
} else if (Array.isArray(response.data)) {
|
||||||
|
attrs = response.data;
|
||||||
|
} else if (response.data && typeof response.data === 'object') {
|
||||||
|
const keys = Object.keys(response.data);
|
||||||
|
for (const key of keys) {
|
||||||
|
if (Array.isArray(response.data[key])) {
|
||||||
|
attrs = response.data[key];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttributes(attrs);
|
||||||
|
return attrs;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching attributes:', error);
|
||||||
|
setAttributes([]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch permissions from backend
|
||||||
|
const fetchPermissions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const perms = await checkPermission('DATA', 'trustee.position');
|
||||||
|
setPermissions(perms);
|
||||||
|
return perms;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching permissions:', error);
|
||||||
|
const defaultPerms: UserPermissions = {
|
||||||
|
view: false,
|
||||||
|
read: 'n',
|
||||||
|
create: 'n',
|
||||||
|
update: 'n',
|
||||||
|
delete: 'n',
|
||||||
|
};
|
||||||
|
setPermissions(defaultPerms);
|
||||||
|
return defaultPerms;
|
||||||
|
}
|
||||||
|
}, [checkPermission]);
|
||||||
|
|
||||||
|
const fetchPositions = useCallback(async (params?: PaginationParams) => {
|
||||||
|
try {
|
||||||
|
const data = await fetchPositionsApi(request, params);
|
||||||
|
|
||||||
|
if (data && typeof data === 'object' && 'items' in data) {
|
||||||
|
const items = Array.isArray(data.items) ? data.items : [];
|
||||||
|
setPositions(items);
|
||||||
|
if (data.pagination) {
|
||||||
|
setPagination(data.pagination);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const items = Array.isArray(data) ? data : [];
|
||||||
|
setPositions(items);
|
||||||
|
setPagination(null);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
setPositions([]);
|
||||||
|
setPagination(null);
|
||||||
|
}
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
// Optimistically remove a position
|
||||||
|
const removeOptimistically = (positionId: string) => {
|
||||||
|
setPositions(prevPositions => prevPositions.filter(pos => pos.id !== positionId));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optimistically update a position
|
||||||
|
const updateOptimistically = (positionId: string, updateData: Partial<TrusteePosition>) => {
|
||||||
|
setPositions(prevPositions =>
|
||||||
|
prevPositions.map(pos =>
|
||||||
|
pos.id === positionId
|
||||||
|
? { ...pos, ...updateData }
|
||||||
|
: pos
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch a single position by ID
|
||||||
|
const fetchPositionById = useCallback(async (positionId: string): Promise<TrusteePosition | null> => {
|
||||||
|
return await fetchPositionByIdApi(request, positionId);
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
// Generate edit fields from attributes dynamically with MwSt calculation logic
|
||||||
|
const generateEditFieldsFromAttributes = useCallback((): Array<{
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' | 'number';
|
||||||
|
editable?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
validator?: (value: any, formData?: any) => string | null;
|
||||||
|
onChange?: (value: any, formData: any) => Partial<any>;
|
||||||
|
options?: Array<{ value: string | number; label: string }>;
|
||||||
|
optionsReference?: string;
|
||||||
|
dependsOn?: string;
|
||||||
|
minRows?: number;
|
||||||
|
maxRows?: number;
|
||||||
|
}> => {
|
||||||
|
if (!attributes || attributes.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const editableFields = attributes
|
||||||
|
.filter(attr => {
|
||||||
|
if (attr.readonly === true || attr.editable === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
|
||||||
|
return !nonEditableFields.includes(attr.name);
|
||||||
|
})
|
||||||
|
.map(attr => {
|
||||||
|
const isDescField = attr.name === 'desc' || attr.name.toLowerCase().includes('description');
|
||||||
|
|
||||||
|
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' | 'number' = 'string';
|
||||||
|
let options: Array<{ value: string | number; label: string }> | undefined = undefined;
|
||||||
|
let optionsReference: string | undefined = undefined;
|
||||||
|
let dependsOn: string | undefined = undefined;
|
||||||
|
let minRows: number | undefined = undefined;
|
||||||
|
let maxRows: number | undefined = undefined;
|
||||||
|
let onChange: ((value: any, formData: any) => Partial<any>) | undefined = undefined;
|
||||||
|
|
||||||
|
if (isDescField) {
|
||||||
|
fieldType = 'textarea';
|
||||||
|
minRows = 3;
|
||||||
|
maxRows = 8;
|
||||||
|
} else if (attr.type === 'checkbox') {
|
||||||
|
fieldType = 'boolean';
|
||||||
|
} else if (attr.type === 'email') {
|
||||||
|
fieldType = 'email';
|
||||||
|
} else if (attr.type === 'date') {
|
||||||
|
fieldType = 'date';
|
||||||
|
} else if (attr.type === 'number') {
|
||||||
|
fieldType = 'number';
|
||||||
|
} else if (attr.type === 'select') {
|
||||||
|
fieldType = 'enum';
|
||||||
|
if (Array.isArray(attr.options)) {
|
||||||
|
options = attr.options.map(opt => {
|
||||||
|
const labelValue = typeof opt.label === 'string'
|
||||||
|
? opt.label
|
||||||
|
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
|
||||||
|
return {
|
||||||
|
value: opt.value,
|
||||||
|
label: labelValue
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else if (typeof attr.options === 'string') {
|
||||||
|
optionsReference = attr.options;
|
||||||
|
}
|
||||||
|
} else if (attr.type === 'textarea') {
|
||||||
|
fieldType = 'textarea';
|
||||||
|
minRows = minRows || 3;
|
||||||
|
maxRows = maxRows || 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// contractId depends on organisationId
|
||||||
|
if (attr.name === 'contractId') {
|
||||||
|
dependsOn = 'organisationId';
|
||||||
|
}
|
||||||
|
|
||||||
|
// CUSTOM LOGIC: MwSt-Berechnung
|
||||||
|
// When bookingAmount or vatPercentage changes, auto-calculate vatAmount
|
||||||
|
if (attr.name === 'bookingAmount') {
|
||||||
|
onChange = (value: number, formData: any) => {
|
||||||
|
const amount = parseFloat(String(value)) || 0;
|
||||||
|
const percentage = parseFloat(String(formData.vatPercentage)) || 0;
|
||||||
|
const calculatedVat = amount * (percentage / 100);
|
||||||
|
return { vatAmount: calculatedVat };
|
||||||
|
};
|
||||||
|
} else if (attr.name === 'vatPercentage') {
|
||||||
|
onChange = (value: number, formData: any) => {
|
||||||
|
const percentage = parseFloat(String(value)) || 0;
|
||||||
|
const amount = parseFloat(String(formData.bookingAmount)) || 0;
|
||||||
|
const calculatedVat = amount * (percentage / 100);
|
||||||
|
return { vatAmount: calculatedVat };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let required = attr.required === true;
|
||||||
|
let validator: ((value: any, formData?: any) => string | null) | undefined = undefined;
|
||||||
|
|
||||||
|
// CUSTOM LOGIC: vatAmount validator - warn if manually overridden
|
||||||
|
if (attr.name === 'vatAmount') {
|
||||||
|
validator = (value: any, formData?: any) => {
|
||||||
|
if (!formData) return null;
|
||||||
|
|
||||||
|
const vatAmount = parseFloat(String(value)) || 0;
|
||||||
|
const bookingAmount = parseFloat(String(formData.bookingAmount)) || 0;
|
||||||
|
const vatPercentage = parseFloat(String(formData.vatPercentage)) || 0;
|
||||||
|
const calculatedVat = bookingAmount * (vatPercentage / 100);
|
||||||
|
|
||||||
|
if (Math.abs(vatAmount - calculatedVat) > 0.01) {
|
||||||
|
return 'MwSt-Betrag weicht von Berechnung ab (manuell überschrieben)';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard validators
|
||||||
|
if (attr.name === 'organisationId' || attr.name === 'contractId') {
|
||||||
|
required = true;
|
||||||
|
validator = (value: any) => {
|
||||||
|
if (!value || (typeof value === 'string' && value.trim() === '')) {
|
||||||
|
return `${attr.label || attr.name} is required`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
} else if (attr.name === 'valuta' || attr.name === 'transactionDateTime') {
|
||||||
|
required = true;
|
||||||
|
} else if (attr.name === 'bookingCurrency' || attr.name === 'originalCurrency') {
|
||||||
|
required = true;
|
||||||
|
} else if (attr.name === 'bookingAmount' || attr.name === 'originalAmount') {
|
||||||
|
required = true;
|
||||||
|
validator = (value: any) => {
|
||||||
|
const num = parseFloat(String(value));
|
||||||
|
if (isNaN(num)) {
|
||||||
|
return 'Must be a valid number';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: attr.name,
|
||||||
|
label: attr.label || attr.name,
|
||||||
|
type: fieldType,
|
||||||
|
editable: attr.editable !== false && attr.readonly !== true,
|
||||||
|
required,
|
||||||
|
validator,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
optionsReference,
|
||||||
|
dependsOn,
|
||||||
|
minRows,
|
||||||
|
maxRows
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return editableFields;
|
||||||
|
}, [attributes]);
|
||||||
|
|
||||||
|
// Ensure attributes are loaded
|
||||||
|
const ensureAttributesLoaded = useCallback(async () => {
|
||||||
|
if (attributes && attributes.length > 0) {
|
||||||
|
return attributes;
|
||||||
|
}
|
||||||
|
const fetchedAttributes = await fetchAttributes();
|
||||||
|
return fetchedAttributes;
|
||||||
|
}, [attributes, fetchAttributes]);
|
||||||
|
|
||||||
|
// Fetch attributes and permissions on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAttributes();
|
||||||
|
fetchPermissions();
|
||||||
|
}, [fetchAttributes, fetchPermissions]);
|
||||||
|
|
||||||
|
// Initial fetch
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPositions();
|
||||||
|
}, [fetchPositions]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
positions,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch: fetchPositions,
|
||||||
|
removeOptimistically,
|
||||||
|
updateOptimistically,
|
||||||
|
attributes,
|
||||||
|
permissions,
|
||||||
|
pagination,
|
||||||
|
fetchPositionById,
|
||||||
|
generateEditFieldsFromAttributes,
|
||||||
|
ensureAttributesLoaded
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position operations hook
|
||||||
|
export function useTrusteePositionOperations() {
|
||||||
|
const [deletingPositions, setDeletingPositions] = useState<Set<string>>(new Set());
|
||||||
|
const [creatingPosition, setCreatingPosition] = useState(false);
|
||||||
|
const { request, isLoading } = useApiRequest();
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handlePositionDelete = async (positionId: string) => {
|
||||||
|
setDeleteError(null);
|
||||||
|
setDeletingPositions(prev => new Set(prev).add(positionId));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deletePositionApi(request, positionId);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
setDeleteError(error.message);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setDeletingPositions(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(positionId);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePositionCreate = async (positionData: Partial<TrusteePosition>) => {
|
||||||
|
setCreateError(null);
|
||||||
|
setCreatingPosition(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentUserData = getUserDataCache();
|
||||||
|
const mandateId = currentUserData?.mandateId || '';
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
...positionData,
|
||||||
|
mandate: mandateId
|
||||||
|
};
|
||||||
|
|
||||||
|
const newPosition = await createPositionApi(request, requestBody);
|
||||||
|
|
||||||
|
return { success: true, positionData: newPosition };
|
||||||
|
} catch (error: any) {
|
||||||
|
setCreateError(error.message);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
} finally {
|
||||||
|
setCreatingPosition(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePositionUpdate = async (
|
||||||
|
positionId: string,
|
||||||
|
updateData: Partial<TrusteePosition>,
|
||||||
|
_originalData?: any
|
||||||
|
) => {
|
||||||
|
setUpdateError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentUserData = getUserDataCache();
|
||||||
|
const mandateId = currentUserData?.mandateId || '';
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
...updateData,
|
||||||
|
mandate: mandateId
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedPosition = await updatePositionApi(request, positionId, requestBody);
|
||||||
|
|
||||||
|
return { success: true, positionData: updatedPosition };
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage = error.response?.data?.message || error.message || 'Failed to update position';
|
||||||
|
const statusCode = error.response?.status;
|
||||||
|
|
||||||
|
setUpdateError(errorMessage);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
statusCode,
|
||||||
|
isPermissionError: statusCode === 403,
|
||||||
|
isValidationError: statusCode === 400
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
deletingPositions,
|
||||||
|
creatingPosition,
|
||||||
|
deleteError,
|
||||||
|
createError,
|
||||||
|
updateError,
|
||||||
|
handlePositionDelete,
|
||||||
|
handlePositionCreate,
|
||||||
|
handlePositionUpdate,
|
||||||
|
isLoading
|
||||||
|
};
|
||||||
|
}
|
||||||
368
src/hooks/useTrusteeRoles.ts
Normal file
368
src/hooks/useTrusteeRoles.ts
Normal file
|
|
@ -0,0 +1,368 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useApiRequest } from './useApi';
|
||||||
|
import { getUserDataCache } from '../utils/userCache';
|
||||||
|
import api from '../api';
|
||||||
|
import { usePermissions, type UserPermissions } from './usePermissions';
|
||||||
|
import {
|
||||||
|
fetchRoles as fetchRolesApi,
|
||||||
|
fetchRoleById as fetchRoleByIdApi,
|
||||||
|
createRole as createRoleApi,
|
||||||
|
updateRole as updateRoleApi,
|
||||||
|
deleteRole as deleteRoleApi,
|
||||||
|
type TrusteeRole,
|
||||||
|
type AttributeDefinition,
|
||||||
|
type PaginationParams
|
||||||
|
} from '../api/trusteeApi';
|
||||||
|
|
||||||
|
// Re-export types
|
||||||
|
export type { TrusteeRole, AttributeDefinition, PaginationParams };
|
||||||
|
|
||||||
|
// Roles list hook
|
||||||
|
export function useTrusteeRoles() {
|
||||||
|
const [roles, setRoles] = useState<TrusteeRole[]>([]);
|
||||||
|
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
|
||||||
|
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
|
||||||
|
const [pagination, setPagination] = useState<{
|
||||||
|
currentPage: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
} | null>(null);
|
||||||
|
const { request, isLoading: loading, error } = useApiRequest<null, TrusteeRole[]>();
|
||||||
|
const { checkPermission } = usePermissions();
|
||||||
|
|
||||||
|
// Fetch attributes from backend
|
||||||
|
const fetchAttributes = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/api/attributes/TrusteeRole');
|
||||||
|
|
||||||
|
let attrs: AttributeDefinition[] = [];
|
||||||
|
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
|
||||||
|
attrs = response.data.attributes;
|
||||||
|
} else if (Array.isArray(response.data)) {
|
||||||
|
attrs = response.data;
|
||||||
|
} else if (response.data && typeof response.data === 'object') {
|
||||||
|
const keys = Object.keys(response.data);
|
||||||
|
for (const key of keys) {
|
||||||
|
if (Array.isArray(response.data[key])) {
|
||||||
|
attrs = response.data[key];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttributes(attrs);
|
||||||
|
return attrs;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching attributes:', error);
|
||||||
|
setAttributes([]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch permissions from backend
|
||||||
|
const fetchPermissions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const perms = await checkPermission('DATA', 'trustee.role');
|
||||||
|
setPermissions(perms);
|
||||||
|
return perms;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching permissions:', error);
|
||||||
|
const defaultPerms: UserPermissions = {
|
||||||
|
view: false,
|
||||||
|
read: 'n',
|
||||||
|
create: 'n',
|
||||||
|
update: 'n',
|
||||||
|
delete: 'n',
|
||||||
|
};
|
||||||
|
setPermissions(defaultPerms);
|
||||||
|
return defaultPerms;
|
||||||
|
}
|
||||||
|
}, [checkPermission]);
|
||||||
|
|
||||||
|
const fetchRoles = useCallback(async (params?: PaginationParams) => {
|
||||||
|
try {
|
||||||
|
const data = await fetchRolesApi(request, params);
|
||||||
|
|
||||||
|
if (data && typeof data === 'object' && 'items' in data) {
|
||||||
|
const items = Array.isArray(data.items) ? data.items : [];
|
||||||
|
setRoles(items);
|
||||||
|
if (data.pagination) {
|
||||||
|
setPagination(data.pagination);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const items = Array.isArray(data) ? data : [];
|
||||||
|
setRoles(items);
|
||||||
|
setPagination(null);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
setRoles([]);
|
||||||
|
setPagination(null);
|
||||||
|
}
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
// Optimistically remove a role
|
||||||
|
const removeOptimistically = (roleId: string) => {
|
||||||
|
setRoles(prevRoles => prevRoles.filter(role => role.id !== roleId));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optimistically update a role
|
||||||
|
const updateOptimistically = (roleId: string, updateData: Partial<TrusteeRole>) => {
|
||||||
|
setRoles(prevRoles =>
|
||||||
|
prevRoles.map(role =>
|
||||||
|
role.id === roleId
|
||||||
|
? { ...role, ...updateData }
|
||||||
|
: role
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch a single role by ID
|
||||||
|
const fetchRoleById = useCallback(async (roleId: string): Promise<TrusteeRole | null> => {
|
||||||
|
return await fetchRoleByIdApi(request, roleId);
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
// Generate edit fields from attributes dynamically
|
||||||
|
// Ensure attributes are loaded
|
||||||
|
const ensureAttributesLoaded = useCallback(async () => {
|
||||||
|
if (attributes && attributes.length > 0) {
|
||||||
|
return attributes;
|
||||||
|
}
|
||||||
|
const fetchedAttributes = await fetchAttributes();
|
||||||
|
return fetchedAttributes;
|
||||||
|
}, [attributes, fetchAttributes]);
|
||||||
|
|
||||||
|
// Fetch attributes and permissions on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAttributes();
|
||||||
|
fetchPermissions();
|
||||||
|
}, [fetchAttributes, fetchPermissions]);
|
||||||
|
|
||||||
|
// Initial fetch
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRoles();
|
||||||
|
}, [fetchRoles]);
|
||||||
|
|
||||||
|
const generateEditFieldsFromAttributes = useCallback((): Array<{
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly';
|
||||||
|
editable?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
validator?: (value: any) => string | null;
|
||||||
|
minRows?: number;
|
||||||
|
maxRows?: number;
|
||||||
|
options?: Array<{ value: string | number; label: string }>;
|
||||||
|
optionsReference?: string;
|
||||||
|
}> => {
|
||||||
|
if (!attributes || attributes.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const editableFields = attributes
|
||||||
|
.filter(attr => {
|
||||||
|
if (attr.readonly === true || attr.editable === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const nonEditableFields = ['mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
|
||||||
|
return !nonEditableFields.includes(attr.name);
|
||||||
|
})
|
||||||
|
.map(attr => {
|
||||||
|
const isDescField = attr.name === 'desc' || attr.name.toLowerCase().includes('description');
|
||||||
|
|
||||||
|
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string';
|
||||||
|
let options: Array<{ value: string | number; label: string }> | undefined = undefined;
|
||||||
|
let optionsReference: string | undefined = undefined;
|
||||||
|
let minRows: number | undefined = undefined;
|
||||||
|
let maxRows: number | undefined = undefined;
|
||||||
|
|
||||||
|
if (isDescField) {
|
||||||
|
fieldType = 'textarea';
|
||||||
|
minRows = 3;
|
||||||
|
maxRows = 8;
|
||||||
|
} else if (attr.type === 'checkbox') {
|
||||||
|
fieldType = 'boolean';
|
||||||
|
} else if (attr.type === 'email') {
|
||||||
|
fieldType = 'email';
|
||||||
|
} else if (attr.type === 'date') {
|
||||||
|
fieldType = 'date';
|
||||||
|
} else if (attr.type === 'select') {
|
||||||
|
fieldType = 'enum';
|
||||||
|
if (Array.isArray(attr.options)) {
|
||||||
|
options = attr.options.map(opt => {
|
||||||
|
const labelValue = typeof opt.label === 'string'
|
||||||
|
? opt.label
|
||||||
|
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
|
||||||
|
return {
|
||||||
|
value: opt.value,
|
||||||
|
label: labelValue
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else if (typeof attr.options === 'string') {
|
||||||
|
optionsReference = attr.options;
|
||||||
|
}
|
||||||
|
} else if (attr.type === 'textarea') {
|
||||||
|
fieldType = 'textarea';
|
||||||
|
minRows = minRows || 3;
|
||||||
|
maxRows = maxRows || 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
let required = attr.required === true;
|
||||||
|
let validator: ((value: any) => string | null) | undefined = undefined;
|
||||||
|
|
||||||
|
if (attr.name === 'id') {
|
||||||
|
required = true;
|
||||||
|
validator = (value: string) => {
|
||||||
|
if (!value || value.trim() === '') {
|
||||||
|
return 'Role ID cannot be empty';
|
||||||
|
}
|
||||||
|
if (!/^[a-zA-Z0-9_-]{3,50}$/.test(value)) {
|
||||||
|
return 'ID must be 3-50 characters long (alphanumeric, dash, underscore only)';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
} else if (attr.name === 'desc') {
|
||||||
|
required = true;
|
||||||
|
validator = (value: string) => {
|
||||||
|
if (!value || value.trim() === '') {
|
||||||
|
return 'Description cannot be empty';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: attr.name,
|
||||||
|
label: attr.label || attr.name,
|
||||||
|
type: fieldType,
|
||||||
|
editable: attr.editable !== false && attr.readonly !== true,
|
||||||
|
required,
|
||||||
|
validator,
|
||||||
|
minRows,
|
||||||
|
maxRows,
|
||||||
|
options,
|
||||||
|
optionsReference
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return editableFields;
|
||||||
|
}, [attributes]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
roles,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch: fetchRoles,
|
||||||
|
removeOptimistically,
|
||||||
|
updateOptimistically,
|
||||||
|
attributes,
|
||||||
|
permissions,
|
||||||
|
pagination,
|
||||||
|
fetchRoleById,
|
||||||
|
generateEditFieldsFromAttributes,
|
||||||
|
ensureAttributesLoaded
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role operations hook
|
||||||
|
export function useTrusteeRoleOperations() {
|
||||||
|
const [deletingRoles, setDeletingRoles] = useState<Set<string>>(new Set());
|
||||||
|
const [creatingRole, setCreatingRole] = useState(false);
|
||||||
|
const { request, isLoading } = useApiRequest();
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleRoleDelete = async (roleId: string) => {
|
||||||
|
setDeleteError(null);
|
||||||
|
setDeletingRoles(prev => new Set(prev).add(roleId));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteRoleApi(request, roleId);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage = error.response?.data?.detail || error.message || 'Failed to delete role';
|
||||||
|
// Backend returns error if role is in use
|
||||||
|
setDeleteError(errorMessage);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setDeletingRoles(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(roleId);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoleCreate = async (roleData: Partial<TrusteeRole>) => {
|
||||||
|
setCreateError(null);
|
||||||
|
setCreatingRole(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentUserData = getUserDataCache();
|
||||||
|
const mandateId = currentUserData?.mandateId || '';
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
...roleData,
|
||||||
|
mandate: mandateId
|
||||||
|
};
|
||||||
|
|
||||||
|
const newRole = await createRoleApi(request, requestBody);
|
||||||
|
|
||||||
|
return { success: true, roleData: newRole };
|
||||||
|
} catch (error: any) {
|
||||||
|
setCreateError(error.message);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
} finally {
|
||||||
|
setCreatingRole(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoleUpdate = async (
|
||||||
|
roleId: string,
|
||||||
|
updateData: Partial<TrusteeRole>,
|
||||||
|
_originalData?: any
|
||||||
|
) => {
|
||||||
|
setUpdateError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentUserData = getUserDataCache();
|
||||||
|
const mandateId = currentUserData?.mandateId || '';
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
...updateData,
|
||||||
|
mandate: mandateId
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedRole = await updateRoleApi(request, roleId, requestBody);
|
||||||
|
|
||||||
|
return { success: true, roleData: updatedRole };
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage = error.response?.data?.message || error.message || 'Failed to update role';
|
||||||
|
const statusCode = error.response?.status;
|
||||||
|
|
||||||
|
setUpdateError(errorMessage);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
statusCode,
|
||||||
|
isPermissionError: statusCode === 403,
|
||||||
|
isValidationError: statusCode === 400
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
deletingRoles,
|
||||||
|
creatingRole,
|
||||||
|
deleteError,
|
||||||
|
createError,
|
||||||
|
updateError,
|
||||||
|
handleRoleDelete,
|
||||||
|
handleRoleCreate,
|
||||||
|
handleRoleUpdate,
|
||||||
|
isLoading
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useCurrentInstance } from '../hooks/useCurrentInstance';
|
import { useCurrentInstance } from '../hooks/useCurrentInstance';
|
||||||
import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
|
import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
|
||||||
import useNavigation from '../hooks/useNavigation';
|
import { getLabel, FEATURE_REGISTRY } from '../types/mandate';
|
||||||
|
|
||||||
// Trustee Views
|
// Trustee Views
|
||||||
// Note: TrusteeOrganisationsView and TrusteeContractsView removed - Feature-Instanz = Organisation
|
// Note: TrusteeOrganisationsView and TrusteeContractsView removed - Feature-Instanz = Organisation
|
||||||
|
|
@ -25,12 +25,6 @@ import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsVi
|
||||||
// RealEstate Views
|
// RealEstate Views
|
||||||
import { RealEstatePekView, RealEstateProjectsView, RealEstateParcelsView, RealEstateInstanceRolesPlaceholder } from './views/realestate';
|
import { RealEstatePekView, RealEstateProjectsView, RealEstateParcelsView, RealEstateInstanceRolesPlaceholder } from './views/realestate';
|
||||||
|
|
||||||
// Chat Playground Views (reusing existing workflow pages)
|
|
||||||
import { PlaygroundPage, WorkflowsPage } from './workflows';
|
|
||||||
|
|
||||||
// Automation Views (reusing existing workflow pages)
|
|
||||||
import { AutomationsPage, AutomationTemplatesPage } from './workflows';
|
|
||||||
|
|
||||||
import styles from './FeatureView.module.css';
|
import styles from './FeatureView.module.css';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -109,15 +103,6 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
||||||
parcels: RealEstateParcelsView,
|
parcels: RealEstateParcelsView,
|
||||||
'instance-roles': RealEstateInstanceRolesPlaceholder,
|
'instance-roles': RealEstateInstanceRolesPlaceholder,
|
||||||
},
|
},
|
||||||
chatplayground: {
|
|
||||||
playground: PlaygroundPage,
|
|
||||||
workflows: WorkflowsPage,
|
|
||||||
},
|
|
||||||
automation: {
|
|
||||||
definitions: AutomationsPage,
|
|
||||||
templates: AutomationTemplatesPage,
|
|
||||||
logs: () => <PlaceholderView title="Execution Logs" description="Automatisierungs-Ausführungsprotokolle" />,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -129,13 +114,31 @@ interface FeatureViewPageProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
||||||
const { instance, featureCode, mandateId, isValid } = useCurrentInstance();
|
const { instance, featureCode, isValid } = useCurrentInstance();
|
||||||
const { dynamicBlock } = useNavigation();
|
|
||||||
|
|
||||||
// Berechtigungs-Check
|
// Berechtigungs-Check
|
||||||
const viewCode = `${featureCode}-${view}`;
|
const viewCode = `${featureCode}-${view}`;
|
||||||
const canView = useCanViewFeatureView(viewCode);
|
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
|
// Nicht valider Kontext
|
||||||
if (!isValid || !featureCode || !instance) {
|
if (!isValid || !featureCode || !instance) {
|
||||||
return <NotFound />;
|
return <NotFound />;
|
||||||
|
|
@ -157,17 +160,10 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
||||||
return <NotFound />;
|
return <NotFound />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// View-Label aus Backend-Navigation ermitteln
|
// View-Info aus Registry
|
||||||
let viewLabel = view;
|
const featureConfig = FEATURE_REGISTRY[featureCode];
|
||||||
if (dynamicBlock) {
|
const viewConfig = featureConfig?.views?.find(v => v.code === view);
|
||||||
const navMandate = dynamicBlock.mandates.find(m => m.id === mandateId);
|
const viewLabel = viewConfig ? getLabel(viewConfig.label) : view;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.featureView}>
|
<div className={styles.featureView}>
|
||||||
|
|
|
||||||
|
|
@ -185,7 +185,6 @@ export const RealEstateParcelsView: React.FC = () => {
|
||||||
<FormGeneratorTable
|
<FormGeneratorTable
|
||||||
data={parcels}
|
data={parcels}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
apiEndpoint={instanceId ? `/api/realestate/${instanceId}/parcels` : undefined}
|
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={true}
|
pagination={true}
|
||||||
pageSize={25}
|
pageSize={25}
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,6 @@ export const RealEstateProjectsView: React.FC = () => {
|
||||||
<FormGeneratorTable
|
<FormGeneratorTable
|
||||||
data={projects}
|
data={projects}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
apiEndpoint={instanceId ? `/api/realestate/${instanceId}/projects` : undefined}
|
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={true}
|
pagination={true}
|
||||||
pageSize={25}
|
pageSize={25}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue