fixed merge conflicts
This commit is contained in:
commit
5808bd4bee
28 changed files with 2757 additions and 1009 deletions
|
|
@ -43,7 +43,7 @@ export async function fetchAttributes(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
entityType: string
|
entityType: string
|
||||||
): Promise<AttributeDefinition[]> {
|
): Promise<AttributeDefinition[]> {
|
||||||
const data = await request({
|
const data = await request<any>({
|
||||||
url: `/api/attributes/${entityType}`,
|
url: `/api/attributes/${entityType}`,
|
||||||
method: 'get'
|
method: 'get'
|
||||||
});
|
});
|
||||||
|
|
@ -81,7 +81,7 @@ export async function fetchConnectionAttributes(request: ApiRequestFunction): Pr
|
||||||
* Endpoint: GET /api/attributes/FileItem
|
* Endpoint: GET /api/attributes/FileItem
|
||||||
*/
|
*/
|
||||||
export async function fetchFileAttributes(request: ApiRequestFunction): Promise<AttributeDefinition[]> {
|
export async function fetchFileAttributes(request: ApiRequestFunction): Promise<AttributeDefinition[]> {
|
||||||
const data = await request({
|
const data = await request<AttributeDefinition[] | { attributes: AttributeDefinition[] }>({
|
||||||
url: '/api/attributes/FileItem',
|
url: '/api/attributes/FileItem',
|
||||||
method: 'get'
|
method: 'get'
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
||||||
import { IoIosEye } from 'react-icons/io';
|
import { IoIosEye } from 'react-icons/io';
|
||||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
import { ContentPreview } from '../../../ContentPreview';
|
import { ContentPreview } from '../../../ContentPreview';
|
||||||
|
import { Popup } from '../../../UiComponents/Popup';
|
||||||
import styles from '../ActionButton.module.css';
|
import styles from '../ActionButton.module.css';
|
||||||
|
|
||||||
export interface ViewActionButtonProps<T = any> {
|
export interface ViewActionButtonProps<T = any> {
|
||||||
|
|
@ -69,6 +70,17 @@ export function ViewActionButton<T = any>({
|
||||||
// Determine the final button title (tooltip)
|
// Determine the final button title (tooltip)
|
||||||
const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle;
|
const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle;
|
||||||
|
|
||||||
|
// Check if this is a file (has mimeType) or a regular entity (show details)
|
||||||
|
const mimeType = (row as any)[typeField];
|
||||||
|
const isFile = mimeType && typeof mimeType === 'string' && mimeType.length > 0;
|
||||||
|
|
||||||
|
// Format row data for display
|
||||||
|
const formatValue = (value: any): string => {
|
||||||
|
if (value === null || value === undefined) return '-';
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value, null, 2);
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
|
|
@ -82,14 +94,53 @@ export function ViewActionButton<T = any>({
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Content Preview Component */}
|
{/* Content Preview Component for files */}
|
||||||
<ContentPreview
|
{isFile && (
|
||||||
isOpen={isPopupOpen}
|
<ContentPreview
|
||||||
onClose={() => setIsPopupOpen(false)}
|
isOpen={isPopupOpen}
|
||||||
fileId={(row as any)[idField]}
|
onClose={() => setIsPopupOpen(false)}
|
||||||
fileName={(row as any)[nameField] || 'Unknown Item'}
|
fileId={(row as any)[idField]}
|
||||||
mimeType={(row as any)[typeField]}
|
fileName={(row as any)[nameField] || 'Unknown Item'}
|
||||||
/>
|
mimeType={mimeType}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Details Popup for non-file entities */}
|
||||||
|
{!isFile && (
|
||||||
|
<Popup
|
||||||
|
isOpen={isPopupOpen}
|
||||||
|
title={t('common.details', 'Details')}
|
||||||
|
onClose={() => setIsPopupOpen(false)}
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
<div style={{ padding: '20px' }}>
|
||||||
|
<h3 style={{ marginBottom: '20px', fontSize: '1.2rem', fontWeight: 'bold' }}>
|
||||||
|
{(row as any)[nameField] || (row as any)[idField] || 'Details'}
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'grid', gap: '15px' }}>
|
||||||
|
{Object.entries(row as Record<string, any>)
|
||||||
|
.filter(([key]) => !key.startsWith('_') && key !== 'id')
|
||||||
|
.map(([key, value]) => (
|
||||||
|
<div key={key} style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
|
||||||
|
<strong style={{ fontSize: '0.9rem', color: 'var(--color-text-secondary, #666)' }}>
|
||||||
|
{key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1')}
|
||||||
|
</strong>
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
backgroundColor: 'var(--color-background-secondary, #f5f5f5)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
whiteSpace: 'pre-wrap'
|
||||||
|
}}>
|
||||||
|
{formatValue(value)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -162,8 +162,7 @@ export function FormGeneratorControls({
|
||||||
{/* Delete Controls - Show when items are selected */}
|
{/* Delete Controls - Show when items are selected */}
|
||||||
{selectable && selectedCount > 0 && (
|
{selectable && selectedCount > 0 && (
|
||||||
<div className={styles.deleteControlsIntegrated}>
|
<div className={styles.deleteControlsIntegrated}>
|
||||||
{/* Show delete single only if exactly 1 item selected AND not all items */}
|
{selectedCount === 1 && onDeleteSingle && !(selectedCount === _displayData.length && _displayData.length > 0) && (
|
||||||
{selectedCount === 1 && !allItemsSelected && onDeleteSingle && (
|
|
||||||
<Button
|
<Button
|
||||||
onClick={onDeleteSingle}
|
onClick={onDeleteSingle}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
|
@ -173,8 +172,7 @@ export function FormGeneratorControls({
|
||||||
{t('formgen.delete.single', 'Delete')}
|
{t('formgen.delete.single', 'Delete')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{/* Show delete multiple if more than 1 selected OR all items are selected */}
|
{(selectedCount > 1 || (selectedCount === _displayData.length && _displayData.length > 0)) && onDeleteMultiple && (
|
||||||
{(selectedCount > 1 || allItemsSelected) && onDeleteMultiple && (
|
|
||||||
<Button
|
<Button
|
||||||
onClick={onDeleteMultiple}
|
onClick={onDeleteMultiple}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
|
|
||||||
|
|
@ -37,12 +37,23 @@
|
||||||
max-height: none;
|
max-height: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Empty state styling */
|
||||||
|
.emptyState {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 200px;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Empty message styling */
|
/* Empty message styling */
|
||||||
.emptyMessage {
|
.emptyMessage {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
opacity: 0.6;
|
opacity: 0.7;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,8 @@ export interface FormGeneratorTableProps<T = any> {
|
||||||
getRowDataAttributes?: (row: T, index: number) => Record<string, string>;
|
getRowDataAttributes?: (row: T, index: number) => Record<string, string>;
|
||||||
// For passing hook data to action buttons
|
// For passing hook data to action buttons
|
||||||
hookData?: any; // Contains all hook data: refetch, operations, loading states, etc.
|
hookData?: any; // Contains all hook data: refetch, operations, loading states, etc.
|
||||||
|
// Custom empty message when table is empty
|
||||||
|
emptyMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormGeneratorTable<T extends Record<string, any>>({
|
export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
|
|
@ -105,7 +107,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
onRefresh,
|
onRefresh,
|
||||||
className = '',
|
className = '',
|
||||||
getRowDataAttributes,
|
getRowDataAttributes,
|
||||||
hookData
|
hookData,
|
||||||
|
emptyMessage
|
||||||
}: FormGeneratorTableProps<T>) {
|
}: FormGeneratorTableProps<T>) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
// Use provided columns (from attributes) if available, otherwise auto-detect from data
|
// Use provided columns (from attributes) if available, otherwise auto-detect from data
|
||||||
|
|
@ -520,6 +523,66 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle objects/arrays (e.g., references to other entities)
|
||||||
|
// Check if value is an object (but not Date, Array, or null)
|
||||||
|
if (typeof value === 'object' && value !== null && !(value instanceof Date) && !Array.isArray(value)) {
|
||||||
|
// Try to find a display field in common order: label, name, title, id
|
||||||
|
const displayFields = ['label', 'name', 'title', 'id', 'value', 'text'];
|
||||||
|
for (const field of displayFields) {
|
||||||
|
if (value[field] !== undefined && value[field] !== null) {
|
||||||
|
const displayValue = value[field];
|
||||||
|
// If the display value is itself an object, try to stringify it nicely
|
||||||
|
if (typeof displayValue === 'object' && displayValue !== null) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(displayValue);
|
||||||
|
} catch {
|
||||||
|
return String(displayValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(displayValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If no display field found, try to stringify the object (limited to avoid huge output)
|
||||||
|
try {
|
||||||
|
const stringified = JSON.stringify(value);
|
||||||
|
// Truncate if too long
|
||||||
|
if (stringified.length > 100) {
|
||||||
|
return stringified.substring(0, 97) + '...';
|
||||||
|
}
|
||||||
|
return stringified;
|
||||||
|
} catch {
|
||||||
|
// If stringification fails, show object type
|
||||||
|
return `[${value.constructor?.name || 'Object'}]`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle arrays (e.g., multiselect values)
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length === 0) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
// If array contains objects, try to extract display values
|
||||||
|
const displayValues = value.map(item => {
|
||||||
|
if (typeof item === 'object' && item !== null) {
|
||||||
|
// Try to find a display field
|
||||||
|
const displayFields = ['label', 'name', 'title', 'id', 'value', 'text'];
|
||||||
|
for (const field of displayFields) {
|
||||||
|
if (item[field] !== undefined && item[field] !== null) {
|
||||||
|
return String(item[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to stringified object
|
||||||
|
try {
|
||||||
|
return JSON.stringify(item);
|
||||||
|
} catch {
|
||||||
|
return String(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(item);
|
||||||
|
});
|
||||||
|
return displayValues.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
// Use custom formatter if provided (but only if not an ID/hash field)
|
// Use custom formatter if provided (but only if not an ID/hash field)
|
||||||
if (column.formatter) {
|
if (column.formatter) {
|
||||||
return column.formatter(value, row);
|
return column.formatter(value, row);
|
||||||
|
|
@ -656,7 +719,18 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
const selectedRow = displayData[selectedIndex];
|
const selectedRow = displayData[selectedIndex];
|
||||||
handleDeleteSingle(selectedRow, selectedIndex);
|
handleDeleteSingle(selectedRow, selectedIndex);
|
||||||
} : undefined}
|
} : undefined}
|
||||||
onDeleteMultiple={selectedRows.size > 1 && onDeleteMultiple ? handleDeleteMultiple : undefined}
|
onDeleteMultiple={(() => {
|
||||||
|
if (!onDeleteMultiple) return undefined;
|
||||||
|
// Show delete multiple button if:
|
||||||
|
// 1. More than 1 item is selected, OR
|
||||||
|
// 2. All selectable items are selected (even if it's just 1)
|
||||||
|
const selectableIndices = displayData
|
||||||
|
.map((row, index) => ({ row, index }))
|
||||||
|
.filter(({ row }) => !isRowSelectable || isRowSelectable(row))
|
||||||
|
.map(({ index }) => index);
|
||||||
|
const allSelected = selectedRows.size === selectableIndices.length && selectableIndices.length > 0;
|
||||||
|
return (selectedRows.size > 1 || allSelected) ? handleDeleteMultiple : undefined;
|
||||||
|
})()}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
searchable={searchable}
|
searchable={searchable}
|
||||||
filterable={filterable}
|
filterable={filterable}
|
||||||
|
|
@ -673,6 +747,10 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
<div className={styles.loadingSpinner}></div>
|
<div className={styles.loadingSpinner}></div>
|
||||||
<p>{t('common.loading', 'Loading...')}</p>
|
<p>{t('common.loading', 'Loading...')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : displayData.length === 0 ? (
|
||||||
|
<div className={styles.emptyState}>
|
||||||
|
<p className={styles.emptyMessage}>{emptyMessage || t('formgen.empty', 'No data available')}</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table ref={tableRef} className={styles.table}>
|
<table ref={tableRef} className={styles.table}>
|
||||||
<thead>
|
<thead>
|
||||||
|
|
@ -732,15 +810,9 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
{displayData.length > 0 && (
|
||||||
{displayData.length === 0 ? (
|
<tbody>
|
||||||
<tr>
|
{displayData.map((row, index) => {
|
||||||
<td colSpan={detectedColumns.length + (selectable ? 1 : 0) + (actionButtons.length > 0 ? 1 : 0)} className={styles.emptyMessage}>
|
|
||||||
{t('formgen.empty', 'No data available')}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
displayData.map((row, index) => {
|
|
||||||
const dataAttributes = getRowDataAttributes ? getRowDataAttributes(row, index) : {};
|
const dataAttributes = getRowDataAttributes ? getRowDataAttributes(row, index) : {};
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
|
|
@ -870,9 +942,9 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
)}
|
</tbody>
|
||||||
</tbody>
|
)}
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,9 @@ const Button: React.FC<ButtonProps> = ({
|
||||||
].filter(Boolean).join(' ');
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
// Handle click
|
// Handle click
|
||||||
const handleClick = () => {
|
const handleClick = (e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
|
||||||
if (!disabled && !loading && onClick) {
|
if (!disabled && !loading && onClick) {
|
||||||
|
e.preventDefault();
|
||||||
onClick();
|
onClick();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -52,4 +52,5 @@ export interface CreateButtonProps extends BaseButtonProps {
|
||||||
iconPosition?: 'left' | 'right';
|
iconPosition?: 'left' | 'right';
|
||||||
onSuccess?: (result: any) => void;
|
onSuccess?: (result: any) => void;
|
||||||
onError?: (error: string) => void;
|
onError?: (error: string) => void;
|
||||||
|
multiStep?: boolean; // Enable multi-step form mode
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,216 @@
|
||||||
|
.step1Container,
|
||||||
|
.step2Container {
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepIndicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 2px solid var(--border-color, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepNumber {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--primary-color, #007bff);
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepLabel {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step2Container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addressFields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addressRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 2fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchButtons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapContainer {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.parcelInfo {
|
||||||
|
background-color: var(--background-secondary, #f5f5f5);
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedParcelsList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedParcelCard {
|
||||||
|
background-color: var(--background-color, #fff);
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedParcelHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedParcelTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeParcelButton {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--error-color, #dc3545);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeParcelButton:hover {
|
||||||
|
background-color: var(--error-light, #fee2e2);
|
||||||
|
color: var(--error-dark, #dc2626);
|
||||||
|
}
|
||||||
|
|
||||||
|
.parcelInfoTitle {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: var(--text-color, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.parcelInfoGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parcelInfoItem {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parcelInfoLabel {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.parcelInfoValue {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--text-color, #333);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adjacentParcels {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.adjacentTitle {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
color: var(--text-color, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.adjacentList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adjacentItem {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: var(--background-color, #fff);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-color, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.parcelInfoGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorText {
|
||||||
|
color: var(--error-color, #dc3545);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonGroup {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.addressRow {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -4,6 +4,276 @@ import Button from '../Button';
|
||||||
import { Popup } from '../../Popup';
|
import { Popup } from '../../Popup';
|
||||||
import { FormGeneratorForm, AttributeDefinition } from '../../../FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm, AttributeDefinition } from '../../../FormGenerator/FormGeneratorForm';
|
||||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
|
import { TextField } from '../../TextField';
|
||||||
|
import { PekProvider } from '../../../../contexts/PekContext';
|
||||||
|
import { MapView } from '../../MapView';
|
||||||
|
import { usePekContext } from '../../../../contexts/PekContext';
|
||||||
|
import { FaLocationArrow, FaTimes } from 'react-icons/fa';
|
||||||
|
import { IoMdSend } from 'react-icons/io';
|
||||||
|
import styles from './CreateButton.module.css';
|
||||||
|
|
||||||
|
// Step 2 component for parcel selection (must be inside PekProvider)
|
||||||
|
const Step2Content: React.FC<{
|
||||||
|
onNext: (data: any) => void;
|
||||||
|
onBack: () => void;
|
||||||
|
addressData: {
|
||||||
|
street: string;
|
||||||
|
postalCode: string;
|
||||||
|
city: string;
|
||||||
|
};
|
||||||
|
onAddressChange: (field: string, value: string) => void;
|
||||||
|
}> = ({ onNext, onBack, addressData, onAddressChange }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const {
|
||||||
|
selectedParcels,
|
||||||
|
searchParcel,
|
||||||
|
useCurrentLocation,
|
||||||
|
isGettingLocation,
|
||||||
|
isSearchingParcel,
|
||||||
|
setAdresse,
|
||||||
|
mapCenter,
|
||||||
|
mapZoomBounds,
|
||||||
|
parcelGeometries,
|
||||||
|
handleMapClick,
|
||||||
|
handleParcelClick,
|
||||||
|
removeParcel,
|
||||||
|
setIsPanelOpen
|
||||||
|
} = usePekContext();
|
||||||
|
const [step2Errors, setStep2Errors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// Prevent panel from opening when parcel is clicked
|
||||||
|
React.useEffect(() => {
|
||||||
|
setIsPanelOpen(false);
|
||||||
|
}, [selectedParcels, setIsPanelOpen]);
|
||||||
|
|
||||||
|
// Build location string from address fields
|
||||||
|
const buildLocationString = () => {
|
||||||
|
const parts = [];
|
||||||
|
if (addressData.street.trim()) parts.push(addressData.street.trim());
|
||||||
|
if (addressData.postalCode.trim()) parts.push(addressData.postalCode.trim());
|
||||||
|
if (addressData.city.trim()) parts.push(addressData.city.trim());
|
||||||
|
return parts.join(', ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = async () => {
|
||||||
|
const locationString = buildLocationString();
|
||||||
|
if (locationString.trim()) {
|
||||||
|
// Update the adresse field in context for consistency
|
||||||
|
setAdresse(locationString);
|
||||||
|
await searchParcel(locationString.trim(), true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUseCurrentLocation = async () => {
|
||||||
|
await useCurrentLocation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!addressData.street.trim()) {
|
||||||
|
newErrors.street = t('formgen.form.required', 'Strasse und Hausnummer ist erforderlich');
|
||||||
|
}
|
||||||
|
if (!addressData.postalCode.trim()) {
|
||||||
|
newErrors.postalCode = t('formgen.form.required', 'Postleitzahl ist erforderlich');
|
||||||
|
}
|
||||||
|
if (!addressData.city.trim()) {
|
||||||
|
newErrors.city = t('formgen.form.required', 'Stadt ist erforderlich');
|
||||||
|
}
|
||||||
|
if (!selectedParcels || selectedParcels.length === 0) {
|
||||||
|
newErrors.parcel = t('formgen.form.required', 'Bitte wählen Sie mindestens eine Parzelle aus');
|
||||||
|
}
|
||||||
|
|
||||||
|
setStep2Errors(newErrors);
|
||||||
|
|
||||||
|
if (Object.keys(newErrors).length === 0) {
|
||||||
|
onNext({
|
||||||
|
address: addressData,
|
||||||
|
parzellen: selectedParcels
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.step2Container}>
|
||||||
|
<div className={styles.stepIndicator}>
|
||||||
|
<span className={styles.stepNumber}>2</span>
|
||||||
|
<span className={styles.stepLabel}>Parzelle hinzufügen</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.addressFields}>
|
||||||
|
<TextField
|
||||||
|
value={addressData.street}
|
||||||
|
onChange={(value) => onAddressChange('street', value)}
|
||||||
|
label="Strasse und Hausnummer"
|
||||||
|
placeholder="z.B. Bundesplatz 3"
|
||||||
|
required
|
||||||
|
error={step2Errors.street}
|
||||||
|
size="md"
|
||||||
|
type="text"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSearch();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={styles.addressRow}>
|
||||||
|
<TextField
|
||||||
|
value={addressData.postalCode}
|
||||||
|
onChange={(value) => onAddressChange('postalCode', value)}
|
||||||
|
label="Postleitzahl"
|
||||||
|
placeholder="z.B. 3000"
|
||||||
|
required
|
||||||
|
error={step2Errors.postalCode}
|
||||||
|
size="md"
|
||||||
|
type="text"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSearch();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
value={addressData.city}
|
||||||
|
onChange={(value) => onAddressChange('city', value)}
|
||||||
|
label="Stadt"
|
||||||
|
placeholder="z.B. Bern"
|
||||||
|
required
|
||||||
|
error={step2Errors.city}
|
||||||
|
size="md"
|
||||||
|
type="text"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSearch();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search buttons */}
|
||||||
|
<div className={styles.searchButtons}>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
icon={IoMdSend}
|
||||||
|
onClick={handleSearch}
|
||||||
|
disabled={!buildLocationString().trim() || isGettingLocation || isSearchingParcel}
|
||||||
|
loading={isSearchingParcel}
|
||||||
|
>
|
||||||
|
Suchen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="md"
|
||||||
|
icon={FaLocationArrow}
|
||||||
|
onClick={handleUseCurrentLocation}
|
||||||
|
disabled={isGettingLocation || isSearchingParcel}
|
||||||
|
loading={isGettingLocation}
|
||||||
|
>
|
||||||
|
Meine Position
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.mapSection}>
|
||||||
|
<div className={styles.mapContainer}>
|
||||||
|
<MapView
|
||||||
|
parcels={parcelGeometries}
|
||||||
|
center={mapCenter || undefined}
|
||||||
|
zoomBounds={mapZoomBounds || undefined}
|
||||||
|
onMapClick={handleMapClick}
|
||||||
|
onParcelClick={handleParcelClick}
|
||||||
|
height="400px"
|
||||||
|
emptyMessage="Klicken Sie auf die Karte, um einen Standort auszuwählen, oder suchen Sie nach einer Adresse oben."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected parcels list displayed below map */}
|
||||||
|
{selectedParcels && selectedParcels.length > 0 && (
|
||||||
|
<div className={styles.parcelInfo}>
|
||||||
|
<h3 className={styles.parcelInfoTitle}>Ausgewählte Parzellen ({selectedParcels.length})</h3>
|
||||||
|
<div className={styles.selectedParcelsList}>
|
||||||
|
{selectedParcels.map((selectedParcel, index) => (
|
||||||
|
<div key={selectedParcel.parcel.id || index} className={styles.selectedParcelCard}>
|
||||||
|
<div className={styles.selectedParcelHeader}>
|
||||||
|
<h4 className={styles.selectedParcelTitle}>
|
||||||
|
Parzelle {index + 1}: {selectedParcel.parcel.number || selectedParcel.parcel.id || 'Unbekannt'}
|
||||||
|
</h4>
|
||||||
|
<button
|
||||||
|
className={styles.removeParcelButton}
|
||||||
|
onClick={() => removeParcel(selectedParcel.parcel.id)}
|
||||||
|
title="Parzelle entfernen"
|
||||||
|
>
|
||||||
|
<FaTimes />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.parcelInfoGrid}>
|
||||||
|
{selectedParcel.parcel.id && (
|
||||||
|
<div className={styles.parcelInfoItem}>
|
||||||
|
<span className={styles.parcelInfoLabel}>ID:</span>
|
||||||
|
<span className={styles.parcelInfoValue}>{selectedParcel.parcel.id}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedParcel.parcel.number && (
|
||||||
|
<div className={styles.parcelInfoItem}>
|
||||||
|
<span className={styles.parcelInfoLabel}>Nummer:</span>
|
||||||
|
<span className={styles.parcelInfoValue}>{selectedParcel.parcel.number}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedParcel.parcel.egrid && (
|
||||||
|
<div className={styles.parcelInfoItem}>
|
||||||
|
<span className={styles.parcelInfoLabel}>EGRID:</span>
|
||||||
|
<span className={styles.parcelInfoValue}>{selectedParcel.parcel.egrid}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedParcel.parcel.address && (
|
||||||
|
<div className={styles.parcelInfoItem}>
|
||||||
|
<span className={styles.parcelInfoLabel}>Adresse:</span>
|
||||||
|
<span className={styles.parcelInfoValue}>{selectedParcel.parcel.address}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedParcel.parcel.area_m2 && (
|
||||||
|
<div className={styles.parcelInfoItem}>
|
||||||
|
<span className={styles.parcelInfoLabel}>Fläche (m²):</span>
|
||||||
|
<span className={styles.parcelInfoValue}>{selectedParcel.parcel.area_m2}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step2Errors.parcel && (
|
||||||
|
<span className={styles.errorText}>{step2Errors.parcel}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.buttonGroup}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="md"
|
||||||
|
onClick={onBack}
|
||||||
|
>
|
||||||
|
{t('common.back', 'Zurück')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
onClick={handleNext}
|
||||||
|
>
|
||||||
|
{t('common.finish', 'Abschließen')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const CreateButton: React.FC<CreateButtonProps> = ({
|
const CreateButton: React.FC<CreateButtonProps> = ({
|
||||||
onCreate,
|
onCreate,
|
||||||
|
|
@ -20,15 +290,32 @@ const CreateButton: React.FC<CreateButtonProps> = ({
|
||||||
size = 'md',
|
size = 'md',
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onError,
|
onError,
|
||||||
|
multiStep = false,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [isPopupOpen, setIsPopupOpen] = useState(false);
|
const [isPopupOpen, setIsPopupOpen] = useState(false);
|
||||||
|
const [currentStep, setCurrentStep] = useState<1 | 2>(1);
|
||||||
|
const [step1Data, setStep1Data] = useState<any>({});
|
||||||
|
const [addressData, setAddressData] = useState({
|
||||||
|
street: '',
|
||||||
|
postalCode: '',
|
||||||
|
city: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter fields for multi-step: Step 1 only shows "label" field
|
||||||
|
const step1Fields = useMemo(() => {
|
||||||
|
if (multiStep) {
|
||||||
|
return fields.filter(field => field.key === 'label');
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}, [fields, multiStep]);
|
||||||
|
|
||||||
// Convert CreateButtonFieldConfig to AttributeDefinition format
|
// Convert CreateButtonFieldConfig to AttributeDefinition format
|
||||||
const attributes: AttributeDefinition[] = useMemo(() => {
|
const attributes: AttributeDefinition[] = useMemo(() => {
|
||||||
return fields.map(field => {
|
const fieldsToUse = multiStep ? step1Fields : fields;
|
||||||
|
return fieldsToUse.map(field => {
|
||||||
// Convert options to AttributeOption[] format
|
// Convert options to AttributeOption[] format
|
||||||
let options: AttributeDefinition['options'] = undefined;
|
let options: AttributeDefinition['options'] = undefined;
|
||||||
|
|
||||||
|
|
@ -84,12 +371,13 @@ const CreateButton: React.FC<CreateButtonProps> = ({
|
||||||
options: options
|
options: options
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [fields]);
|
}, [fields, multiStep, step1Fields]);
|
||||||
|
|
||||||
// Initialize form data with default values
|
// Initialize form data with default values
|
||||||
const initialFormData = useMemo(() => {
|
const initialFormData = useMemo(() => {
|
||||||
const data: any = {};
|
const data: any = {};
|
||||||
fields.forEach(field => {
|
const fieldsToUse = multiStep ? step1Fields : fields;
|
||||||
|
fieldsToUse.forEach(field => {
|
||||||
if (field.type === 'multiselect') {
|
if (field.type === 'multiselect') {
|
||||||
// Multiselect fields should default to empty array
|
// Multiselect fields should default to empty array
|
||||||
data[field.key] = field.defaultValue || [];
|
data[field.key] = field.defaultValue || [];
|
||||||
|
|
@ -102,15 +390,116 @@ const CreateButton: React.FC<CreateButtonProps> = ({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
}, [fields]);
|
}, [fields, multiStep, step1Fields]);
|
||||||
|
|
||||||
const handleButtonClick = () => {
|
const handleButtonClick = () => {
|
||||||
if (!disabled && !loading && !isCreating) {
|
if (!disabled && !loading && !isCreating) {
|
||||||
setIsPopupOpen(true);
|
setIsPopupOpen(true);
|
||||||
|
// Reset to step 1 when opening popup
|
||||||
|
if (multiStep) {
|
||||||
|
setCurrentStep(1);
|
||||||
|
setStep1Data({});
|
||||||
|
setAddressData({ street: '', postalCode: '', city: '' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStep1Next = (formData: any) => {
|
||||||
|
// Validate label is present
|
||||||
|
if (!formData.label || !formData.label.trim()) {
|
||||||
|
return; // FormGeneratorForm will show validation error
|
||||||
|
}
|
||||||
|
setStep1Data(formData);
|
||||||
|
setCurrentStep(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStep2Back = () => {
|
||||||
|
setCurrentStep(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStep2Finish = async (step2FormData: any) => {
|
||||||
|
// Combine step 1 and step 2 data
|
||||||
|
const selectedParcels = step2FormData.parzellen || [];
|
||||||
|
const completeData: any = {
|
||||||
|
label: step1Data.label,
|
||||||
|
// mandateId is NOT included - will be set by backend
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add parzellen array if parcels were selected - include ALL parcel information for each
|
||||||
|
if (selectedParcels && selectedParcels.length > 0) {
|
||||||
|
completeData.parzellen = selectedParcels.map((selectedParcel: any) => ({
|
||||||
|
// Basic parcel info
|
||||||
|
id: selectedParcel.parcel.id,
|
||||||
|
egrid: selectedParcel.parcel.egrid,
|
||||||
|
number: selectedParcel.parcel.number,
|
||||||
|
name: selectedParcel.parcel.name,
|
||||||
|
identnd: selectedParcel.parcel.identnd,
|
||||||
|
canton: selectedParcel.parcel.canton,
|
||||||
|
municipality_code: selectedParcel.parcel.municipality_code,
|
||||||
|
municipality_name: selectedParcel.parcel.municipality_name,
|
||||||
|
address: selectedParcel.parcel.address,
|
||||||
|
area_m2: selectedParcel.parcel.area_m2,
|
||||||
|
centroid: selectedParcel.parcel.centroid,
|
||||||
|
geoportal_url: selectedParcel.parcel.geoportal_url,
|
||||||
|
realestate_type: selectedParcel.parcel.realestate_type,
|
||||||
|
|
||||||
|
// User-entered address fields (from step 2)
|
||||||
|
userAddress: {
|
||||||
|
street: step2FormData.address.street,
|
||||||
|
postalCode: step2FormData.address.postalCode,
|
||||||
|
city: step2FormData.address.city
|
||||||
|
},
|
||||||
|
|
||||||
|
// Geometry and map data
|
||||||
|
geometry: selectedParcel.map_view?.geometry_geojson,
|
||||||
|
perimeter: selectedParcel.parcel.perimeter,
|
||||||
|
map_view: selectedParcel.map_view,
|
||||||
|
|
||||||
|
// Adjacent parcels
|
||||||
|
adjacent_parcels: selectedParcel.adjacent_parcels || [],
|
||||||
|
|
||||||
|
// Include any other parcel properties that might exist
|
||||||
|
...selectedParcel.parcel
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send request to backend via onCreate handler
|
||||||
|
setIsCreating(true);
|
||||||
|
try {
|
||||||
|
const result = await onCreate(completeData);
|
||||||
|
|
||||||
|
if (result?.success !== false) {
|
||||||
|
// Success - close popup
|
||||||
|
setIsPopupOpen(false);
|
||||||
|
setCurrentStep(1);
|
||||||
|
setStep1Data({});
|
||||||
|
setAddressData({ street: '', postalCode: '', city: '' });
|
||||||
|
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess(result);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle error
|
||||||
|
if (onError) {
|
||||||
|
onError(result?.error || 'Projekt konnte nicht erstellt werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Project creation failed:', error);
|
||||||
|
if (onError) {
|
||||||
|
onError(error.message || 'Projekt konnte nicht erstellt werden');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async (updatedData: any) => {
|
const handleSave = async (updatedData: any) => {
|
||||||
|
if (multiStep && currentStep === 1) {
|
||||||
|
handleStep1Next(updatedData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -119,6 +508,11 @@ const CreateButton: React.FC<CreateButtonProps> = ({
|
||||||
if (result?.success !== false) {
|
if (result?.success !== false) {
|
||||||
// Success
|
// Success
|
||||||
setIsPopupOpen(false);
|
setIsPopupOpen(false);
|
||||||
|
if (multiStep) {
|
||||||
|
setCurrentStep(1);
|
||||||
|
setStep1Data({});
|
||||||
|
setAddressData({ street: '', postalCode: '', city: '' });
|
||||||
|
}
|
||||||
if (onSuccess) {
|
if (onSuccess) {
|
||||||
onSuccess(result);
|
onSuccess(result);
|
||||||
}
|
}
|
||||||
|
|
@ -140,6 +534,18 @@ const CreateButton: React.FC<CreateButtonProps> = ({
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
setIsPopupOpen(false);
|
setIsPopupOpen(false);
|
||||||
|
if (multiStep) {
|
||||||
|
setCurrentStep(1);
|
||||||
|
setStep1Data({});
|
||||||
|
setAddressData({ street: '', postalCode: '', city: '' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddressChange = (field: string, value: string) => {
|
||||||
|
setAddressData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[field]: value
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const isDisabled = disabled || loading || isCreating;
|
const isDisabled = disabled || loading || isCreating;
|
||||||
|
|
@ -184,18 +590,47 @@ const CreateButton: React.FC<CreateButtonProps> = ({
|
||||||
isOpen={isPopupOpen}
|
isOpen={isPopupOpen}
|
||||||
title={resolvedPopupTitle}
|
title={resolvedPopupTitle}
|
||||||
onClose={handleCancel}
|
onClose={handleCancel}
|
||||||
size={popupSize}
|
size={multiStep ? 'large' : popupSize}
|
||||||
closable={!isCreating}
|
closable={!isCreating}
|
||||||
>
|
>
|
||||||
<FormGeneratorForm
|
{multiStep ? (
|
||||||
attributes={resolvedAttributes}
|
currentStep === 1 ? (
|
||||||
data={initialFormData}
|
<div className={styles.step1Container}>
|
||||||
mode="create"
|
<div className={styles.stepIndicator}>
|
||||||
onSubmit={handleSave}
|
<span className={styles.stepNumber}>1</span>
|
||||||
onCancel={handleCancel}
|
<span className={styles.stepLabel}>Titel festlegen</span>
|
||||||
submitButtonText={t('common.create', 'Create')}
|
</div>
|
||||||
cancelButtonText={t('common.cancel', 'Cancel')}
|
<FormGeneratorForm
|
||||||
/>
|
attributes={resolvedAttributes}
|
||||||
|
data={initialFormData}
|
||||||
|
mode="create"
|
||||||
|
onSubmit={handleSave}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
submitButtonText={t('common.next', 'Weiter')}
|
||||||
|
cancelButtonText={t('common.cancel', 'Abbrechen')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<PekProvider>
|
||||||
|
<Step2Content
|
||||||
|
onNext={handleStep2Finish}
|
||||||
|
onBack={handleStep2Back}
|
||||||
|
addressData={addressData}
|
||||||
|
onAddressChange={handleAddressChange}
|
||||||
|
/>
|
||||||
|
</PekProvider>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<FormGeneratorForm
|
||||||
|
attributes={resolvedAttributes}
|
||||||
|
data={initialFormData}
|
||||||
|
mode="create"
|
||||||
|
onSubmit={handleSave}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
submitButtonText={t('common.create', 'Create')}
|
||||||
|
cancelButtonText={t('common.cancel', 'Cancel')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Popup>
|
</Popup>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -72,13 +72,57 @@
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sectionHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 2px solid var(--color-primary, #3b82f6);
|
||||||
|
}
|
||||||
|
|
||||||
.sectionTitle {
|
.sectionTitle {
|
||||||
margin: 0 0 1rem 0;
|
margin: 0;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text, #111827);
|
color: var(--color-text, #111827);
|
||||||
padding-bottom: 0.5rem;
|
}
|
||||||
border-bottom: 2px solid var(--color-primary, #3b82f6);
|
|
||||||
|
.removeButton {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-error, #ef4444);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeButton:hover {
|
||||||
|
background-color: var(--color-error-light, #fee2e2);
|
||||||
|
color: var(--color-error-dark, #dc2626);
|
||||||
|
}
|
||||||
|
|
||||||
|
.parcelsList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapViewSection {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subSectionTitle {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
}
|
}
|
||||||
|
|
||||||
.infoGrid {
|
.infoGrid {
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,24 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { FaTimes } from 'react-icons/fa';
|
import { FaTimes, FaTrash } from 'react-icons/fa';
|
||||||
import styles from './ParcelInfoPanel.module.css';
|
import styles from './ParcelInfoPanel.module.css';
|
||||||
|
|
||||||
export interface ParcelInfoPanelProps {
|
export interface ParcelInfoPanelProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
parcelData: any;
|
parcels: any[];
|
||||||
|
onRemoveParcel?: (parcelId: string) => void;
|
||||||
adjacentParcels?: any[];
|
adjacentParcels?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
|
const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
parcelData,
|
parcels,
|
||||||
|
onRemoveParcel,
|
||||||
adjacentParcels = []
|
adjacentParcels = []
|
||||||
}) => {
|
}) => {
|
||||||
if (!parcelData) return null;
|
if (!parcels || parcels.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
|
|
@ -41,146 +43,163 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
|
||||||
className={styles.panel}
|
className={styles.panel}
|
||||||
>
|
>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<h2 className={styles.title}>Parzellen-Informationen</h2>
|
<h2 className={styles.title}>Parzellen-Informationen ({parcels.length})</h2>
|
||||||
<button className={styles.closeButton} onClick={onClose}>
|
<button className={styles.closeButton} onClick={onClose}>
|
||||||
<FaTimes />
|
<FaTimes />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
{/* Main Parcel */}
|
{/* Selected Parcels List */}
|
||||||
<section className={styles.section}>
|
<div className={styles.parcelsList}>
|
||||||
<h3 className={styles.sectionTitle}>Ausgewählte Parzelle</h3>
|
{parcels.map((parcelData, index) => (
|
||||||
<div className={styles.infoGrid}>
|
<section key={parcelData.parcel.id || index} className={styles.section}>
|
||||||
{parcelData.parcel.id && (
|
<div className={styles.sectionHeader}>
|
||||||
<div className={styles.infoItem}>
|
<h3 className={styles.sectionTitle}>
|
||||||
<span className={styles.label}>ID:</span>
|
Parzelle {index + 1}: {parcelData.parcel.number || parcelData.parcel.id || 'Unbekannt'}
|
||||||
<span className={styles.value}>{parcelData.parcel.id}</span>
|
</h3>
|
||||||
|
{onRemoveParcel && (
|
||||||
|
<button
|
||||||
|
className={styles.removeButton}
|
||||||
|
onClick={() => onRemoveParcel(parcelData.parcel.id)}
|
||||||
|
title="Parzelle entfernen"
|
||||||
|
>
|
||||||
|
<FaTrash />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className={styles.infoGrid}>
|
||||||
{parcelData.parcel.number && (
|
{parcelData.parcel.id && (
|
||||||
<div className={styles.infoItem}>
|
<div className={styles.infoItem}>
|
||||||
<span className={styles.label}>Nummer:</span>
|
<span className={styles.label}>ID:</span>
|
||||||
<span className={styles.value}>{parcelData.parcel.number}</span>
|
<span className={styles.value}>{parcelData.parcel.id}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{parcelData.parcel.name && (
|
{parcelData.parcel.number && (
|
||||||
<div className={styles.infoItem}>
|
<div className={styles.infoItem}>
|
||||||
<span className={styles.label}>Name:</span>
|
<span className={styles.label}>Nummer:</span>
|
||||||
<span className={styles.value}>{parcelData.parcel.name}</span>
|
<span className={styles.value}>{parcelData.parcel.number}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{parcelData.parcel.egrid && (
|
{parcelData.parcel.name && (
|
||||||
<div className={styles.infoItem}>
|
<div className={styles.infoItem}>
|
||||||
<span className={styles.label}>EGRID:</span>
|
<span className={styles.label}>Name:</span>
|
||||||
<span className={styles.value}>{parcelData.parcel.egrid}</span>
|
<span className={styles.value}>{parcelData.parcel.name}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{parcelData.parcel.identnd && (
|
{parcelData.parcel.egrid && (
|
||||||
<div className={styles.infoItem}>
|
<div className={styles.infoItem}>
|
||||||
<span className={styles.label}>IdentND:</span>
|
<span className={styles.label}>EGRID:</span>
|
||||||
<span className={styles.value}>{parcelData.parcel.identnd}</span>
|
<span className={styles.value}>{parcelData.parcel.egrid}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{parcelData.parcel.address && (
|
{parcelData.parcel.identnd && (
|
||||||
<div className={styles.infoItem}>
|
<div className={styles.infoItem}>
|
||||||
<span className={styles.label}>Adresse:</span>
|
<span className={styles.label}>IdentND:</span>
|
||||||
<span className={styles.value}>{parcelData.parcel.address}</span>
|
<span className={styles.value}>{parcelData.parcel.identnd}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{parcelData.parcel.canton && (
|
{parcelData.parcel.address && (
|
||||||
<div className={styles.infoItem}>
|
<div className={styles.infoItem}>
|
||||||
<span className={styles.label}>Kanton:</span>
|
<span className={styles.label}>Adresse:</span>
|
||||||
<span className={styles.value}>{parcelData.parcel.canton}</span>
|
<span className={styles.value}>{parcelData.parcel.address}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{parcelData.parcel.municipality_name && (
|
{parcelData.parcel.canton && (
|
||||||
<div className={styles.infoItem}>
|
<div className={styles.infoItem}>
|
||||||
<span className={styles.label}>Gemeinde:</span>
|
<span className={styles.label}>Kanton:</span>
|
||||||
<span className={styles.value}>{parcelData.parcel.municipality_name}</span>
|
<span className={styles.value}>{parcelData.parcel.canton}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{parcelData.parcel.municipality_code && (
|
{parcelData.parcel.municipality_name && (
|
||||||
<div className={styles.infoItem}>
|
<div className={styles.infoItem}>
|
||||||
<span className={styles.label}>Gemeinde-Code:</span>
|
<span className={styles.label}>Gemeinde:</span>
|
||||||
<span className={styles.value}>{parcelData.parcel.municipality_code}</span>
|
<span className={styles.value}>{parcelData.parcel.municipality_name}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{parcelData.parcel.area_m2 !== undefined && (
|
{parcelData.parcel.municipality_code && (
|
||||||
<div className={styles.infoItem}>
|
<div className={styles.infoItem}>
|
||||||
<span className={styles.label}>Fläche:</span>
|
<span className={styles.label}>Gemeinde-Code:</span>
|
||||||
<span className={styles.value}>
|
<span className={styles.value}>{parcelData.parcel.municipality_code}</span>
|
||||||
{parcelData.parcel.area_m2.toFixed(2)} m²
|
</div>
|
||||||
{parcelData.parcel.area_m2 >= 10000 && (
|
)}
|
||||||
<span className={styles.subValue}>
|
{parcelData.parcel.area_m2 !== undefined && (
|
||||||
{' '}({(parcelData.parcel.area_m2 / 10000).toFixed(2)} ha)
|
<div className={styles.infoItem}>
|
||||||
|
<span className={styles.label}>Fläche:</span>
|
||||||
|
<span className={styles.value}>
|
||||||
|
{parcelData.parcel.area_m2.toFixed(2)} m²
|
||||||
|
{parcelData.parcel.area_m2 >= 10000 && (
|
||||||
|
<span className={styles.subValue}>
|
||||||
|
{' '}({(parcelData.parcel.area_m2 / 10000).toFixed(2)} ha)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
</div>
|
||||||
</span>
|
)}
|
||||||
|
{parcelData.parcel.realestate_type && (
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<span className={styles.label}>Grundstückstyp:</span>
|
||||||
|
<span className={styles.value}>{parcelData.parcel.realestate_type}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{parcelData.parcel.centroid && (
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<span className={styles.label}>Zentrum (LV95):</span>
|
||||||
|
<span className={styles.value}>
|
||||||
|
{parcelData.parcel.centroid.x.toFixed(2)}, {parcelData.parcel.centroid.y.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{parcelData.parcel.geoportal_url && (
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<span className={styles.label}>Geoportal:</span>
|
||||||
|
<a
|
||||||
|
href={parcelData.parcel.geoportal_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={styles.link}
|
||||||
|
>
|
||||||
|
Link öffnen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{parcelData.parcel.realestate_type && (
|
|
||||||
<div className={styles.infoItem}>
|
|
||||||
<span className={styles.label}>Grundstückstyp:</span>
|
|
||||||
<span className={styles.value}>{parcelData.parcel.realestate_type}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{parcelData.parcel.centroid && (
|
|
||||||
<div className={styles.infoItem}>
|
|
||||||
<span className={styles.label}>Zentrum (LV95):</span>
|
|
||||||
<span className={styles.value}>
|
|
||||||
{parcelData.parcel.centroid.x.toFixed(2)}, {parcelData.parcel.centroid.y.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{parcelData.parcel.geoportal_url && (
|
|
||||||
<div className={styles.infoItem}>
|
|
||||||
<span className={styles.label}>Geoportal:</span>
|
|
||||||
<a
|
|
||||||
href={parcelData.parcel.geoportal_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className={styles.link}
|
|
||||||
>
|
|
||||||
Link öffnen
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Map View Info */}
|
{/* Map View Info for this parcel */}
|
||||||
{parcelData.map_view && (
|
{parcelData.map_view && (
|
||||||
<section className={styles.section}>
|
<div className={styles.mapViewSection}>
|
||||||
<h3 className={styles.sectionTitle}>Kartenansicht</h3>
|
<h4 className={styles.subSectionTitle}>Kartenansicht</h4>
|
||||||
<div className={styles.infoGrid}>
|
<div className={styles.infoGrid}>
|
||||||
{parcelData.map_view.center && (
|
{parcelData.map_view.center && (
|
||||||
<div className={styles.infoItem}>
|
<div className={styles.infoItem}>
|
||||||
<span className={styles.label}>Zentrum:</span>
|
<span className={styles.label}>Zentrum:</span>
|
||||||
<span className={styles.value}>
|
<span className={styles.value}>
|
||||||
{parcelData.map_view.center.x.toFixed(2)}, {parcelData.map_view.center.y.toFixed(2)}
|
{parcelData.map_view.center.x.toFixed(2)}, {parcelData.map_view.center.y.toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{parcelData.map_view.zoom_bounds && (
|
||||||
|
<>
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<span className={styles.label}>Bounds Min:</span>
|
||||||
|
<span className={styles.value}>
|
||||||
|
{parcelData.map_view.zoom_bounds.min_x.toFixed(2)}, {parcelData.map_view.zoom_bounds.min_y.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<span className={styles.label}>Bounds Max:</span>
|
||||||
|
<span className={styles.value}>
|
||||||
|
{parcelData.map_view.zoom_bounds.max_x.toFixed(2)}, {parcelData.map_view.zoom_bounds.max_y.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{parcelData.map_view.zoom_bounds && (
|
</section>
|
||||||
<>
|
))}
|
||||||
<div className={styles.infoItem}>
|
</div>
|
||||||
<span className={styles.label}>Bounds Min:</span>
|
|
||||||
<span className={styles.value}>
|
|
||||||
{parcelData.map_view.zoom_bounds.min_x.toFixed(2)}, {parcelData.map_view.zoom_bounds.min_y.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.infoItem}>
|
|
||||||
<span className={styles.label}>Bounds Max:</span>
|
|
||||||
<span className={styles.value}>
|
|
||||||
{parcelData.map_view.zoom_bounds.max_x.toFixed(2)}, {parcelData.map_view.zoom_bounds.max_y.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Adjacent Parcels */}
|
{/* Adjacent Parcels */}
|
||||||
{adjacentParcels.length > 0 && (
|
{adjacentParcels.length > 0 && (
|
||||||
|
|
|
||||||
44
src/components/UiComponents/Tabs/Tabs.module.css
Normal file
44
src/components/UiComponents/Tabs/Tabs.module.css
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
.tabsContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabsHeader {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
border-bottom: 2px solid var(--color-border, #e0e0e0);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabButton {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text, #666);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin-bottom: -2px;
|
||||||
|
font-family: var(--font-family);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabButton:hover {
|
||||||
|
color: var(--color-text, #333);
|
||||||
|
background: var(--color-bg-hover, rgba(0, 0, 0, 0.02));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabButtonActive {
|
||||||
|
color: var(--color-secondary, #007bff);
|
||||||
|
border-bottom-color: var(--color-primary, #007bff);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabsContent {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
55
src/components/UiComponents/Tabs/Tabs.tsx
Normal file
55
src/components/UiComponents/Tabs/Tabs.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import styles from './Tabs.module.css';
|
||||||
|
|
||||||
|
export interface Tab {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
content: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabsProps {
|
||||||
|
tabs: Tab[];
|
||||||
|
defaultTabId?: string;
|
||||||
|
onTabChange?: (tabId: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tabs({ tabs, defaultTabId, onTabChange, className = '' }: TabsProps) {
|
||||||
|
const [activeTabId, setActiveTabId] = useState<string>(
|
||||||
|
defaultTabId || tabs[0]?.id || ''
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTabClick = (tabId: string) => {
|
||||||
|
setActiveTabId(tabId);
|
||||||
|
onTabChange?.(tabId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeTab = tabs.find(tab => tab.id === activeTabId) || tabs[0];
|
||||||
|
|
||||||
|
if (!tabs || tabs.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.tabsContainer} ${className}`}>
|
||||||
|
<div className={styles.tabsHeader}>
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
className={`${styles.tabButton} ${activeTabId === tab.id ? styles.tabButtonActive : ''}`}
|
||||||
|
onClick={() => handleTabClick(tab.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={styles.tabsContent}>
|
||||||
|
{activeTab && activeTab.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Tabs;
|
||||||
|
|
||||||
3
src/components/UiComponents/Tabs/index.ts
Normal file
3
src/components/UiComponents/Tabs/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { Tabs, default } from './Tabs';
|
||||||
|
export type { TabsProps, Tab } from './Tabs';
|
||||||
|
|
||||||
|
|
@ -17,3 +17,5 @@ export type { LogMessageProps } from './Log/LogMessage';
|
||||||
export { WorkflowStatus } from './WorkflowStatus';
|
export { WorkflowStatus } from './WorkflowStatus';
|
||||||
export type { WorkflowStatusProps } from './WorkflowStatus/WorkflowStatusTypes';
|
export type { WorkflowStatusProps } from './WorkflowStatus/WorkflowStatusTypes';
|
||||||
export * from './AutoScroll';
|
export * from './AutoScroll';
|
||||||
|
export * from './Tabs';
|
||||||
|
export type { TabsProps, Tab } from './Tabs';
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,13 @@ interface PekContextType {
|
||||||
locationError: string | null;
|
locationError: string | null;
|
||||||
|
|
||||||
// Parcel search
|
// Parcel search
|
||||||
selectedParcel: any;
|
selectedParcels: any[];
|
||||||
searchParcel: (location: string, includeAdjacent?: boolean) => Promise<any>;
|
searchParcel: (location: string, includeAdjacent?: boolean) => Promise<any>;
|
||||||
isSearchingParcel: boolean;
|
isSearchingParcel: boolean;
|
||||||
parcelSearchError: string | null;
|
parcelSearchError: string | null;
|
||||||
|
removeParcel: (parcelId: string) => void;
|
||||||
|
clearSelectedParcels: () => void;
|
||||||
|
isParcelSelected: (parcelId: string) => boolean;
|
||||||
|
|
||||||
// Map view
|
// Map view
|
||||||
mapCenter: any;
|
mapCenter: any;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,8 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { GenericPageData, PageButton, PageContent, resolveLanguageText, SettingsFieldConfig, SettingsSectionConfig } from './pageInterface';
|
import { GenericPageData, PageButton, PageContent, resolveLanguageText, SettingsFieldConfig, SettingsSectionConfig, GenericDataHook } from './pageInterface';
|
||||||
import { FormGenerator } from '../../components/FormGenerator';
|
import { FormGenerator } from '../../components/FormGenerator';
|
||||||
import { FormGeneratorForm, AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm, AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { Button, UploadButton, CreateButton, TextField, Messages, ChatMessage, LogMessage, DropdownSelect, Log, WorkflowStatus } from '../../components/UiComponents';
|
import { Button, UploadButton, CreateButton, TextField, Messages, ChatMessage, LogMessage, DropdownSelect, Log, WorkflowStatus, Tabs } from '../../components/UiComponents';
|
||||||
import { FormGeneratorList, FieldConfig } from '../../components/FormGenerator';
|
|
||||||
import { LuPlus } from 'react-icons/lu';
|
|
||||||
import type { ChatbotWorkflow } from '../../api/chatbotApi';
|
|
||||||
import { Popup } from '../../components/UiComponents/Popup';
|
import { Popup } from '../../components/UiComponents/Popup';
|
||||||
import { ConnectedFilesList } from '../../components/UiComponents/ConnectedFilesList';
|
import { ConnectedFilesList } from '../../components/UiComponents/ConnectedFilesList';
|
||||||
import type { DropdownSelectItem } from '../../components/UiComponents/DropdownSelect';
|
import type { DropdownSelectItem } from '../../components/UiComponents/DropdownSelect';
|
||||||
|
|
@ -158,6 +155,38 @@ const FixedHeightTextField: React.FC<{
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Component for rendering tabs from page content
|
||||||
|
const TabsRenderer: React.FC<{
|
||||||
|
content: PageContent;
|
||||||
|
renderContent: (content: PageContent, key?: string | number) => React.ReactNode;
|
||||||
|
t: (key: string, fallback?: string) => string;
|
||||||
|
}> = ({ content, renderContent, t }) => {
|
||||||
|
const tabsConfig = content.tabsConfig;
|
||||||
|
if (!tabsConfig || !tabsConfig.tabs || tabsConfig.tabs.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert PageContent tabs to Tabs component format
|
||||||
|
const tabs = tabsConfig.tabs.map(tab => ({
|
||||||
|
id: tab.id,
|
||||||
|
label: resolveLanguageText(tab.label, t),
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
{tab.content.map((nestedContent, index) =>
|
||||||
|
renderContent(nestedContent, index)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
tabs={tabs}
|
||||||
|
defaultTabId={tabsConfig.defaultTabId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Component to handle async permission checks for content
|
// Component to handle async permission checks for content
|
||||||
const ContentRenderer: React.FC<{
|
const ContentRenderer: React.FC<{
|
||||||
contents: PageContent[];
|
contents: PageContent[];
|
||||||
|
|
@ -308,6 +337,34 @@ const ContentRenderer: React.FC<{
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper function to recursively find all table content sections
|
||||||
|
const findAllTableContents = (contents: PageContent[]): PageContent[] => {
|
||||||
|
const tableContents: PageContent[] = [];
|
||||||
|
|
||||||
|
const traverse = (contentList: PageContent[]) => {
|
||||||
|
for (const content of contentList) {
|
||||||
|
if (content.type === 'table') {
|
||||||
|
tableContents.push(content);
|
||||||
|
}
|
||||||
|
// Check nested content in tabs
|
||||||
|
if (content.type === 'tabs' && content.tabsConfig) {
|
||||||
|
for (const tab of content.tabsConfig.tabs) {
|
||||||
|
traverse(tab.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check nested content in columns
|
||||||
|
if (content.type === 'columns' && content.columnsConfig) {
|
||||||
|
for (const column of content.columnsConfig.columns) {
|
||||||
|
traverse(column.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
traverse(contents);
|
||||||
|
return tableContents;
|
||||||
|
};
|
||||||
|
|
||||||
const PageRenderer: React.FC<PageRendererProps> = ({
|
const PageRenderer: React.FC<PageRendererProps> = ({
|
||||||
pageData,
|
pageData,
|
||||||
onButtonClick
|
onButtonClick
|
||||||
|
|
@ -316,27 +373,65 @@ const PageRenderer: React.FC<PageRendererProps> = ({
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { hasPermission } = usePermissions();
|
const { hasPermission } = usePermissions();
|
||||||
|
|
||||||
// Call the hook at the top level to ensure it persists across renders
|
// Find all table content sections (including nested ones)
|
||||||
// This is CRITICAL - hooks must be called in the same order on every render
|
const allTableContents = React.useMemo(() =>
|
||||||
const tableContent = pageData.content?.find(content => content.type === 'table');
|
findAllTableContents(pageData.content || []),
|
||||||
|
[pageData.content]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create hook instances for all table contents - MUST be at top level
|
||||||
|
// We need to call hookFactory() at top level, but the actual hook() calls happen below
|
||||||
|
const tableHookFactories = React.useMemo(() => {
|
||||||
|
return allTableContents.map((tableContent, index) => {
|
||||||
|
const hookFactory = tableContent.tableConfig?.hookFactory;
|
||||||
|
if (hookFactory) {
|
||||||
|
const key = tableContent.id || `table-${index}`;
|
||||||
|
return { key, hookFactory };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}).filter((item): item is { key: string; hookFactory: () => () => GenericDataHook } => item !== null);
|
||||||
|
}, [allTableContents]);
|
||||||
|
|
||||||
|
// Call all hook factories at top level to create hook instances
|
||||||
|
// This must happen unconditionally and in the same order every render
|
||||||
|
const tableHookInstances = tableHookFactories.map(({ key, hookFactory }) => ({
|
||||||
|
key,
|
||||||
|
hook: hookFactory() // This creates the hook function, doesn't call it yet
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Call all hooks at top level - MUST be unconditional
|
||||||
|
// All hooks are called in the same order every render
|
||||||
|
const tableHookDataArray = tableHookInstances.map(({ key, hook }) => ({
|
||||||
|
key,
|
||||||
|
data: hook() // This is the actual hook call - must be at top level
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Convert to Map for easy lookup
|
||||||
|
const tableHookData = React.useMemo(() => {
|
||||||
|
const dataMap = new Map<string, GenericDataHook | null>();
|
||||||
|
tableHookDataArray.forEach(({ key, data }) => {
|
||||||
|
dataMap.set(key, data);
|
||||||
|
});
|
||||||
|
return dataMap;
|
||||||
|
}, [tableHookDataArray]);
|
||||||
|
|
||||||
|
// Also check for top-level inputForm and settings (for backward compatibility)
|
||||||
const inputFormContent = pageData.content?.find(content => content.type === 'inputForm');
|
const inputFormContent = pageData.content?.find(content => content.type === 'inputForm');
|
||||||
const settingsContent = pageData.content?.find(content => content.type === 'settings');
|
const settingsContent = pageData.content?.find(content => content.type === 'settings');
|
||||||
const hookFactory = tableContent?.tableConfig?.hookFactory
|
const hookFactory = inputFormContent?.inputFormConfig?.hookFactory
|
||||||
|| inputFormContent?.inputFormConfig?.hookFactory
|
|
||||||
|| settingsContent?.settingsConfig?.hookFactory;
|
|| settingsContent?.settingsConfig?.hookFactory;
|
||||||
|
|
||||||
// Create a stable hook instance using React.useMemo
|
// Create hook instance at top level
|
||||||
// This ensures the same hook instance is used across re-renders
|
const useTableData = hookFactory ? hookFactory() : null;
|
||||||
const useTableData = React.useMemo(() => {
|
|
||||||
if (hookFactory) {
|
|
||||||
return hookFactory();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [hookFactory]);
|
|
||||||
|
|
||||||
// Call the hook to get the current data
|
// Call the hook to get the current data (for backward compatibility)
|
||||||
// This will be called on every render, but it's the SAME hook instance
|
// If no inputForm/settings hook, try to use the first table hook for header buttons
|
||||||
const hookData = useTableData ? useTableData() : null;
|
let hookData = useTableData ? useTableData() : null;
|
||||||
|
if (!hookData && tableHookData.size > 0) {
|
||||||
|
// Use the first table hook data for header buttons
|
||||||
|
const firstTableHook = Array.from(tableHookData.values())[0];
|
||||||
|
hookData = firstTableHook;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -819,21 +914,27 @@ const PageRenderer: React.FC<PageRendererProps> = ({
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
case 'table':
|
case 'table':
|
||||||
if (content.tableConfig && hookData) {
|
// Get hookData for this specific table (nested tables use their own hooks)
|
||||||
const { columns: configColumns, actionButtons, ...tableProps } = content.tableConfig;
|
const currentTableHookData = content.id && tableHookData.has(content.id)
|
||||||
|
? tableHookData.get(content.id)!
|
||||||
|
: hookData; // Fallback to top-level hookData for backward compatibility
|
||||||
|
|
||||||
|
if (content.tableConfig && currentTableHookData) {
|
||||||
|
|
||||||
|
const { columns: configColumns, actionButtons, emptyMessage, ...tableProps } = content.tableConfig;
|
||||||
|
|
||||||
// Only show loading spinner on initial load (when there's no data yet)
|
// Only show loading spinner on initial load (when there's no data yet)
|
||||||
// During refetch, keep the existing data visible
|
// During refetch, keep the existing data visible
|
||||||
const showLoadingSpinner = hookData.loading && hookData.data.length === 0;
|
const showLoadingSpinner = currentTableHookData.loading && currentTableHookData.data.length === 0;
|
||||||
|
|
||||||
// Show error state if there's an error
|
// Show error state if there's an error
|
||||||
if (hookData.error) {
|
if (currentTableHookData.error) {
|
||||||
return (
|
return (
|
||||||
<div key={content.id} className={styles.tableContainer}>
|
<div key={content.id} className={styles.tableContainer}>
|
||||||
<div className={styles.errorState}>
|
<div className={styles.errorState}>
|
||||||
<p>Error loading data: {hookData.error}</p>
|
<p>Error loading data: {currentTableHookData.error}</p>
|
||||||
{hookData.refetch && (
|
{currentTableHookData.refetch && (
|
||||||
<button onClick={hookData.refetch} className={styles.retryButton}>
|
<button onClick={() => currentTableHookData.refetch?.()} className={styles.retryButton}>
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -845,7 +946,7 @@ const PageRenderer: React.FC<PageRendererProps> = ({
|
||||||
// Use columns from hook data if available, otherwise use config columns
|
// Use columns from hook data if available, otherwise use config columns
|
||||||
// CRITICAL: Preserve columns even when data is empty (e.g., after filtering)
|
// CRITICAL: Preserve columns even when data is empty (e.g., after filtering)
|
||||||
// Columns from attributes should persist regardless of data state
|
// Columns from attributes should persist regardless of data state
|
||||||
const hookColumns = hookData.columns && hookData.columns.length > 0 ? hookData.columns : undefined;
|
const hookColumns = currentTableHookData.columns && currentTableHookData.columns.length > 0 ? currentTableHookData.columns : undefined;
|
||||||
const configCols = configColumns && configColumns.length > 0 ? configColumns : undefined;
|
const configCols = configColumns && configColumns.length > 0 ? configColumns : undefined;
|
||||||
// Prioritize hookColumns (from attributes) over configColumns to ensure persistence
|
// Prioritize hookColumns (from attributes) over configColumns to ensure persistence
|
||||||
const columns = hookColumns || configCols;
|
const columns = hookColumns || configCols;
|
||||||
|
|
@ -960,30 +1061,31 @@ const PageRenderer: React.FC<PageRendererProps> = ({
|
||||||
// Debug logging for table rendering
|
// Debug logging for table rendering
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
console.log('🔍 Rendering FormGenerator:', {
|
console.log('🔍 Rendering FormGenerator:', {
|
||||||
dataLength: hookData.data?.length || 0,
|
dataLength: currentTableHookData.data?.length || 0,
|
||||||
columnsCount: resolvedColumns?.length || 0,
|
columnsCount: resolvedColumns?.length || 0,
|
||||||
loading: showLoadingSpinner,
|
loading: showLoadingSpinner,
|
||||||
hasError: !!hookData.error,
|
hasError: !!currentTableHookData.error,
|
||||||
data: hookData.data,
|
data: currentTableHookData.data,
|
||||||
willAutoDetect: !resolvedColumns
|
willAutoDetect: !resolvedColumns
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={content.id} className={styles.tableContainer}>
|
<div key={content.id} className={styles.tableContainer}>
|
||||||
{hookData.isRefetching && (
|
{currentTableHookData.isRefetching && (
|
||||||
<div className={styles.refetchingIndicator}>
|
<div className={styles.refetchingIndicator}>
|
||||||
Refreshing...
|
Refreshing...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<FormGenerator
|
<FormGenerator
|
||||||
data={hookData.data || []}
|
data={currentTableHookData.data || []}
|
||||||
columns={resolvedColumns}
|
columns={resolvedColumns}
|
||||||
loading={showLoadingSpinner}
|
loading={showLoadingSpinner}
|
||||||
actionButtons={formGeneratorActions}
|
actionButtons={formGeneratorActions}
|
||||||
hookData={hookData}
|
hookData={currentTableHookData}
|
||||||
onDelete={hookData.onDelete}
|
onDelete={currentTableHookData.onDelete}
|
||||||
onDeleteMultiple={hookData.onDeleteMultiple}
|
onDeleteMultiple={currentTableHookData.onDeleteMultiple}
|
||||||
|
emptyMessage={emptyMessage}
|
||||||
{...tableProps}
|
{...tableProps}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1648,158 +1750,46 @@ const PageRenderer: React.FC<PageRendererProps> = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'chatHistory': {
|
case 'tabs': {
|
||||||
const chatHistoryConfig = content.chatHistoryConfig || {};
|
return (
|
||||||
const threads: ChatbotWorkflow[] = (hookData as any)?.threads || [];
|
<TabsRenderer
|
||||||
const selectedThreadId = (hookData as any)?.selectedThreadId || null;
|
key={content.id}
|
||||||
const selectThread = (hookData as any)?.selectThread;
|
content={content}
|
||||||
const startNewChat = (hookData as any)?.startNewChat;
|
renderContent={renderContent}
|
||||||
const threadsLoading = (hookData as any)?.threadsLoading || false;
|
t={t}
|
||||||
const threadsError = (hookData as any)?.threadsError || null;
|
/>
|
||||||
|
);
|
||||||
if (!selectThread) {
|
}
|
||||||
console.warn('ChatHistoryList requires selectThread method from hookData');
|
|
||||||
|
case 'columns': {
|
||||||
|
const columnsConfig = content.columnsConfig;
|
||||||
|
if (!columnsConfig || !columnsConfig.columns || columnsConfig.columns.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format date function for relative time display
|
// Build grid template columns
|
||||||
const formatDate = (timestamp?: number): string => {
|
const gridTemplateColumns = columnsConfig.columns
|
||||||
if (!timestamp) return '';
|
.map(col => col.width || '1fr')
|
||||||
|
.join(' ');
|
||||||
const date = new Date(timestamp);
|
const gap = columnsConfig.gap || '1rem';
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now.getTime() - date.getTime();
|
return (
|
||||||
const diffMins = Math.floor(diffMs / 60000);
|
<div
|
||||||
const diffHours = Math.floor(diffMs / 3600000);
|
key={content.id}
|
||||||
const diffDays = Math.floor(diffMs / 86400000);
|
className={styles.columnsContainer}
|
||||||
|
style={{
|
||||||
if (diffMins < 1) return 'Just now';
|
display: 'grid',
|
||||||
if (diffMins < 60) return `${diffMins}m ago`;
|
gridTemplateColumns,
|
||||||
if (diffHours < 24) return `${diffHours}h ago`;
|
gap
|
||||||
if (diffDays < 7) return `${diffDays}d ago`;
|
}}
|
||||||
|
>
|
||||||
// Format as date
|
{columnsConfig.columns.map((column, colIndex) => (
|
||||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
<div key={column.id} className={styles.column}>
|
||||||
};
|
{column.content.map((nestedContent, index) =>
|
||||||
|
renderContent(nestedContent, `${colIndex}-${index}`)
|
||||||
// Get thread preview function
|
|
||||||
const getThreadPreview = (thread: ChatbotWorkflow): string => {
|
|
||||||
if (thread.name) return thread.name;
|
|
||||||
return `Chat ${thread.id.slice(0, 8)}...`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Field configuration for ChatbotWorkflow
|
|
||||||
// First field: Thread preview (uses name field but formats it)
|
|
||||||
// Second field: Date (uses lastActivity field but formats it)
|
|
||||||
// Third field: Status
|
|
||||||
const fields: FieldConfig[] = [
|
|
||||||
{
|
|
||||||
key: 'name',
|
|
||||||
label: 'Thread',
|
|
||||||
type: 'text',
|
|
||||||
formatter: (value: any, row: ChatbotWorkflow) => getThreadPreview(row)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'lastActivity',
|
|
||||||
label: 'Date',
|
|
||||||
type: 'timestamp',
|
|
||||||
formatter: (value: any, row: ChatbotWorkflow) => {
|
|
||||||
const timestamp = row.lastActivity || row.startedAt;
|
|
||||||
return formatDate(timestamp);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
label: 'Status',
|
|
||||||
type: 'text',
|
|
||||||
formatter: (value: any, row: ChatbotWorkflow) => row.status || ''
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// Handle error state
|
|
||||||
if (threadsError) {
|
|
||||||
return (
|
|
||||||
<div key={content.id} className={styles.chatHistorySection}>
|
|
||||||
<div className={styles.chatHistoryHeader}>
|
|
||||||
<h3 className={styles.chatHistoryTitle}>Chat History</h3>
|
|
||||||
{startNewChat && (
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
icon={LuPlus}
|
|
||||||
onClick={startNewChat}
|
|
||||||
title="New Chat"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.chatHistoryError}>
|
))}
|
||||||
{threadsError}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const emptyMessage = chatHistoryConfig.emptyMessage
|
|
||||||
? resolveLanguageText(chatHistoryConfig.emptyMessage, t)
|
|
||||||
: 'No chat history yet. Start a conversation to see it here.';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={content.id} className={styles.chatHistorySection}>
|
|
||||||
<FormGeneratorList<ChatbotWorkflow>
|
|
||||||
data={threads}
|
|
||||||
fields={fields}
|
|
||||||
title="Chat History"
|
|
||||||
searchable={false}
|
|
||||||
filterable={false}
|
|
||||||
sortable={false}
|
|
||||||
pagination={false}
|
|
||||||
selectable={true}
|
|
||||||
loading={threadsLoading}
|
|
||||||
onItemClick={(row) => selectThread(row.id)}
|
|
||||||
onItemSelect={(selectedRows) => {
|
|
||||||
// Handle multiselect - select first item when multiple selected
|
|
||||||
if (selectedRows.length > 0 && selectedRows[0]) {
|
|
||||||
selectThread(selectedRows[0].id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDeleteMultiple={(selectedRows) => {
|
|
||||||
// Handle bulk delete
|
|
||||||
selectedRows.forEach(row => {
|
|
||||||
// Delete logic handled by action button
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
getItemDataAttributes={(row) => ({
|
|
||||||
'selected-thread-id': selectedThreadId === row.id ? 'true' : 'false',
|
|
||||||
'thread-id': row.id
|
|
||||||
})}
|
|
||||||
actionButtons={[
|
|
||||||
{
|
|
||||||
type: 'delete',
|
|
||||||
idField: 'id',
|
|
||||||
operationName: 'handleDelete',
|
|
||||||
loadingStateName: 'deletingItems',
|
|
||||||
onAction: async (row) => {
|
|
||||||
// If deleted thread was selected, start new chat
|
|
||||||
if (selectedThreadId === row.id && startNewChat) {
|
|
||||||
startNewChat();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
hookData={hookData}
|
|
||||||
className={styles.chatHistoryList}
|
|
||||||
emptyMessage={emptyMessage}
|
|
||||||
headerButton={startNewChat ? (
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
icon={LuPlus}
|
|
||||||
onClick={startNewChat}
|
|
||||||
title="New Chat"
|
|
||||||
className={styles.chatHistoryNewButton}
|
|
||||||
/>
|
|
||||||
) : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -2075,6 +2065,7 @@ const PageRenderer: React.FC<PageRendererProps> = ({
|
||||||
size={button.size || 'md'}
|
size={button.size || 'md'}
|
||||||
icon={button.icon}
|
icon={button.icon}
|
||||||
disabled={disabledValue}
|
disabled={disabledValue}
|
||||||
|
multiStep={button.formConfig.multiStep || false}
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
// Refetch data after successful creation
|
// Refetch data after successful creation
|
||||||
if (hookData.refetch) {
|
if (hookData.refetch) {
|
||||||
|
|
|
||||||
|
|
@ -1,105 +1,181 @@
|
||||||
import { GenericPageData } from '../../pageInterface';
|
import { GenericPageData } from '../../pageInterface';
|
||||||
import { FaTable } from 'react-icons/fa';
|
import { FaTable, FaPlus } from 'react-icons/fa';
|
||||||
import { IoMdSend } from 'react-icons/io';
|
import { createProjectsTableHook, createParzellenTableHook } from '../../../../hooks/usePekTables';
|
||||||
import { usePekTablesContext } from '../../../../contexts/PekTablesContext';
|
|
||||||
import PekTablesDropdown from './pek-tables/PekTablesDropdown';
|
|
||||||
import PekTablesPageWrapper from './pek-tables/PekTablesPageWrapper';
|
|
||||||
import PekTablesTable from './pek-tables/PekTablesTable';
|
|
||||||
|
|
||||||
// Hook factory for PEK Tables page
|
|
||||||
const createPekTablesHook = () => {
|
|
||||||
return () => {
|
|
||||||
const pekTablesData = usePekTablesContext();
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
await pekTablesData.processCommand(pekTablesData.commandInput);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
// Table data
|
|
||||||
data: pekTablesData.tableData,
|
|
||||||
loading: pekTablesData.isLoadingTableData || pekTablesData.isLoadingTables,
|
|
||||||
error: pekTablesData.tableDataError || pekTablesData.tablesError,
|
|
||||||
|
|
||||||
// Messages for command results
|
|
||||||
messages: pekTablesData.messages.map(msg => ({
|
|
||||||
id: msg.id,
|
|
||||||
role: msg.role,
|
|
||||||
message: msg.message,
|
|
||||||
publishedAt: msg.timestamp.getTime(),
|
|
||||||
...(msg.data && { data: msg.data })
|
|
||||||
})),
|
|
||||||
|
|
||||||
// Input form properties (for command input)
|
|
||||||
inputValue: pekTablesData.commandInput,
|
|
||||||
onInputChange: pekTablesData.setCommandInput,
|
|
||||||
handleSubmit,
|
|
||||||
isSubmitting: pekTablesData.isProcessingCommand,
|
|
||||||
|
|
||||||
// Refresh function
|
|
||||||
onRefresh: pekTablesData.refreshTableData,
|
|
||||||
isRefetching: false
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const pekTablesPageData: GenericPageData = {
|
export const pekTablesPageData: GenericPageData = {
|
||||||
id: 'pek-tables',
|
id: 'pek-tables',
|
||||||
path: 'start/pek-tables',
|
path: 'start/pek-tables',
|
||||||
name: 'data-management.title',
|
name: 'Projektmanagement',
|
||||||
description: 'data-management.description',
|
description: 'Projektmanagement mit Tabellen',
|
||||||
|
|
||||||
// Parent page
|
// Parent page
|
||||||
parentPath: 'start',
|
parentPath: 'start',
|
||||||
|
|
||||||
// Visual
|
// Visual
|
||||||
icon: FaTable,
|
icon: FaTable,
|
||||||
title: 'data-management.title',
|
title: 'Projektmanagement',
|
||||||
subtitle: 'data-management.subtitle',
|
subtitle: 'Datenverwaltung',
|
||||||
|
|
||||||
// Header buttons
|
// Header buttons
|
||||||
headerButtons: [],
|
headerButtons: [
|
||||||
|
{
|
||||||
|
id: 'create-project',
|
||||||
|
label: 'Neues Projekt',
|
||||||
|
variant: 'primary',
|
||||||
|
size: 'lg',
|
||||||
|
icon: FaPlus,
|
||||||
|
formConfig: {
|
||||||
|
fields: [], // Will be generated from attributes via generateEditFieldsFromAttributes
|
||||||
|
popupTitle: 'Neues Projekt erstellen',
|
||||||
|
popupSize: 'large',
|
||||||
|
createOperationName: 'handleProjectCreate',
|
||||||
|
multiStep: true // Enable multi-step form with Step 1 (label) and Step 2 (parcel selection)
|
||||||
|
},
|
||||||
|
disabled: (hookData: any) => {
|
||||||
|
if (!hookData?.permissions) return { disabled: false };
|
||||||
|
const hasCreate = hookData.permissions.create !== 'n' && hookData.permissions.view;
|
||||||
|
return { disabled: !hasCreate, message: 'No permission to create projects' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
// Content sections
|
// Content sections
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
id: 'pek-tables-description',
|
id: 'projektmanagement-layout',
|
||||||
type: 'paragraph',
|
type: 'columns',
|
||||||
content: 'data-management.description_text'
|
columnsConfig: {
|
||||||
},
|
columns: [
|
||||||
{
|
{
|
||||||
id: 'pek-tables-dropdown',
|
id: 'main-column',
|
||||||
type: 'custom',
|
width: '3fr',
|
||||||
customComponent: PekTablesDropdown
|
content: [
|
||||||
},
|
{
|
||||||
{
|
id: 'tables-tabs',
|
||||||
id: 'pek-tables-command-input',
|
type: 'tabs',
|
||||||
type: 'inputForm',
|
tabsConfig: {
|
||||||
inputFormConfig: {
|
tabs: [
|
||||||
hookFactory: createPekTablesHook,
|
{
|
||||||
placeholder: 'data-management.command.placeholder',
|
id: 'projects',
|
||||||
buttonLabel: 'Senden',
|
label: 'Projekte',
|
||||||
buttonIcon: IoMdSend,
|
content: [
|
||||||
buttonVariant: 'primary',
|
{
|
||||||
buttonSize: 'md',
|
id: 'projects-table',
|
||||||
textFieldSize: 'md'
|
type: 'table',
|
||||||
|
tableConfig: {
|
||||||
|
hookFactory: createProjectsTableHook,
|
||||||
|
searchable: true,
|
||||||
|
filterable: true,
|
||||||
|
sortable: true,
|
||||||
|
resizable: true,
|
||||||
|
pagination: true,
|
||||||
|
pageSize: 10,
|
||||||
|
emptyMessage: 'Noch keine Projekte erstellt, erstelle jetzt dein erstes Projekt!',
|
||||||
|
actionButtons: [
|
||||||
|
{
|
||||||
|
type: 'edit',
|
||||||
|
title: 'common.edit',
|
||||||
|
idField: 'id',
|
||||||
|
operationName: 'handleProjectUpdate',
|
||||||
|
loadingStateName: 'editingProjects',
|
||||||
|
fetchItemFunctionName: 'fetchProjectById',
|
||||||
|
disabled: (hookData: any) => {
|
||||||
|
if (!hookData?.permissions) return { disabled: false };
|
||||||
|
const hasUpdate = hookData.permissions.update !== 'n' && hookData.permissions.view;
|
||||||
|
return { disabled: !hasUpdate, message: 'No permission to edit projects' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'delete',
|
||||||
|
title: 'common.delete',
|
||||||
|
idField: 'id',
|
||||||
|
operationName: 'handleDelete',
|
||||||
|
loadingStateName: 'deletingProjects',
|
||||||
|
disabled: (hookData: any) => {
|
||||||
|
if (!hookData?.permissions) return { disabled: false };
|
||||||
|
const hasDelete = hookData.permissions.delete !== 'n' && hookData.permissions.view;
|
||||||
|
return { disabled: !hasDelete, message: 'No permission to delete projects' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'parzellen',
|
||||||
|
label: 'Parzellen',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
id: 'parzellen-table',
|
||||||
|
type: 'table',
|
||||||
|
tableConfig: {
|
||||||
|
hookFactory: createParzellenTableHook,
|
||||||
|
searchable: true,
|
||||||
|
filterable: true,
|
||||||
|
sortable: true,
|
||||||
|
resizable: true,
|
||||||
|
pagination: true,
|
||||||
|
pageSize: 10,
|
||||||
|
emptyMessage: 'Noch keine Parzellen erstellt, erstelle jetzt dein erstes Projekt und füge eine Parzelle hinzu!',
|
||||||
|
actionButtons: [
|
||||||
|
{
|
||||||
|
type: 'view',
|
||||||
|
title: 'common.view',
|
||||||
|
idField: 'id',
|
||||||
|
nameField: 'label',
|
||||||
|
operationName: 'handleParzelleView',
|
||||||
|
loadingStateName: 'viewingParzellen',
|
||||||
|
disabled: (hookData: any) => {
|
||||||
|
if (!hookData?.permissions) return { disabled: false };
|
||||||
|
const hasRead = hookData.permissions.read !== 'n' && hookData.permissions.view;
|
||||||
|
return { disabled: !hasRead, message: 'No permission to view parzellen' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'edit',
|
||||||
|
title: 'common.edit',
|
||||||
|
idField: 'id',
|
||||||
|
operationName: 'handleParzelleUpdate',
|
||||||
|
loadingStateName: 'editingParzellen',
|
||||||
|
fetchItemFunctionName: 'fetchParzelleById',
|
||||||
|
disabled: (hookData: any) => {
|
||||||
|
if (!hookData?.permissions) return { disabled: false };
|
||||||
|
const hasUpdate = hookData.permissions.update !== 'n' && hookData.permissions.view;
|
||||||
|
return { disabled: !hasUpdate, message: 'No permission to edit parzellen' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'delete',
|
||||||
|
title: 'common.delete',
|
||||||
|
idField: 'id',
|
||||||
|
operationName: 'handleDelete',
|
||||||
|
loadingStateName: 'deletingParzellen',
|
||||||
|
disabled: (hookData: any) => {
|
||||||
|
if (!hookData?.permissions) return { disabled: false };
|
||||||
|
const hasDelete = hookData.permissions.delete !== 'n' && hookData.permissions.view;
|
||||||
|
return { disabled: !hasDelete, message: 'No permission to delete parzellen' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
defaultTabId: 'projects'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sidebar-column',
|
||||||
|
width: '1fr',
|
||||||
|
content: []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
gap: '1rem'
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'pek-tables-command-results',
|
|
||||||
type: 'messages',
|
|
||||||
messagesConfig: {
|
|
||||||
variant: 'chat',
|
|
||||||
showDocuments: false,
|
|
||||||
showMetadata: false,
|
|
||||||
showProgress: false,
|
|
||||||
emptyMessage: 'data-management.command.empty'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'pek-tables-table',
|
|
||||||
type: 'custom',
|
|
||||||
customComponent: PekTablesTable
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
@ -112,10 +188,7 @@ export const pekTablesPageData: GenericPageData = {
|
||||||
// Sidebar
|
// Sidebar
|
||||||
order: 11,
|
order: 11,
|
||||||
showInSidebar: true,
|
showInSidebar: true,
|
||||||
|
|
||||||
// Custom component wrapper with PekTablesProvider
|
|
||||||
customComponent: PekTablesPageWrapper,
|
|
||||||
|
|
||||||
// Lifecycle hooks
|
// Lifecycle hooks
|
||||||
onActivate: async () => {
|
onActivate: async () => {
|
||||||
if (import.meta.env.DEV) console.log('PEK Tables page activated');
|
if (import.meta.env.DEV) console.log('PEK Tables page activated');
|
||||||
|
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
.collapsableContainer {
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
border: 1px solid var(--color-border, #e5e7eb);
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
background-color: var(--color-bg, #ffffff);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapseButton {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
background-color: var(--color-bg-secondary, #f9fafb);
|
|
||||||
border: none;
|
|
||||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
font-family: var(--font-family);
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapseButton:hover {
|
|
||||||
background-color: var(--color-hover, #f3f4f6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapseButton:focus {
|
|
||||||
outline: 2px solid var(--color-primary, #3b82f6);
|
|
||||||
outline-offset: -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapseButtonText {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text, #111827);
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapseIcon {
|
|
||||||
color: var(--color-text-secondary, #6b7280);
|
|
||||||
font-size: 1rem;
|
|
||||||
transition: transform 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapsableContent {
|
|
||||||
padding: 0;
|
|
||||||
animation: slideDown 0.2s ease-out;
|
|
||||||
height: calc(100vh - 250px); /* Very tall height, starting below dropdown and extending downward */
|
|
||||||
min-height: 500px; /* Minimum height for usability */
|
|
||||||
max-height: calc(100vh - 150px); /* Maximum height to leave minimal space for other elements */
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideDown {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
max-height: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
max-height: 1000px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { FaChevronDown, FaChevronUp } from 'react-icons/fa';
|
|
||||||
import styles from './PekTablesCollapsable.module.css';
|
|
||||||
|
|
||||||
interface PekTablesCollapsableProps {
|
|
||||||
title?: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
defaultCollapsed?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PekTablesCollapsable: React.FC<PekTablesCollapsableProps> = ({
|
|
||||||
title = 'Tabellenansicht',
|
|
||||||
children,
|
|
||||||
defaultCollapsed = false
|
|
||||||
}) => {
|
|
||||||
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
|
|
||||||
|
|
||||||
const toggleCollapse = () => {
|
|
||||||
setIsCollapsed(!isCollapsed);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.collapsableContainer}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.collapseButton}
|
|
||||||
onClick={toggleCollapse}
|
|
||||||
aria-expanded={!isCollapsed}
|
|
||||||
>
|
|
||||||
<span className={styles.collapseButtonText}>{title}</span>
|
|
||||||
{isCollapsed ? (
|
|
||||||
<FaChevronDown className={styles.collapseIcon} />
|
|
||||||
) : (
|
|
||||||
<FaChevronUp className={styles.collapseIcon} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{!isCollapsed && (
|
|
||||||
<div className={styles.collapsableContent}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PekTablesCollapsable;
|
|
||||||
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { DropdownSelect } from '../../../../../components/UiComponents';
|
|
||||||
import { usePekTablesContext } from '../../../../../contexts/PekTablesContext';
|
|
||||||
|
|
||||||
const PekTablesDropdown: React.FC = () => {
|
|
||||||
const {
|
|
||||||
tables,
|
|
||||||
isLoadingTables,
|
|
||||||
tablesError,
|
|
||||||
selectedTable,
|
|
||||||
setSelectedTable
|
|
||||||
} = usePekTablesContext();
|
|
||||||
|
|
||||||
// Ensure tables is always an array
|
|
||||||
const safeTables = tables || [];
|
|
||||||
|
|
||||||
// Convert tables to DropdownSelectItem format
|
|
||||||
const tableItems = safeTables.map((table, index) => ({
|
|
||||||
id: table.model || `table-${index}`,
|
|
||||||
label: `${table.name}${table.description ? ` - ${table.description}` : ''}`,
|
|
||||||
value: table.model
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Find selected item ID
|
|
||||||
const selectedItemId = selectedTable
|
|
||||||
? tableItems.find(item => item.value === selectedTable)?.id || null
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const handleSelect = (item: { id: string | number; label: string; value: any } | null) => {
|
|
||||||
if (item) {
|
|
||||||
setSelectedTable(item.value);
|
|
||||||
} else {
|
|
||||||
setSelectedTable('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ marginBottom: '1.5rem' }}>
|
|
||||||
<label style={{
|
|
||||||
display: 'block',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
fontWeight: 500,
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
marginBottom: '0.5rem'
|
|
||||||
}}>
|
|
||||||
Tabelle
|
|
||||||
</label>
|
|
||||||
<DropdownSelect
|
|
||||||
items={tableItems}
|
|
||||||
selectedItemId={selectedItemId}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
placeholder={isLoadingTables ? 'Lade Tabellen...' : 'Tabelle auswählen'}
|
|
||||||
emptyMessage="Keine Tabellen verfügbar"
|
|
||||||
disabled={isLoadingTables || safeTables.length === 0}
|
|
||||||
loading={isLoadingTables}
|
|
||||||
size="md"
|
|
||||||
variant="primary"
|
|
||||||
minWidth="100%"
|
|
||||||
showClearButton={false}
|
|
||||||
/>
|
|
||||||
{tablesError && (
|
|
||||||
<div style={{ marginTop: '0.5rem', color: '#ef4444', fontSize: '0.875rem' }}>
|
|
||||||
{tablesError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PekTablesDropdown;
|
|
||||||
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { PekTablesProvider } from '../../../../../contexts/PekTablesContext';
|
|
||||||
import PageRenderer from '../../../PageRenderer';
|
|
||||||
import { pekTablesPageData } from '../pek-tables';
|
|
||||||
|
|
||||||
const PekTablesPageWrapper: React.FC = () => {
|
|
||||||
// Create a version of pageData without customComponent to avoid infinite loop
|
|
||||||
const { customComponent, ...pageDataWithoutCustom } = pekTablesPageData;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PekTablesProvider>
|
|
||||||
<PageRenderer pageData={pageDataWithoutCustom} />
|
|
||||||
</PekTablesProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PekTablesPageWrapper;
|
|
||||||
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { FormGenerator } from '../../../../../components/FormGenerator';
|
|
||||||
import { usePekTablesContext } from '../../../../../contexts/PekTablesContext';
|
|
||||||
import { FaChevronDown, FaChevronUp } from 'react-icons/fa';
|
|
||||||
import styles from './PekTablesCollapsable.module.css';
|
|
||||||
|
|
||||||
const PekTablesTable: React.FC = () => {
|
|
||||||
const {
|
|
||||||
tableData,
|
|
||||||
isLoadingTableData,
|
|
||||||
tableDataError,
|
|
||||||
refreshTableData,
|
|
||||||
selectedTable
|
|
||||||
} = usePekTablesContext();
|
|
||||||
|
|
||||||
const [isCollapsed, setIsCollapsed] = useState(true); // Default collapsed to show chat window
|
|
||||||
|
|
||||||
// Debug logging
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('📊 PekTablesTable render:', {
|
|
||||||
selectedTable,
|
|
||||||
tableDataLength: tableData?.length || 0,
|
|
||||||
isLoadingTableData,
|
|
||||||
tableDataError
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleCollapse = () => {
|
|
||||||
setIsCollapsed(!isCollapsed);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Show error state if there's an error
|
|
||||||
if (tableDataError) {
|
|
||||||
return (
|
|
||||||
<div className={styles.collapsableContainer}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.collapseButton}
|
|
||||||
onClick={toggleCollapse}
|
|
||||||
aria-expanded={!isCollapsed}
|
|
||||||
>
|
|
||||||
<span className={styles.collapseButtonText}>Tabellenansicht</span>
|
|
||||||
{isCollapsed ? (
|
|
||||||
<FaChevronDown className={styles.collapseIcon} />
|
|
||||||
) : (
|
|
||||||
<FaChevronUp className={styles.collapseIcon} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{!isCollapsed && (
|
|
||||||
<div className={styles.collapsableContent}>
|
|
||||||
<div style={{ padding: '1rem', color: '#ef4444' }}>
|
|
||||||
<p>Fehler beim Laden der Daten: {tableDataError}</p>
|
|
||||||
{refreshTableData && (
|
|
||||||
<button onClick={refreshTableData} style={{ marginTop: '0.5rem', padding: '0.5rem 1rem', backgroundColor: '#3b82f6', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
|
|
||||||
Erneut versuchen
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only show loading spinner on initial load (when there's no data yet)
|
|
||||||
const showLoadingSpinner = isLoadingTableData && (!tableData || tableData.length === 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.collapsableContainer}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.collapseButton}
|
|
||||||
onClick={toggleCollapse}
|
|
||||||
aria-expanded={!isCollapsed}
|
|
||||||
>
|
|
||||||
<span className={styles.collapseButtonText}>
|
|
||||||
Tabellenansicht {selectedTable && `(${selectedTable})`} {tableData && tableData.length > 0 && `- ${tableData.length} Einträge`}
|
|
||||||
</span>
|
|
||||||
{isCollapsed ? (
|
|
||||||
<FaChevronDown className={styles.collapseIcon} />
|
|
||||||
) : (
|
|
||||||
<FaChevronUp className={styles.collapseIcon} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{!isCollapsed && (
|
|
||||||
<div className={styles.collapsableContent}>
|
|
||||||
{tableData && tableData.length > 0 ? (
|
|
||||||
<FormGenerator
|
|
||||||
data={tableData}
|
|
||||||
columns={undefined} // Auto-detect columns - undefined triggers auto-detection
|
|
||||||
loading={showLoadingSpinner}
|
|
||||||
searchable={true}
|
|
||||||
filterable={true}
|
|
||||||
sortable={true}
|
|
||||||
pagination={true}
|
|
||||||
pageSize={10}
|
|
||||||
selectable={true}
|
|
||||||
/>
|
|
||||||
) : isLoadingTableData ? (
|
|
||||||
<div style={{ padding: '1rem', textAlign: 'center', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
||||||
<div>Lade Daten...</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ padding: '1rem', textAlign: 'center', color: '#6b7280', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
||||||
Keine Daten verfügbar. Bitte wählen Sie eine Tabelle aus.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PekTablesTable;
|
|
||||||
|
|
||||||
|
|
@ -9,11 +9,27 @@ const PekMapView: React.FC = () => {
|
||||||
parcelGeometries,
|
parcelGeometries,
|
||||||
handleMapClick,
|
handleMapClick,
|
||||||
handleParcelClick,
|
handleParcelClick,
|
||||||
selectedParcel,
|
selectedParcels,
|
||||||
|
removeParcel,
|
||||||
isPanelOpen,
|
isPanelOpen,
|
||||||
setIsPanelOpen
|
setIsPanelOpen
|
||||||
} = usePekContext();
|
} = usePekContext();
|
||||||
|
|
||||||
|
// Aggregate all adjacent parcels from all selected parcels
|
||||||
|
const allAdjacentParcels = React.useMemo(() => {
|
||||||
|
const adjacentSet = new Map<string, any>();
|
||||||
|
selectedParcels.forEach(parcel => {
|
||||||
|
if (parcel.adjacent_parcels) {
|
||||||
|
parcel.adjacent_parcels.forEach(adj => {
|
||||||
|
if (!adjacentSet.has(adj.id)) {
|
||||||
|
adjacentSet.set(adj.id, adj);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(adjacentSet.values());
|
||||||
|
}, [selectedParcels]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{ marginBottom: '1.5rem' }}>
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
|
@ -31,8 +47,9 @@ const PekMapView: React.FC = () => {
|
||||||
<ParcelInfoPanel
|
<ParcelInfoPanel
|
||||||
isOpen={isPanelOpen}
|
isOpen={isPanelOpen}
|
||||||
onClose={() => setIsPanelOpen(false)}
|
onClose={() => setIsPanelOpen(false)}
|
||||||
parcelData={selectedParcel}
|
parcels={selectedParcels}
|
||||||
adjacentParcels={selectedParcel?.adjacent_parcels || []}
|
onRemoveParcel={removeParcel}
|
||||||
|
adjacentParcels={allAdjacentParcels}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ export interface PageButton {
|
||||||
createOperationName?: string; // Name of the create operation in hookData (e.g., 'handlePromptCreate')
|
createOperationName?: string; // Name of the create operation in hookData (e.g., 'handlePromptCreate')
|
||||||
successMessage?: string | LanguageText;
|
successMessage?: string | LanguageText;
|
||||||
errorMessage?: string | LanguageText;
|
errorMessage?: string | LanguageText;
|
||||||
|
multiStep?: boolean; // Enable multi-step form mode
|
||||||
};
|
};
|
||||||
// Dropdown configuration for dropdown selection buttons
|
// Dropdown configuration for dropdown selection buttons
|
||||||
dropdownConfig?: DropdownConfig;
|
dropdownConfig?: DropdownConfig;
|
||||||
|
|
@ -124,7 +125,7 @@ export interface SettingsConfig {
|
||||||
// Content section for paragraphs
|
// Content section for paragraphs
|
||||||
export interface PageContent {
|
export interface PageContent {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'paragraph' | 'heading' | 'list' | 'code' | 'divider' | 'custom' | 'table' | 'inputForm' | 'messages' | 'settings' | 'log' | 'chatHistory';
|
type: 'paragraph' | 'heading' | 'list' | 'code' | 'divider' | 'custom' | 'table' | 'inputForm' | 'messages' | 'settings' | 'log' | 'tabs' | 'columns';
|
||||||
content?: string | LanguageText; // Optional for dividers
|
content?: string | LanguageText; // Optional for dividers
|
||||||
level?: number; // For headings (1-6)
|
level?: number; // For headings (1-6)
|
||||||
items?: (string | LanguageText)[]; // For lists
|
items?: (string | LanguageText)[]; // For lists
|
||||||
|
|
@ -149,9 +150,23 @@ export interface PageContent {
|
||||||
logConfig?: {
|
logConfig?: {
|
||||||
emptyMessage?: string | LanguageText;
|
emptyMessage?: string | LanguageText;
|
||||||
};
|
};
|
||||||
// Chat history-specific properties
|
// Tabs-specific properties
|
||||||
chatHistoryConfig?: {
|
tabsConfig?: {
|
||||||
emptyMessage?: string | LanguageText;
|
tabs: Array<{
|
||||||
|
id: string;
|
||||||
|
label: string | LanguageText;
|
||||||
|
content: PageContent[]; // Nested content sections for each tab
|
||||||
|
}>;
|
||||||
|
defaultTabId?: string;
|
||||||
|
};
|
||||||
|
// Columns-specific properties
|
||||||
|
columnsConfig?: {
|
||||||
|
columns: Array<{
|
||||||
|
id: string;
|
||||||
|
width?: string; // CSS width (e.g., "3fr", "1fr", "75%", "25%")
|
||||||
|
content: PageContent[]; // Nested content sections for each column
|
||||||
|
}>;
|
||||||
|
gap?: string; // CSS gap value
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -161,7 +176,15 @@ export interface GenericDataHook {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
isRefetching?: boolean; // True when refetching data (keeps existing data visible)
|
isRefetching?: boolean; // True when refetching data (keeps existing data visible)
|
||||||
error: string | null;
|
error: string | null;
|
||||||
refetch?: () => Promise<void>;
|
refetch?: (params?: { page?: number; pageSize?: number; sort?: Array<{field: string; direction: 'asc' | 'desc'}>; filters?: any; search?: string }) => Promise<void>;
|
||||||
|
pagination?: {
|
||||||
|
currentPage: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||||
|
filters?: any;
|
||||||
|
} | null;
|
||||||
removeFileOptimistically?: (fileId: string) => void; // For optimistic updates
|
removeFileOptimistically?: (fileId: string) => void; // For optimistic updates
|
||||||
columns?: any[]; // Optional columns configuration
|
columns?: any[]; // Optional columns configuration
|
||||||
// File operations
|
// File operations
|
||||||
|
|
@ -272,6 +295,7 @@ export interface TableContentConfig {
|
||||||
pagination?: boolean;
|
pagination?: boolean;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
emptyMessage?: string; // Custom message to display when table is empty
|
||||||
}
|
}
|
||||||
|
|
||||||
// Language-aware text interface
|
// Language-aware text interface
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
import type { MapPoint, ParcelGeometry } from '../components/UiComponents/MapView';
|
import type { MapPoint, ParcelGeometry } from '../components/UiComponents/MapView';
|
||||||
import { wgs84ToLV95 } from '../components/UiComponents/MapView/LV95Converter';
|
import { wgs84ToLV95 } from '../components/UiComponents/MapView/LV95Converter';
|
||||||
|
|
@ -134,7 +134,7 @@ export function usePek() {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parcel search state
|
// Parcel search state
|
||||||
const [selectedParcel, setSelectedParcel] = useState<ParcelSearchResponse | null>(null);
|
const [selectedParcels, setSelectedParcels] = useState<ParcelSearchResponse[]>([]);
|
||||||
const [isSearchingParcel, setIsSearchingParcel] = useState(false);
|
const [isSearchingParcel, setIsSearchingParcel] = useState(false);
|
||||||
const [parcelSearchError, setParcelSearchError] = useState<string | null>(null);
|
const [parcelSearchError, setParcelSearchError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -163,6 +163,19 @@ export function usePek() {
|
||||||
// Panel state
|
// Panel state
|
||||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||||
|
|
||||||
|
// Update parcel geometries when selected parcels change
|
||||||
|
// Ensure all selected parcels are marked as selected and not as adjacent
|
||||||
|
useEffect(() => {
|
||||||
|
const selectedParcelIds = new Set(selectedParcels.map(p => p.parcel.id));
|
||||||
|
|
||||||
|
setParcelGeometries(prev => prev.map(geo => {
|
||||||
|
const isSelected = selectedParcelIds.has(geo.id);
|
||||||
|
// If parcel is selected, it should not be marked as adjacent
|
||||||
|
const isAdjacent = isSelected ? false : geo.isAdjacent;
|
||||||
|
return { ...geo, isSelected, isAdjacent };
|
||||||
|
}));
|
||||||
|
}, [selectedParcels]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current geolocation and directly search for parcel
|
* Get current geolocation and directly search for parcel
|
||||||
* Does not fill input fields, directly makes the request
|
* Does not fill input fields, directly makes the request
|
||||||
|
|
@ -259,166 +272,160 @@ export function usePek() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update selected parcel
|
// Add parcel to selected parcels array if not already selected
|
||||||
setSelectedParcel(data);
|
// Update geometries within the callback to have access to updated selectedParcels
|
||||||
|
setSelectedParcels(prev => {
|
||||||
// Open panel when parcel is found
|
const exists = prev.some(p => p.parcel.id === data.parcel.id);
|
||||||
setIsPanelOpen(true);
|
if (exists) {
|
||||||
|
return prev; // Already selected, don't add again
|
||||||
// Update map center and zoom bounds
|
|
||||||
if (data.map_view) {
|
|
||||||
setMapCenter(data.map_view.center);
|
|
||||||
setMapZoomBounds(data.map_view.zoom_bounds);
|
|
||||||
|
|
||||||
// Convert parcel data to geometries
|
|
||||||
const geometries: ParcelGeometry[] = [];
|
|
||||||
|
|
||||||
// Main parcel - use geometry_geojson if available, otherwise use perimeter.punkte
|
|
||||||
let mainParcelCoordinates: MapPoint[] = [];
|
|
||||||
|
|
||||||
if (data.map_view.geometry_geojson?.geometry?.coordinates) {
|
|
||||||
// GeoJSON format: coordinates is an array of coordinate arrays
|
|
||||||
// For Polygon: coordinates[0] is the outer ring
|
|
||||||
const coords = data.map_view.geometry_geojson.geometry.coordinates[0];
|
|
||||||
if (Array.isArray(coords)) {
|
|
||||||
mainParcelCoordinates = coords.map((coord: number[]) => ({
|
|
||||||
x: coord[0], // Longitude/X in LV95
|
|
||||||
y: coord[1] // Latitude/Y in LV95
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} else if (data.parcel.perimeter?.punkte) {
|
|
||||||
// Fallback to perimeter.punkte
|
|
||||||
mainParcelCoordinates = data.parcel.perimeter.punkte.map((p) => ({
|
|
||||||
x: p.x,
|
|
||||||
y: p.y
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mainParcelCoordinates.length > 0) {
|
const updatedSelectedParcels = [...prev, data];
|
||||||
geometries.push({
|
const selectedParcelIds = new Set(updatedSelectedParcels.map(p => p.parcel.id));
|
||||||
id: data.parcel.id,
|
|
||||||
egrid: data.parcel.egrid,
|
// Update geometries
|
||||||
number: data.parcel.number,
|
setParcelGeometries(currentGeometries => {
|
||||||
coordinates: mainParcelCoordinates,
|
const geometryMap = new Map<string, ParcelGeometry>();
|
||||||
isSelected: true,
|
|
||||||
isAdjacent: false
|
// Keep existing geometries
|
||||||
|
currentGeometries.forEach(geo => {
|
||||||
|
geometryMap.set(geo.id, geo);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
// Update map center and zoom bounds
|
||||||
|
if (data.map_view) {
|
||||||
|
setMapCenter(data.map_view.center);
|
||||||
|
setMapZoomBounds(data.map_view.zoom_bounds);
|
||||||
|
|
||||||
// Adjacent parcels (if available)
|
// Main parcel - use geometry_geojson if available, otherwise use perimeter.punkte
|
||||||
// Use geometries from the response (no need to fetch separately)
|
let mainParcelCoordinates: MapPoint[] = [];
|
||||||
if (data.adjacent_parcels && includeAdjacent && data.adjacent_parcels.length > 0) {
|
|
||||||
const adjacentGeometries: ParcelGeometry[] = [];
|
|
||||||
|
|
||||||
data.adjacent_parcels.forEach((adjacent) => {
|
if (data.map_view.geometry_geojson?.geometry?.coordinates) {
|
||||||
if (import.meta.env.DEV) {
|
const coords = data.map_view.geometry_geojson.geometry.coordinates[0];
|
||||||
console.log(`🔍 Processing adjacent parcel ${adjacent.id}:`, {
|
if (Array.isArray(coords)) {
|
||||||
hasGeometryGeoJson: !!adjacent.geometry_geojson,
|
mainParcelCoordinates = coords.map((coord: number[]) => ({
|
||||||
hasPerimeter: !!adjacent.perimeter,
|
|
||||||
geometryGeoJson: adjacent.geometry_geojson,
|
|
||||||
perimeter: adjacent.perimeter
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let adjCoordinates: MapPoint[] = [];
|
|
||||||
|
|
||||||
// Extract coordinates from geometry_geojson if available
|
|
||||||
if (adjacent.geometry_geojson?.geometry?.coordinates) {
|
|
||||||
const coords = adjacent.geometry_geojson.geometry.coordinates[0];
|
|
||||||
if (Array.isArray(coords) && coords.length > 0) {
|
|
||||||
adjCoordinates = coords.map((coord: number[]) => ({
|
|
||||||
x: coord[0],
|
x: coord[0],
|
||||||
y: coord[1]
|
y: coord[1]
|
||||||
}));
|
}));
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log(`✅ Extracted ${adjCoordinates.length} coordinates from geometry_geojson for ${adjacent.id}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
} else if (data.parcel.perimeter?.punkte) {
|
||||||
// Fallback to perimeter.punkte if available
|
mainParcelCoordinates = data.parcel.perimeter.punkte.map((p) => ({
|
||||||
else if (adjacent.perimeter?.punkte) {
|
|
||||||
adjCoordinates = adjacent.perimeter.punkte.map((p) => ({
|
|
||||||
x: p.x,
|
x: p.x,
|
||||||
y: p.y
|
y: p.y
|
||||||
}));
|
}));
|
||||||
if (import.meta.env.DEV) {
|
}
|
||||||
console.log(`✅ Extracted ${adjCoordinates.length} coordinates from perimeter for ${adjacent.id}`);
|
|
||||||
|
if (mainParcelCoordinates.length > 0) {
|
||||||
|
geometryMap.set(data.parcel.id, {
|
||||||
|
id: data.parcel.id,
|
||||||
|
egrid: data.parcel.egrid,
|
||||||
|
number: data.parcel.number,
|
||||||
|
coordinates: mainParcelCoordinates,
|
||||||
|
isSelected: true,
|
||||||
|
isAdjacent: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add adjacent parcels, but skip if already selected
|
||||||
|
if (data.adjacent_parcels && includeAdjacent && data.adjacent_parcels.length > 0) {
|
||||||
|
data.adjacent_parcels.forEach((adjacent) => {
|
||||||
|
// Skip if this adjacent parcel is already selected
|
||||||
|
if (selectedParcelIds.has(adjacent.id)) {
|
||||||
|
// If it exists, mark as selected, not adjacent
|
||||||
|
const existingGeo = geometryMap.get(adjacent.id);
|
||||||
|
if (existingGeo) {
|
||||||
|
geometryMap.set(adjacent.id, {
|
||||||
|
...existingGeo,
|
||||||
|
isSelected: true,
|
||||||
|
isAdjacent: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.log(`⏭️ Skipping adjacent parcel ${adjacent.id} - already selected`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only add if not already in map
|
||||||
|
if (!geometryMap.has(adjacent.id)) {
|
||||||
|
let adjCoordinates: MapPoint[] = [];
|
||||||
|
|
||||||
|
if (adjacent.geometry_geojson?.geometry?.coordinates) {
|
||||||
|
const coords = adjacent.geometry_geojson.geometry.coordinates[0];
|
||||||
|
if (Array.isArray(coords) && coords.length > 0) {
|
||||||
|
adjCoordinates = coords.map((coord: number[]) => ({
|
||||||
|
x: coord[0],
|
||||||
|
y: coord[1]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else if (adjacent.perimeter?.punkte) {
|
||||||
|
adjCoordinates = adjacent.perimeter.punkte.map((p) => ({
|
||||||
|
x: p.x,
|
||||||
|
y: p.y
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adjCoordinates.length >= 3) {
|
||||||
|
geometryMap.set(adjacent.id, {
|
||||||
|
id: adjacent.id,
|
||||||
|
egrid: adjacent.egrid,
|
||||||
|
number: adjacent.number,
|
||||||
|
coordinates: adjCoordinates,
|
||||||
|
isSelected: false,
|
||||||
|
isAdjacent: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If no map_view, still try to use parcel data
|
||||||
|
if (data.parcel.perimeter?.punkte) {
|
||||||
|
const coordinates = data.parcel.perimeter.punkte.map((p) => ({
|
||||||
|
x: p.x,
|
||||||
|
y: p.y
|
||||||
|
}));
|
||||||
|
|
||||||
|
geometryMap.set(data.parcel.id, {
|
||||||
|
id: data.parcel.id,
|
||||||
|
egrid: data.parcel.egrid,
|
||||||
|
number: data.parcel.number,
|
||||||
|
coordinates,
|
||||||
|
isSelected: true,
|
||||||
|
isAdjacent: false
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.parcel.centroid) {
|
||||||
|
setMapCenter(data.parcel.centroid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Only add if we have valid coordinates
|
|
||||||
if (adjCoordinates.length >= 3) {
|
// Update all geometries: mark selected ones and unmark adjacent for selected ones
|
||||||
adjacentGeometries.push({
|
const updatedGeometries = Array.from(geometryMap.values()).map(geo => {
|
||||||
id: adjacent.id,
|
const isSelected = selectedParcelIds.has(geo.id);
|
||||||
egrid: adjacent.egrid,
|
return {
|
||||||
number: adjacent.number,
|
...geo,
|
||||||
coordinates: adjCoordinates,
|
isSelected,
|
||||||
isSelected: false,
|
isAdjacent: isSelected ? false : geo.isAdjacent
|
||||||
isAdjacent: true
|
};
|
||||||
});
|
|
||||||
} else if (import.meta.env.DEV) {
|
|
||||||
console.warn(`⚠️ Adjacent parcel ${adjacent.id} has insufficient geometry data:`, {
|
|
||||||
coordCount: adjCoordinates.length,
|
|
||||||
hasGeometryGeoJson: !!adjacent.geometry_geojson,
|
|
||||||
hasPerimeter: !!adjacent.perimeter,
|
|
||||||
geometryGeoJsonStructure: adjacent.geometry_geojson ? {
|
|
||||||
hasGeometry: !!adjacent.geometry_geojson.geometry,
|
|
||||||
hasCoordinates: !!adjacent.geometry_geojson.geometry?.coordinates,
|
|
||||||
coordinatesLength: adjacent.geometry_geojson.geometry?.coordinates?.length,
|
|
||||||
firstCoordLength: adjacent.geometry_geojson.geometry?.coordinates?.[0]?.length
|
|
||||||
} : null
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
console.log(`📦 Adjacent parcels summary:`, {
|
console.log(`🗺️ Total geometries to display: ${updatedGeometries.length}`, {
|
||||||
requested: data.adjacent_parcels.length,
|
selected: updatedGeometries.filter(g => g.isSelected).length,
|
||||||
valid: adjacentGeometries.length,
|
adjacent: updatedGeometries.filter(g => g.isAdjacent).length
|
||||||
geometries: adjacentGeometries.map(g => ({
|
|
||||||
id: g.id,
|
|
||||||
number: g.number,
|
|
||||||
coordCount: g.coordinates.length
|
|
||||||
}))
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add adjacent parcels to geometries array
|
return updatedGeometries;
|
||||||
geometries.push(...adjacentGeometries);
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Update parcel geometries with all parcels (main + adjacent)
|
|
||||||
setParcelGeometries(geometries);
|
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
return updatedSelectedParcels;
|
||||||
console.log(`🗺️ Total geometries to display: ${geometries.length}`, {
|
});
|
||||||
main: geometries.filter(g => g.isSelected).length,
|
|
||||||
adjacent: geometries.filter(g => g.isAdjacent).length
|
// Open panel when parcel is found
|
||||||
});
|
setIsPanelOpen(true);
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// If no map_view, still try to use parcel data
|
|
||||||
if (data.parcel.perimeter?.punkte) {
|
|
||||||
const coordinates = data.parcel.perimeter.punkte.map((p) => ({
|
|
||||||
x: p.x,
|
|
||||||
y: p.y
|
|
||||||
}));
|
|
||||||
|
|
||||||
setParcelGeometries([{
|
|
||||||
id: data.parcel.id,
|
|
||||||
egrid: data.parcel.egrid,
|
|
||||||
number: data.parcel.number,
|
|
||||||
coordinates,
|
|
||||||
isSelected: true,
|
|
||||||
isAdjacent: false
|
|
||||||
}]);
|
|
||||||
|
|
||||||
// Set center from centroid if available
|
|
||||||
if (data.parcel.centroid) {
|
|
||||||
setMapCenter(data.parcel.centroid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, data };
|
return { success: true, data };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|
@ -444,43 +451,77 @@ export function usePek() {
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle parcel click on map - select the clicked parcel
|
* Check if a parcel is selected
|
||||||
|
*/
|
||||||
|
const isParcelSelected = useCallback((parcelId: string): boolean => {
|
||||||
|
return selectedParcels.some(p => p.parcel.id === parcelId);
|
||||||
|
}, [selectedParcels]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a parcel from selection
|
||||||
|
*/
|
||||||
|
const removeParcel = useCallback((parcelId: string) => {
|
||||||
|
setSelectedParcels(prev => prev.filter(p => p.parcel.id !== parcelId));
|
||||||
|
// Update geometries to reflect deselection
|
||||||
|
setParcelGeometries(prev => prev.map(geo =>
|
||||||
|
geo.id === parcelId ? { ...geo, isSelected: false } : geo
|
||||||
|
));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all selected parcels
|
||||||
|
*/
|
||||||
|
const clearSelectedParcels = useCallback(() => {
|
||||||
|
setSelectedParcels([]);
|
||||||
|
// Update geometries to reflect deselection
|
||||||
|
setParcelGeometries(prev => prev.map(geo => ({ ...geo, isSelected: false })));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle parcel click on map - toggle parcel selection
|
||||||
*/
|
*/
|
||||||
const handleParcelClick = useCallback(async (parcelId: string) => {
|
const handleParcelClick = useCallback(async (parcelId: string) => {
|
||||||
// Find the clicked parcel in the geometries
|
// Check if parcel is already selected
|
||||||
const clickedParcel = parcelGeometries.find(p => p.id === parcelId);
|
const isSelected = isParcelSelected(parcelId);
|
||||||
|
|
||||||
if (clickedParcel && clickedParcel.coordinates.length > 0) {
|
if (isSelected) {
|
||||||
// Use a point inside the parcel (first coordinate is always on the boundary, which is inside)
|
// Remove from selection
|
||||||
// For better accuracy, use a point slightly inside the boundary
|
removeParcel(parcelId);
|
||||||
const firstCoord = clickedParcel.coordinates[0];
|
|
||||||
|
|
||||||
// Calculate centroid as fallback, but prefer a point we know is inside
|
|
||||||
// const sumX = clickedParcel.coordinates.reduce((sum, coord) => sum + coord.x, 0);
|
|
||||||
// const sumY = clickedParcel.coordinates.reduce((sum, coord) => sum + coord.y, 0);
|
|
||||||
// const _centroidX = sumX / clickedParcel.coordinates.length;
|
|
||||||
// const _centroidY = sumY / clickedParcel.coordinates.length;
|
|
||||||
|
|
||||||
// Use first coordinate (guaranteed to be on/in the parcel) for search
|
|
||||||
const locationString = `${firstCoord.x},${firstCoord.y}`;
|
|
||||||
await searchParcel(locationString, true); // Always include adjacent parcels
|
|
||||||
} else {
|
} else {
|
||||||
// Fallback: try to search by parcel ID/EGRID if available
|
// Find the clicked parcel in the geometries
|
||||||
if (selectedParcel?.adjacent_parcels) {
|
const clickedParcel = parcelGeometries.find(p => p.id === parcelId);
|
||||||
const adjacentParcel = selectedParcel.adjacent_parcels.find(p => p.id === parcelId);
|
|
||||||
if (adjacentParcel?.egrid) {
|
if (clickedParcel && clickedParcel.coordinates.length > 0) {
|
||||||
// Search by EGRID
|
// Use a point inside the parcel (first coordinate is always on the boundary, which is inside)
|
||||||
await searchParcel(adjacentParcel.egrid, true);
|
const firstCoord = clickedParcel.coordinates[0];
|
||||||
} else if (adjacentParcel?.number) {
|
|
||||||
// Try searching by number (might need address context)
|
// Use first coordinate (guaranteed to be on/in the parcel) for search
|
||||||
await searchParcel(adjacentParcel.number, true);
|
const locationString = `${firstCoord.x},${firstCoord.y}`;
|
||||||
} else if (adjacentParcel?.id) {
|
await searchParcel(locationString, true); // Always include adjacent parcels
|
||||||
// Last resort: try searching by ID
|
} else {
|
||||||
await searchParcel(adjacentParcel.id, true);
|
// Fallback: try to search by parcel ID/EGRID if available
|
||||||
|
// Check all selected parcels for adjacent parcels
|
||||||
|
for (const selectedParcel of selectedParcels) {
|
||||||
|
if (selectedParcel.adjacent_parcels) {
|
||||||
|
const adjacentParcel = selectedParcel.adjacent_parcels.find(p => p.id === parcelId);
|
||||||
|
if (adjacentParcel?.egrid) {
|
||||||
|
// Search by EGRID
|
||||||
|
await searchParcel(adjacentParcel.egrid, true);
|
||||||
|
break;
|
||||||
|
} else if (adjacentParcel?.number) {
|
||||||
|
// Try searching by number (might need address context)
|
||||||
|
await searchParcel(adjacentParcel.number, true);
|
||||||
|
break;
|
||||||
|
} else if (adjacentParcel?.id) {
|
||||||
|
// Last resort: try searching by ID
|
||||||
|
await searchParcel(adjacentParcel.id, true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [parcelGeometries, selectedParcel, searchParcel]);
|
}, [parcelGeometries, selectedParcels, isParcelSelected, removeParcel, searchParcel]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process natural language command
|
* Process natural language command
|
||||||
|
|
@ -510,26 +551,46 @@ export function usePek() {
|
||||||
userInput: userInput.trim()
|
userInput: userInput.trim()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Always include the currently selected parcel if available
|
// Always include the currently selected parcels if available
|
||||||
if (selectedParcel) {
|
if (selectedParcels.length > 0) {
|
||||||
|
// Use first selected parcel for backward compatibility
|
||||||
|
const firstParcel = selectedParcels[0];
|
||||||
requestBody.selectedParcel = {
|
requestBody.selectedParcel = {
|
||||||
id: selectedParcel.parcel.id,
|
id: firstParcel.parcel.id,
|
||||||
egrid: selectedParcel.parcel.egrid,
|
egrid: firstParcel.parcel.egrid,
|
||||||
number: selectedParcel.parcel.number,
|
number: firstParcel.parcel.number,
|
||||||
name: selectedParcel.parcel.name,
|
name: firstParcel.parcel.name,
|
||||||
identnd: selectedParcel.parcel.identnd,
|
identnd: firstParcel.parcel.identnd,
|
||||||
canton: selectedParcel.parcel.canton,
|
canton: firstParcel.parcel.canton,
|
||||||
municipality_code: selectedParcel.parcel.municipality_code,
|
municipality_code: firstParcel.parcel.municipality_code,
|
||||||
municipality_name: selectedParcel.parcel.municipality_name,
|
municipality_name: firstParcel.parcel.municipality_name,
|
||||||
address: selectedParcel.parcel.address,
|
address: firstParcel.parcel.address,
|
||||||
area_m2: selectedParcel.parcel.area_m2,
|
area_m2: firstParcel.parcel.area_m2,
|
||||||
centroid: selectedParcel.parcel.centroid,
|
centroid: firstParcel.parcel.centroid,
|
||||||
geoportal_url: selectedParcel.parcel.geoportal_url,
|
geoportal_url: firstParcel.parcel.geoportal_url,
|
||||||
realestate_type: selectedParcel.parcel.realestate_type,
|
realestate_type: firstParcel.parcel.realestate_type,
|
||||||
// Include geometry data if available
|
// Include geometry data if available
|
||||||
geometry_geojson: selectedParcel.map_view?.geometry_geojson,
|
geometry_geojson: firstParcel.map_view?.geometry_geojson,
|
||||||
perimeter: selectedParcel.parcel.perimeter
|
perimeter: firstParcel.parcel.perimeter
|
||||||
};
|
};
|
||||||
|
// Also include all selected parcels as array
|
||||||
|
requestBody.selectedParcels = selectedParcels.map(p => ({
|
||||||
|
id: p.parcel.id,
|
||||||
|
egrid: p.parcel.egrid,
|
||||||
|
number: p.parcel.number,
|
||||||
|
name: p.parcel.name,
|
||||||
|
identnd: p.parcel.identnd,
|
||||||
|
canton: p.parcel.canton,
|
||||||
|
municipality_code: p.parcel.municipality_code,
|
||||||
|
municipality_name: p.parcel.municipality_name,
|
||||||
|
address: p.parcel.address,
|
||||||
|
area_m2: p.parcel.area_m2,
|
||||||
|
centroid: p.parcel.centroid,
|
||||||
|
geoportal_url: p.parcel.geoportal_url,
|
||||||
|
realestate_type: p.parcel.realestate_type,
|
||||||
|
geometry_geojson: p.map_view?.geometry_geojson,
|
||||||
|
perimeter: p.parcel.perimeter
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await api.post('/api/realestate/command', requestBody);
|
const response = await api.post('/api/realestate/command', requestBody);
|
||||||
|
|
@ -560,8 +621,8 @@ export function usePek() {
|
||||||
};
|
};
|
||||||
setCommandResults((prev) => [...prev, assistantMessage]);
|
setCommandResults((prev) => [...prev, assistantMessage]);
|
||||||
|
|
||||||
// If a project was created and there's a selected parcel, automatically add it
|
// If a project was created and there are selected parcels, automatically add them
|
||||||
if (data.success && data.intent === 'CREATE' && data.entity === 'Projekt' && selectedParcel) {
|
if (data.success && data.intent === 'CREATE' && data.entity === 'Projekt' && selectedParcels.length > 0) {
|
||||||
try {
|
try {
|
||||||
// Extract projekt from result
|
// Extract projekt from result
|
||||||
const projektResult = data.result?.result || data.result;
|
const projektResult = data.result?.result || data.result;
|
||||||
|
|
@ -569,42 +630,51 @@ export function usePek() {
|
||||||
// Set as current projekt
|
// Set as current projekt
|
||||||
setCurrentProjekt(projektResult);
|
setCurrentProjekt(projektResult);
|
||||||
|
|
||||||
// Add the selected parcel to the newly created project via direct API call
|
// Add all selected parcels to the newly created project via direct API call
|
||||||
const addParcelRequestBody: any = {
|
let addedCount = 0;
|
||||||
parcelId: selectedParcel.parcel.id,
|
for (const selectedParcel of selectedParcels) {
|
||||||
parcelData: {
|
try {
|
||||||
id: selectedParcel.parcel.id,
|
const addParcelRequestBody: any = {
|
||||||
egrid: selectedParcel.parcel.egrid,
|
parcelId: selectedParcel.parcel.id,
|
||||||
number: selectedParcel.parcel.number,
|
parcelData: {
|
||||||
name: selectedParcel.parcel.name,
|
id: selectedParcel.parcel.id,
|
||||||
identnd: selectedParcel.parcel.identnd,
|
egrid: selectedParcel.parcel.egrid,
|
||||||
canton: selectedParcel.parcel.canton,
|
number: selectedParcel.parcel.number,
|
||||||
municipality_code: selectedParcel.parcel.municipality_code,
|
name: selectedParcel.parcel.name,
|
||||||
municipality_name: selectedParcel.parcel.municipality_name,
|
identnd: selectedParcel.parcel.identnd,
|
||||||
address: selectedParcel.parcel.address,
|
canton: selectedParcel.parcel.canton,
|
||||||
area_m2: selectedParcel.parcel.area_m2,
|
municipality_code: selectedParcel.parcel.municipality_code,
|
||||||
centroid: selectedParcel.parcel.centroid,
|
municipality_name: selectedParcel.parcel.municipality_name,
|
||||||
geoportal_url: selectedParcel.parcel.geoportal_url,
|
address: selectedParcel.parcel.address,
|
||||||
realestate_type: selectedParcel.parcel.realestate_type,
|
area_m2: selectedParcel.parcel.area_m2,
|
||||||
geometry_geojson: selectedParcel.map_view?.geometry_geojson,
|
centroid: selectedParcel.parcel.centroid,
|
||||||
perimeter: selectedParcel.parcel.perimeter
|
geoportal_url: selectedParcel.parcel.geoportal_url,
|
||||||
|
realestate_type: selectedParcel.parcel.realestate_type,
|
||||||
|
geometry_geojson: selectedParcel.map_view?.geometry_geojson,
|
||||||
|
perimeter: selectedParcel.parcel.perimeter
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addResponse = await api.post(
|
||||||
|
`/api/realestate/projekt/${projektResult.id}/add-parcel`,
|
||||||
|
addParcelRequestBody
|
||||||
|
);
|
||||||
|
const addResult: AddParcelResponse = addResponse.data;
|
||||||
|
|
||||||
|
// Update current projekt with the updated version that includes the parcel
|
||||||
|
setCurrentProjekt(addResult.projekt);
|
||||||
|
addedCount++;
|
||||||
|
} catch (addError: any) {
|
||||||
|
console.error(`Failed to add parcel ${selectedParcel.parcel.id} to project:`, addError);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const addResponse = await api.post(
|
// Update the assistant message to indicate parcels were added
|
||||||
`/api/realestate/projekt/${projektResult.id}/add-parcel`,
|
const parcelText = addedCount === 1 ? 'Parzelle' : 'Parzellen';
|
||||||
addParcelRequestBody
|
|
||||||
);
|
|
||||||
const addResult: AddParcelResponse = addResponse.data;
|
|
||||||
|
|
||||||
// Update current projekt with the updated version that includes the parcel
|
|
||||||
setCurrentProjekt(addResult.projekt);
|
|
||||||
|
|
||||||
// Update the assistant message to indicate parcel was added
|
|
||||||
const updateMessage = {
|
const updateMessage = {
|
||||||
...assistantMessage,
|
...assistantMessage,
|
||||||
id: `assistant-update-${Date.now()}`,
|
id: `assistant-update-${Date.now()}`,
|
||||||
message: `${responseMessage}\n\n✅ Parzelle wurde automatisch zum Projekt hinzugefügt.`
|
message: `${responseMessage}\n\n✅ ${addedCount} ${parcelText} ${addedCount === 1 ? 'wurde' : 'wurden'} automatisch zum Projekt hinzugefügt.`
|
||||||
};
|
};
|
||||||
setCommandResults((prev) => {
|
setCommandResults((prev) => {
|
||||||
const updated = [...prev];
|
const updated = [...prev];
|
||||||
|
|
@ -629,8 +699,9 @@ export function usePek() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If a parcel was created and there's a selected parcel, automatically populate it with the selected parcel data
|
// If a parcel was created and there are selected parcels, automatically populate it with the first selected parcel data
|
||||||
if (data.success && data.intent === 'CREATE' && data.entity === 'Parzelle' && selectedParcel) {
|
if (data.success && data.intent === 'CREATE' && data.entity === 'Parzelle' && selectedParcels.length > 0) {
|
||||||
|
const selectedParcel = selectedParcels[0]; // Use first selected parcel
|
||||||
try {
|
try {
|
||||||
// Extract parzelle from result
|
// Extract parzelle from result
|
||||||
const parzelleResult = data.result?.result || data.result;
|
const parzelleResult = data.result?.result || data.result;
|
||||||
|
|
@ -747,7 +818,7 @@ export function usePek() {
|
||||||
} finally {
|
} finally {
|
||||||
setIsProcessingCommand(false);
|
setIsProcessingCommand(false);
|
||||||
}
|
}
|
||||||
}, [selectedParcel]);
|
}, [selectedParcels]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new project
|
* Create a new project
|
||||||
|
|
@ -872,10 +943,13 @@ export function usePek() {
|
||||||
locationError,
|
locationError,
|
||||||
|
|
||||||
// Parcel search
|
// Parcel search
|
||||||
selectedParcel,
|
selectedParcels,
|
||||||
searchParcel,
|
searchParcel,
|
||||||
isSearchingParcel,
|
isSearchingParcel,
|
||||||
parcelSearchError,
|
parcelSearchError,
|
||||||
|
removeParcel,
|
||||||
|
clearSelectedParcels,
|
||||||
|
isParcelSelected,
|
||||||
|
|
||||||
// Map view
|
// Map view
|
||||||
mapCenter,
|
mapCenter,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,17 @@
|
||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
|
import type { GenericDataHook } from '../core/PageManager/pageInterface';
|
||||||
|
import { fetchAttributes } from '../api/attributesApi';
|
||||||
|
import { useApiRequest } from './useApi';
|
||||||
|
import { usePermissions, type UserPermissions } from './usePermissions';
|
||||||
|
import {
|
||||||
|
isDateTimeType,
|
||||||
|
isSelectType,
|
||||||
|
isMultiselectType,
|
||||||
|
isCheckboxType,
|
||||||
|
isTextareaType,
|
||||||
|
type AttributeType
|
||||||
|
} from '../utils/attributeTypeMapper';
|
||||||
|
|
||||||
// Table list response interface
|
// Table list response interface
|
||||||
export interface TableInfo {
|
export interface TableInfo {
|
||||||
|
|
@ -262,3 +274,911 @@ export function usePekTables() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook factory that creates a hook for a specific table model
|
||||||
|
* Returns a hook function compatible with GenericDataHook interface
|
||||||
|
*/
|
||||||
|
export function createPekTableHook(tableModel: string): () => GenericDataHook {
|
||||||
|
return () => {
|
||||||
|
const [tableData, setTableData] = useState<any[]>([]);
|
||||||
|
const [isLoadingTableData, setIsLoadingTableData] = useState(false);
|
||||||
|
const [tableDataError, setTableDataError] = useState<string | null>(null);
|
||||||
|
const [pagination, setPagination] = useState<TableDataResponse['pagination']>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load data for the specific table
|
||||||
|
*/
|
||||||
|
const loadTableData = useCallback(async (
|
||||||
|
page?: number,
|
||||||
|
pageSize?: number,
|
||||||
|
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>,
|
||||||
|
filters?: any,
|
||||||
|
search?: string
|
||||||
|
) => {
|
||||||
|
setIsLoadingTableData(true);
|
||||||
|
setTableDataError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params: any = {};
|
||||||
|
|
||||||
|
// Build pagination object
|
||||||
|
const paginationObj: any = {
|
||||||
|
page: page || 1,
|
||||||
|
pageSize: pageSize || 10,
|
||||||
|
sort: sort || []
|
||||||
|
};
|
||||||
|
|
||||||
|
if (filters) {
|
||||||
|
paginationObj.filters = filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
paginationObj.search = search;
|
||||||
|
}
|
||||||
|
|
||||||
|
params.pagination = JSON.stringify(paginationObj);
|
||||||
|
|
||||||
|
const response = await api.get(`/api/realestate/table/${tableModel}`, { params });
|
||||||
|
const data: TableDataResponse = response.data;
|
||||||
|
|
||||||
|
setTableData(data.items || []);
|
||||||
|
setPagination(data.pagination);
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage =
|
||||||
|
err.response?.data?.detail || err.message || `Fehler beim Laden der Tabelle ${tableModel}`;
|
||||||
|
setTableDataError(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingTableData(false);
|
||||||
|
}
|
||||||
|
}, [tableModel]);
|
||||||
|
|
||||||
|
// Load table data on mount
|
||||||
|
useEffect(() => {
|
||||||
|
loadTableData();
|
||||||
|
}, [loadTableData]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refetch function compatible with GenericDataHook interface
|
||||||
|
*/
|
||||||
|
const refetch = useCallback(async (params?: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
sort?: Array<{field: string; direction: 'asc' | 'desc'}>;
|
||||||
|
filters?: any;
|
||||||
|
search?: string;
|
||||||
|
}) => {
|
||||||
|
await loadTableData(
|
||||||
|
params?.page,
|
||||||
|
params?.pageSize,
|
||||||
|
params?.sort,
|
||||||
|
params?.filters,
|
||||||
|
params?.search
|
||||||
|
);
|
||||||
|
}, [loadTableData]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: tableData,
|
||||||
|
loading: isLoadingTableData,
|
||||||
|
error: tableDataError,
|
||||||
|
refetch,
|
||||||
|
pagination: pagination || null,
|
||||||
|
columns: undefined // Columns can be loaded from attributes API if needed
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attribute definition interface
|
||||||
|
interface AttributeDefinition {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type: AttributeType;
|
||||||
|
sortable?: boolean;
|
||||||
|
filterable?: boolean;
|
||||||
|
searchable?: boolean;
|
||||||
|
width?: number;
|
||||||
|
minWidth?: number;
|
||||||
|
maxWidth?: number;
|
||||||
|
filterOptions?: string[];
|
||||||
|
editable?: boolean;
|
||||||
|
visible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to convert attribute definitions to column config
|
||||||
|
const attributesToColumns = (attributes: AttributeDefinition[], hiddenColumns: string[] = []) => {
|
||||||
|
return attributes
|
||||||
|
.filter(attr => !hiddenColumns.includes(attr.name) && !hiddenColumns.includes(attr.label))
|
||||||
|
.map(attr => {
|
||||||
|
// Use attributeTypeMapper to check if this is a date/timestamp field - disable filtering for these
|
||||||
|
const isDateField = isDateTimeType(attr.type);
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: attr.name,
|
||||||
|
label: attr.label || attr.name,
|
||||||
|
type: attr.type || 'string',
|
||||||
|
width: attr.width || 200,
|
||||||
|
minWidth: attr.minWidth || 100,
|
||||||
|
maxWidth: attr.maxWidth || 400,
|
||||||
|
sortable: attr.sortable !== false,
|
||||||
|
// Disable filtering for date/timestamp fields
|
||||||
|
filterable: isDateField ? false : (attr.filterable !== false),
|
||||||
|
searchable: attr.searchable !== false,
|
||||||
|
filterOptions: attr.filterOptions
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook factory for Projekte table with edit/delete support
|
||||||
|
*/
|
||||||
|
export function createProjectsTableHook(): () => GenericDataHook {
|
||||||
|
return () => {
|
||||||
|
const [tableData, setTableData] = useState<any[]>([]);
|
||||||
|
const [isLoadingTableData, setIsLoadingTableData] = useState(false);
|
||||||
|
const [tableDataError, setTableDataError] = useState<string | null>(null);
|
||||||
|
const [pagination, setPagination] = useState<TableDataResponse['pagination']>(null);
|
||||||
|
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
|
||||||
|
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
|
||||||
|
const [editingProjects, setEditingProjects] = useState<Set<string>>(new Set());
|
||||||
|
const [deletingProjects, setDeletingProjects] = useState<Set<string>>(new Set());
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const { checkPermission } = usePermissions();
|
||||||
|
|
||||||
|
// Columns to hide in Projekte table
|
||||||
|
const hiddenColumns = ['mandateId', 'Mandat Id', 'perimeter', 'Perimeter', 'dokumente', 'Dokumente', 'kontextInformationen', 'Kontext Informationen'];
|
||||||
|
|
||||||
|
// Fetch attributes from backend
|
||||||
|
const fetchAttributesData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const attrs = await fetchAttributes(request, 'Projekt');
|
||||||
|
setAttributes(attrs);
|
||||||
|
return attrs;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching Projekt attributes:', error);
|
||||||
|
setAttributes([]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
// Fetch permissions from backend
|
||||||
|
const fetchPermissionsData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const perms = await checkPermission('DATA', 'Projekt');
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// Generate columns from attributes
|
||||||
|
const generatedColumns = attributes && attributes.length > 0
|
||||||
|
? attributesToColumns(attributes, hiddenColumns)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load data for the specific table
|
||||||
|
*/
|
||||||
|
const loadTableData = useCallback(async (
|
||||||
|
page?: number,
|
||||||
|
pageSize?: number,
|
||||||
|
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>,
|
||||||
|
filters?: any,
|
||||||
|
search?: string
|
||||||
|
) => {
|
||||||
|
setIsLoadingTableData(true);
|
||||||
|
setTableDataError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params: any = {};
|
||||||
|
|
||||||
|
// Build pagination object
|
||||||
|
const paginationObj: any = {
|
||||||
|
page: page || 1,
|
||||||
|
pageSize: pageSize || 10,
|
||||||
|
sort: sort || []
|
||||||
|
};
|
||||||
|
|
||||||
|
if (filters) {
|
||||||
|
paginationObj.filters = filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
paginationObj.search = search;
|
||||||
|
}
|
||||||
|
|
||||||
|
params.pagination = JSON.stringify(paginationObj);
|
||||||
|
|
||||||
|
const response = await api.get(`/api/realestate/table/Projekt`, { params });
|
||||||
|
const data: TableDataResponse = response.data;
|
||||||
|
|
||||||
|
setTableData(data.items || []);
|
||||||
|
setPagination(data.pagination);
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage =
|
||||||
|
err.response?.data?.detail || err.message || `Fehler beim Laden der Tabelle Projekt`;
|
||||||
|
setTableDataError(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingTableData(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch a single project by ID
|
||||||
|
const fetchProjectById = useCallback(async (id: string): Promise<any | null> => {
|
||||||
|
try {
|
||||||
|
// Load all projects and find the one with matching ID
|
||||||
|
// Note: If backend supports GET /api/realestate/table/Projekt/{id}, use that instead
|
||||||
|
const response = await api.get(`/api/realestate/table/Projekt`);
|
||||||
|
const data: TableDataResponse = response.data;
|
||||||
|
const project = (data.items || []).find((item: any) => item.id === id || item.id?.toString() === id);
|
||||||
|
return project || null;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error fetching project by ID:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update project
|
||||||
|
const handleProjectUpdate = useCallback(async (id: string, updateData: any, originalData?: any): Promise<{ success: boolean }> => {
|
||||||
|
try {
|
||||||
|
setEditingProjects(prev => new Set(prev).add(id));
|
||||||
|
|
||||||
|
// Use command API for update
|
||||||
|
const response = await api.post('/api/realestate/command', {
|
||||||
|
userInput: `UPDATE Projekt ${id} with ${JSON.stringify(updateData)}`
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: CommandResponse = response.data;
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// Refetch table data
|
||||||
|
await loadTableData();
|
||||||
|
return { success: true };
|
||||||
|
} else {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error updating project:', err);
|
||||||
|
return { success: false };
|
||||||
|
} finally {
|
||||||
|
setEditingProjects(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [loadTableData]);
|
||||||
|
|
||||||
|
// Delete project
|
||||||
|
const handleProjectDelete = useCallback(async (id: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
setDeletingProjects(prev => new Set(prev).add(id));
|
||||||
|
|
||||||
|
// Use command API for delete
|
||||||
|
const response = await api.post('/api/realestate/command', {
|
||||||
|
userInput: `DELETE Projekt ${id}`
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: CommandResponse = response.data;
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// Refetch table data
|
||||||
|
await loadTableData();
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error deleting project:', err);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setDeletingProjects(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [loadTableData]);
|
||||||
|
|
||||||
|
// Create project
|
||||||
|
const handleProjectCreate = useCallback(async (projectData: any): Promise<{ success: boolean; data?: any; error?: string }> => {
|
||||||
|
try {
|
||||||
|
// The projectData now contains:
|
||||||
|
// - label: string
|
||||||
|
// - parzellen: Array<{ ...all parcel data including userAddress, geometry, map_view, adjacent_parcels, etc. }>
|
||||||
|
// - mandateId is NOT included (set by backend)
|
||||||
|
|
||||||
|
// Backward compatibility: if parzelle (singular) exists, convert to parzellen array
|
||||||
|
if (projectData.parzelle && !projectData.parzellen) {
|
||||||
|
projectData.parzellen = [projectData.parzelle];
|
||||||
|
delete projectData.parzelle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean and flatten parzellen data: Extract parcel data from ParcelSearchResponse structure
|
||||||
|
// Each parcel should use its own address data from Swiss Topo API
|
||||||
|
if (projectData.parzellen && Array.isArray(projectData.parzellen)) {
|
||||||
|
projectData.parzellen = projectData.parzellen.map((parzelleItem: any) => {
|
||||||
|
// Handle both structures:
|
||||||
|
// 1. ParcelSearchResponse structure: { parcel: {...}, map_view: {...}, adjacent_parcels: [...] }
|
||||||
|
// 2. Already flattened structure: { id, address, plz, ... }
|
||||||
|
let parcelData: any;
|
||||||
|
|
||||||
|
if (parzelleItem.parcel) {
|
||||||
|
// ParcelSearchResponse structure - extract parcel data and merge with map_view/adjacent_parcels
|
||||||
|
parcelData = {
|
||||||
|
...parzelleItem.parcel, // All parcel fields (id, address, plz, perimeter, etc.)
|
||||||
|
// Preserve map_view and adjacent_parcels if needed
|
||||||
|
map_view: parzelleItem.map_view,
|
||||||
|
adjacent_parcels: parzelleItem.adjacent_parcels
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Already flattened - use as-is
|
||||||
|
parcelData = { ...parzelleItem };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove userAddress to ensure Swiss Topo API data is used
|
||||||
|
delete parcelData.userAddress;
|
||||||
|
|
||||||
|
// Ensure address and plz from Swiss Topo are preserved
|
||||||
|
// These come from the parcel search API response for THIS specific parcel
|
||||||
|
return parcelData;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the complete project data structure to the backend
|
||||||
|
const response = await api.post('/api/realestate/table/Projekt', projectData);
|
||||||
|
|
||||||
|
// Refetch table data after successful creation
|
||||||
|
await loadTableData();
|
||||||
|
return { success: true, data: response.data };
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error creating project:', err);
|
||||||
|
const errorMessage = err.response?.data?.detail || err.message || 'Fehler beim Erstellen des Projekts';
|
||||||
|
return { success: false, error: errorMessage };
|
||||||
|
}
|
||||||
|
}, [loadTableData]);
|
||||||
|
|
||||||
|
// Handle single project deletion for FormGenerator
|
||||||
|
const handleDeleteSingle = useCallback(async (project: any) => {
|
||||||
|
const success = await handleProjectDelete(project.id);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
await loadTableData();
|
||||||
|
}
|
||||||
|
}, [handleProjectDelete, loadTableData]);
|
||||||
|
|
||||||
|
// Handle multiple project deletion for FormGenerator
|
||||||
|
const handleDeleteMultiple = useCallback(async (selectedProjects: any[]) => {
|
||||||
|
const projectIds = selectedProjects.map(project => project.id);
|
||||||
|
|
||||||
|
// Delete all projects sequentially
|
||||||
|
const results = await Promise.all(
|
||||||
|
projectIds.map(id => handleProjectDelete(id))
|
||||||
|
);
|
||||||
|
|
||||||
|
const allSuccessful = results.every(result => result);
|
||||||
|
|
||||||
|
if (allSuccessful) {
|
||||||
|
await loadTableData();
|
||||||
|
}
|
||||||
|
}, [handleProjectDelete, loadTableData]);
|
||||||
|
|
||||||
|
// Optimistic update
|
||||||
|
const updateOptimistically = useCallback((id: string, updateData: any) => {
|
||||||
|
setTableData(prev => prev.map(item => {
|
||||||
|
if (item.id === id || item.id?.toString() === id) {
|
||||||
|
return { ...item, ...updateData };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Optimistic remove
|
||||||
|
const removeOptimistically = useCallback((id: string) => {
|
||||||
|
setTableData(prev => prev.filter(item => item.id !== id && item.id?.toString() !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Generate edit fields from attributes for create button
|
||||||
|
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 => {
|
||||||
|
// Show all fields from backend - filter out ID fields and mandateId for create forms
|
||||||
|
// mandateId is set by backend, not editable by user
|
||||||
|
const nonEditableFields = ['id', 'mandateId']; // Filter out ID fields and mandateId for create forms
|
||||||
|
return !nonEditableFields.includes(attr.name);
|
||||||
|
})
|
||||||
|
.map(attr => {
|
||||||
|
// Map backend attribute type to form field type using attributeTypeMapper utilities
|
||||||
|
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;
|
||||||
|
|
||||||
|
const attrType = attr.type as AttributeType;
|
||||||
|
|
||||||
|
// Use attributeTypeMapper utilities to determine field type
|
||||||
|
if (isCheckboxType(attrType)) {
|
||||||
|
fieldType = 'boolean';
|
||||||
|
} else if (attrType === 'email') {
|
||||||
|
fieldType = 'email';
|
||||||
|
} else if (isDateTimeType(attrType)) {
|
||||||
|
fieldType = 'date';
|
||||||
|
} else if (isSelectType(attrType)) {
|
||||||
|
fieldType = 'enum';
|
||||||
|
const attrOptions = (attr as any).options;
|
||||||
|
if (Array.isArray(attrOptions)) {
|
||||||
|
options = attrOptions.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 attrOptions === 'string') {
|
||||||
|
optionsReference = attrOptions;
|
||||||
|
}
|
||||||
|
} else if (isMultiselectType(attrType)) {
|
||||||
|
fieldType = 'multiselect';
|
||||||
|
const attrOptions = (attr as any).options;
|
||||||
|
if (Array.isArray(attrOptions)) {
|
||||||
|
options = attrOptions.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 attrOptions === 'string') {
|
||||||
|
optionsReference = attrOptions;
|
||||||
|
}
|
||||||
|
} else if (isTextareaType(attrType)) {
|
||||||
|
fieldType = 'textarea';
|
||||||
|
} else if (attrType === 'readonly') {
|
||||||
|
fieldType = 'readonly';
|
||||||
|
} else {
|
||||||
|
fieldType = 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: attr.name,
|
||||||
|
label: attr.label || attr.name,
|
||||||
|
type: fieldType,
|
||||||
|
required: (attr as any).required || false,
|
||||||
|
placeholder: (attr as any).placeholder,
|
||||||
|
editable: true, // All fields from backend should be editable in create form
|
||||||
|
minRows: isTextareaType(attrType) ? 4 : undefined,
|
||||||
|
maxRows: isTextareaType(attrType) ? 8 : undefined,
|
||||||
|
options: options,
|
||||||
|
optionsReference: optionsReference
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return editableFields;
|
||||||
|
}, [attributes, hiddenColumns]);
|
||||||
|
|
||||||
|
// Ensure attributes are loaded
|
||||||
|
const ensureAttributesLoaded = useCallback(async () => {
|
||||||
|
if (attributes.length === 0) {
|
||||||
|
await fetchAttributesData();
|
||||||
|
}
|
||||||
|
}, [attributes.length, fetchAttributesData]);
|
||||||
|
|
||||||
|
// Load attributes and permissions first, then table data
|
||||||
|
useEffect(() => {
|
||||||
|
const initializeData = async () => {
|
||||||
|
// Load attributes first to ensure columns are available
|
||||||
|
await fetchAttributesData();
|
||||||
|
await fetchPermissionsData();
|
||||||
|
// Then load table data
|
||||||
|
await loadTableData();
|
||||||
|
};
|
||||||
|
initializeData();
|
||||||
|
}, [fetchAttributesData, fetchPermissionsData, loadTableData]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refetch function compatible with GenericDataHook interface
|
||||||
|
*/
|
||||||
|
const refetch = useCallback(async (params?: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
sort?: Array<{field: string; direction: 'asc' | 'desc'}>;
|
||||||
|
filters?: any;
|
||||||
|
search?: string;
|
||||||
|
}) => {
|
||||||
|
await loadTableData(
|
||||||
|
params?.page,
|
||||||
|
params?.pageSize,
|
||||||
|
params?.sort,
|
||||||
|
params?.filters,
|
||||||
|
params?.search
|
||||||
|
);
|
||||||
|
}, [loadTableData]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: tableData,
|
||||||
|
loading: isLoadingTableData,
|
||||||
|
error: tableDataError,
|
||||||
|
refetch,
|
||||||
|
pagination: pagination || null,
|
||||||
|
columns: generatedColumns,
|
||||||
|
// Operations
|
||||||
|
handleProjectCreate,
|
||||||
|
handleProjectUpdate,
|
||||||
|
handleDelete: handleProjectDelete,
|
||||||
|
handleDeleteMultiple,
|
||||||
|
// FormGenerator specific handlers
|
||||||
|
onDelete: handleDeleteSingle,
|
||||||
|
onDeleteMultiple: handleDeleteMultiple,
|
||||||
|
// Loading states
|
||||||
|
editingProjects,
|
||||||
|
deletingProjects,
|
||||||
|
// Optimistic updates
|
||||||
|
updateOptimistically,
|
||||||
|
removeOptimistically,
|
||||||
|
// Attributes and permissions
|
||||||
|
attributes,
|
||||||
|
permissions,
|
||||||
|
// Functions for EditActionButton
|
||||||
|
fetchProjectById,
|
||||||
|
ensureAttributesLoaded,
|
||||||
|
// Functions for CreateButton
|
||||||
|
generateEditFieldsFromAttributes,
|
||||||
|
// Entity type
|
||||||
|
entityType: 'Projekt'
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook factory for Parzellen table with edit/delete/view support
|
||||||
|
*/
|
||||||
|
export function createParzellenTableHook(): () => GenericDataHook {
|
||||||
|
return () => {
|
||||||
|
const [tableData, setTableData] = useState<any[]>([]);
|
||||||
|
const [isLoadingTableData, setIsLoadingTableData] = useState(false);
|
||||||
|
const [tableDataError, setTableDataError] = useState<string | null>(null);
|
||||||
|
const [pagination, setPagination] = useState<TableDataResponse['pagination']>(null);
|
||||||
|
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
|
||||||
|
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
|
||||||
|
const [editingParzellen, setEditingParzellen] = useState<Set<string>>(new Set());
|
||||||
|
const [deletingParzellen, setDeletingParzellen] = useState<Set<string>>(new Set());
|
||||||
|
const [viewingParzellen, setViewingParzellen] = useState<Set<string>>(new Set());
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const { checkPermission } = usePermissions();
|
||||||
|
|
||||||
|
// Columns to hide in Parzellen table
|
||||||
|
const hiddenColumns = [
|
||||||
|
'mandateId', 'Mandate ID', 'Mandat Id',
|
||||||
|
'aliasTags', 'Alias Tags',
|
||||||
|
'perimeter', 'Perimeter',
|
||||||
|
'baulinie', 'Baulinie',
|
||||||
|
'bauzone', 'Bauzone',
|
||||||
|
'az', 'Az', 'AZ',
|
||||||
|
'bz', 'Bz', 'BZ',
|
||||||
|
'vollgeschossZahl', 'Vollgeschoss Zahl',
|
||||||
|
'anrechenbarDachgeschoss', 'Anrechenbar Dachgeschoss',
|
||||||
|
'anrechenbarUntergeschoss', 'Anrechendbar Untergeschoss',
|
||||||
|
'gebaudehoheMax', 'Gebäudehöhe Max',
|
||||||
|
'regelnGrenzabstand', 'Regeln Grenzabstand',
|
||||||
|
'regelnMehrhoehenzuschlag', 'Regln Mehrhoehenzuschlag',
|
||||||
|
'parzelleBebaut', 'Parzelle Bebaut',
|
||||||
|
'parzelleErschlossen', 'Parzelle Erschlossen',
|
||||||
|
'parzelleHanglage', 'Parzelle Hanglage',
|
||||||
|
'laermschutzzone', 'Lärmschutzzone',
|
||||||
|
'gebaeudehoeheMax', 'Gebäudehöhe Max',
|
||||||
|
'regelnMehrlaengenzuschlag', 'Regeln Mehrlängenzuschlag',
|
||||||
|
'hochwasserschutzzone', 'Hochwasserschutzzone',
|
||||||
|
'grundwasserschutzzone', 'Grundwasserschutzzone',
|
||||||
|
'dokumente', 'Dokumente',
|
||||||
|
'kontextInformationen', 'Kontext Informationen'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Fetch attributes from backend
|
||||||
|
const fetchAttributesData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const attrs = await fetchAttributes(request, 'Parzelle');
|
||||||
|
setAttributes(attrs);
|
||||||
|
return attrs;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching Parzelle attributes:', error);
|
||||||
|
setAttributes([]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
// Fetch permissions from backend
|
||||||
|
const fetchPermissionsData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const perms = await checkPermission('DATA', 'Parzelle');
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// Generate columns from attributes
|
||||||
|
const generatedColumns = attributes && attributes.length > 0
|
||||||
|
? attributesToColumns(attributes, hiddenColumns)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load data for the specific table
|
||||||
|
*/
|
||||||
|
const loadTableData = useCallback(async (
|
||||||
|
page?: number,
|
||||||
|
pageSize?: number,
|
||||||
|
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>,
|
||||||
|
filters?: any,
|
||||||
|
search?: string
|
||||||
|
) => {
|
||||||
|
setIsLoadingTableData(true);
|
||||||
|
setTableDataError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params: any = {};
|
||||||
|
|
||||||
|
// Build pagination object
|
||||||
|
const paginationObj: any = {
|
||||||
|
page: page || 1,
|
||||||
|
pageSize: pageSize || 10,
|
||||||
|
sort: sort || []
|
||||||
|
};
|
||||||
|
|
||||||
|
if (filters) {
|
||||||
|
paginationObj.filters = filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
paginationObj.search = search;
|
||||||
|
}
|
||||||
|
|
||||||
|
params.pagination = JSON.stringify(paginationObj);
|
||||||
|
|
||||||
|
const response = await api.get(`/api/realestate/table/Parzelle`, { params });
|
||||||
|
const data: TableDataResponse = response.data;
|
||||||
|
|
||||||
|
setTableData(data.items || []);
|
||||||
|
setPagination(data.pagination);
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage =
|
||||||
|
err.response?.data?.detail || err.message || `Fehler beim Laden der Tabelle Parzelle`;
|
||||||
|
setTableDataError(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingTableData(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch a single parzelle by ID
|
||||||
|
const fetchParzelleById = useCallback(async (id: string): Promise<any | null> => {
|
||||||
|
try {
|
||||||
|
// Load all parzellen and find the one with matching ID
|
||||||
|
const response = await api.get(`/api/realestate/table/Parzelle`);
|
||||||
|
const data: TableDataResponse = response.data;
|
||||||
|
const parzelle = (data.items || []).find((item: any) => item.id === id || item.id?.toString() === id);
|
||||||
|
return parzelle || null;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error fetching parzelle by ID:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update parzelle
|
||||||
|
const handleParzelleUpdate = useCallback(async (id: string, updateData: any, originalData?: any): Promise<{ success: boolean }> => {
|
||||||
|
try {
|
||||||
|
setEditingParzellen(prev => new Set(prev).add(id));
|
||||||
|
|
||||||
|
// Use command API for update
|
||||||
|
const response = await api.post('/api/realestate/command', {
|
||||||
|
userInput: `UPDATE Parzelle ${id} with ${JSON.stringify(updateData)}`
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: CommandResponse = response.data;
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// Refetch table data
|
||||||
|
await loadTableData();
|
||||||
|
return { success: true };
|
||||||
|
} else {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error updating parzelle:', err);
|
||||||
|
return { success: false };
|
||||||
|
} finally {
|
||||||
|
setEditingParzellen(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [loadTableData]);
|
||||||
|
|
||||||
|
// Delete parzelle
|
||||||
|
const handleParzelleDelete = useCallback(async (id: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
setDeletingParzellen(prev => new Set(prev).add(id));
|
||||||
|
|
||||||
|
// Use command API for delete
|
||||||
|
const response = await api.post('/api/realestate/command', {
|
||||||
|
userInput: `DELETE Parzelle ${id}`
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: CommandResponse = response.data;
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// Refetch table data
|
||||||
|
await loadTableData();
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error deleting parzelle:', err);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setDeletingParzellen(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [loadTableData]);
|
||||||
|
|
||||||
|
// View parzelle (for preview/details)
|
||||||
|
const handleParzelleView = useCallback(async (parzelle: any): Promise<void> => {
|
||||||
|
const id = parzelle.id || parzelle.id?.toString();
|
||||||
|
if (id) {
|
||||||
|
setViewingParzellen(prev => new Set(prev).add(id));
|
||||||
|
// The actual viewing is handled by the ViewActionButton component
|
||||||
|
// This is just for tracking loading state
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Optimistic update
|
||||||
|
const updateOptimistically = useCallback((id: string, updateData: any) => {
|
||||||
|
setTableData(prev => prev.map(item => {
|
||||||
|
if (item.id === id || item.id?.toString() === id) {
|
||||||
|
return { ...item, ...updateData };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Optimistic remove
|
||||||
|
const removeOptimistically = useCallback((id: string) => {
|
||||||
|
setTableData(prev => prev.filter(item => item.id !== id && item.id?.toString() !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Ensure attributes are loaded
|
||||||
|
const ensureAttributesLoaded = useCallback(async () => {
|
||||||
|
if (attributes.length === 0) {
|
||||||
|
await fetchAttributesData();
|
||||||
|
}
|
||||||
|
}, [attributes.length, fetchAttributesData]);
|
||||||
|
|
||||||
|
// Load table data and attributes on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const initializeData = async () => {
|
||||||
|
// Load attributes first to ensure columns are available
|
||||||
|
await fetchAttributesData();
|
||||||
|
await fetchPermissionsData();
|
||||||
|
// Then load table data
|
||||||
|
await loadTableData();
|
||||||
|
};
|
||||||
|
initializeData();
|
||||||
|
}, [fetchAttributesData, fetchPermissionsData, loadTableData]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refetch function compatible with GenericDataHook interface
|
||||||
|
*/
|
||||||
|
const refetch = useCallback(async (params?: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
sort?: Array<{field: string; direction: 'asc' | 'desc'}>;
|
||||||
|
filters?: any;
|
||||||
|
search?: string;
|
||||||
|
}) => {
|
||||||
|
await loadTableData(
|
||||||
|
params?.page,
|
||||||
|
params?.pageSize,
|
||||||
|
params?.sort,
|
||||||
|
params?.filters,
|
||||||
|
params?.search
|
||||||
|
);
|
||||||
|
}, [loadTableData]);
|
||||||
|
|
||||||
|
// Handle single parzelle deletion for FormGenerator
|
||||||
|
const handleDeleteSingle = useCallback(async (parzelle: any) => {
|
||||||
|
const success = await handleParzelleDelete(parzelle.id);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
await loadTableData();
|
||||||
|
}
|
||||||
|
}, [handleParzelleDelete, loadTableData]);
|
||||||
|
|
||||||
|
// Handle multiple parzelle deletion for FormGenerator
|
||||||
|
const handleDeleteMultiple = useCallback(async (selectedParzellen: any[]) => {
|
||||||
|
const parzelleIds = selectedParzellen.map(parzelle => parzelle.id);
|
||||||
|
|
||||||
|
// Delete all parzellen sequentially
|
||||||
|
const results = await Promise.all(
|
||||||
|
parzelleIds.map(id => handleParzelleDelete(id))
|
||||||
|
);
|
||||||
|
|
||||||
|
const allSuccessful = results.every(result => result);
|
||||||
|
|
||||||
|
if (allSuccessful) {
|
||||||
|
await loadTableData();
|
||||||
|
}
|
||||||
|
}, [handleParzelleDelete, loadTableData]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: tableData,
|
||||||
|
loading: isLoadingTableData,
|
||||||
|
error: tableDataError,
|
||||||
|
refetch,
|
||||||
|
pagination: pagination || null,
|
||||||
|
columns: generatedColumns,
|
||||||
|
// Operations
|
||||||
|
handleParzelleUpdate,
|
||||||
|
handleDelete: handleParzelleDelete,
|
||||||
|
handleDeleteMultiple,
|
||||||
|
handleParzelleView,
|
||||||
|
// FormGenerator specific handlers
|
||||||
|
onDelete: handleDeleteSingle,
|
||||||
|
onDeleteMultiple: handleDeleteMultiple,
|
||||||
|
// Loading states
|
||||||
|
editingParzellen,
|
||||||
|
deletingParzellen,
|
||||||
|
viewingParzellen,
|
||||||
|
// Optimistic updates
|
||||||
|
updateOptimistically,
|
||||||
|
removeOptimistically,
|
||||||
|
// Attributes and permissions
|
||||||
|
attributes,
|
||||||
|
permissions,
|
||||||
|
// Functions for EditActionButton
|
||||||
|
fetchParzelleById,
|
||||||
|
ensureAttributesLoaded,
|
||||||
|
// Entity type
|
||||||
|
entityType: 'Parzelle'
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -650,4 +650,18 @@
|
||||||
.pageTitle {
|
.pageTitle {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Columns container */
|
||||||
|
.columnsContainer {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
min-width: 0; /* Prevent overflow */
|
||||||
}
|
}
|
||||||
Loading…
Reference in a new issue