Merge pull request #8 from valueonag/feat/auxiliaries2
Feat/auxiliaries2
This commit is contained in:
commit
3ee156c5f8
28 changed files with 6047 additions and 372 deletions
44
src/App.tsx
44
src/App.tsx
|
|
@ -1,8 +1,8 @@
|
|||
/**
|
||||
* App.tsx
|
||||
*
|
||||
*
|
||||
* Haupt-App-Komponente mit Multi-Tenant Router-Setup.
|
||||
*
|
||||
*
|
||||
* URL-Struktur:
|
||||
* - / → Dashboard/Übersicht
|
||||
* - /settings → Benutzer-Einstellungen
|
||||
|
|
@ -30,25 +30,15 @@ import { ProtectedRoute } from './providers/auth/ProtectedRoute';
|
|||
import { LanguageProvider } from './providers/language/LanguageContext';
|
||||
import { ToastProvider } from './contexts/ToastContext';
|
||||
import { WorkflowSelectionProvider } from './contexts/WorkflowSelectionContext';
|
||||
import { FileProvider } from './contexts/FileContext';
|
||||
|
||||
// Layouts
|
||||
import { MainLayout } from './layouts/MainLayout';
|
||||
import { FeatureLayout } from './layouts/FeatureLayout';
|
||||
|
||||
// Pages
|
||||
import { DashboardPage } from './pages/Dashboard';
|
||||
import { SettingsPage } from './pages/Settings';
|
||||
import { GDPRPage } from './pages/GDPR';
|
||||
import { FeatureViewPage } from './pages/FeatureView';
|
||||
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminAutomationEventsPage } from './pages/admin';
|
||||
|
||||
// Basedata Pages (global)
|
||||
import { AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolesPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage } from './pages/admin';
|
||||
import { PlaygroundPage, WorkflowsPage, AutomationsPage } from './pages/workflows';
|
||||
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
||||
|
||||
// Billing Pages
|
||||
import { BillingDataView, BillingAdmin } from './pages/billing';
|
||||
|
||||
function App() {
|
||||
// Load saved theme preference and set app name on app mount
|
||||
useEffect(() => {
|
||||
|
|
@ -93,9 +83,7 @@ function App() {
|
|||
{/* ================================================== */}
|
||||
<Route path="/" element={
|
||||
<ProtectedRoute>
|
||||
<FileProvider>
|
||||
<MainLayout />
|
||||
</FileProvider>
|
||||
</ProtectedRoute>
|
||||
}>
|
||||
{/* Dashboard (Root) */}
|
||||
|
|
@ -105,6 +93,15 @@ function App() {
|
|||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="gdpr" element={<GDPRPage />} />
|
||||
|
||||
{/* ============================================== */}
|
||||
{/* WORKFLOWS ROUTES (global) */}
|
||||
{/* ============================================== */}
|
||||
<Route path="workflows">
|
||||
<Route path="playground" element={<PlaygroundPage />} />
|
||||
<Route path="list" element={<WorkflowsPage />} />
|
||||
<Route path="automations" element={<AutomationsPage />} />
|
||||
</Route>
|
||||
|
||||
{/* ============================================== */}
|
||||
{/* BASISDATEN ROUTES (global) */}
|
||||
{/* ============================================== */}
|
||||
|
|
@ -114,13 +111,10 @@ function App() {
|
|||
<Route path="connections" element={<ConnectionsPage />} />
|
||||
</Route>
|
||||
|
||||
{/* ============================================== */}
|
||||
{/* BILLING ROUTES */}
|
||||
{/* ============================================== */}
|
||||
<Route path="billing">
|
||||
<Route index element={<Navigate to="/billing/transactions" replace />} />
|
||||
<Route path="transactions" element={<BillingDataView />} />
|
||||
</Route>
|
||||
{/* Legacy top-level routes – redirect to dashboard (migrated to feature-instance routes) */}
|
||||
<Route path="chatbot" element={<Navigate to="/" replace />} />
|
||||
<Route path="pek" element={<Navigate to="/" replace />} />
|
||||
<Route path="speech" element={<Navigate to="/" replace />} />
|
||||
|
||||
{/* ============================================== */}
|
||||
{/* FEATURE-INSTANZ ROUTES */}
|
||||
|
|
@ -168,8 +162,6 @@ function App() {
|
|||
{/* ADMIN ROUTES (nur SysAdmin) */}
|
||||
{/* ============================================== */}
|
||||
<Route path="admin">
|
||||
<Route index element={<Navigate to="/admin/access" replace />} />
|
||||
<Route path="access" element={<AccessManagementHub />} />
|
||||
<Route path="mandates" element={<AdminMandatesPage />} />
|
||||
<Route path="users" element={<AdminUsersPage />} />
|
||||
<Route path="user-mandates" element={<AdminUserMandatesPage />} />
|
||||
|
|
@ -180,8 +172,6 @@ function App() {
|
|||
<Route path="mandate-roles" element={<AdminMandateRolesPage />} />
|
||||
<Route path="mandate-role-permissions" element={<AdminMandateRolePermissionsPage />} />
|
||||
<Route path="user-access-overview" element={<AdminUserAccessOverviewPage />} />
|
||||
<Route path="billing" element={<BillingAdmin />} />
|
||||
<Route path="automation-events" element={<AdminAutomationEventsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
|
|
|
|||
344
src/components/ContentPreview/UrlContentPreview.tsx
Normal file
344
src/components/ContentPreview/UrlContentPreview.tsx
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { IoIosDownload } from 'react-icons/io';
|
||||
import { Popup, PopupAction } from '../UiComponents/Popup/Popup';
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import { PdfRenderer, LoadingRenderer } from './renderers';
|
||||
import styles from './ContentPreview.module.css';
|
||||
|
||||
export interface UrlContentPreviewProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
url: string;
|
||||
fileName: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export function UrlContentPreview({
|
||||
isOpen,
|
||||
onClose,
|
||||
url,
|
||||
fileName,
|
||||
mimeType = 'application/pdf'
|
||||
}: UrlContentPreviewProps) {
|
||||
const { t } = useLanguage();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const [warning, setWarning] = useState<string | null>(null);
|
||||
const [showPdfAnyway, setShowPdfAnyway] = useState(false);
|
||||
const [usePdfJs, setUsePdfJs] = useState(false);
|
||||
|
||||
// Reset state when modal opens/closes
|
||||
useEffect(() => {
|
||||
if (isOpen && url) {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setWarning(null);
|
||||
setHasLoaded(false);
|
||||
setShowPdfAnyway(false);
|
||||
setUsePdfJs(false); // Start with iframe
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
setWarning(null);
|
||||
setHasLoaded(false);
|
||||
setShowPdfAnyway(false);
|
||||
setUsePdfJs(false);
|
||||
}
|
||||
}, [isOpen, url]);
|
||||
|
||||
const handleDownload = () => {
|
||||
try {
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (err) {
|
||||
console.error('Failed to download file:', err);
|
||||
// Fallback: open in new tab
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
};
|
||||
|
||||
// PDF load is handled by the PdfRenderer's onError callback;
|
||||
// successful load is implicit when no error occurs.
|
||||
|
||||
const handlePdfError = () => {
|
||||
// Try PDF.js as fallback instead of showing error immediately
|
||||
if (!usePdfJs) {
|
||||
console.log('Iframe failed, switching to PDF.js fallback');
|
||||
setUsePdfJs(true);
|
||||
setIsLoading(true); // Restart loading with PDF.js
|
||||
setError(null);
|
||||
setWarning(null);
|
||||
return;
|
||||
}
|
||||
// If PDF.js also fails, show error
|
||||
setIsLoading(false);
|
||||
setError('Failed to load PDF. This might be due to CORS restrictions. You can try downloading the file or opening it in a new tab.');
|
||||
setShowPdfAnyway(true);
|
||||
};
|
||||
|
||||
const handleOpenInNewTab = () => {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
// Set up progressive timeout for loading (schnellerer Fallback)
|
||||
useEffect(() => {
|
||||
if (isOpen && isLoading && !hasLoaded) {
|
||||
// Schnellerer Timeout für externe PDFs: Warning after 3s, Error after 5s
|
||||
const QUICK_TIMEOUT = 5000; // 5 Sekunden
|
||||
const WARNING_TIMEOUT = 3000; // 3 Sekunden Warnung
|
||||
|
||||
const warningTimeout = setTimeout(() => {
|
||||
if (isLoading && !hasLoaded) {
|
||||
setWarning('PDF lädt langsam. Sie können es auch direkt herunterladen oder in einem neuen Tab öffnen.');
|
||||
// Don't set isLoading to false - let it continue
|
||||
}
|
||||
}, WARNING_TIMEOUT);
|
||||
|
||||
const errorTimeout = setTimeout(() => {
|
||||
if (isLoading && !hasLoaded && !usePdfJs) {
|
||||
// Try PDF.js as fallback after 5 seconds
|
||||
console.log('PDF loading timeout, switching to PDF.js fallback');
|
||||
setUsePdfJs(true);
|
||||
setIsLoading(true); // Restart loading with PDF.js
|
||||
setWarning('PDF lädt langsam. Versuche alternative Anzeigemethode...');
|
||||
} else if (isLoading && !hasLoaded && usePdfJs) {
|
||||
// PDF.js also failed, show error
|
||||
setShowPdfAnyway(true);
|
||||
setError('PDF lädt langsam. Bitte verwenden Sie den Download-Button oder öffnen Sie es in einem neuen Tab.');
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, QUICK_TIMEOUT);
|
||||
|
||||
return () => {
|
||||
clearTimeout(warningTimeout);
|
||||
clearTimeout(errorTimeout);
|
||||
};
|
||||
}
|
||||
}, [isOpen, isLoading, hasLoaded, usePdfJs]);
|
||||
|
||||
// Validate URL
|
||||
useEffect(() => {
|
||||
if (isOpen && url) {
|
||||
try {
|
||||
new URL(url);
|
||||
} catch (e) {
|
||||
setError('Invalid URL');
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [isOpen, url]);
|
||||
|
||||
// Create action buttons for the popup header
|
||||
const actions: PopupAction[] = [
|
||||
{
|
||||
label: String(''),
|
||||
icon: <IoIosDownload />,
|
||||
onClick: handleDownload,
|
||||
disabled: false,
|
||||
variant: 'success' as const
|
||||
}
|
||||
];
|
||||
|
||||
const renderPreview = () => {
|
||||
// Show warning but continue loading
|
||||
const showWarning = warning && !error;
|
||||
|
||||
// For PDF files, always try to show PDF (even if there's an error)
|
||||
if (mimeType === 'application/pdf' && (hasLoaded || showPdfAnyway || !error)) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{showWarning && (
|
||||
<div style={{
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'var(--color-warning-bg, #fef3c7)',
|
||||
borderBottom: '1px solid var(--color-border, #e5e7eb)',
|
||||
color: 'var(--color-warning-text, #92400e)',
|
||||
fontSize: '0.875rem'
|
||||
}}>
|
||||
⚠️ {warning}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'var(--color-error-bg, #fee2e2)',
|
||||
borderBottom: '1px solid var(--color-border, #e5e7eb)',
|
||||
color: 'var(--color-error-text, #991b1b)',
|
||||
fontSize: '0.875rem'
|
||||
}}>
|
||||
⚠️ {error}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={handleOpenInNewTab}
|
||||
className={styles.retryButton}
|
||||
style={{
|
||||
background: 'var(--color-primary, #3b82f6)',
|
||||
fontSize: '0.75rem',
|
||||
padding: '0.5rem 1rem'
|
||||
}}
|
||||
>
|
||||
In neuem Tab öffnen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className={styles.retryButton}
|
||||
style={{
|
||||
background: 'var(--color-success, #10b981)',
|
||||
fontSize: '0.75rem',
|
||||
padding: '0.5rem 1rem'
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, position: 'relative' }}>
|
||||
<PdfRenderer
|
||||
previewUrl={url}
|
||||
fileName={fileName}
|
||||
onError={handlePdfError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error only if we're not showing PDF anyway
|
||||
if (error && !showPdfAnyway) {
|
||||
return (
|
||||
<div className={styles.errorContainer}>
|
||||
<div className={styles.errorIcon}>⚠️</div>
|
||||
<p>{error}</p>
|
||||
<div style={{ display: 'flex', gap: '1rem', marginTop: '1rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setWarning(null);
|
||||
setIsLoading(true);
|
||||
setHasLoaded(false);
|
||||
setShowPdfAnyway(false);
|
||||
setUsePdfJs(false); // Reset to iframe
|
||||
}}
|
||||
className={styles.retryButton}
|
||||
>
|
||||
{t('common.retry', 'Retry')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleOpenInNewTab}
|
||||
className={styles.retryButton}
|
||||
style={{
|
||||
background: 'var(--color-primary, #3b82f6)',
|
||||
fontSize: '0.875rem',
|
||||
padding: '0.625rem 1.25rem',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
In neuem Tab öffnen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className={styles.retryButton}
|
||||
style={{
|
||||
background: 'var(--color-success, #10b981)',
|
||||
fontSize: '0.875rem',
|
||||
padding: '0.625rem 1.25rem',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Download File
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading && !hasLoaded && !showPdfAnyway) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{warning && (
|
||||
<div style={{
|
||||
padding: '1rem',
|
||||
background: 'var(--color-warning-bg, #fef3c7)',
|
||||
borderBottom: '1px solid var(--color-border, #e5e7eb)',
|
||||
color: 'var(--color-warning-text, #92400e)',
|
||||
fontSize: '0.875rem',
|
||||
marginBottom: '1rem'
|
||||
}}>
|
||||
⚠️ {warning}
|
||||
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={handleOpenInNewTab}
|
||||
className={styles.retryButton}
|
||||
style={{
|
||||
background: 'var(--color-primary, #3b82f6)',
|
||||
fontSize: '0.875rem',
|
||||
padding: '0.625rem 1.25rem',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
In neuem Tab öffnen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className={styles.retryButton}
|
||||
style={{
|
||||
background: 'var(--color-success, #10b981)',
|
||||
fontSize: '0.875rem',
|
||||
padding: '0.625rem 1.25rem',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<LoadingRenderer />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// For other file types, show unsupported message
|
||||
if (mimeType !== 'application/pdf') {
|
||||
return (
|
||||
<div className={styles.unsupportedContainer}>
|
||||
<div className={styles.unsupportedIcon}>📄</div>
|
||||
<div className={styles.fileName}>{fileName}</div>
|
||||
<p>Preview not supported for this file type. Please download the file to view it.</p>
|
||||
<button onClick={handleDownload} className={styles.retryButton}>
|
||||
Download File
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Popup
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={`${t('files.preview.title', 'Content Preview')}: ${fileName}`}
|
||||
size="fullscreen"
|
||||
className={styles.contentPreviewPopup}
|
||||
actions={actions}
|
||||
>
|
||||
<div className={styles.previewContainer}>
|
||||
{renderPreview()}
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
export default UrlContentPreview;
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
export { ContentPreview } from './ContentPreview';
|
||||
export type { ContentPreviewProps } from './ContentPreview';
|
||||
export { UrlContentPreview } from './UrlContentPreview';
|
||||
export type { UrlContentPreviewProps } from './UrlContentPreview';
|
||||
|
||||
|
|
|
|||
239
src/components/ContentPreview/renderers/PdfJsRenderer.tsx
Normal file
239
src/components/ContentPreview/renderers/PdfJsRenderer.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
// @ts-ignore
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import styles from '../ContentPreview.module.css';
|
||||
|
||||
// Set worker source for PDF.js
|
||||
if (typeof window !== 'undefined') {
|
||||
// Try to use local worker first, fallback to CDN
|
||||
try {
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'../../../../node_modules/pdfjs-dist/build/pdf.worker.min.js',
|
||||
import.meta.url
|
||||
).toString();
|
||||
} catch (e) {
|
||||
// Fallback to CDN if local worker not available
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.js`;
|
||||
}
|
||||
}
|
||||
|
||||
interface PdfJsRendererProps {
|
||||
previewUrl: string;
|
||||
fileName: string;
|
||||
onError: () => void;
|
||||
onLoad?: () => void;
|
||||
}
|
||||
|
||||
export function PdfJsRenderer({ previewUrl, fileName: _fileName, onError, onLoad }: PdfJsRendererProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [numPages, setNumPages] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [scale, setScale] = useState(1.5);
|
||||
|
||||
useEffect(() => {
|
||||
let pdfDoc: pdfjsLib.PDFDocumentProxy | null = null;
|
||||
let isMounted = true;
|
||||
|
||||
const loadPdf = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Load PDF using fetch (like download)
|
||||
const response = await fetch(previewUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch PDF: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
|
||||
pdfDoc = await loadingTask.promise;
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
setNumPages(pdfDoc.numPages);
|
||||
setIsLoading(false);
|
||||
if (onLoad) {
|
||||
onLoad();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading PDF with PDF.js:', err);
|
||||
if (isMounted) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load PDF');
|
||||
setIsLoading(false);
|
||||
onError();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadPdf();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [previewUrl, onLoad, onError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || isLoading || error) return;
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const renderPage = async (pageNum: number) => {
|
||||
try {
|
||||
// Load PDF again for rendering (could be optimized with caching)
|
||||
const response = await fetch(previewUrl);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
|
||||
const pdfDoc = await loadingTask.promise;
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
const page = await pdfDoc.getPage(pageNum);
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
canvas.height = viewport.height;
|
||||
canvas.width = viewport.width;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return;
|
||||
|
||||
const renderContext = {
|
||||
canvasContext: context,
|
||||
viewport: viewport,
|
||||
};
|
||||
|
||||
await page.render(renderContext).promise;
|
||||
} catch (err) {
|
||||
console.error('Error rendering PDF page:', err);
|
||||
if (isMounted) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to render PDF page');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
renderPage(currentPage);
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [previewUrl, currentPage, scale, isLoading, error]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.errorContainer}>
|
||||
<div className={styles.errorIcon}>⚠️</div>
|
||||
<p>Fehler beim Laden der PDF: {error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner}></div>
|
||||
<p>PDF wird geladen...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ width: '100%', height: '100%', overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Navigation Controls */}
|
||||
{numPages > 1 && (
|
||||
<div style={{
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'var(--color-background-secondary, #f3f4f6)',
|
||||
borderBottom: '1px solid var(--color-border, #e5e7eb)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
background: 'var(--color-primary, #3b82f6)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: currentPage === 1 ? 'not-allowed' : 'pointer',
|
||||
opacity: currentPage === 1 ? 0.5 : 1
|
||||
}}
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
<span style={{ fontSize: '0.875rem', color: 'var(--color-text, #1f2937)' }}>
|
||||
Seite {currentPage} von {numPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.min(numPages, prev + 1))}
|
||||
disabled={currentPage === numPages}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
background: 'var(--color-primary, #3b82f6)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: currentPage === numPages ? 'not-allowed' : 'pointer',
|
||||
opacity: currentPage === numPages ? 0.5 : 1
|
||||
}}
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<button
|
||||
onClick={() => setScale(prev => Math.max(0.5, prev - 0.25))}
|
||||
style={{
|
||||
padding: '0.25rem 0.75rem',
|
||||
background: 'var(--color-background, #ffffff)',
|
||||
border: '1px solid var(--color-border, #e5e7eb)',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.75rem'
|
||||
}}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span style={{ fontSize: '0.75rem', minWidth: '3rem', textAlign: 'center' }}>
|
||||
{Math.round(scale * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setScale(prev => Math.min(3, prev + 0.25))}
|
||||
style={{
|
||||
padding: '0.25rem 0.75rem',
|
||||
background: 'var(--color-background, #ffffff)',
|
||||
border: '1px solid var(--color-border, #e5e7eb)',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.75rem'
|
||||
}}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PDF Canvas */}
|
||||
<div style={{ flex: 1, display: 'flex', justifyContent: 'center', alignItems: 'flex-start', padding: '1rem', overflow: 'auto' }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
background: 'white'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,24 +8,26 @@
|
|||
* Backend liefert Blocks-Struktur mit Static und Dynamic Blocks.
|
||||
* UI mappt uiComponent zu Icons via pageRegistry.
|
||||
*
|
||||
* TREE STRUCTURE (alles collapsible):
|
||||
* ▼ Meine Sicht
|
||||
* - Übersicht, Einstellungen, Prompts, Dateien, Verbindungen, Billing
|
||||
* ─────────────
|
||||
* ▼ Mandant 1
|
||||
* - 🎯 Instanz 1 (Feature-Icon + Instanz-Name)
|
||||
* - 💼 Instanz 2 (Feature-Icon + Instanz-Name)
|
||||
* ─────────────
|
||||
* ▶ Administration
|
||||
* - Users, Mandates, Roles, ...
|
||||
* Struktur (gemäss Navigation-API-Konzept):
|
||||
* - SYSTEM (static block, order: 10)
|
||||
* - MEINE FEATURES (dynamic block, order: 15)
|
||||
* - Mandant 1
|
||||
* - Feature A
|
||||
* - Instanz 1 (mit Views)
|
||||
* - WORKFLOWS (static block, order: 20)
|
||||
* - BASISDATEN (static block, order: 30)
|
||||
* - MIGRATE TO FEATURES (static block, order: 40)
|
||||
* - ADMINISTRATION (static block, order: 200)
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { useNavigation } from '../../hooks/useNavigation';
|
||||
import type {
|
||||
StaticBlock,
|
||||
DynamicBlock,
|
||||
NavigationItem,
|
||||
NavigationMandate,
|
||||
MandateFeature,
|
||||
FeatureInstance,
|
||||
FeatureView
|
||||
} from '../../hooks/useNavigation';
|
||||
|
|
@ -51,20 +53,13 @@ function navigationItemToTreeNode(item: NavigationItem): TreeNodeItem {
|
|||
}
|
||||
|
||||
/**
|
||||
* Convert a list of NavigationItems into a collapsible TreeNodeItem container.
|
||||
* Used for grouping static items under "Meine Sicht" and "Administration".
|
||||
* Convert a StaticBlock to TreeItem (section)
|
||||
*/
|
||||
function _staticItemsToTreeNode(
|
||||
id: string,
|
||||
label: string,
|
||||
items: NavigationItem[],
|
||||
defaultExpanded: boolean = true,
|
||||
): TreeNodeItem {
|
||||
function staticBlockToTreeItem(block: StaticBlock): TreeItem {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
children: items.map(navigationItemToTreeNode),
|
||||
defaultExpanded,
|
||||
type: 'section',
|
||||
title: block.title,
|
||||
children: block.items.map(navigationItemToTreeNode),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -80,52 +75,59 @@ function featureViewToTreeNode(view: FeatureView): TreeNodeItem {
|
|||
}
|
||||
|
||||
/**
|
||||
* Convert a FeatureInstance to TreeNodeItem (with feature icon)
|
||||
* Instance node gets path to first view so clicking the instance name navigates to dashboard.
|
||||
* Shows the feature icon next to the instance name for visual distinction.
|
||||
* Convert a FeatureInstance to TreeNodeItem
|
||||
*/
|
||||
function featureInstanceToTreeNode(instance: FeatureInstance, featureUiComponent: string): TreeNodeItem {
|
||||
const children = instance.views.map(featureViewToTreeNode);
|
||||
function featureInstanceToTreeNode(instance: FeatureInstance): TreeNodeItem {
|
||||
return {
|
||||
id: instance.id,
|
||||
label: instance.uiLabel,
|
||||
icon: getPageIcon(featureUiComponent), // Use feature icon for instance
|
||||
path: instance.views.length > 0 ? instance.views[0].uiPath : undefined,
|
||||
children,
|
||||
children: instance.views.map(featureViewToTreeNode),
|
||||
defaultExpanded: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a MandateFeature to TreeNodeItem
|
||||
*/
|
||||
function mandateFeatureToTreeNode(feature: MandateFeature): TreeNodeItem | null {
|
||||
if (feature.instances.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: feature.uiComponent,
|
||||
label: feature.uiLabel,
|
||||
icon: getPageIcon(feature.uiComponent),
|
||||
badge: feature.instances.length,
|
||||
path: feature.instances.length > 0 && feature.instances[0].views.length > 0
|
||||
? feature.instances[0].views[0].uiPath
|
||||
: undefined,
|
||||
children: feature.instances.map(featureInstanceToTreeNode),
|
||||
defaultExpanded: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a NavigationMandate to TreeNodeItem
|
||||
*
|
||||
* FLAT STRUCTURE: Instances are listed directly under mandate (no feature grouping).
|
||||
* Each instance shows the feature's icon for visual distinction.
|
||||
*
|
||||
* Before: Mandate → Feature → Instance → Views
|
||||
* Now: Mandate → Instance (with feature icon) → Views
|
||||
*/
|
||||
function navigationMandateToTreeNode(mandate: NavigationMandate): TreeNodeItem | null {
|
||||
if (mandate.features.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Flatten: collect all instances from all features directly under mandate
|
||||
const instanceNodes: TreeNodeItem[] = [];
|
||||
for (const feature of mandate.features) {
|
||||
for (const instance of feature.instances) {
|
||||
instanceNodes.push(featureInstanceToTreeNode(instance, feature.uiComponent));
|
||||
}
|
||||
}
|
||||
const children = mandate.features
|
||||
.map(mandateFeatureToTreeNode)
|
||||
.filter((node): node is TreeNodeItem => node !== null);
|
||||
|
||||
if (instanceNodes.length === 0) {
|
||||
if (children.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: mandate.id,
|
||||
label: mandate.uiLabel,
|
||||
children: instanceNodes,
|
||||
children,
|
||||
defaultExpanded: true,
|
||||
};
|
||||
}
|
||||
|
|
@ -172,49 +174,40 @@ export const MandateNavigation: React.FC = () => {
|
|||
const { blocks, loading } = useNavigation('de');
|
||||
|
||||
// Build navigation items from blocks
|
||||
// Groups static items into collapsible containers:
|
||||
// - "Meine Sicht": all non-admin static items (Übersicht, Einstellungen, Prompts, etc.)
|
||||
// - "Administration": all admin static items
|
||||
// - Dynamic block (mandates) renders between them
|
||||
const navigationItems: TreeItem[] = useMemo(() => {
|
||||
const items: TreeItem[] = [];
|
||||
|
||||
// Collect static items by category
|
||||
const meineSichtItems: NavigationItem[] = [];
|
||||
let adminItems: NavigationItem[] = [];
|
||||
|
||||
|
||||
// Process blocks in order (already sorted by backend)
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'static') {
|
||||
if (block.id === 'admin') {
|
||||
adminItems = [...block.items];
|
||||
} else if (block.items.length > 0) {
|
||||
meineSichtItems.push(...block.items);
|
||||
// Static block: system, workflows, basedata, migrate, admin
|
||||
if (block.items.length > 0) {
|
||||
// Add separator before admin block
|
||||
if (block.id === 'admin') {
|
||||
items.push({ type: 'separator' });
|
||||
}
|
||||
items.push(staticBlockToTreeItem(block));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "Meine Sicht" - collapsible container for user-facing pages
|
||||
if (meineSichtItems.length > 0) {
|
||||
items.push(_staticItemsToTreeNode('meine-sicht', 'Meine Sicht', meineSichtItems, true));
|
||||
}
|
||||
|
||||
// Dynamic block: mandates with feature instances
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'dynamic') {
|
||||
} else if (block.type === 'dynamic') {
|
||||
// Dynamic block: features/mandates
|
||||
// Add separator before dynamic block
|
||||
items.push({ type: 'separator' });
|
||||
|
||||
const mandateNodes = dynamicBlockToTreeNodes(block);
|
||||
if (mandateNodes.length > 0) {
|
||||
if (items.length > 0) items.push({ type: 'separator' });
|
||||
items.push(...mandateNodes);
|
||||
}
|
||||
|
||||
// Add separator after dynamic block (before next static blocks)
|
||||
items.push({ type: 'separator' });
|
||||
}
|
||||
}
|
||||
|
||||
// "Administration" - collapsible container for admin pages
|
||||
if (adminItems.length > 0) {
|
||||
if (items.length > 0) items.push({ type: 'separator' });
|
||||
items.push(_staticItemsToTreeNode('administration', 'Administration', adminItems, false));
|
||||
|
||||
// Remove trailing separator if present
|
||||
while (items.length > 0 && (items[items.length - 1] as TreeItem & { type?: string }).type === 'separator') {
|
||||
items.pop();
|
||||
}
|
||||
|
||||
|
||||
return items;
|
||||
}, [blocks]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,311 @@
|
|||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import TextField from '../TextField/TextField';
|
||||
import { BaseTextFieldProps } from '../TextField/TextFieldTypes';
|
||||
import { autocompleteAddress, AddressSuggestion } from '../../../api/realEstateApi';
|
||||
import styles from './AddressAutocomplete.module.css';
|
||||
|
||||
interface AddressAutocompleteProps extends BaseTextFieldProps {
|
||||
onSelect?: (suggestion: AddressSuggestion) => void;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
|
||||
debounceMs?: number;
|
||||
minQueryLength?: number;
|
||||
maxSuggestions?: number;
|
||||
}
|
||||
|
||||
const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({
|
||||
value = '',
|
||||
onChange,
|
||||
onSelect,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
required = false,
|
||||
readonly = false,
|
||||
size = 'md',
|
||||
error,
|
||||
helperText,
|
||||
label,
|
||||
className = '',
|
||||
type = 'text',
|
||||
name,
|
||||
id,
|
||||
onKeyDown,
|
||||
debounceMs = 300,
|
||||
minQueryLength = 2,
|
||||
maxSuggestions = 10,
|
||||
...props
|
||||
}) => {
|
||||
const [suggestions, setSuggestions] = useState<AddressSuggestion[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [query, setQuery] = useState(value);
|
||||
const [autocompleteError, setAutocompleteError] = useState<string | null>(null);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const suggestionsRef = useRef<HTMLUListElement>(null);
|
||||
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Sync query with value prop
|
||||
useEffect(() => {
|
||||
setQuery(value);
|
||||
}, [value]);
|
||||
|
||||
// Debounced search function
|
||||
const performSearch = useCallback(async (searchQuery: string) => {
|
||||
if (searchQuery.length < minQueryLength) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🔍 [AddressAutocomplete] Query too short:', {
|
||||
query: searchQuery,
|
||||
length: searchQuery.length,
|
||||
minLength: minQueryLength
|
||||
});
|
||||
}
|
||||
setSuggestions([]);
|
||||
setShowSuggestions(false);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🔍 [AddressAutocomplete] Starting search:', {
|
||||
query: searchQuery,
|
||||
length: searchQuery.length,
|
||||
maxSuggestions: maxSuggestions
|
||||
});
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setAutocompleteError(null); // Clear previous errors
|
||||
try {
|
||||
const results = await autocompleteAddress(searchQuery, maxSuggestions);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('✅ [AddressAutocomplete] Search completed:', {
|
||||
query: searchQuery,
|
||||
resultCount: results.length,
|
||||
results: results.slice(0, 3) // Log first 3
|
||||
});
|
||||
}
|
||||
|
||||
setSuggestions(results);
|
||||
setShowSuggestions(results.length > 0 || true); // Show dropdown even if empty to show "no results" or error
|
||||
setSelectedIndex(-1);
|
||||
setAutocompleteError(null); // Clear any previous errors on success
|
||||
} catch (err: any) {
|
||||
console.error('❌ [AddressAutocomplete] Error in performSearch:', err);
|
||||
const errorMessage = err?.response?.data?.detail || err?.message || 'Fehler beim Laden der Adressvorschläge';
|
||||
setAutocompleteError(errorMessage);
|
||||
setSuggestions([]);
|
||||
setShowSuggestions(true); // Show dropdown to display error
|
||||
setSelectedIndex(-1);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [minQueryLength, maxSuggestions]);
|
||||
|
||||
// Handle input change with debouncing
|
||||
const handleInputChange = useCallback((newValue: string) => {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('⌨️ [AddressAutocomplete] Input changed:', {
|
||||
newValue: newValue,
|
||||
length: newValue.length,
|
||||
willSearch: newValue.length >= minQueryLength
|
||||
});
|
||||
}
|
||||
|
||||
setQuery(newValue);
|
||||
setAutocompleteError(null); // Clear error on new input
|
||||
|
||||
// Update parent component immediately
|
||||
if (onChange) {
|
||||
onChange(newValue);
|
||||
}
|
||||
|
||||
// Clear existing timer
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
// Set new timer for debounced search
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('⏱️ [AddressAutocomplete] Debounce timer fired, calling performSearch');
|
||||
}
|
||||
performSearch(newValue);
|
||||
}, debounceMs);
|
||||
}, [onChange, debounceMs, performSearch, minQueryLength]);
|
||||
|
||||
// Handle suggestion selection
|
||||
const handleSelectSuggestion = useCallback((suggestion: AddressSuggestion) => {
|
||||
setQuery(suggestion.value);
|
||||
setShowSuggestions(false);
|
||||
setSelectedIndex(-1);
|
||||
|
||||
if (onChange) {
|
||||
onChange(suggestion.value);
|
||||
}
|
||||
|
||||
if (onSelect) {
|
||||
onSelect(suggestion);
|
||||
}
|
||||
}, [onChange, onSelect]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
if (!showSuggestions || suggestions.length === 0) {
|
||||
if (onKeyDown) {
|
||||
onKeyDown(e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev =>
|
||||
prev < suggestions.length - 1 ? prev + 1 : prev
|
||||
);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => prev > 0 ? prev - 1 : -1);
|
||||
break;
|
||||
case 'Enter':
|
||||
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
|
||||
e.preventDefault();
|
||||
handleSelectSuggestion(suggestions[selectedIndex]);
|
||||
} else if (onKeyDown) {
|
||||
onKeyDown(e);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
setShowSuggestions(false);
|
||||
setSelectedIndex(-1);
|
||||
break;
|
||||
default:
|
||||
if (onKeyDown) {
|
||||
onKeyDown(e);
|
||||
}
|
||||
}
|
||||
}, [showSuggestions, suggestions, selectedIndex, handleSelectSuggestion, onKeyDown]);
|
||||
|
||||
// Click outside handler
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setShowSuggestions(false);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
if (showSuggestions) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}
|
||||
}, [showSuggestions]);
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
if (selectedIndex >= 0 && suggestionsRef.current) {
|
||||
const selectedElement = suggestionsRef.current.children[selectedIndex] as HTMLElement;
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({
|
||||
block: 'nearest',
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
// Highlight matching text in suggestion
|
||||
const highlightText = (text: string, query: string): React.ReactNode => {
|
||||
if (!query || query.length < minQueryLength) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const parts = text.split(new RegExp(`(${query})`, 'gi'));
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, index) =>
|
||||
part.toLowerCase() === query.toLowerCase() ? (
|
||||
<mark key={index} className={styles.highlight}>{part}</mark>
|
||||
) : (
|
||||
<span key={index}>{part}</span>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`${styles.autocompleteContainer} ${className}`}>
|
||||
<TextField
|
||||
value={query}
|
||||
onChange={handleInputChange}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
readonly={readonly}
|
||||
size={size}
|
||||
error={undefined}
|
||||
helperText={helperText}
|
||||
label={label}
|
||||
type={type}
|
||||
name={name}
|
||||
id={id}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{showSuggestions && (
|
||||
<div className={styles.suggestionsWrapper}>
|
||||
<ul ref={suggestionsRef} className={styles.suggestionsList}>
|
||||
{isLoading && (
|
||||
<li className={styles.suggestionItem}>
|
||||
<span className={styles.loadingText}>Suche Adressen...</span>
|
||||
</li>
|
||||
)}
|
||||
{!isLoading && autocompleteError && (
|
||||
<li className={styles.suggestionItem}>
|
||||
<span className={styles.errorText}>{autocompleteError}</span>
|
||||
</li>
|
||||
)}
|
||||
{!isLoading && !autocompleteError && suggestions.length === 0 && query.length >= minQueryLength && (
|
||||
<li className={styles.suggestionItem}>
|
||||
<span className={styles.noResultsText}>Keine Adressen gefunden</span>
|
||||
</li>
|
||||
)}
|
||||
{!isLoading && suggestions.map((suggestion, index) => (
|
||||
<li
|
||||
key={`${suggestion.value}-${index}`}
|
||||
className={`${styles.suggestionItem} ${
|
||||
index === selectedIndex ? styles.suggestionItemSelected : ''
|
||||
}`}
|
||||
onClick={() => handleSelectSuggestion(suggestion)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<span className={styles.suggestionText}>
|
||||
{highlightText(suggestion.label, query)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddressAutocomplete;
|
||||
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>
|
||||
);
|
||||
};
|
||||
1
src/components/UiComponents/OerebSection/index.ts
Normal file
1
src/components/UiComponents/OerebSection/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { OerebSection } from './OerebSection';
|
||||
|
|
@ -125,6 +125,24 @@
|
|||
color: var(--color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.documentsSection {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 2px solid var(--color-primary, #3b82f6);
|
||||
background-color: var(--color-bg-secondary, #f9fafb);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin-left: -1.5rem;
|
||||
margin-right: -1.5rem;
|
||||
}
|
||||
|
||||
.documentsSectionTitle {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.infoGrid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -241,6 +259,670 @@
|
|||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.documentsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.documentLink {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--color-bg, #ffffff);
|
||||
border: 2px solid var(--color-primary, #3b82f6);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.documentLink:hover {
|
||||
background-color: var(--color-primary-light, #eff6ff);
|
||||
border-color: var(--color-primary-dark, #2563eb);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.documentLink:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.documentLabel {
|
||||
font-weight: 600;
|
||||
color: var(--color-primary, #3b82f6);
|
||||
font-size: 1rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.documentLink:hover .documentLabel {
|
||||
color: var(--color-primary-dark, #2563eb);
|
||||
}
|
||||
|
||||
.documentType {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* BZO Information Styles */
|
||||
.bzoButtonContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.bzoButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: var(--color-primary, #3b82f6);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.bzoButton:hover:not(:disabled) {
|
||||
background-color: var(--color-primary-dark, #2563eb);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.bzoButton:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
color: var(--color-error, #ef4444);
|
||||
font-size: 0.875rem;
|
||||
padding: 0.5rem;
|
||||
background-color: var(--color-error-light, #fee2e2);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-error, #ef4444);
|
||||
}
|
||||
|
||||
.bzoSection {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 2px solid var(--color-primary, #3b82f6);
|
||||
background-color: var(--color-bg-secondary, #f9fafb);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin-left: -1.5rem;
|
||||
margin-right: -1.5rem;
|
||||
}
|
||||
|
||||
.bzoHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.bzoSectionTitle {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.toggleButton {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.toggleButton:hover {
|
||||
background-color: var(--color-hover, #f3f4f6);
|
||||
}
|
||||
|
||||
.bzoContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.bzoSubSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.bzoSubTitle {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #111827);
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.bzoSummary {
|
||||
padding: 1rem;
|
||||
background-color: var(--color-bg, #ffffff);
|
||||
border-left: 4px solid var(--color-primary, #3b82f6);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
/* Markdown Styles for BZO Content */
|
||||
.bzoMarkdown {
|
||||
line-height: 1.6;
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.bzoMarkdownH1,
|
||||
.bzoMarkdownH2,
|
||||
.bzoMarkdownH3,
|
||||
.bzoMarkdownH4,
|
||||
.bzoMarkdownH5,
|
||||
.bzoMarkdownH6 {
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #111827);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.bzoMarkdownH1 {
|
||||
font-size: 1.5rem;
|
||||
border-bottom: 2px solid var(--color-border, #e5e7eb);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.bzoMarkdownH2 {
|
||||
font-size: 1.25rem;
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.bzoMarkdownH3 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.bzoMarkdownH4 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.bzoMarkdownH5 {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.bzoMarkdownH6 {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.bzoMarkdownP {
|
||||
margin: 0.75rem 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.bzoMarkdownP:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.bzoMarkdownP:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.bzoMarkdownUl,
|
||||
.bzoMarkdownOl {
|
||||
margin: 0.75rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.bzoMarkdownLi {
|
||||
margin: 0.5rem 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.bzoMarkdownUl .bzoMarkdownLi {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.bzoMarkdownOl .bzoMarkdownLi {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.bzoMarkdownTableWrapper {
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.bzoMarkdownTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bzoMarkdownThead {
|
||||
background-color: var(--color-bg-secondary, #f9fafb);
|
||||
}
|
||||
|
||||
.bzoMarkdownTh {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid var(--color-border, #e5e7eb);
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.bzoMarkdownTd {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.bzoMarkdownTr:last-child .bzoMarkdownTd {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.bzoMarkdownTr:hover {
|
||||
background-color: var(--color-hover, #f3f4f6);
|
||||
}
|
||||
|
||||
.bzoMarkdownCodeInline {
|
||||
background-color: var(--color-bg-secondary, #f9fafb);
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
color: var(--color-primary, #3b82f6);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.bzoMarkdownPre {
|
||||
background-color: var(--color-bg-secondary, #f9fafb);
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.bzoMarkdownCodeBlock {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.bzoMarkdownBlockquote {
|
||||
margin: 1rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border-left: 4px solid var(--color-primary, #3b82f6);
|
||||
background-color: var(--color-bg-secondary, #f9fafb);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.bzoMarkdownStrong {
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.bzoMarkdownEm {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.bzoMarkdownLink {
|
||||
color: var(--color-primary, #3b82f6);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.bzoMarkdownLink:hover {
|
||||
color: var(--color-primary-dark, #2563eb);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.bzoMarkdownHr {
|
||||
margin: 1.5rem 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.bzoInfoGrid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bzoInfoItem {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bzoLabel {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.bzoValue {
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.bzoZonesList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.bzoZoneCard {
|
||||
padding: 1rem;
|
||||
background-color: var(--color-bg, #ffffff);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.bzoZoneCard:hover {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.bzoZoneHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.bzoZoneCode {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-primary, #3b82f6);
|
||||
}
|
||||
|
||||
.bzoZoneName {
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.bzoZoneDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bzoZoneDetailItem {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bzoDetailLabel {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
min-width: 140px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.bzoDetailValue {
|
||||
color: var(--color-text, #111827);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.bzoRulesList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.bzoRuleCard {
|
||||
padding: 1rem;
|
||||
background-color: var(--color-bg, #ffffff);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-left: 4px solid var(--color-primary, #3b82f6);
|
||||
border-radius: 6px;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.bzoRuleCard:hover {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.bzoRuleHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.bzoRuleType {
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #111827);
|
||||
text-transform: capitalize;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.bzoConfidence {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
background-color: var(--color-bg-secondary, #f9fafb);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.bzoRuleValue {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.bzoRuleNumeric {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary, #3b82f6);
|
||||
}
|
||||
|
||||
.bzoRuleText {
|
||||
color: var(--color-text, #111827);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bzoRuleSnippet {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background-color: var(--color-bg-secondary, #f9fafb);
|
||||
border-radius: 4px;
|
||||
font-style: italic;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.bzoRuleZone,
|
||||
.bzoRulePage {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.bzoRuleMeta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.bzoArticlesList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.bzoArticleCard {
|
||||
padding: 1rem;
|
||||
background-color: var(--color-bg, #ffffff);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.bzoArticleCard:hover {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.bzoArticleHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.bzoArticleLabel {
|
||||
font-weight: 700;
|
||||
color: var(--color-primary, #3b82f6);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.bzoArticleTitle {
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.bzoArticleText {
|
||||
margin-bottom: 0.75rem;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text, #111827);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.bzoArticleMeta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.bzoDocumentsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bzoDocumentItem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
background-color: var(--color-bg, #ffffff);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.bzoDocumentLabel {
|
||||
font-weight: 500;
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.bzoDocumentType {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.bzoErrors,
|
||||
.bzoWarnings {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.bzoErrorTitle {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-error, #ef4444);
|
||||
}
|
||||
|
||||
.bzoWarningTitle {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.bzoErrorList,
|
||||
.bzoWarningList {
|
||||
margin: 0;
|
||||
padding-left: 1.5rem;
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.bzoErrorList li {
|
||||
color: var(--color-error, #ef4444);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.bzoWarningList li {
|
||||
color: #f59e0b;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.bzoStats {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bzoStatItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.bzoStatLabel {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bzoStatValue {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary, #3b82f6);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.panel {
|
||||
width: 100vw;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback } from 'react';
|
||||
import { GenericPageData } from '../../../pageInterface';
|
||||
import { FaLink, FaPlus } from 'react-icons/fa';
|
||||
import { useTrusteePositionDocuments, useTrusteePositionDocumentOperations } from '../../../../../hooks/useTrustee';
|
||||
import { useTrusteePositionDocuments, useTrusteePositionDocumentOperations } from '../../../../../hooks/useTrusteePositionDocuments';
|
||||
|
||||
// Helper function to convert attribute definitions to column config
|
||||
const attributesToColumns = (attributes: any[]) => {
|
||||
|
|
@ -28,7 +28,7 @@ const attributesToColumns = (attributes: any[]) => {
|
|||
const createPositionDocumentsHook = () => {
|
||||
return () => {
|
||||
const {
|
||||
items: positionDocuments,
|
||||
positionDocuments,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
|
|
@ -36,15 +36,15 @@ const createPositionDocumentsHook = () => {
|
|||
attributes,
|
||||
permissions,
|
||||
pagination,
|
||||
fetchById: fetchPositionDocumentById,
|
||||
fetchPositionDocumentById,
|
||||
generateEditFieldsFromAttributes,
|
||||
ensureAttributesLoaded
|
||||
} = useTrusteePositionDocuments();
|
||||
const {
|
||||
handleDelete: handlePositionDocumentDelete,
|
||||
handleCreate: handlePositionDocumentCreate,
|
||||
deletingItems: deletingPositionDocuments,
|
||||
creatingItem: creatingPositionDocument,
|
||||
handlePositionDocumentDelete,
|
||||
handlePositionDocumentCreate,
|
||||
deletingPositionDocuments,
|
||||
creatingPositionDocument,
|
||||
deleteError,
|
||||
createError
|
||||
} = useTrusteePositionDocumentOperations();
|
||||
|
|
@ -70,7 +70,7 @@ const createPositionDocumentsHook = () => {
|
|||
positionDocumentIds.map(id => handlePositionDocumentDelete(id))
|
||||
);
|
||||
|
||||
const allSuccessful = results.every((result: boolean) => result);
|
||||
const allSuccessful = results.every(result => result);
|
||||
if (allSuccessful) {
|
||||
refetch();
|
||||
}
|
||||
|
|
|
|||
383
src/hooks/useTrusteeAccess.ts
Normal file
383
src/hooks/useTrusteeAccess.ts
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useApiRequest } from './useApi';
|
||||
import api from '../api';
|
||||
import { usePermissions, type UserPermissions } from './usePermissions';
|
||||
import { useInstanceId } from './useCurrentInstance';
|
||||
import {
|
||||
fetchAccess as fetchAccessApi,
|
||||
fetchAccessById as fetchAccessByIdApi,
|
||||
createAccess as createAccessApi,
|
||||
updateAccess as updateAccessApi,
|
||||
deleteAccess as deleteAccessApi,
|
||||
type TrusteeAccess,
|
||||
type PaginationParams
|
||||
} from '../api/trusteeApi';
|
||||
|
||||
export interface AttributeDefinition {
|
||||
name: string;
|
||||
type: 'text' | 'email' | 'date' | 'checkbox' | 'select' | 'multiselect' | 'number' | 'textarea' | 'timestamp' | 'file';
|
||||
label: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
default?: any;
|
||||
options?: any[] | string;
|
||||
readonly?: boolean;
|
||||
editable?: boolean;
|
||||
visible?: boolean;
|
||||
order?: number;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
searchable?: boolean;
|
||||
width?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
filterOptions?: string[];
|
||||
dependsOn?: string;
|
||||
}
|
||||
|
||||
// Re-export types
|
||||
export type { TrusteeAccess, PaginationParams };
|
||||
|
||||
// Access list hook
|
||||
export function useTrusteeAccess() {
|
||||
const instanceId = useInstanceId();
|
||||
|
||||
const [accessRecords, setAccessRecords] = useState<TrusteeAccess[]>([]);
|
||||
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
|
||||
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
|
||||
const [pagination, setPagination] = useState<{
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
} | null>(null);
|
||||
const { request, isLoading: loading, error } = useApiRequest<null, TrusteeAccess[]>();
|
||||
const { checkPermission } = usePermissions();
|
||||
|
||||
// Fetch attributes from backend
|
||||
const fetchAttributes = useCallback(async () => {
|
||||
if (!instanceId) return [];
|
||||
|
||||
try {
|
||||
const response = await api.get(`/api/trustee/${instanceId}/attributes/TrusteeAccess`);
|
||||
|
||||
let attrs: AttributeDefinition[] = [];
|
||||
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
|
||||
attrs = response.data.attributes;
|
||||
} else if (Array.isArray(response.data)) {
|
||||
attrs = response.data;
|
||||
} else if (response.data && typeof response.data === 'object') {
|
||||
const keys = Object.keys(response.data);
|
||||
for (const key of keys) {
|
||||
if (Array.isArray(response.data[key])) {
|
||||
attrs = response.data[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setAttributes(attrs);
|
||||
return attrs;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching attributes:', error);
|
||||
setAttributes([]);
|
||||
return [];
|
||||
}
|
||||
}, [instanceId]);
|
||||
|
||||
// Fetch permissions from backend
|
||||
const fetchPermissions = useCallback(async () => {
|
||||
try {
|
||||
const objectKey = 'data.feature.trustee.TrusteeAccess';
|
||||
const perms = await checkPermission('DATA', objectKey);
|
||||
setPermissions(perms);
|
||||
return perms;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching permissions:', error);
|
||||
const defaultPerms: UserPermissions = {
|
||||
view: false,
|
||||
read: 'n',
|
||||
create: 'n',
|
||||
update: 'n',
|
||||
delete: 'n',
|
||||
};
|
||||
setPermissions(defaultPerms);
|
||||
return defaultPerms;
|
||||
}
|
||||
}, [checkPermission]);
|
||||
|
||||
const fetchAccess = useCallback(async (params?: PaginationParams) => {
|
||||
if (!instanceId) {
|
||||
setAccessRecords([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchAccessApi(request, instanceId, params);
|
||||
|
||||
if (data && typeof data === 'object' && 'items' in data) {
|
||||
const items = Array.isArray(data.items) ? data.items : [];
|
||||
setAccessRecords(items);
|
||||
if (data.pagination) {
|
||||
setPagination(data.pagination);
|
||||
}
|
||||
} else {
|
||||
const items = Array.isArray(data) ? data : [];
|
||||
setAccessRecords(items);
|
||||
setPagination(null);
|
||||
}
|
||||
} catch (error: any) {
|
||||
setAccessRecords([]);
|
||||
setPagination(null);
|
||||
}
|
||||
}, [request, instanceId]);
|
||||
|
||||
// Optimistically remove an access record
|
||||
const removeOptimistically = (accessId: string) => {
|
||||
setAccessRecords(prevAccess => prevAccess.filter(acc => acc.id !== accessId));
|
||||
};
|
||||
|
||||
// Optimistically update an access record
|
||||
const updateOptimistically = (accessId: string, updateData: Partial<TrusteeAccess>) => {
|
||||
setAccessRecords(prevAccess =>
|
||||
prevAccess.map(acc =>
|
||||
acc.id === accessId
|
||||
? { ...acc, ...updateData }
|
||||
: acc
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Fetch a single access record by ID
|
||||
const fetchAccessById = useCallback(async (accessId: string): Promise<TrusteeAccess | null> => {
|
||||
if (!instanceId) return null;
|
||||
return await fetchAccessByIdApi(request, instanceId, accessId);
|
||||
}, [request, instanceId]);
|
||||
|
||||
// Generate edit fields from attributes dynamically
|
||||
const generateEditFieldsFromAttributes = useCallback((): Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly';
|
||||
editable?: boolean;
|
||||
required?: boolean;
|
||||
validator?: (value: any) => string | null;
|
||||
options?: Array<{ value: string | number; label: string }>;
|
||||
optionsReference?: string;
|
||||
dependsOn?: string;
|
||||
}> => {
|
||||
if (!attributes || attributes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const editableFields = attributes
|
||||
.filter(attr => {
|
||||
if (attr.readonly === true || attr.editable === false) {
|
||||
return false;
|
||||
}
|
||||
const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
|
||||
return !nonEditableFields.includes(attr.name);
|
||||
})
|
||||
.map(attr => {
|
||||
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string';
|
||||
let options: Array<{ value: string | number; label: string }> | undefined = undefined;
|
||||
let optionsReference: string | undefined = undefined;
|
||||
let dependsOn: string | undefined = undefined;
|
||||
|
||||
if (attr.type === 'checkbox') {
|
||||
fieldType = 'boolean';
|
||||
} else if (attr.type === 'email') {
|
||||
fieldType = 'email';
|
||||
} else if (attr.type === 'date') {
|
||||
fieldType = 'date';
|
||||
} else if (attr.type === 'select') {
|
||||
fieldType = 'enum';
|
||||
if (Array.isArray(attr.options)) {
|
||||
options = attr.options.map((opt: any) => {
|
||||
const labelValue = typeof opt.label === 'string'
|
||||
? opt.label
|
||||
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
|
||||
return {
|
||||
value: opt.value,
|
||||
label: labelValue
|
||||
};
|
||||
});
|
||||
} else if (typeof attr.options === 'string') {
|
||||
optionsReference = attr.options;
|
||||
}
|
||||
} else if (attr.type === 'textarea') {
|
||||
fieldType = 'textarea';
|
||||
}
|
||||
|
||||
// contractId dropdown depends on organisationId
|
||||
if (attr.name === 'contractId') {
|
||||
dependsOn = 'organisationId';
|
||||
}
|
||||
|
||||
let required = attr.required === true;
|
||||
let validator: ((value: any) => string | null) | undefined = undefined;
|
||||
|
||||
// contractId is optional
|
||||
if (attr.name === 'contractId') {
|
||||
required = false;
|
||||
} else if (attr.name === 'organisationId' || attr.name === 'roleId' || attr.name === 'userId') {
|
||||
required = true;
|
||||
validator = (value: any) => {
|
||||
if (!value || (typeof value === 'string' && value.trim() === '')) {
|
||||
return `${attr.label || attr.name} is required`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: fieldType,
|
||||
editable: attr.editable !== false && attr.readonly !== true,
|
||||
required,
|
||||
validator,
|
||||
options,
|
||||
optionsReference,
|
||||
dependsOn
|
||||
};
|
||||
});
|
||||
|
||||
return editableFields;
|
||||
}, [attributes]);
|
||||
|
||||
// Ensure attributes are loaded
|
||||
const ensureAttributesLoaded = useCallback(async () => {
|
||||
if (attributes && attributes.length > 0) {
|
||||
return attributes;
|
||||
}
|
||||
const fetchedAttributes = await fetchAttributes();
|
||||
return fetchedAttributes;
|
||||
}, [attributes, fetchAttributes]);
|
||||
|
||||
// Fetch data when instanceId is available
|
||||
useEffect(() => {
|
||||
if (instanceId) {
|
||||
fetchAttributes();
|
||||
fetchPermissions();
|
||||
fetchAccess();
|
||||
}
|
||||
}, [instanceId, fetchAttributes, fetchPermissions, fetchAccess]);
|
||||
|
||||
return {
|
||||
accessRecords,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchAccess,
|
||||
removeOptimistically,
|
||||
updateOptimistically,
|
||||
attributes,
|
||||
permissions,
|
||||
pagination,
|
||||
fetchAccessById,
|
||||
generateEditFieldsFromAttributes,
|
||||
ensureAttributesLoaded,
|
||||
instanceId
|
||||
};
|
||||
}
|
||||
|
||||
// Access operations hook
|
||||
export function useTrusteeAccessOperations() {
|
||||
const instanceId = useInstanceId();
|
||||
|
||||
const [deletingAccess, setDeletingAccess] = useState<Set<string>>(new Set());
|
||||
const [creatingAccess, setCreatingAccess] = useState(false);
|
||||
const { request, isLoading } = useApiRequest();
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||
|
||||
const handleAccessDelete = async (accessId: string) => {
|
||||
if (!instanceId) {
|
||||
setDeleteError('No instance context');
|
||||
return false;
|
||||
}
|
||||
|
||||
setDeleteError(null);
|
||||
setDeletingAccess(prev => new Set(prev).add(accessId));
|
||||
|
||||
try {
|
||||
await deleteAccessApi(request, instanceId, accessId);
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
setDeleteError(error.message);
|
||||
return false;
|
||||
} finally {
|
||||
setDeletingAccess(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(accessId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccessCreate = async (accessData: Partial<TrusteeAccess>) => {
|
||||
if (!instanceId) {
|
||||
setCreateError('No instance context');
|
||||
return { success: false, error: 'No instance context' };
|
||||
}
|
||||
|
||||
setCreateError(null);
|
||||
setCreatingAccess(true);
|
||||
|
||||
try {
|
||||
const newAccess = await createAccessApi(request, instanceId, accessData);
|
||||
return { success: true, accessData: newAccess };
|
||||
} catch (error: any) {
|
||||
setCreateError(error.message);
|
||||
return { success: false, error: error.message };
|
||||
} finally {
|
||||
setCreatingAccess(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccessUpdate = async (
|
||||
accessId: string,
|
||||
updateData: Partial<TrusteeAccess>,
|
||||
_originalData?: any
|
||||
) => {
|
||||
if (!instanceId) {
|
||||
setUpdateError('No instance context');
|
||||
return { success: false, error: 'No instance context' };
|
||||
}
|
||||
|
||||
setUpdateError(null);
|
||||
|
||||
try {
|
||||
const updatedAccess = await updateAccessApi(request, instanceId, accessId, updateData);
|
||||
return { success: true, accessData: updatedAccess };
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.message || error.message || 'Failed to update access';
|
||||
const statusCode = error.response?.status;
|
||||
|
||||
setUpdateError(errorMessage);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
statusCode,
|
||||
isPermissionError: statusCode === 403,
|
||||
isValidationError: statusCode === 400
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
deletingAccess,
|
||||
creatingAccess,
|
||||
deleteError,
|
||||
createError,
|
||||
updateError,
|
||||
handleAccessDelete,
|
||||
handleAccessCreate,
|
||||
handleAccessUpdate,
|
||||
isLoading,
|
||||
instanceId
|
||||
};
|
||||
}
|
||||
391
src/hooks/useTrusteeContracts.ts
Normal file
391
src/hooks/useTrusteeContracts.ts
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useApiRequest } from './useApi';
|
||||
import api from '../api';
|
||||
import { usePermissions, type UserPermissions } from './usePermissions';
|
||||
import { useInstanceId } from './useCurrentInstance';
|
||||
import {
|
||||
fetchContracts as fetchContractsApi,
|
||||
fetchContractById as fetchContractByIdApi,
|
||||
createContract as createContractApi,
|
||||
updateContract as updateContractApi,
|
||||
deleteContract as deleteContractApi,
|
||||
type TrusteeContract,
|
||||
type PaginationParams
|
||||
} from '../api/trusteeApi';
|
||||
|
||||
export interface AttributeDefinition {
|
||||
name: string;
|
||||
type: 'text' | 'email' | 'date' | 'checkbox' | 'select' | 'multiselect' | 'number' | 'textarea' | 'timestamp' | 'file';
|
||||
label: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
default?: any;
|
||||
options?: any[] | string;
|
||||
readonly?: boolean;
|
||||
editable?: boolean;
|
||||
visible?: boolean;
|
||||
order?: number;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
searchable?: boolean;
|
||||
width?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
filterOptions?: string[];
|
||||
dependsOn?: string;
|
||||
}
|
||||
|
||||
// Re-export types
|
||||
export type { TrusteeContract, PaginationParams };
|
||||
|
||||
// Contracts list hook
|
||||
export function useTrusteeContracts() {
|
||||
const instanceId = useInstanceId();
|
||||
|
||||
const [contracts, setContracts] = useState<TrusteeContract[]>([]);
|
||||
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
|
||||
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
|
||||
const [pagination, setPagination] = useState<{
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
} | null>(null);
|
||||
const { request, isLoading: loading, error } = useApiRequest<null, TrusteeContract[]>();
|
||||
const { checkPermission } = usePermissions();
|
||||
|
||||
// Fetch attributes from backend
|
||||
const fetchAttributes = useCallback(async () => {
|
||||
if (!instanceId) return [];
|
||||
|
||||
try {
|
||||
const response = await api.get(`/api/trustee/${instanceId}/attributes/TrusteeContract`);
|
||||
|
||||
let attrs: AttributeDefinition[] = [];
|
||||
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
|
||||
attrs = response.data.attributes;
|
||||
} else if (Array.isArray(response.data)) {
|
||||
attrs = response.data;
|
||||
} else if (response.data && typeof response.data === 'object') {
|
||||
const keys = Object.keys(response.data);
|
||||
for (const key of keys) {
|
||||
if (Array.isArray(response.data[key])) {
|
||||
attrs = response.data[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setAttributes(attrs);
|
||||
return attrs;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching attributes:', error);
|
||||
setAttributes([]);
|
||||
return [];
|
||||
}
|
||||
}, [instanceId]);
|
||||
|
||||
// Fetch permissions from backend
|
||||
const fetchPermissions = useCallback(async () => {
|
||||
try {
|
||||
const objectKey = 'data.feature.trustee.TrusteeContract';
|
||||
const perms = await checkPermission('DATA', objectKey);
|
||||
setPermissions(perms);
|
||||
return perms;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching permissions:', error);
|
||||
const defaultPerms: UserPermissions = {
|
||||
view: false,
|
||||
read: 'n',
|
||||
create: 'n',
|
||||
update: 'n',
|
||||
delete: 'n',
|
||||
};
|
||||
setPermissions(defaultPerms);
|
||||
return defaultPerms;
|
||||
}
|
||||
}, [checkPermission]);
|
||||
|
||||
const fetchContracts = useCallback(async (params?: PaginationParams) => {
|
||||
if (!instanceId) {
|
||||
setContracts([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchContractsApi(request, instanceId, params);
|
||||
|
||||
if (data && typeof data === 'object' && 'items' in data) {
|
||||
const items = Array.isArray(data.items) ? data.items : [];
|
||||
setContracts(items);
|
||||
if (data.pagination) {
|
||||
setPagination(data.pagination);
|
||||
}
|
||||
} else {
|
||||
const items = Array.isArray(data) ? data : [];
|
||||
setContracts(items);
|
||||
setPagination(null);
|
||||
}
|
||||
} catch (error: any) {
|
||||
setContracts([]);
|
||||
setPagination(null);
|
||||
}
|
||||
}, [request, instanceId]);
|
||||
|
||||
// Optimistically remove a contract
|
||||
const removeOptimistically = (contractId: string) => {
|
||||
setContracts(prevContracts => prevContracts.filter(contract => contract.id !== contractId));
|
||||
};
|
||||
|
||||
// Optimistically update a contract
|
||||
const updateOptimistically = (contractId: string, updateData: Partial<TrusteeContract>) => {
|
||||
setContracts(prevContracts =>
|
||||
prevContracts.map(contract =>
|
||||
contract.id === contractId
|
||||
? { ...contract, ...updateData }
|
||||
: contract
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Fetch a single contract by ID
|
||||
const fetchContractById = useCallback(async (contractId: string): Promise<TrusteeContract | null> => {
|
||||
if (!instanceId) return null;
|
||||
return await fetchContractByIdApi(request, instanceId, contractId);
|
||||
}, [request, instanceId]);
|
||||
|
||||
// Generate edit fields from attributes dynamically
|
||||
const generateEditFieldsFromAttributes = useCallback((): Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly';
|
||||
editable?: boolean;
|
||||
required?: boolean;
|
||||
validator?: (value: any) => string | null;
|
||||
options?: Array<{ value: string | number; label: string }>;
|
||||
optionsReference?: string;
|
||||
readonlyCondition?: (formData: any) => boolean;
|
||||
}> => {
|
||||
if (!attributes || attributes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const editableFields = attributes
|
||||
.filter(attr => {
|
||||
if (attr.readonly === true || attr.editable === false) {
|
||||
return false;
|
||||
}
|
||||
const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
|
||||
return !nonEditableFields.includes(attr.name);
|
||||
})
|
||||
.map(attr => {
|
||||
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string';
|
||||
let options: Array<{ value: string | number; label: string }> | undefined = undefined;
|
||||
let optionsReference: string | undefined = undefined;
|
||||
let readonlyCondition: ((formData: any) => boolean) | undefined = undefined;
|
||||
|
||||
if (attr.type === 'checkbox') {
|
||||
fieldType = 'boolean';
|
||||
} else if (attr.type === 'email') {
|
||||
fieldType = 'email';
|
||||
} else if (attr.type === 'date') {
|
||||
fieldType = 'date';
|
||||
} else if (attr.type === 'select') {
|
||||
fieldType = 'enum';
|
||||
if (Array.isArray(attr.options)) {
|
||||
options = attr.options.map((opt: any) => {
|
||||
const labelValue = typeof opt.label === 'string'
|
||||
? opt.label
|
||||
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
|
||||
return {
|
||||
value: opt.value,
|
||||
label: labelValue
|
||||
};
|
||||
});
|
||||
} else if (typeof attr.options === 'string') {
|
||||
optionsReference = attr.options;
|
||||
}
|
||||
} else if (attr.type === 'textarea') {
|
||||
fieldType = 'textarea';
|
||||
}
|
||||
|
||||
// IMPORTANT: organisationId is immutable after creation
|
||||
// It's readonly when id is present (non-blank)
|
||||
if (attr.name === 'organisationId') {
|
||||
readonlyCondition = (formData: any) => {
|
||||
return formData && formData.id && formData.id !== '';
|
||||
};
|
||||
}
|
||||
|
||||
let required = attr.required === true;
|
||||
let validator: ((value: any) => string | null) | undefined = undefined;
|
||||
|
||||
if (attr.name === 'organisationId') {
|
||||
required = true;
|
||||
validator = (value: any) => {
|
||||
if (!value || (typeof value === 'string' && value.trim() === '')) {
|
||||
return 'Organisation is required';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
} else if (attr.name === 'label') {
|
||||
required = true;
|
||||
validator = (value: string) => {
|
||||
if (!value || value.trim() === '') {
|
||||
return 'Label cannot be empty';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: fieldType,
|
||||
editable: attr.editable !== false && attr.readonly !== true,
|
||||
required,
|
||||
validator,
|
||||
options,
|
||||
optionsReference,
|
||||
readonlyCondition
|
||||
};
|
||||
});
|
||||
|
||||
return editableFields;
|
||||
}, [attributes]);
|
||||
|
||||
// Ensure attributes are loaded
|
||||
const ensureAttributesLoaded = useCallback(async () => {
|
||||
if (attributes && attributes.length > 0) {
|
||||
return attributes;
|
||||
}
|
||||
const fetchedAttributes = await fetchAttributes();
|
||||
return fetchedAttributes;
|
||||
}, [attributes, fetchAttributes]);
|
||||
|
||||
// Fetch data when instanceId is available
|
||||
useEffect(() => {
|
||||
if (instanceId) {
|
||||
fetchAttributes();
|
||||
fetchPermissions();
|
||||
fetchContracts();
|
||||
}
|
||||
}, [instanceId, fetchAttributes, fetchPermissions, fetchContracts]);
|
||||
|
||||
return {
|
||||
contracts,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchContracts,
|
||||
removeOptimistically,
|
||||
updateOptimistically,
|
||||
attributes,
|
||||
permissions,
|
||||
pagination,
|
||||
fetchContractById,
|
||||
generateEditFieldsFromAttributes,
|
||||
ensureAttributesLoaded,
|
||||
instanceId
|
||||
};
|
||||
}
|
||||
|
||||
// Contract operations hook
|
||||
export function useTrusteeContractOperations() {
|
||||
const instanceId = useInstanceId();
|
||||
|
||||
const [deletingContracts, setDeletingContracts] = useState<Set<string>>(new Set());
|
||||
const [creatingContract, setCreatingContract] = useState(false);
|
||||
const { request, isLoading } = useApiRequest();
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||
|
||||
const handleContractDelete = async (contractId: string) => {
|
||||
if (!instanceId) {
|
||||
setDeleteError('No instance context');
|
||||
return false;
|
||||
}
|
||||
|
||||
setDeleteError(null);
|
||||
setDeletingContracts(prev => new Set(prev).add(contractId));
|
||||
|
||||
try {
|
||||
await deleteContractApi(request, instanceId, contractId);
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
setDeleteError(error.message);
|
||||
return false;
|
||||
} finally {
|
||||
setDeletingContracts(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(contractId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleContractCreate = async (contractData: Partial<TrusteeContract>) => {
|
||||
if (!instanceId) {
|
||||
setCreateError('No instance context');
|
||||
return { success: false, error: 'No instance context' };
|
||||
}
|
||||
|
||||
setCreateError(null);
|
||||
setCreatingContract(true);
|
||||
|
||||
try {
|
||||
const newContract = await createContractApi(request, instanceId, contractData);
|
||||
return { success: true, contractData: newContract };
|
||||
} catch (error: any) {
|
||||
setCreateError(error.message);
|
||||
return { success: false, error: error.message };
|
||||
} finally {
|
||||
setCreatingContract(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContractUpdate = async (
|
||||
contractId: string,
|
||||
updateData: Partial<TrusteeContract>,
|
||||
_originalData?: any
|
||||
) => {
|
||||
if (!instanceId) {
|
||||
setUpdateError('No instance context');
|
||||
return { success: false, error: 'No instance context' };
|
||||
}
|
||||
|
||||
setUpdateError(null);
|
||||
|
||||
try {
|
||||
const updatedContract = await updateContractApi(request, instanceId, contractId, updateData);
|
||||
return { success: true, contractData: updatedContract };
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.message || error.message || 'Failed to update contract';
|
||||
const statusCode = error.response?.status;
|
||||
|
||||
setUpdateError(errorMessage);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
statusCode,
|
||||
isPermissionError: statusCode === 403,
|
||||
isValidationError: statusCode === 400
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
deletingContracts,
|
||||
creatingContract,
|
||||
deleteError,
|
||||
createError,
|
||||
updateError,
|
||||
handleContractDelete,
|
||||
handleContractCreate,
|
||||
handleContractUpdate,
|
||||
isLoading,
|
||||
instanceId
|
||||
};
|
||||
}
|
||||
434
src/hooks/useTrusteeDocuments.ts
Normal file
434
src/hooks/useTrusteeDocuments.ts
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useApiRequest } from './useApi';
|
||||
import api from '../api';
|
||||
import { usePermissions, type UserPermissions } from './usePermissions';
|
||||
import { useInstanceId } from './useCurrentInstance';
|
||||
import {
|
||||
fetchDocuments as fetchDocumentsApi,
|
||||
fetchDocumentById as fetchDocumentByIdApi,
|
||||
createDocument as createDocumentApi,
|
||||
updateDocument as updateDocumentApi,
|
||||
deleteDocument as deleteDocumentApi,
|
||||
type TrusteeDocument,
|
||||
type PaginationParams
|
||||
} from '../api/trusteeApi';
|
||||
|
||||
export interface AttributeDefinition {
|
||||
name: string;
|
||||
type: 'text' | 'email' | 'date' | 'checkbox' | 'select' | 'multiselect' | 'number' | 'textarea' | 'timestamp' | 'file';
|
||||
label: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
default?: any;
|
||||
options?: any[] | string;
|
||||
readonly?: boolean;
|
||||
editable?: boolean;
|
||||
visible?: boolean;
|
||||
order?: number;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
searchable?: boolean;
|
||||
width?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
filterOptions?: string[];
|
||||
dependsOn?: string;
|
||||
}
|
||||
|
||||
// Re-export types
|
||||
export type { TrusteeDocument, PaginationParams };
|
||||
|
||||
// Documents list hook
|
||||
export function useTrusteeDocuments() {
|
||||
const instanceId = useInstanceId();
|
||||
|
||||
const [documents, setDocuments] = useState<TrusteeDocument[]>([]);
|
||||
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
|
||||
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
|
||||
const [pagination, setPagination] = useState<{
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
} | null>(null);
|
||||
const { request, isLoading: loading, error } = useApiRequest<null, TrusteeDocument[]>();
|
||||
const { checkPermission } = usePermissions();
|
||||
|
||||
// Fetch attributes from backend
|
||||
const fetchAttributes = useCallback(async () => {
|
||||
if (!instanceId) return [];
|
||||
|
||||
try {
|
||||
const response = await api.get(`/api/trustee/${instanceId}/attributes/TrusteeDocument`);
|
||||
|
||||
let attrs: AttributeDefinition[] = [];
|
||||
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
|
||||
attrs = response.data.attributes;
|
||||
} else if (Array.isArray(response.data)) {
|
||||
attrs = response.data;
|
||||
} else if (response.data && typeof response.data === 'object') {
|
||||
const keys = Object.keys(response.data);
|
||||
for (const key of keys) {
|
||||
if (Array.isArray(response.data[key])) {
|
||||
attrs = response.data[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setAttributes(attrs);
|
||||
return attrs;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching attributes:', error);
|
||||
setAttributes([]);
|
||||
return [];
|
||||
}
|
||||
}, [instanceId]);
|
||||
|
||||
// Fetch permissions from backend
|
||||
const fetchPermissions = useCallback(async () => {
|
||||
try {
|
||||
const objectKey = 'data.feature.trustee.TrusteeDocument';
|
||||
const perms = await checkPermission('DATA', objectKey);
|
||||
setPermissions(perms);
|
||||
return perms;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching permissions:', error);
|
||||
const defaultPerms: UserPermissions = {
|
||||
view: false,
|
||||
read: 'n',
|
||||
create: 'n',
|
||||
update: 'n',
|
||||
delete: 'n',
|
||||
};
|
||||
setPermissions(defaultPerms);
|
||||
return defaultPerms;
|
||||
}
|
||||
}, [checkPermission]);
|
||||
|
||||
const fetchDocuments = useCallback(async (params?: PaginationParams) => {
|
||||
if (!instanceId) {
|
||||
setDocuments([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchDocumentsApi(request, instanceId, params);
|
||||
|
||||
if (data && typeof data === 'object' && 'items' in data) {
|
||||
const items = Array.isArray(data.items) ? data.items : [];
|
||||
setDocuments(items);
|
||||
if (data.pagination) {
|
||||
setPagination(data.pagination);
|
||||
}
|
||||
} else {
|
||||
const items = Array.isArray(data) ? data : [];
|
||||
setDocuments(items);
|
||||
setPagination(null);
|
||||
}
|
||||
} catch (error: any) {
|
||||
setDocuments([]);
|
||||
setPagination(null);
|
||||
}
|
||||
}, [request, instanceId]);
|
||||
|
||||
// Optimistically remove a document
|
||||
const removeOptimistically = (documentId: string) => {
|
||||
setDocuments(prevDocs => prevDocs.filter(doc => doc.id !== documentId));
|
||||
};
|
||||
|
||||
// Optimistically update a document
|
||||
const updateOptimistically = (documentId: string, updateData: Partial<TrusteeDocument>) => {
|
||||
setDocuments(prevDocs =>
|
||||
prevDocs.map(doc =>
|
||||
doc.id === documentId
|
||||
? { ...doc, ...updateData }
|
||||
: doc
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Fetch a single document by ID
|
||||
const fetchDocumentById = useCallback(async (documentId: string): Promise<TrusteeDocument | null> => {
|
||||
if (!instanceId) return null;
|
||||
return await fetchDocumentByIdApi(request, instanceId, documentId);
|
||||
}, [request, instanceId]);
|
||||
|
||||
// Generate edit fields from attributes dynamically
|
||||
const generateEditFieldsFromAttributes = useCallback((): Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly';
|
||||
editable?: boolean;
|
||||
required?: boolean;
|
||||
validator?: (value: any) => string | null;
|
||||
options?: Array<{ value: string | number; label: string }>;
|
||||
optionsReference?: string;
|
||||
dependsOn?: string;
|
||||
}> => {
|
||||
if (!attributes || attributes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const editableFields = attributes
|
||||
.filter(attr => {
|
||||
if (attr.readonly === true || attr.editable === false) {
|
||||
return false;
|
||||
}
|
||||
// documentData is handled separately (binary upload)
|
||||
const nonEditableFields = ['id', 'documentData', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
|
||||
return !nonEditableFields.includes(attr.name);
|
||||
})
|
||||
.map(attr => {
|
||||
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string';
|
||||
let options: Array<{ value: string | number; label: string }> | undefined = undefined;
|
||||
let optionsReference: string | undefined = undefined;
|
||||
let dependsOn: string | undefined = undefined;
|
||||
|
||||
if (attr.type === 'checkbox') {
|
||||
fieldType = 'boolean';
|
||||
} else if (attr.type === 'email') {
|
||||
fieldType = 'email';
|
||||
} else if (attr.type === 'date') {
|
||||
fieldType = 'date';
|
||||
} else if (attr.type === 'select') {
|
||||
fieldType = 'enum';
|
||||
if (Array.isArray(attr.options)) {
|
||||
options = attr.options.map((opt: any) => {
|
||||
const labelValue = typeof opt.label === 'string'
|
||||
? opt.label
|
||||
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
|
||||
return {
|
||||
value: opt.value,
|
||||
label: labelValue
|
||||
};
|
||||
});
|
||||
} else if (typeof attr.options === 'string') {
|
||||
optionsReference = attr.options;
|
||||
}
|
||||
} else if (attr.type === 'textarea') {
|
||||
fieldType = 'textarea';
|
||||
}
|
||||
|
||||
// contractId depends on organisationId
|
||||
if (attr.name === 'contractId') {
|
||||
dependsOn = 'organisationId';
|
||||
}
|
||||
|
||||
let required = attr.required === true;
|
||||
let validator: ((value: any) => string | null) | undefined = undefined;
|
||||
|
||||
if (attr.name === 'organisationId' || attr.name === 'contractId' || attr.name === 'documentName') {
|
||||
required = true;
|
||||
validator = (value: any) => {
|
||||
if (!value || (typeof value === 'string' && value.trim() === '')) {
|
||||
return `${attr.label || attr.name} is required`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: fieldType,
|
||||
editable: attr.editable !== false && attr.readonly !== true,
|
||||
required,
|
||||
validator,
|
||||
options,
|
||||
optionsReference,
|
||||
dependsOn
|
||||
};
|
||||
});
|
||||
|
||||
return editableFields;
|
||||
}, [attributes]);
|
||||
|
||||
// Ensure attributes are loaded
|
||||
const ensureAttributesLoaded = useCallback(async () => {
|
||||
if (attributes && attributes.length > 0) {
|
||||
return attributes;
|
||||
}
|
||||
const fetchedAttributes = await fetchAttributes();
|
||||
return fetchedAttributes;
|
||||
}, [attributes, fetchAttributes]);
|
||||
|
||||
// Fetch data when instanceId is available
|
||||
useEffect(() => {
|
||||
if (instanceId) {
|
||||
fetchAttributes();
|
||||
fetchPermissions();
|
||||
fetchDocuments();
|
||||
}
|
||||
}, [instanceId, fetchAttributes, fetchPermissions, fetchDocuments]);
|
||||
|
||||
return {
|
||||
documents,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchDocuments,
|
||||
removeOptimistically,
|
||||
updateOptimistically,
|
||||
attributes,
|
||||
permissions,
|
||||
pagination,
|
||||
fetchDocumentById,
|
||||
generateEditFieldsFromAttributes,
|
||||
ensureAttributesLoaded,
|
||||
instanceId
|
||||
};
|
||||
}
|
||||
|
||||
// Document operations hook
|
||||
export function useTrusteeDocumentOperations() {
|
||||
const instanceId = useInstanceId();
|
||||
|
||||
const [deletingDocuments, setDeletingDocuments] = useState<Set<string>>(new Set());
|
||||
const [creatingDocument, setCreatingDocument] = useState(false);
|
||||
const [downloadingDocuments, setDownloadingDocuments] = useState<Set<string>>(new Set());
|
||||
const { request, isLoading } = useApiRequest();
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||
const [downloadError, setDownloadError] = useState<string | null>(null);
|
||||
|
||||
const handleDocumentDelete = async (documentId: string) => {
|
||||
if (!instanceId) {
|
||||
setDeleteError('No instance context');
|
||||
return false;
|
||||
}
|
||||
|
||||
setDeleteError(null);
|
||||
setDeletingDocuments(prev => new Set(prev).add(documentId));
|
||||
|
||||
try {
|
||||
await deleteDocumentApi(request, instanceId, documentId);
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
setDeleteError(error.message);
|
||||
return false;
|
||||
} finally {
|
||||
setDeletingDocuments(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(documentId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDocumentCreate = async (documentData: Partial<TrusteeDocument>) => {
|
||||
if (!instanceId) {
|
||||
setCreateError('No instance context');
|
||||
return { success: false, error: 'No instance context' };
|
||||
}
|
||||
|
||||
setCreateError(null);
|
||||
setCreatingDocument(true);
|
||||
|
||||
try {
|
||||
const newDocument = await createDocumentApi(request, instanceId, documentData);
|
||||
return { success: true, documentData: newDocument };
|
||||
} catch (error: any) {
|
||||
setCreateError(error.message);
|
||||
return { success: false, error: error.message };
|
||||
} finally {
|
||||
setCreatingDocument(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDocumentUpdate = async (
|
||||
documentId: string,
|
||||
updateData: Partial<TrusteeDocument>,
|
||||
_originalData?: any
|
||||
) => {
|
||||
if (!instanceId) {
|
||||
setUpdateError('No instance context');
|
||||
return { success: false, error: 'No instance context' };
|
||||
}
|
||||
|
||||
setUpdateError(null);
|
||||
|
||||
try {
|
||||
const updatedDocument = await updateDocumentApi(request, instanceId, documentId, updateData);
|
||||
return { success: true, documentData: updatedDocument };
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.message || error.message || 'Failed to update document';
|
||||
const statusCode = error.response?.status;
|
||||
|
||||
setUpdateError(errorMessage);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
statusCode,
|
||||
isPermissionError: statusCode === 403,
|
||||
isValidationError: statusCode === 400
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const handleDocumentDownload = async (documentId: string, documentName: string) => {
|
||||
if (!instanceId) {
|
||||
setDownloadError('No instance context');
|
||||
return false;
|
||||
}
|
||||
|
||||
setDownloadError(null);
|
||||
setDownloadingDocuments(prev => new Set(prev).add(documentId));
|
||||
|
||||
try {
|
||||
const doc = await fetchDocumentByIdApi(request, instanceId, documentId);
|
||||
if (!doc || !doc.documentData) {
|
||||
throw new Error('Document data not found');
|
||||
}
|
||||
|
||||
// Convert base64 to blob
|
||||
const byteCharacters = atob(doc.documentData);
|
||||
const byteNumbers = new Array(byteCharacters.length);
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
const blob = new Blob([byteArray], { type: doc.documentMimeType || 'application/octet-stream' });
|
||||
|
||||
// Create download link
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = documentName || `document-${documentId}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.message || 'Failed to download document';
|
||||
setDownloadError(errorMessage);
|
||||
return false;
|
||||
} finally {
|
||||
setDownloadingDocuments(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(documentId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
deletingDocuments,
|
||||
creatingDocument,
|
||||
downloadingDocuments,
|
||||
deleteError,
|
||||
createError,
|
||||
updateError,
|
||||
downloadError,
|
||||
handleDocumentDelete,
|
||||
handleDocumentCreate,
|
||||
handleDocumentUpdate,
|
||||
handleDocumentDownload,
|
||||
isLoading,
|
||||
instanceId
|
||||
};
|
||||
}
|
||||
399
src/hooks/useTrusteeOrganisations.ts
Normal file
399
src/hooks/useTrusteeOrganisations.ts
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useApiRequest } from './useApi';
|
||||
import api from '../api';
|
||||
import { usePermissions, type UserPermissions } from './usePermissions';
|
||||
import { useInstanceId } from './useCurrentInstance';
|
||||
import {
|
||||
fetchOrganisations as fetchOrganisationsApi,
|
||||
fetchOrganisationById as fetchOrganisationByIdApi,
|
||||
createOrganisation as createOrganisationApi,
|
||||
updateOrganisation as updateOrganisationApi,
|
||||
deleteOrganisation as deleteOrganisationApi,
|
||||
type TrusteeOrganisation,
|
||||
type PaginationParams
|
||||
} from '../api/trusteeApi';
|
||||
|
||||
export interface AttributeDefinition {
|
||||
name: string;
|
||||
type: 'text' | 'email' | 'date' | 'checkbox' | 'select' | 'multiselect' | 'number' | 'textarea' | 'timestamp' | 'file';
|
||||
label: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
default?: any;
|
||||
options?: any[] | string;
|
||||
readonly?: boolean;
|
||||
editable?: boolean;
|
||||
visible?: boolean;
|
||||
order?: number;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
searchable?: boolean;
|
||||
width?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
filterOptions?: string[];
|
||||
dependsOn?: string;
|
||||
}
|
||||
|
||||
// Re-export types
|
||||
export type { TrusteeOrganisation, PaginationParams };
|
||||
|
||||
// Organisations list hook
|
||||
export function useTrusteeOrganisations() {
|
||||
const instanceId = useInstanceId();
|
||||
|
||||
const [organisations, setOrganisations] = useState<TrusteeOrganisation[]>([]);
|
||||
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
|
||||
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
|
||||
const [pagination, setPagination] = useState<{
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
} | null>(null);
|
||||
const { request, isLoading: loading, error } = useApiRequest<null, TrusteeOrganisation[]>();
|
||||
const { checkPermission } = usePermissions();
|
||||
|
||||
// Fetch attributes from backend
|
||||
const fetchAttributes = useCallback(async () => {
|
||||
if (!instanceId) return [];
|
||||
|
||||
try {
|
||||
const response = await api.get(`/api/trustee/${instanceId}/attributes/TrusteeOrganisation`);
|
||||
|
||||
let attrs: AttributeDefinition[] = [];
|
||||
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
|
||||
attrs = response.data.attributes;
|
||||
} else if (Array.isArray(response.data)) {
|
||||
attrs = response.data;
|
||||
} else if (response.data && typeof response.data === 'object') {
|
||||
const keys = Object.keys(response.data);
|
||||
for (const key of keys) {
|
||||
if (Array.isArray(response.data[key])) {
|
||||
attrs = response.data[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setAttributes(attrs);
|
||||
return attrs;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching attributes:', error);
|
||||
setAttributes([]);
|
||||
return [];
|
||||
}
|
||||
}, [instanceId]);
|
||||
|
||||
// Fetch permissions from backend
|
||||
const fetchPermissions = useCallback(async () => {
|
||||
try {
|
||||
const objectKey = 'data.feature.trustee.TrusteeOrganisation';
|
||||
const perms = await checkPermission('DATA', objectKey);
|
||||
setPermissions(perms);
|
||||
return perms;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching permissions:', error);
|
||||
const defaultPerms: UserPermissions = {
|
||||
view: false,
|
||||
read: 'n',
|
||||
create: 'n',
|
||||
update: 'n',
|
||||
delete: 'n',
|
||||
};
|
||||
setPermissions(defaultPerms);
|
||||
return defaultPerms;
|
||||
}
|
||||
}, [checkPermission]);
|
||||
|
||||
const fetchOrganisations = useCallback(async (params?: PaginationParams) => {
|
||||
if (!instanceId) {
|
||||
setOrganisations([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchOrganisationsApi(request, instanceId, params);
|
||||
|
||||
if (data && typeof data === 'object' && 'items' in data) {
|
||||
const items = Array.isArray(data.items) ? data.items : [];
|
||||
setOrganisations(items);
|
||||
if (data.pagination) {
|
||||
setPagination(data.pagination);
|
||||
}
|
||||
} else {
|
||||
const items = Array.isArray(data) ? data : [];
|
||||
setOrganisations(items);
|
||||
setPagination(null);
|
||||
}
|
||||
} catch (error: any) {
|
||||
setOrganisations([]);
|
||||
setPagination(null);
|
||||
}
|
||||
}, [request, instanceId]);
|
||||
|
||||
// Optimistically remove an organisation
|
||||
const removeOptimistically = (organisationId: string) => {
|
||||
setOrganisations(prevOrgs => prevOrgs.filter(org => org.id !== organisationId));
|
||||
};
|
||||
|
||||
// Optimistically update an organisation
|
||||
const updateOptimistically = (organisationId: string, updateData: Partial<TrusteeOrganisation>) => {
|
||||
setOrganisations(prevOrgs =>
|
||||
prevOrgs.map(org =>
|
||||
org.id === organisationId
|
||||
? { ...org, ...updateData }
|
||||
: org
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Fetch a single organisation by ID
|
||||
const fetchOrganisationById = useCallback(async (organisationId: string): Promise<TrusteeOrganisation | null> => {
|
||||
if (!instanceId) return null;
|
||||
return await fetchOrganisationByIdApi(request, instanceId, organisationId);
|
||||
}, [request, instanceId]);
|
||||
|
||||
// Generate edit fields from attributes dynamically
|
||||
const generateEditFieldsFromAttributes = useCallback((): Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly';
|
||||
editable?: boolean;
|
||||
required?: boolean;
|
||||
validator?: (value: any) => string | null;
|
||||
options?: Array<{ value: string | number; label: string }>;
|
||||
optionsReference?: string;
|
||||
}> => {
|
||||
if (!attributes || attributes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const editableFields = attributes
|
||||
.filter(attr => {
|
||||
if (attr.readonly === true || attr.editable === false) {
|
||||
return false;
|
||||
}
|
||||
const nonEditableFields = ['mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
|
||||
return !nonEditableFields.includes(attr.name);
|
||||
})
|
||||
.map(attr => {
|
||||
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string';
|
||||
let options: Array<{ value: string | number; label: string }> | undefined = undefined;
|
||||
let optionsReference: string | undefined = undefined;
|
||||
|
||||
if (attr.type === 'checkbox') {
|
||||
fieldType = 'boolean';
|
||||
} else if (attr.type === 'email') {
|
||||
fieldType = 'email';
|
||||
} else if (attr.type === 'date') {
|
||||
fieldType = 'date';
|
||||
} else if (attr.type === 'select') {
|
||||
fieldType = 'enum';
|
||||
if (Array.isArray(attr.options)) {
|
||||
options = attr.options.map((opt: any) => {
|
||||
const labelValue = typeof opt.label === 'string'
|
||||
? opt.label
|
||||
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
|
||||
return {
|
||||
value: opt.value,
|
||||
label: labelValue
|
||||
};
|
||||
});
|
||||
} else if (typeof attr.options === 'string') {
|
||||
optionsReference = attr.options;
|
||||
}
|
||||
} else if (attr.type === 'multiselect') {
|
||||
fieldType = 'multiselect';
|
||||
if (Array.isArray(attr.options)) {
|
||||
options = attr.options.map((opt: any) => {
|
||||
const labelValue = typeof opt.label === 'string'
|
||||
? opt.label
|
||||
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
|
||||
return {
|
||||
value: opt.value,
|
||||
label: labelValue
|
||||
};
|
||||
});
|
||||
} else if (typeof attr.options === 'string') {
|
||||
optionsReference = attr.options;
|
||||
}
|
||||
} else if (attr.type === 'textarea') {
|
||||
fieldType = 'textarea';
|
||||
}
|
||||
|
||||
let required = attr.required === true;
|
||||
let validator: ((value: any) => string | null) | undefined = undefined;
|
||||
|
||||
// Special validation for 'id' field (alphanumeric + dash/underscore, 3-50 chars)
|
||||
if (attr.name === 'id') {
|
||||
required = true;
|
||||
validator = (value: string) => {
|
||||
if (!value || value.trim() === '') {
|
||||
return 'Organisation ID cannot be empty';
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]{3,50}$/.test(value)) {
|
||||
return 'ID must be 3-50 characters long (alphanumeric, dash, underscore only)';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
} else if (attr.name === 'label') {
|
||||
required = true;
|
||||
validator = (value: string) => {
|
||||
if (!value || value.trim() === '') {
|
||||
return 'Label cannot be empty';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: fieldType,
|
||||
editable: attr.editable !== false && attr.readonly !== true,
|
||||
required,
|
||||
validator,
|
||||
options,
|
||||
optionsReference
|
||||
};
|
||||
});
|
||||
|
||||
return editableFields;
|
||||
}, [attributes]);
|
||||
|
||||
// Ensure attributes are loaded
|
||||
const ensureAttributesLoaded = useCallback(async () => {
|
||||
if (attributes && attributes.length > 0) {
|
||||
return attributes;
|
||||
}
|
||||
const fetchedAttributes = await fetchAttributes();
|
||||
return fetchedAttributes;
|
||||
}, [attributes, fetchAttributes]);
|
||||
|
||||
// Fetch data when instanceId is available
|
||||
useEffect(() => {
|
||||
if (instanceId) {
|
||||
fetchAttributes();
|
||||
fetchPermissions();
|
||||
fetchOrganisations();
|
||||
}
|
||||
}, [instanceId, fetchAttributes, fetchPermissions, fetchOrganisations]);
|
||||
|
||||
return {
|
||||
organisations,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchOrganisations,
|
||||
removeOptimistically,
|
||||
updateOptimistically,
|
||||
attributes,
|
||||
permissions,
|
||||
pagination,
|
||||
fetchOrganisationById,
|
||||
generateEditFieldsFromAttributes,
|
||||
ensureAttributesLoaded,
|
||||
instanceId
|
||||
};
|
||||
}
|
||||
|
||||
// Organisation operations hook
|
||||
export function useTrusteeOrganisationOperations() {
|
||||
const instanceId = useInstanceId();
|
||||
|
||||
const [deletingOrganisations, setDeletingOrganisations] = useState<Set<string>>(new Set());
|
||||
const [creatingOrganisation, setCreatingOrganisation] = useState(false);
|
||||
const { request, isLoading } = useApiRequest();
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||
|
||||
const handleOrganisationDelete = async (organisationId: string) => {
|
||||
if (!instanceId) {
|
||||
setDeleteError('No instance context');
|
||||
return false;
|
||||
}
|
||||
|
||||
setDeleteError(null);
|
||||
setDeletingOrganisations(prev => new Set(prev).add(organisationId));
|
||||
|
||||
try {
|
||||
await deleteOrganisationApi(request, instanceId, organisationId);
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
setDeleteError(error.message);
|
||||
return false;
|
||||
} finally {
|
||||
setDeletingOrganisations(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(organisationId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleOrganisationCreate = async (organisationData: Partial<TrusteeOrganisation>) => {
|
||||
if (!instanceId) {
|
||||
setCreateError('No instance context');
|
||||
return { success: false, error: 'No instance context' };
|
||||
}
|
||||
|
||||
setCreateError(null);
|
||||
setCreatingOrganisation(true);
|
||||
|
||||
try {
|
||||
const newOrganisation = await createOrganisationApi(request, instanceId, organisationData);
|
||||
return { success: true, organisationData: newOrganisation };
|
||||
} catch (error: any) {
|
||||
setCreateError(error.message);
|
||||
return { success: false, error: error.message };
|
||||
} finally {
|
||||
setCreatingOrganisation(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOrganisationUpdate = async (
|
||||
organisationId: string,
|
||||
updateData: Partial<TrusteeOrganisation>,
|
||||
_originalData?: any
|
||||
) => {
|
||||
if (!instanceId) {
|
||||
setUpdateError('No instance context');
|
||||
return { success: false, error: 'No instance context' };
|
||||
}
|
||||
|
||||
setUpdateError(null);
|
||||
|
||||
try {
|
||||
const updatedOrganisation = await updateOrganisationApi(request, instanceId, organisationId, updateData);
|
||||
return { success: true, organisationData: updatedOrganisation };
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.message || error.message || 'Failed to update organisation';
|
||||
const statusCode = error.response?.status;
|
||||
|
||||
setUpdateError(errorMessage);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
statusCode,
|
||||
isPermissionError: statusCode === 403,
|
||||
isValidationError: statusCode === 400
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
deletingOrganisations,
|
||||
creatingOrganisation,
|
||||
deleteError,
|
||||
createError,
|
||||
updateError,
|
||||
handleOrganisationDelete,
|
||||
handleOrganisationCreate,
|
||||
handleOrganisationUpdate,
|
||||
isLoading,
|
||||
instanceId
|
||||
};
|
||||
}
|
||||
337
src/hooks/useTrusteePositionDocuments.ts
Normal file
337
src/hooks/useTrusteePositionDocuments.ts
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useApiRequest } from './useApi';
|
||||
import api from '../api';
|
||||
import { usePermissions, type UserPermissions } from './usePermissions';
|
||||
import { useInstanceId } from './useCurrentInstance';
|
||||
import {
|
||||
fetchPositionDocuments as fetchPositionDocumentsApi,
|
||||
fetchPositionDocumentById as fetchPositionDocumentByIdApi,
|
||||
createPositionDocument as createPositionDocumentApi,
|
||||
deletePositionDocument as deletePositionDocumentApi,
|
||||
type TrusteePositionDocument,
|
||||
type PaginationParams
|
||||
} from '../api/trusteeApi';
|
||||
|
||||
export interface AttributeDefinition {
|
||||
name: string;
|
||||
type: 'text' | 'email' | 'date' | 'checkbox' | 'select' | 'multiselect' | 'number' | 'textarea' | 'timestamp' | 'file';
|
||||
label: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
default?: any;
|
||||
options?: any[] | string;
|
||||
readonly?: boolean;
|
||||
editable?: boolean;
|
||||
visible?: boolean;
|
||||
order?: number;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
searchable?: boolean;
|
||||
width?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
filterOptions?: string[];
|
||||
dependsOn?: string;
|
||||
}
|
||||
|
||||
// Re-export types
|
||||
export type { TrusteePositionDocument, PaginationParams };
|
||||
|
||||
// Position-Documents list hook
|
||||
export function useTrusteePositionDocuments() {
|
||||
const instanceId = useInstanceId();
|
||||
|
||||
const [positionDocuments, setPositionDocuments] = useState<TrusteePositionDocument[]>([]);
|
||||
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
|
||||
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
|
||||
const [pagination, setPagination] = useState<{
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
} | null>(null);
|
||||
const { request, isLoading: loading, error } = useApiRequest<null, TrusteePositionDocument[]>();
|
||||
const { checkPermission } = usePermissions();
|
||||
|
||||
// Fetch attributes from backend
|
||||
const fetchAttributes = useCallback(async () => {
|
||||
if (!instanceId) return [];
|
||||
|
||||
try {
|
||||
const response = await api.get(`/api/trustee/${instanceId}/attributes/TrusteePositionDocument`);
|
||||
|
||||
let attrs: AttributeDefinition[] = [];
|
||||
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
|
||||
attrs = response.data.attributes;
|
||||
} else if (Array.isArray(response.data)) {
|
||||
attrs = response.data;
|
||||
} else if (response.data && typeof response.data === 'object') {
|
||||
const keys = Object.keys(response.data);
|
||||
for (const key of keys) {
|
||||
if (Array.isArray(response.data[key])) {
|
||||
attrs = response.data[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setAttributes(attrs);
|
||||
return attrs;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching attributes:', error);
|
||||
setAttributes([]);
|
||||
return [];
|
||||
}
|
||||
}, [instanceId]);
|
||||
|
||||
// Fetch permissions from backend
|
||||
const fetchPermissions = useCallback(async () => {
|
||||
try {
|
||||
const objectKey = 'data.feature.trustee.TrusteePositionDocument';
|
||||
const perms = await checkPermission('DATA', objectKey);
|
||||
setPermissions(perms);
|
||||
return perms;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching permissions:', error);
|
||||
const defaultPerms: UserPermissions = {
|
||||
view: false,
|
||||
read: 'n',
|
||||
create: 'n',
|
||||
update: 'n',
|
||||
delete: 'n',
|
||||
};
|
||||
setPermissions(defaultPerms);
|
||||
return defaultPerms;
|
||||
}
|
||||
}, [checkPermission]);
|
||||
|
||||
const fetchPositionDocuments = useCallback(async (params?: PaginationParams) => {
|
||||
if (!instanceId) {
|
||||
setPositionDocuments([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchPositionDocumentsApi(request, instanceId, params);
|
||||
|
||||
if (data && typeof data === 'object' && 'items' in data) {
|
||||
const items = Array.isArray(data.items) ? data.items : [];
|
||||
setPositionDocuments(items);
|
||||
if (data.pagination) {
|
||||
setPagination(data.pagination);
|
||||
}
|
||||
} else {
|
||||
const items = Array.isArray(data) ? data : [];
|
||||
setPositionDocuments(items);
|
||||
setPagination(null);
|
||||
}
|
||||
} catch (error: any) {
|
||||
setPositionDocuments([]);
|
||||
setPagination(null);
|
||||
}
|
||||
}, [request, instanceId]);
|
||||
|
||||
// Optimistically remove a position-document link
|
||||
const removeOptimistically = (positionDocumentId: string) => {
|
||||
setPositionDocuments(prevPD => prevPD.filter(pd => pd.id !== positionDocumentId));
|
||||
};
|
||||
|
||||
// Fetch a single position-document by ID
|
||||
const fetchPositionDocumentById = useCallback(async (positionDocumentId: string): Promise<TrusteePositionDocument | null> => {
|
||||
if (!instanceId) return null;
|
||||
return await fetchPositionDocumentByIdApi(request, instanceId, positionDocumentId);
|
||||
}, [request, instanceId]);
|
||||
|
||||
// Generate edit fields from attributes dynamically
|
||||
const generateEditFieldsFromAttributes = useCallback((): Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly';
|
||||
editable?: boolean;
|
||||
required?: boolean;
|
||||
validator?: (value: any) => string | null;
|
||||
options?: Array<{ value: string | number; label: string }>;
|
||||
optionsReference?: string;
|
||||
dependsOn?: string;
|
||||
}> => {
|
||||
if (!attributes || attributes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const editableFields = attributes
|
||||
.filter(attr => {
|
||||
if (attr.readonly === true || attr.editable === false) {
|
||||
return false;
|
||||
}
|
||||
const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
|
||||
return !nonEditableFields.includes(attr.name);
|
||||
})
|
||||
.map(attr => {
|
||||
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string';
|
||||
let options: Array<{ value: string | number; label: string }> | undefined = undefined;
|
||||
let optionsReference: string | undefined = undefined;
|
||||
let dependsOn: string | undefined = undefined;
|
||||
|
||||
if (attr.type === 'checkbox') {
|
||||
fieldType = 'boolean';
|
||||
} else if (attr.type === 'email') {
|
||||
fieldType = 'email';
|
||||
} else if (attr.type === 'date') {
|
||||
fieldType = 'date';
|
||||
} else if (attr.type === 'select') {
|
||||
fieldType = 'enum';
|
||||
if (Array.isArray(attr.options)) {
|
||||
options = attr.options.map((opt: any) => {
|
||||
const labelValue = typeof opt.label === 'string'
|
||||
? opt.label
|
||||
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
|
||||
return {
|
||||
value: opt.value,
|
||||
label: labelValue
|
||||
};
|
||||
});
|
||||
} else if (typeof attr.options === 'string') {
|
||||
optionsReference = attr.options;
|
||||
}
|
||||
} else if (attr.type === 'textarea') {
|
||||
fieldType = 'textarea';
|
||||
}
|
||||
|
||||
// Dependency chain: contractId depends on organisationId
|
||||
// positionId and documentId depend on contractId
|
||||
if (attr.name === 'contractId') {
|
||||
dependsOn = 'organisationId';
|
||||
} else if (attr.name === 'positionId' || attr.name === 'documentId') {
|
||||
dependsOn = 'contractId';
|
||||
}
|
||||
|
||||
let required = attr.required === true;
|
||||
let validator: ((value: any) => string | null) | undefined = undefined;
|
||||
|
||||
if (attr.name === 'organisationId' || attr.name === 'contractId' ||
|
||||
attr.name === 'positionId' || attr.name === 'documentId') {
|
||||
required = true;
|
||||
validator = (value: any) => {
|
||||
if (!value || (typeof value === 'string' && value.trim() === '')) {
|
||||
return `${attr.label || attr.name} is required`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: fieldType,
|
||||
editable: attr.editable !== false && attr.readonly !== true,
|
||||
required,
|
||||
validator,
|
||||
options,
|
||||
optionsReference,
|
||||
dependsOn
|
||||
};
|
||||
});
|
||||
|
||||
return editableFields;
|
||||
}, [attributes]);
|
||||
|
||||
// Ensure attributes are loaded
|
||||
const ensureAttributesLoaded = useCallback(async () => {
|
||||
if (attributes && attributes.length > 0) {
|
||||
return attributes;
|
||||
}
|
||||
const fetchedAttributes = await fetchAttributes();
|
||||
return fetchedAttributes;
|
||||
}, [attributes, fetchAttributes]);
|
||||
|
||||
// Fetch data when instanceId is available
|
||||
useEffect(() => {
|
||||
if (instanceId) {
|
||||
fetchAttributes();
|
||||
fetchPermissions();
|
||||
fetchPositionDocuments();
|
||||
}
|
||||
}, [instanceId, fetchAttributes, fetchPermissions, fetchPositionDocuments]);
|
||||
|
||||
return {
|
||||
positionDocuments,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchPositionDocuments,
|
||||
removeOptimistically,
|
||||
attributes,
|
||||
permissions,
|
||||
pagination,
|
||||
fetchPositionDocumentById,
|
||||
generateEditFieldsFromAttributes,
|
||||
ensureAttributesLoaded,
|
||||
instanceId
|
||||
};
|
||||
}
|
||||
|
||||
// Position-Document operations hook
|
||||
export function useTrusteePositionDocumentOperations() {
|
||||
const instanceId = useInstanceId();
|
||||
|
||||
const [deletingPositionDocuments, setDeletingPositionDocuments] = useState<Set<string>>(new Set());
|
||||
const [creatingPositionDocument, setCreatingPositionDocument] = useState(false);
|
||||
const { request, isLoading } = useApiRequest();
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const handlePositionDocumentDelete = async (positionDocumentId: string) => {
|
||||
if (!instanceId) {
|
||||
setDeleteError('No instance context');
|
||||
return false;
|
||||
}
|
||||
|
||||
setDeleteError(null);
|
||||
setDeletingPositionDocuments(prev => new Set(prev).add(positionDocumentId));
|
||||
|
||||
try {
|
||||
await deletePositionDocumentApi(request, instanceId, positionDocumentId);
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
setDeleteError(error.message);
|
||||
return false;
|
||||
} finally {
|
||||
setDeletingPositionDocuments(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(positionDocumentId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePositionDocumentCreate = async (positionDocumentData: Partial<TrusteePositionDocument>) => {
|
||||
if (!instanceId) {
|
||||
setCreateError('No instance context');
|
||||
return { success: false, error: 'No instance context' };
|
||||
}
|
||||
|
||||
setCreateError(null);
|
||||
setCreatingPositionDocument(true);
|
||||
|
||||
try {
|
||||
const newPositionDocument = await createPositionDocumentApi(request, instanceId, positionDocumentData);
|
||||
return { success: true, positionDocumentData: newPositionDocument };
|
||||
} catch (error: any) {
|
||||
setCreateError(error.message);
|
||||
return { success: false, error: error.message };
|
||||
} finally {
|
||||
setCreatingPositionDocument(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
deletingPositionDocuments,
|
||||
creatingPositionDocument,
|
||||
deleteError,
|
||||
createError,
|
||||
handlePositionDocumentDelete,
|
||||
handlePositionDocumentCreate,
|
||||
isLoading,
|
||||
instanceId
|
||||
};
|
||||
}
|
||||
448
src/hooks/useTrusteePositions.ts
Normal file
448
src/hooks/useTrusteePositions.ts
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useApiRequest } from './useApi';
|
||||
import api from '../api';
|
||||
import { usePermissions, type UserPermissions } from './usePermissions';
|
||||
import { useInstanceId } from './useCurrentInstance';
|
||||
import {
|
||||
fetchPositions as fetchPositionsApi,
|
||||
fetchPositionById as fetchPositionByIdApi,
|
||||
createPosition as createPositionApi,
|
||||
updatePosition as updatePositionApi,
|
||||
deletePosition as deletePositionApi,
|
||||
type TrusteePosition,
|
||||
type PaginationParams
|
||||
} from '../api/trusteeApi';
|
||||
|
||||
export interface AttributeDefinition {
|
||||
name: string;
|
||||
type: 'text' | 'email' | 'date' | 'checkbox' | 'select' | 'multiselect' | 'number' | 'textarea' | 'timestamp' | 'file';
|
||||
label: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
default?: any;
|
||||
options?: any[] | string;
|
||||
readonly?: boolean;
|
||||
editable?: boolean;
|
||||
visible?: boolean;
|
||||
order?: number;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
searchable?: boolean;
|
||||
width?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
filterOptions?: string[];
|
||||
dependsOn?: string;
|
||||
}
|
||||
|
||||
// Re-export types
|
||||
export type { TrusteePosition, PaginationParams };
|
||||
|
||||
// Positions list hook
|
||||
export function useTrusteePositions() {
|
||||
const instanceId = useInstanceId();
|
||||
|
||||
const [positions, setPositions] = useState<TrusteePosition[]>([]);
|
||||
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
|
||||
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
|
||||
const [pagination, setPagination] = useState<{
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
} | null>(null);
|
||||
const { request, isLoading: loading, error } = useApiRequest<null, TrusteePosition[]>();
|
||||
const { checkPermission } = usePermissions();
|
||||
|
||||
// Fetch attributes from backend
|
||||
const fetchAttributes = useCallback(async () => {
|
||||
if (!instanceId) return [];
|
||||
|
||||
try {
|
||||
const response = await api.get(`/api/trustee/${instanceId}/attributes/TrusteePosition`);
|
||||
|
||||
let attrs: AttributeDefinition[] = [];
|
||||
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
|
||||
attrs = response.data.attributes;
|
||||
} else if (Array.isArray(response.data)) {
|
||||
attrs = response.data;
|
||||
} else if (response.data && typeof response.data === 'object') {
|
||||
const keys = Object.keys(response.data);
|
||||
for (const key of keys) {
|
||||
if (Array.isArray(response.data[key])) {
|
||||
attrs = response.data[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setAttributes(attrs);
|
||||
return attrs;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching attributes:', error);
|
||||
setAttributes([]);
|
||||
return [];
|
||||
}
|
||||
}, [instanceId]);
|
||||
|
||||
// Fetch permissions from backend
|
||||
const fetchPermissions = useCallback(async () => {
|
||||
try {
|
||||
const objectKey = 'data.feature.trustee.TrusteePosition';
|
||||
const perms = await checkPermission('DATA', objectKey);
|
||||
setPermissions(perms);
|
||||
return perms;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching permissions:', error);
|
||||
const defaultPerms: UserPermissions = {
|
||||
view: false,
|
||||
read: 'n',
|
||||
create: 'n',
|
||||
update: 'n',
|
||||
delete: 'n',
|
||||
};
|
||||
setPermissions(defaultPerms);
|
||||
return defaultPerms;
|
||||
}
|
||||
}, [checkPermission]);
|
||||
|
||||
const fetchPositions = useCallback(async (params?: PaginationParams) => {
|
||||
if (!instanceId) {
|
||||
setPositions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchPositionsApi(request, instanceId, params);
|
||||
|
||||
if (data && typeof data === 'object' && 'items' in data) {
|
||||
const items = Array.isArray(data.items) ? data.items : [];
|
||||
setPositions(items);
|
||||
if (data.pagination) {
|
||||
setPagination(data.pagination);
|
||||
}
|
||||
} else {
|
||||
const items = Array.isArray(data) ? data : [];
|
||||
setPositions(items);
|
||||
setPagination(null);
|
||||
}
|
||||
} catch (error: any) {
|
||||
setPositions([]);
|
||||
setPagination(null);
|
||||
}
|
||||
}, [request, instanceId]);
|
||||
|
||||
// Optimistically remove a position
|
||||
const removeOptimistically = (positionId: string) => {
|
||||
setPositions(prevPositions => prevPositions.filter(pos => pos.id !== positionId));
|
||||
};
|
||||
|
||||
// Optimistically update a position
|
||||
const updateOptimistically = (positionId: string, updateData: Partial<TrusteePosition>) => {
|
||||
setPositions(prevPositions =>
|
||||
prevPositions.map(pos =>
|
||||
pos.id === positionId
|
||||
? { ...pos, ...updateData }
|
||||
: pos
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Fetch a single position by ID
|
||||
const fetchPositionById = useCallback(async (positionId: string): Promise<TrusteePosition | null> => {
|
||||
if (!instanceId) return null;
|
||||
return await fetchPositionByIdApi(request, instanceId, positionId);
|
||||
}, [request, instanceId]);
|
||||
|
||||
// Generate edit fields from attributes dynamically with MwSt calculation logic
|
||||
const generateEditFieldsFromAttributes = useCallback((): Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' | 'number';
|
||||
editable?: boolean;
|
||||
required?: boolean;
|
||||
validator?: (value: any, formData?: any) => string | null;
|
||||
onChange?: (value: any, formData: any) => Partial<any>;
|
||||
options?: Array<{ value: string | number; label: string }>;
|
||||
optionsReference?: string;
|
||||
dependsOn?: string;
|
||||
minRows?: number;
|
||||
maxRows?: number;
|
||||
}> => {
|
||||
if (!attributes || attributes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const editableFields = attributes
|
||||
.filter(attr => {
|
||||
if (attr.readonly === true || attr.editable === false) {
|
||||
return false;
|
||||
}
|
||||
const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
|
||||
return !nonEditableFields.includes(attr.name);
|
||||
})
|
||||
.map(attr => {
|
||||
const isDescField = attr.name === 'desc' || attr.name.toLowerCase().includes('description');
|
||||
|
||||
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' | 'number' = 'string';
|
||||
let options: Array<{ value: string | number; label: string }> | undefined = undefined;
|
||||
let optionsReference: string | undefined = undefined;
|
||||
let dependsOn: string | undefined = undefined;
|
||||
let minRows: number | undefined = undefined;
|
||||
let maxRows: number | undefined = undefined;
|
||||
let onChange: ((value: any, formData: any) => Partial<any>) | undefined = undefined;
|
||||
|
||||
if (isDescField) {
|
||||
fieldType = 'textarea';
|
||||
minRows = 3;
|
||||
maxRows = 8;
|
||||
} else if (attr.type === 'checkbox') {
|
||||
fieldType = 'boolean';
|
||||
} else if (attr.type === 'email') {
|
||||
fieldType = 'email';
|
||||
} else if (attr.type === 'date') {
|
||||
fieldType = 'date';
|
||||
} else if (attr.type === 'number') {
|
||||
fieldType = 'number';
|
||||
} else if (attr.type === 'select') {
|
||||
fieldType = 'enum';
|
||||
if (Array.isArray(attr.options)) {
|
||||
options = attr.options.map((opt: any) => {
|
||||
const labelValue = typeof opt.label === 'string'
|
||||
? opt.label
|
||||
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
|
||||
return {
|
||||
value: opt.value,
|
||||
label: labelValue
|
||||
};
|
||||
});
|
||||
} else if (typeof attr.options === 'string') {
|
||||
optionsReference = attr.options;
|
||||
}
|
||||
} else if (attr.type === 'textarea') {
|
||||
fieldType = 'textarea';
|
||||
minRows = minRows || 3;
|
||||
maxRows = maxRows || 8;
|
||||
}
|
||||
|
||||
// contractId depends on organisationId
|
||||
if (attr.name === 'contractId') {
|
||||
dependsOn = 'organisationId';
|
||||
}
|
||||
|
||||
// CUSTOM LOGIC: MwSt-Berechnung
|
||||
// When bookingAmount or vatPercentage changes, auto-calculate vatAmount
|
||||
if (attr.name === 'bookingAmount') {
|
||||
onChange = (value: number, formData: any) => {
|
||||
const amount = parseFloat(String(value)) || 0;
|
||||
const percentage = parseFloat(String(formData.vatPercentage)) || 0;
|
||||
const calculatedVat = amount * (percentage / 100);
|
||||
return { vatAmount: calculatedVat };
|
||||
};
|
||||
} else if (attr.name === 'vatPercentage') {
|
||||
onChange = (value: number, formData: any) => {
|
||||
const percentage = parseFloat(String(value)) || 0;
|
||||
const amount = parseFloat(String(formData.bookingAmount)) || 0;
|
||||
const calculatedVat = amount * (percentage / 100);
|
||||
return { vatAmount: calculatedVat };
|
||||
};
|
||||
}
|
||||
|
||||
let required = attr.required === true;
|
||||
let validator: ((value: any, formData?: any) => string | null) | undefined = undefined;
|
||||
|
||||
// CUSTOM LOGIC: vatAmount validator - warn if manually overridden
|
||||
if (attr.name === 'vatAmount') {
|
||||
validator = (value: any, formData?: any) => {
|
||||
if (!formData) return null;
|
||||
|
||||
const vatAmount = parseFloat(String(value)) || 0;
|
||||
const bookingAmount = parseFloat(String(formData.bookingAmount)) || 0;
|
||||
const vatPercentage = parseFloat(String(formData.vatPercentage)) || 0;
|
||||
const calculatedVat = bookingAmount * (vatPercentage / 100);
|
||||
|
||||
if (Math.abs(vatAmount - calculatedVat) > 0.01) {
|
||||
return 'MwSt-Betrag weicht von Berechnung ab (manuell überschrieben)';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
// Standard validators
|
||||
if (attr.name === 'organisationId' || attr.name === 'contractId') {
|
||||
required = true;
|
||||
validator = (value: any) => {
|
||||
if (!value || (typeof value === 'string' && value.trim() === '')) {
|
||||
return `${attr.label || attr.name} is required`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
} else if (attr.name === 'valuta' || attr.name === 'transactionDateTime') {
|
||||
required = true;
|
||||
} else if (attr.name === 'bookingCurrency' || attr.name === 'originalCurrency') {
|
||||
required = true;
|
||||
} else if (attr.name === 'bookingAmount' || attr.name === 'originalAmount') {
|
||||
required = true;
|
||||
validator = (value: any) => {
|
||||
const num = parseFloat(String(value));
|
||||
if (isNaN(num)) {
|
||||
return 'Must be a valid number';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: fieldType,
|
||||
editable: attr.editable !== false && attr.readonly !== true,
|
||||
required,
|
||||
validator,
|
||||
onChange,
|
||||
options,
|
||||
optionsReference,
|
||||
dependsOn,
|
||||
minRows,
|
||||
maxRows
|
||||
};
|
||||
});
|
||||
|
||||
return editableFields;
|
||||
}, [attributes]);
|
||||
|
||||
// Ensure attributes are loaded
|
||||
const ensureAttributesLoaded = useCallback(async () => {
|
||||
if (attributes && attributes.length > 0) {
|
||||
return attributes;
|
||||
}
|
||||
const fetchedAttributes = await fetchAttributes();
|
||||
return fetchedAttributes;
|
||||
}, [attributes, fetchAttributes]);
|
||||
|
||||
// Fetch data when instanceId is available
|
||||
useEffect(() => {
|
||||
if (instanceId) {
|
||||
fetchAttributes();
|
||||
fetchPermissions();
|
||||
fetchPositions();
|
||||
}
|
||||
}, [instanceId, fetchAttributes, fetchPermissions, fetchPositions]);
|
||||
|
||||
return {
|
||||
positions,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchPositions,
|
||||
removeOptimistically,
|
||||
updateOptimistically,
|
||||
attributes,
|
||||
permissions,
|
||||
pagination,
|
||||
fetchPositionById,
|
||||
generateEditFieldsFromAttributes,
|
||||
ensureAttributesLoaded,
|
||||
instanceId
|
||||
};
|
||||
}
|
||||
|
||||
// Position operations hook
|
||||
export function useTrusteePositionOperations() {
|
||||
const instanceId = useInstanceId();
|
||||
|
||||
const [deletingPositions, setDeletingPositions] = useState<Set<string>>(new Set());
|
||||
const [creatingPosition, setCreatingPosition] = useState(false);
|
||||
const { request, isLoading } = useApiRequest();
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||
|
||||
const handlePositionDelete = async (positionId: string) => {
|
||||
if (!instanceId) {
|
||||
setDeleteError('No instance context');
|
||||
return false;
|
||||
}
|
||||
|
||||
setDeleteError(null);
|
||||
setDeletingPositions(prev => new Set(prev).add(positionId));
|
||||
|
||||
try {
|
||||
await deletePositionApi(request, instanceId, positionId);
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
setDeleteError(error.message);
|
||||
return false;
|
||||
} finally {
|
||||
setDeletingPositions(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(positionId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePositionCreate = async (positionData: Partial<TrusteePosition>) => {
|
||||
if (!instanceId) {
|
||||
setCreateError('No instance context');
|
||||
return { success: false, error: 'No instance context' };
|
||||
}
|
||||
|
||||
setCreateError(null);
|
||||
setCreatingPosition(true);
|
||||
|
||||
try {
|
||||
const newPosition = await createPositionApi(request, instanceId, positionData);
|
||||
return { success: true, positionData: newPosition };
|
||||
} catch (error: any) {
|
||||
setCreateError(error.message);
|
||||
return { success: false, error: error.message };
|
||||
} finally {
|
||||
setCreatingPosition(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePositionUpdate = async (
|
||||
positionId: string,
|
||||
updateData: Partial<TrusteePosition>,
|
||||
_originalData?: any
|
||||
) => {
|
||||
if (!instanceId) {
|
||||
setUpdateError('No instance context');
|
||||
return { success: false, error: 'No instance context' };
|
||||
}
|
||||
|
||||
setUpdateError(null);
|
||||
|
||||
try {
|
||||
const updatedPosition = await updatePositionApi(request, instanceId, positionId, updateData);
|
||||
return { success: true, positionData: updatedPosition };
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.message || error.message || 'Failed to update position';
|
||||
const statusCode = error.response?.status;
|
||||
|
||||
setUpdateError(errorMessage);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
statusCode,
|
||||
isPermissionError: statusCode === 403,
|
||||
isValidationError: statusCode === 400
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
deletingPositions,
|
||||
creatingPosition,
|
||||
deleteError,
|
||||
createError,
|
||||
updateError,
|
||||
handlePositionDelete,
|
||||
handlePositionCreate,
|
||||
handlePositionUpdate,
|
||||
isLoading,
|
||||
instanceId
|
||||
};
|
||||
}
|
||||
399
src/hooks/useTrusteeRoles.ts
Normal file
399
src/hooks/useTrusteeRoles.ts
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useApiRequest } from './useApi';
|
||||
import api from '../api';
|
||||
import { usePermissions, type UserPermissions } from './usePermissions';
|
||||
import { useInstanceId } from './useCurrentInstance';
|
||||
import {
|
||||
fetchRoles as fetchRolesApi,
|
||||
fetchRoleById as fetchRoleByIdApi,
|
||||
createRole as createRoleApi,
|
||||
updateRole as updateRoleApi,
|
||||
deleteRole as deleteRoleApi,
|
||||
type TrusteeRole,
|
||||
type PaginationParams
|
||||
} from '../api/trusteeApi';
|
||||
|
||||
export interface AttributeDefinition {
|
||||
name: string;
|
||||
type: 'text' | 'email' | 'date' | 'checkbox' | 'select' | 'multiselect' | 'number' | 'textarea' | 'timestamp' | 'file';
|
||||
label: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
default?: any;
|
||||
options?: any[] | string;
|
||||
readonly?: boolean;
|
||||
editable?: boolean;
|
||||
visible?: boolean;
|
||||
order?: number;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
searchable?: boolean;
|
||||
width?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
filterOptions?: string[];
|
||||
dependsOn?: string;
|
||||
}
|
||||
|
||||
// Re-export types
|
||||
export type { TrusteeRole, PaginationParams };
|
||||
|
||||
// Roles list hook
|
||||
export function useTrusteeRoles() {
|
||||
const instanceId = useInstanceId();
|
||||
|
||||
const [roles, setRoles] = useState<TrusteeRole[]>([]);
|
||||
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
|
||||
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
|
||||
const [pagination, setPagination] = useState<{
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
} | null>(null);
|
||||
const { request, isLoading: loading, error } = useApiRequest<null, TrusteeRole[]>();
|
||||
const { checkPermission } = usePermissions();
|
||||
|
||||
// Fetch attributes from backend
|
||||
const fetchAttributes = useCallback(async () => {
|
||||
if (!instanceId) return [];
|
||||
|
||||
try {
|
||||
const response = await api.get(`/api/trustee/${instanceId}/attributes/TrusteeRole`);
|
||||
|
||||
let attrs: AttributeDefinition[] = [];
|
||||
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
|
||||
attrs = response.data.attributes;
|
||||
} else if (Array.isArray(response.data)) {
|
||||
attrs = response.data;
|
||||
} else if (response.data && typeof response.data === 'object') {
|
||||
const keys = Object.keys(response.data);
|
||||
for (const key of keys) {
|
||||
if (Array.isArray(response.data[key])) {
|
||||
attrs = response.data[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setAttributes(attrs);
|
||||
return attrs;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching attributes:', error);
|
||||
setAttributes([]);
|
||||
return [];
|
||||
}
|
||||
}, [instanceId]);
|
||||
|
||||
// Fetch permissions from backend
|
||||
const fetchPermissions = useCallback(async () => {
|
||||
try {
|
||||
const objectKey = 'data.feature.trustee.TrusteeRole';
|
||||
const perms = await checkPermission('DATA', objectKey);
|
||||
setPermissions(perms);
|
||||
return perms;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching permissions:', error);
|
||||
const defaultPerms: UserPermissions = {
|
||||
view: false,
|
||||
read: 'n',
|
||||
create: 'n',
|
||||
update: 'n',
|
||||
delete: 'n',
|
||||
};
|
||||
setPermissions(defaultPerms);
|
||||
return defaultPerms;
|
||||
}
|
||||
}, [checkPermission]);
|
||||
|
||||
const fetchRoles = useCallback(async (params?: PaginationParams) => {
|
||||
if (!instanceId) {
|
||||
setRoles([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchRolesApi(request, instanceId, params);
|
||||
|
||||
if (data && typeof data === 'object' && 'items' in data) {
|
||||
const items = Array.isArray(data.items) ? data.items : [];
|
||||
setRoles(items);
|
||||
if (data.pagination) {
|
||||
setPagination(data.pagination);
|
||||
}
|
||||
} else {
|
||||
const items = Array.isArray(data) ? data : [];
|
||||
setRoles(items);
|
||||
setPagination(null);
|
||||
}
|
||||
} catch (error: any) {
|
||||
setRoles([]);
|
||||
setPagination(null);
|
||||
}
|
||||
}, [request, instanceId]);
|
||||
|
||||
// Optimistically remove a role
|
||||
const removeOptimistically = (roleId: string) => {
|
||||
setRoles(prevRoles => prevRoles.filter(role => role.id !== roleId));
|
||||
};
|
||||
|
||||
// Optimistically update a role
|
||||
const updateOptimistically = (roleId: string, updateData: Partial<TrusteeRole>) => {
|
||||
setRoles(prevRoles =>
|
||||
prevRoles.map(role =>
|
||||
role.id === roleId
|
||||
? { ...role, ...updateData }
|
||||
: role
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Fetch a single role by ID
|
||||
const fetchRoleById = useCallback(async (roleId: string): Promise<TrusteeRole | null> => {
|
||||
if (!instanceId) return null;
|
||||
return await fetchRoleByIdApi(request, instanceId, roleId);
|
||||
}, [request, instanceId]);
|
||||
|
||||
// Generate edit fields from attributes dynamically
|
||||
const generateEditFieldsFromAttributes = useCallback((): Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly';
|
||||
editable?: boolean;
|
||||
required?: boolean;
|
||||
validator?: (value: any) => string | null;
|
||||
minRows?: number;
|
||||
maxRows?: number;
|
||||
options?: Array<{ value: string | number; label: string }>;
|
||||
optionsReference?: string;
|
||||
}> => {
|
||||
if (!attributes || attributes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const editableFields = attributes
|
||||
.filter(attr => {
|
||||
if (attr.readonly === true || attr.editable === false) {
|
||||
return false;
|
||||
}
|
||||
const nonEditableFields = ['mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
|
||||
return !nonEditableFields.includes(attr.name);
|
||||
})
|
||||
.map(attr => {
|
||||
const isDescField = attr.name === 'desc' || attr.name.toLowerCase().includes('description');
|
||||
|
||||
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string';
|
||||
let options: Array<{ value: string | number; label: string }> | undefined = undefined;
|
||||
let optionsReference: string | undefined = undefined;
|
||||
let minRows: number | undefined = undefined;
|
||||
let maxRows: number | undefined = undefined;
|
||||
|
||||
if (isDescField) {
|
||||
fieldType = 'textarea';
|
||||
minRows = 3;
|
||||
maxRows = 8;
|
||||
} else if (attr.type === 'checkbox') {
|
||||
fieldType = 'boolean';
|
||||
} else if (attr.type === 'email') {
|
||||
fieldType = 'email';
|
||||
} else if (attr.type === 'date') {
|
||||
fieldType = 'date';
|
||||
} else if (attr.type === 'select') {
|
||||
fieldType = 'enum';
|
||||
if (Array.isArray(attr.options)) {
|
||||
options = attr.options.map((opt: any) => {
|
||||
const labelValue = typeof opt.label === 'string'
|
||||
? opt.label
|
||||
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
|
||||
return {
|
||||
value: opt.value,
|
||||
label: labelValue
|
||||
};
|
||||
});
|
||||
} else if (typeof attr.options === 'string') {
|
||||
optionsReference = attr.options;
|
||||
}
|
||||
} else if (attr.type === 'textarea') {
|
||||
fieldType = 'textarea';
|
||||
minRows = minRows || 3;
|
||||
maxRows = maxRows || 8;
|
||||
}
|
||||
|
||||
let required = attr.required === true;
|
||||
let validator: ((value: any) => string | null) | undefined = undefined;
|
||||
|
||||
if (attr.name === 'id') {
|
||||
required = true;
|
||||
validator = (value: string) => {
|
||||
if (!value || value.trim() === '') {
|
||||
return 'Role ID cannot be empty';
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]{3,50}$/.test(value)) {
|
||||
return 'ID must be 3-50 characters long (alphanumeric, dash, underscore only)';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
} else if (attr.name === 'desc') {
|
||||
required = true;
|
||||
validator = (value: string) => {
|
||||
if (!value || value.trim() === '') {
|
||||
return 'Description cannot be empty';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: fieldType,
|
||||
editable: attr.editable !== false && attr.readonly !== true,
|
||||
required,
|
||||
validator,
|
||||
minRows,
|
||||
maxRows,
|
||||
options,
|
||||
optionsReference
|
||||
};
|
||||
});
|
||||
|
||||
return editableFields;
|
||||
}, [attributes]);
|
||||
|
||||
// Ensure attributes are loaded
|
||||
const ensureAttributesLoaded = useCallback(async () => {
|
||||
if (attributes && attributes.length > 0) {
|
||||
return attributes;
|
||||
}
|
||||
const fetchedAttributes = await fetchAttributes();
|
||||
return fetchedAttributes;
|
||||
}, [attributes, fetchAttributes]);
|
||||
|
||||
// Fetch data when instanceId is available
|
||||
useEffect(() => {
|
||||
if (instanceId) {
|
||||
fetchAttributes();
|
||||
fetchPermissions();
|
||||
fetchRoles();
|
||||
}
|
||||
}, [instanceId, fetchAttributes, fetchPermissions, fetchRoles]);
|
||||
|
||||
return {
|
||||
roles,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchRoles,
|
||||
removeOptimistically,
|
||||
updateOptimistically,
|
||||
attributes,
|
||||
permissions,
|
||||
pagination,
|
||||
fetchRoleById,
|
||||
generateEditFieldsFromAttributes,
|
||||
ensureAttributesLoaded,
|
||||
instanceId
|
||||
};
|
||||
}
|
||||
|
||||
// Role operations hook
|
||||
export function useTrusteeRoleOperations() {
|
||||
const instanceId = useInstanceId();
|
||||
|
||||
const [deletingRoles, setDeletingRoles] = useState<Set<string>>(new Set());
|
||||
const [creatingRole, setCreatingRole] = useState(false);
|
||||
const { request, isLoading } = useApiRequest();
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||
|
||||
const handleRoleDelete = async (roleId: string) => {
|
||||
if (!instanceId) {
|
||||
setDeleteError('No instance context');
|
||||
return false;
|
||||
}
|
||||
|
||||
setDeleteError(null);
|
||||
setDeletingRoles(prev => new Set(prev).add(roleId));
|
||||
|
||||
try {
|
||||
await deleteRoleApi(request, instanceId, roleId);
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.detail || error.message || 'Failed to delete role';
|
||||
// Backend returns error if role is in use
|
||||
setDeleteError(errorMessage);
|
||||
return false;
|
||||
} finally {
|
||||
setDeletingRoles(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(roleId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleCreate = async (roleData: Partial<TrusteeRole>) => {
|
||||
if (!instanceId) {
|
||||
setCreateError('No instance context');
|
||||
return { success: false, error: 'No instance context' };
|
||||
}
|
||||
|
||||
setCreateError(null);
|
||||
setCreatingRole(true);
|
||||
|
||||
try {
|
||||
const newRole = await createRoleApi(request, instanceId, roleData);
|
||||
return { success: true, roleData: newRole };
|
||||
} catch (error: any) {
|
||||
setCreateError(error.message);
|
||||
return { success: false, error: error.message };
|
||||
} finally {
|
||||
setCreatingRole(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleUpdate = async (
|
||||
roleId: string,
|
||||
updateData: Partial<TrusteeRole>,
|
||||
_originalData?: any
|
||||
) => {
|
||||
if (!instanceId) {
|
||||
setUpdateError('No instance context');
|
||||
return { success: false, error: 'No instance context' };
|
||||
}
|
||||
|
||||
setUpdateError(null);
|
||||
|
||||
try {
|
||||
const updatedRole = await updateRoleApi(request, instanceId, roleId, updateData);
|
||||
return { success: true, roleData: updatedRole };
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.message || error.message || 'Failed to update role';
|
||||
const statusCode = error.response?.status;
|
||||
|
||||
setUpdateError(errorMessage);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
statusCode,
|
||||
isPermissionError: statusCode === 403,
|
||||
isValidationError: statusCode === 400
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
deletingRoles,
|
||||
creatingRole,
|
||||
deleteError,
|
||||
createError,
|
||||
updateError,
|
||||
handleRoleDelete,
|
||||
handleRoleCreate,
|
||||
handleRoleUpdate,
|
||||
isLoading,
|
||||
instanceId
|
||||
};
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
import React from 'react';
|
||||
import { useCurrentInstance } from '../hooks/useCurrentInstance';
|
||||
import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
|
||||
import useNavigation from '../hooks/useNavigation';
|
||||
import { getLabel, FEATURE_REGISTRY } from '../types/mandate';
|
||||
|
||||
// Trustee Views
|
||||
// Note: TrusteeOrganisationsView and TrusteeContractsView removed - Feature-Instanz = Organisation
|
||||
|
|
@ -139,13 +139,31 @@ interface FeatureViewPageProps {
|
|||
}
|
||||
|
||||
export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
||||
const { instance, featureCode, mandateId, isValid } = useCurrentInstance();
|
||||
const { dynamicBlock } = useNavigation();
|
||||
const { instance, featureCode, isValid } = useCurrentInstance();
|
||||
|
||||
// Berechtigungs-Check
|
||||
const viewCode = `${featureCode}-${view}`;
|
||||
const canView = useCanViewFeatureView(viewCode);
|
||||
|
||||
// DEBUG: Log permission check for chatbot
|
||||
if (featureCode === 'chatbot') {
|
||||
console.log('🔍 [DEBUG] FeatureView Permission Check:', {
|
||||
featureCode,
|
||||
view,
|
||||
viewCode,
|
||||
instanceId: instance?.id,
|
||||
instanceLabel: instance?.instanceLabel,
|
||||
isValid,
|
||||
canView,
|
||||
permissions: instance?.permissions,
|
||||
views: instance?.permissions?.views,
|
||||
viewKeys: instance?.permissions?.views ? Object.keys(instance.permissions.views) : [],
|
||||
hasLegacyView: instance?.permissions?.views?.[viewCode],
|
||||
hasFullObjectKey: instance?.permissions?.views?.[`ui.feature.${featureCode}.${view}`],
|
||||
hasWildcard: instance?.permissions?.views?.['_all'],
|
||||
});
|
||||
}
|
||||
|
||||
// Nicht valider Kontext
|
||||
if (!isValid || !featureCode || !instance) {
|
||||
return <NotFound />;
|
||||
|
|
@ -167,17 +185,10 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
|||
return <NotFound />;
|
||||
}
|
||||
|
||||
// View-Label aus Backend-Navigation ermitteln
|
||||
let viewLabel = view;
|
||||
if (dynamicBlock) {
|
||||
const navMandate = dynamicBlock.mandates.find(m => m.id === mandateId);
|
||||
const navFeature = navMandate?.features.find(f => f.uiComponent.includes(featureCode));
|
||||
const navInstance = navFeature?.instances.find(i => i.id === instance.id);
|
||||
const navView = navInstance?.views.find(v => v.uiComponent.includes(view));
|
||||
if (navView) {
|
||||
viewLabel = navView.uiLabel;
|
||||
}
|
||||
}
|
||||
// View-Info aus Registry
|
||||
const featureConfig = FEATURE_REGISTRY[featureCode];
|
||||
const viewConfig = featureConfig?.views?.find(v => v.code === view);
|
||||
const viewLabel = viewConfig ? getLabel(viewConfig.label) : view;
|
||||
|
||||
return (
|
||||
<div className={styles.featureView}>
|
||||
|
|
|
|||
|
|
@ -185,7 +185,6 @@ export const RealEstateParcelsView: React.FC = () => {
|
|||
<FormGeneratorTable
|
||||
data={parcels}
|
||||
columns={columns}
|
||||
apiEndpoint={instanceId ? `/api/realestate/${instanceId}/parcels` : undefined}
|
||||
loading={loading}
|
||||
pagination={true}
|
||||
pageSize={25}
|
||||
|
|
|
|||
|
|
@ -169,7 +169,6 @@ export const RealEstateProjectsView: React.FC = () => {
|
|||
<FormGeneratorTable
|
||||
data={projects}
|
||||
columns={columns}
|
||||
apiEndpoint={instanceId ? `/api/realestate/${instanceId}/projects` : undefined}
|
||||
loading={loading}
|
||||
pagination={true}
|
||||
pageSize={25}
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@ export { RealEstateDashboardView } from './RealEstateDashboardView';
|
|||
export { RealEstatePekView } from './RealEstatePekView';
|
||||
export { RealEstateProjectsView } from './RealEstateProjectsView';
|
||||
export { RealEstateParcelsView } from './RealEstateParcelsView';
|
||||
export { RealEstateInstanceRolesPlaceholder } from './RealEstateInstanceRolesPlaceholder';
|
||||
export { RealEstateInstanceRolesPlaceholder } from './RealEstateInstanceRolesPlaceholder';
|
||||
|
|
|
|||
|
|
@ -11,7 +11,11 @@ export const TeamsbotSettingsView: React.FC = () => {
|
|||
const { instance } = useCurrentInstance();
|
||||
const instanceId = instance?.id || '';
|
||||
|
||||
<<<<<<< feat/auxiliaries2
|
||||
const [_config, setConfig] = useState<TeamsbotConfig | null>(null);
|
||||
=======
|
||||
const [, setConfig] = useState<TeamsbotConfig | null>(null);
|
||||
>>>>>>> int
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
|
|||
Loading…
Reference in a new issue