feat:multiselect parcels and create projects
This commit is contained in:
parent
407a3c4b82
commit
c5a82dd518
27 changed files with 2763 additions and 850 deletions
|
|
@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
|||
import { IoIosEye } from 'react-icons/io';
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
import { ContentPreview } from '../../../ContentPreview';
|
||||
import { Popup } from '../../../UiComponents/Popup';
|
||||
import styles from '../ActionButton.module.css';
|
||||
|
||||
export interface ViewActionButtonProps<T = any> {
|
||||
|
|
@ -69,6 +70,17 @@ export function ViewActionButton<T = any>({
|
|||
// Determine the final button title (tooltip)
|
||||
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 (
|
||||
<>
|
||||
<button
|
||||
|
|
@ -82,14 +94,53 @@ export function ViewActionButton<T = any>({
|
|||
</span>
|
||||
</button>
|
||||
|
||||
{/* Content Preview Component */}
|
||||
<ContentPreview
|
||||
isOpen={isPopupOpen}
|
||||
onClose={() => setIsPopupOpen(false)}
|
||||
fileId={(row as any)[idField]}
|
||||
fileName={(row as any)[nameField] || 'Unknown Item'}
|
||||
mimeType={(row as any)[typeField]}
|
||||
/>
|
||||
{/* Content Preview Component for files */}
|
||||
{isFile && (
|
||||
<ContentPreview
|
||||
isOpen={isPopupOpen}
|
||||
onClose={() => setIsPopupOpen(false)}
|
||||
fileId={(row as any)[idField]}
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ export function FormGeneratorControls({
|
|||
{/* Delete Controls - Show when items are selected */}
|
||||
{selectable && selectedCount > 0 && (
|
||||
<div className={styles.deleteControlsIntegrated}>
|
||||
{selectedCount === 1 && onDeleteSingle && (
|
||||
{selectedCount === 1 && onDeleteSingle && !(selectedCount === _displayData.length && _displayData.length > 0) && (
|
||||
<Button
|
||||
onClick={onDeleteSingle}
|
||||
variant="primary"
|
||||
|
|
@ -169,7 +169,7 @@ export function FormGeneratorControls({
|
|||
{t('formgen.delete.single', 'Delete')}
|
||||
</Button>
|
||||
)}
|
||||
{selectedCount > 1 && onDeleteMultiple && (
|
||||
{(selectedCount > 1 || (selectedCount === _displayData.length && _displayData.length > 0)) && onDeleteMultiple && (
|
||||
<Button
|
||||
onClick={onDeleteMultiple}
|
||||
variant="primary"
|
||||
|
|
|
|||
|
|
@ -37,12 +37,23 @@
|
|||
max-height: none;
|
||||
}
|
||||
|
||||
/* Empty state styling */
|
||||
.emptyState {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
/* Empty message styling */
|
||||
.emptyMessage {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: var(--color-text);
|
||||
opacity: 0.6;
|
||||
opacity: 0.7;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.loading {
|
||||
|
|
|
|||
|
|
@ -81,6 +81,8 @@ export interface FormGeneratorTableProps<T = any> {
|
|||
getRowDataAttributes?: (row: T, index: number) => Record<string, string>;
|
||||
// For passing hook data to action buttons
|
||||
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>>({
|
||||
|
|
@ -105,7 +107,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
onRefresh,
|
||||
className = '',
|
||||
getRowDataAttributes,
|
||||
hookData
|
||||
hookData,
|
||||
emptyMessage
|
||||
}: FormGeneratorTableProps<T>) {
|
||||
const { t } = useLanguage();
|
||||
// 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)
|
||||
if (column.formatter) {
|
||||
return column.formatter(value, row);
|
||||
|
|
@ -656,7 +719,18 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
const selectedRow = displayData[selectedIndex];
|
||||
handleDeleteSingle(selectedRow, selectedIndex);
|
||||
} : 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}
|
||||
searchable={searchable}
|
||||
filterable={filterable}
|
||||
|
|
@ -673,6 +747,10 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
<div className={styles.loadingSpinner}></div>
|
||||
<p>{t('common.loading', 'Loading...')}</p>
|
||||
</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}>
|
||||
<thead>
|
||||
|
|
@ -732,15 +810,9 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{displayData.length === 0 ? (
|
||||
<tr>
|
||||
<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) => {
|
||||
{displayData.length > 0 && (
|
||||
<tbody>
|
||||
{displayData.map((row, index) => {
|
||||
const dataAttributes = getRowDataAttributes ? getRowDataAttributes(row, index) : {};
|
||||
return (
|
||||
<tr
|
||||
|
|
@ -870,9 +942,9 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
})}
|
||||
</tbody>
|
||||
)}
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -31,8 +31,9 @@ const Button: React.FC<ButtonProps> = ({
|
|||
].filter(Boolean).join(' ');
|
||||
|
||||
// Handle click
|
||||
const handleClick = () => {
|
||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
|
||||
if (!disabled && !loading && onClick) {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -52,4 +52,5 @@ export interface CreateButtonProps extends BaseButtonProps {
|
|||
iconPosition?: 'left' | 'right';
|
||||
onSuccess?: (result: any) => 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 { FormGeneratorForm, AttributeDefinition } from '../../../FormGenerator/FormGeneratorForm';
|
||||
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> = ({
|
||||
onCreate,
|
||||
|
|
@ -20,15 +290,32 @@ const CreateButton: React.FC<CreateButtonProps> = ({
|
|||
size = 'md',
|
||||
onSuccess,
|
||||
onError,
|
||||
multiStep = false,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const [isCreating, setIsCreating] = 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
|
||||
const attributes: AttributeDefinition[] = useMemo(() => {
|
||||
return fields.map(field => {
|
||||
const fieldsToUse = multiStep ? step1Fields : fields;
|
||||
return fieldsToUse.map(field => {
|
||||
// Convert options to AttributeOption[] format
|
||||
let options: AttributeDefinition['options'] = undefined;
|
||||
|
||||
|
|
@ -84,12 +371,13 @@ const CreateButton: React.FC<CreateButtonProps> = ({
|
|||
options: options
|
||||
};
|
||||
});
|
||||
}, [fields]);
|
||||
}, [fields, multiStep, step1Fields]);
|
||||
|
||||
// Initialize form data with default values
|
||||
const initialFormData = useMemo(() => {
|
||||
const data: any = {};
|
||||
fields.forEach(field => {
|
||||
const fieldsToUse = multiStep ? step1Fields : fields;
|
||||
fieldsToUse.forEach(field => {
|
||||
if (field.type === 'multiselect') {
|
||||
// Multiselect fields should default to empty array
|
||||
data[field.key] = field.defaultValue || [];
|
||||
|
|
@ -102,15 +390,116 @@ const CreateButton: React.FC<CreateButtonProps> = ({
|
|||
}
|
||||
});
|
||||
return data;
|
||||
}, [fields]);
|
||||
}, [fields, multiStep, step1Fields]);
|
||||
|
||||
const handleButtonClick = () => {
|
||||
if (!disabled && !loading && !isCreating) {
|
||||
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) => {
|
||||
if (multiStep && currentStep === 1) {
|
||||
handleStep1Next(updatedData);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
|
||||
try {
|
||||
|
|
@ -119,6 +508,11 @@ const CreateButton: React.FC<CreateButtonProps> = ({
|
|||
if (result?.success !== false) {
|
||||
// Success
|
||||
setIsPopupOpen(false);
|
||||
if (multiStep) {
|
||||
setCurrentStep(1);
|
||||
setStep1Data({});
|
||||
setAddressData({ street: '', postalCode: '', city: '' });
|
||||
}
|
||||
if (onSuccess) {
|
||||
onSuccess(result);
|
||||
}
|
||||
|
|
@ -140,6 +534,18 @@ const CreateButton: React.FC<CreateButtonProps> = ({
|
|||
|
||||
const handleCancel = () => {
|
||||
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;
|
||||
|
|
@ -184,18 +590,47 @@ const CreateButton: React.FC<CreateButtonProps> = ({
|
|||
isOpen={isPopupOpen}
|
||||
title={resolvedPopupTitle}
|
||||
onClose={handleCancel}
|
||||
size={popupSize}
|
||||
size={multiStep ? 'large' : popupSize}
|
||||
closable={!isCreating}
|
||||
>
|
||||
<FormGeneratorForm
|
||||
attributes={resolvedAttributes}
|
||||
data={initialFormData}
|
||||
mode="create"
|
||||
onSubmit={handleSave}
|
||||
onCancel={handleCancel}
|
||||
submitButtonText={t('common.create', 'Create')}
|
||||
cancelButtonText={t('common.cancel', 'Cancel')}
|
||||
/>
|
||||
{multiStep ? (
|
||||
currentStep === 1 ? (
|
||||
<div className={styles.step1Container}>
|
||||
<div className={styles.stepIndicator}>
|
||||
<span className={styles.stepNumber}>1</span>
|
||||
<span className={styles.stepLabel}>Titel festlegen</span>
|
||||
</div>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -72,13 +72,57 @@
|
|||
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 {
|
||||
margin: 0 0 1rem 0;
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -1,22 +1,24 @@
|
|||
import React from 'react';
|
||||
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';
|
||||
|
||||
export interface ParcelInfoPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
parcelData: any;
|
||||
parcels: any[];
|
||||
onRemoveParcel?: (parcelId: string) => void;
|
||||
adjacentParcels?: any[];
|
||||
}
|
||||
|
||||
const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
parcelData,
|
||||
parcels,
|
||||
onRemoveParcel,
|
||||
adjacentParcels = []
|
||||
}) => {
|
||||
if (!parcelData) return null;
|
||||
if (!parcels || parcels.length === 0) return null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
|
|
@ -41,146 +43,163 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
|
|||
className={styles.panel}
|
||||
>
|
||||
<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}>
|
||||
<FaTimes />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
{/* Main Parcel */}
|
||||
<section className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>Ausgewählte Parzelle</h3>
|
||||
<div className={styles.infoGrid}>
|
||||
{parcelData.parcel.id && (
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.label}>ID:</span>
|
||||
<span className={styles.value}>{parcelData.parcel.id}</span>
|
||||
{/* Selected Parcels List */}
|
||||
<div className={styles.parcelsList}>
|
||||
{parcels.map((parcelData, index) => (
|
||||
<section key={parcelData.parcel.id || index} className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h3 className={styles.sectionTitle}>
|
||||
Parzelle {index + 1}: {parcelData.parcel.number || parcelData.parcel.id || 'Unbekannt'}
|
||||
</h3>
|
||||
{onRemoveParcel && (
|
||||
<button
|
||||
className={styles.removeButton}
|
||||
onClick={() => onRemoveParcel(parcelData.parcel.id)}
|
||||
title="Parzelle entfernen"
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{parcelData.parcel.number && (
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.label}>Nummer:</span>
|
||||
<span className={styles.value}>{parcelData.parcel.number}</span>
|
||||
</div>
|
||||
)}
|
||||
{parcelData.parcel.name && (
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.label}>Name:</span>
|
||||
<span className={styles.value}>{parcelData.parcel.name}</span>
|
||||
</div>
|
||||
)}
|
||||
{parcelData.parcel.egrid && (
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.label}>EGRID:</span>
|
||||
<span className={styles.value}>{parcelData.parcel.egrid}</span>
|
||||
</div>
|
||||
)}
|
||||
{parcelData.parcel.identnd && (
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.label}>IdentND:</span>
|
||||
<span className={styles.value}>{parcelData.parcel.identnd}</span>
|
||||
</div>
|
||||
)}
|
||||
{parcelData.parcel.address && (
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.label}>Adresse:</span>
|
||||
<span className={styles.value}>{parcelData.parcel.address}</span>
|
||||
</div>
|
||||
)}
|
||||
{parcelData.parcel.canton && (
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.label}>Kanton:</span>
|
||||
<span className={styles.value}>{parcelData.parcel.canton}</span>
|
||||
</div>
|
||||
)}
|
||||
{parcelData.parcel.municipality_name && (
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.label}>Gemeinde:</span>
|
||||
<span className={styles.value}>{parcelData.parcel.municipality_name}</span>
|
||||
</div>
|
||||
)}
|
||||
{parcelData.parcel.municipality_code && (
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.label}>Gemeinde-Code:</span>
|
||||
<span className={styles.value}>{parcelData.parcel.municipality_code}</span>
|
||||
</div>
|
||||
)}
|
||||
{parcelData.parcel.area_m2 !== undefined && (
|
||||
<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)
|
||||
<div className={styles.infoGrid}>
|
||||
{parcelData.parcel.id && (
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.label}>ID:</span>
|
||||
<span className={styles.value}>{parcelData.parcel.id}</span>
|
||||
</div>
|
||||
)}
|
||||
{parcelData.parcel.number && (
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.label}>Nummer:</span>
|
||||
<span className={styles.value}>{parcelData.parcel.number}</span>
|
||||
</div>
|
||||
)}
|
||||
{parcelData.parcel.name && (
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.label}>Name:</span>
|
||||
<span className={styles.value}>{parcelData.parcel.name}</span>
|
||||
</div>
|
||||
)}
|
||||
{parcelData.parcel.egrid && (
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.label}>EGRID:</span>
|
||||
<span className={styles.value}>{parcelData.parcel.egrid}</span>
|
||||
</div>
|
||||
)}
|
||||
{parcelData.parcel.identnd && (
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.label}>IdentND:</span>
|
||||
<span className={styles.value}>{parcelData.parcel.identnd}</span>
|
||||
</div>
|
||||
)}
|
||||
{parcelData.parcel.address && (
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.label}>Adresse:</span>
|
||||
<span className={styles.value}>{parcelData.parcel.address}</span>
|
||||
</div>
|
||||
)}
|
||||
{parcelData.parcel.canton && (
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.label}>Kanton:</span>
|
||||
<span className={styles.value}>{parcelData.parcel.canton}</span>
|
||||
</div>
|
||||
)}
|
||||
{parcelData.parcel.municipality_name && (
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.label}>Gemeinde:</span>
|
||||
<span className={styles.value}>{parcelData.parcel.municipality_name}</span>
|
||||
</div>
|
||||
)}
|
||||
{parcelData.parcel.municipality_code && (
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.label}>Gemeinde-Code:</span>
|
||||
<span className={styles.value}>{parcelData.parcel.municipality_code}</span>
|
||||
</div>
|
||||
)}
|
||||
{parcelData.parcel.area_m2 !== undefined && (
|
||||
<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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{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 */}
|
||||
{parcelData.map_view && (
|
||||
<section className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>Kartenansicht</h3>
|
||||
<div className={styles.infoGrid}>
|
||||
{parcelData.map_view.center && (
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.label}>Zentrum:</span>
|
||||
<span className={styles.value}>
|
||||
{parcelData.map_view.center.x.toFixed(2)}, {parcelData.map_view.center.y.toFixed(2)}
|
||||
</span>
|
||||
{/* Map View Info for this parcel */}
|
||||
{parcelData.map_view && (
|
||||
<div className={styles.mapViewSection}>
|
||||
<h4 className={styles.subSectionTitle}>Kartenansicht</h4>
|
||||
<div className={styles.infoGrid}>
|
||||
{parcelData.map_view.center && (
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.label}>Zentrum:</span>
|
||||
<span className={styles.value}>
|
||||
{parcelData.map_view.center.x.toFixed(2)}, {parcelData.map_view.center.y.toFixed(2)}
|
||||
</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>
|
||||
)}
|
||||
{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>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Adjacent Parcels */}
|
||||
{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 type { WorkflowStatusProps } from './WorkflowStatus/WorkflowStatusTypes';
|
||||
export * from './AutoScroll';
|
||||
export * from './Tabs';
|
||||
export type { TabsProps, Tab } from './Tabs';
|
||||
|
|
|
|||
|
|
@ -18,10 +18,13 @@ interface PekContextType {
|
|||
locationError: string | null;
|
||||
|
||||
// Parcel search
|
||||
selectedParcel: any;
|
||||
selectedParcels: any[];
|
||||
searchParcel: (location: string, includeAdjacent?: boolean) => Promise<any>;
|
||||
isSearchingParcel: boolean;
|
||||
parcelSearchError: string | null;
|
||||
removeParcel: (parcelId: string) => void;
|
||||
clearSelectedParcels: () => void;
|
||||
isParcelSelected: (parcelId: string) => boolean;
|
||||
|
||||
// Map view
|
||||
mapCenter: any;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
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 { 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 { Popup } from '../../components/UiComponents/Popup';
|
||||
import { ConnectedFilesList } from '../../components/UiComponents/ConnectedFilesList';
|
||||
import type { DropdownSelectItem } from '../../components/UiComponents/DropdownSelect';
|
||||
|
|
@ -155,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
|
||||
const ContentRenderer: React.FC<{
|
||||
contents: PageContent[];
|
||||
|
|
@ -259,6 +291,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> = ({
|
||||
pageData,
|
||||
onButtonClick
|
||||
|
|
@ -267,27 +327,65 @@ const PageRenderer: React.FC<PageRendererProps> = ({
|
|||
const { t } = useLanguage();
|
||||
const { hasPermission } = usePermissions();
|
||||
|
||||
// Call the hook at the top level to ensure it persists across renders
|
||||
// This is CRITICAL - hooks must be called in the same order on every render
|
||||
const tableContent = pageData.content?.find(content => content.type === 'table');
|
||||
// Find all table content sections (including nested ones)
|
||||
const allTableContents = React.useMemo(() =>
|
||||
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 settingsContent = pageData.content?.find(content => content.type === 'settings');
|
||||
const hookFactory = tableContent?.tableConfig?.hookFactory
|
||||
|| inputFormContent?.inputFormConfig?.hookFactory
|
||||
const hookFactory = inputFormContent?.inputFormConfig?.hookFactory
|
||||
|| settingsContent?.settingsConfig?.hookFactory;
|
||||
|
||||
// Create a stable hook instance using React.useMemo
|
||||
// This ensures the same hook instance is used across re-renders
|
||||
const useTableData = React.useMemo(() => {
|
||||
if (hookFactory) {
|
||||
return hookFactory();
|
||||
}
|
||||
return null;
|
||||
}, [hookFactory]);
|
||||
// Create hook instance at top level
|
||||
const useTableData = hookFactory ? hookFactory() : null;
|
||||
|
||||
// Call the hook to get the current data
|
||||
// This will be called on every render, but it's the SAME hook instance
|
||||
const hookData = useTableData ? useTableData() : null;
|
||||
// Call the hook to get the current data (for backward compatibility)
|
||||
// If no inputForm/settings hook, try to use the first table hook for header buttons
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -770,21 +868,27 @@ const PageRenderer: React.FC<PageRendererProps> = ({
|
|||
return null;
|
||||
|
||||
case 'table':
|
||||
if (content.tableConfig && hookData) {
|
||||
const { columns: configColumns, actionButtons, ...tableProps } = content.tableConfig;
|
||||
// Get hookData for this specific table (nested tables use their own hooks)
|
||||
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)
|
||||
// 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
|
||||
if (hookData.error) {
|
||||
if (currentTableHookData.error) {
|
||||
return (
|
||||
<div key={content.id} className={styles.tableContainer}>
|
||||
<div className={styles.errorState}>
|
||||
<p>Error loading data: {hookData.error}</p>
|
||||
{hookData.refetch && (
|
||||
<button onClick={hookData.refetch} className={styles.retryButton}>
|
||||
<p>Error loading data: {currentTableHookData.error}</p>
|
||||
{currentTableHookData.refetch && (
|
||||
<button onClick={() => currentTableHookData.refetch?.()} className={styles.retryButton}>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -796,7 +900,7 @@ const PageRenderer: React.FC<PageRendererProps> = ({
|
|||
// Use columns from hook data if available, otherwise use config columns
|
||||
// CRITICAL: Preserve columns even when data is empty (e.g., after filtering)
|
||||
// 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;
|
||||
// Prioritize hookColumns (from attributes) over configColumns to ensure persistence
|
||||
const columns = hookColumns || configCols;
|
||||
|
|
@ -911,30 +1015,31 @@ const PageRenderer: React.FC<PageRendererProps> = ({
|
|||
// Debug logging for table rendering
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🔍 Rendering FormGenerator:', {
|
||||
dataLength: hookData.data?.length || 0,
|
||||
dataLength: currentTableHookData.data?.length || 0,
|
||||
columnsCount: resolvedColumns?.length || 0,
|
||||
loading: showLoadingSpinner,
|
||||
hasError: !!hookData.error,
|
||||
data: hookData.data,
|
||||
hasError: !!currentTableHookData.error,
|
||||
data: currentTableHookData.data,
|
||||
willAutoDetect: !resolvedColumns
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={content.id} className={styles.tableContainer}>
|
||||
{hookData.isRefetching && (
|
||||
{currentTableHookData.isRefetching && (
|
||||
<div className={styles.refetchingIndicator}>
|
||||
Refreshing...
|
||||
</div>
|
||||
)}
|
||||
<FormGenerator
|
||||
data={hookData.data || []}
|
||||
data={currentTableHookData.data || []}
|
||||
columns={resolvedColumns}
|
||||
loading={showLoadingSpinner}
|
||||
actionButtons={formGeneratorActions}
|
||||
hookData={hookData}
|
||||
onDelete={hookData.onDelete}
|
||||
onDeleteMultiple={hookData.onDeleteMultiple}
|
||||
hookData={currentTableHookData}
|
||||
onDelete={currentTableHookData.onDelete}
|
||||
onDeleteMultiple={currentTableHookData.onDeleteMultiple}
|
||||
emptyMessage={emptyMessage}
|
||||
{...tableProps}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -1465,6 +1570,50 @@ const PageRenderer: React.FC<PageRendererProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
case 'tabs': {
|
||||
return (
|
||||
<TabsRenderer
|
||||
key={content.id}
|
||||
content={content}
|
||||
renderContent={renderContent}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case 'columns': {
|
||||
const columnsConfig = content.columnsConfig;
|
||||
if (!columnsConfig || !columnsConfig.columns || columnsConfig.columns.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build grid template columns
|
||||
const gridTemplateColumns = columnsConfig.columns
|
||||
.map(col => col.width || '1fr')
|
||||
.join(' ');
|
||||
const gap = columnsConfig.gap || '1rem';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={content.id}
|
||||
className={styles.columnsContainer}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns,
|
||||
gap
|
||||
}}
|
||||
>
|
||||
{columnsConfig.columns.map((column, colIndex) => (
|
||||
<div key={column.id} className={styles.column}>
|
||||
{column.content.map((nestedContent, index) =>
|
||||
renderContent(nestedContent, `${colIndex}-${index}`)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1738,6 +1887,7 @@ const PageRenderer: React.FC<PageRendererProps> = ({
|
|||
size={button.size || 'md'}
|
||||
icon={button.icon}
|
||||
disabled={disabledValue}
|
||||
multiStep={button.formConfig.multiStep || false}
|
||||
onSuccess={() => {
|
||||
// Refetch data after successful creation
|
||||
if (hookData.refetch) {
|
||||
|
|
|
|||
|
|
@ -1,105 +1,181 @@
|
|||
import { GenericPageData } from '../../pageInterface';
|
||||
import { FaTable } from 'react-icons/fa';
|
||||
import { IoMdSend } from 'react-icons/io';
|
||||
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
|
||||
};
|
||||
};
|
||||
};
|
||||
import { FaTable, FaPlus } from 'react-icons/fa';
|
||||
import { createProjectsTableHook, createParzellenTableHook } from '../../../../hooks/usePekTables';
|
||||
|
||||
export const pekTablesPageData: GenericPageData = {
|
||||
id: 'pek-tables',
|
||||
path: 'start/pek-tables',
|
||||
name: 'PEK Tabellen',
|
||||
description: 'PEK Datenverwaltung mit Tabellen',
|
||||
name: 'Projektmanagement',
|
||||
description: 'Projektmanagement mit Tabellen',
|
||||
|
||||
// Parent page
|
||||
parentPath: 'start',
|
||||
|
||||
// Visual
|
||||
icon: FaTable,
|
||||
title: 'PEK Tabellen',
|
||||
title: 'Projektmanagement',
|
||||
subtitle: 'Datenverwaltung',
|
||||
|
||||
// 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: [
|
||||
{
|
||||
id: 'pek-tables-description',
|
||||
type: 'paragraph',
|
||||
content: 'Verwalten Sie PEK-Daten über Tabellen. Wählen Sie eine Tabelle aus oder verwenden Sie natürliche Sprache, um Befehle auszuführen.'
|
||||
},
|
||||
{
|
||||
id: 'pek-tables-dropdown',
|
||||
type: 'custom',
|
||||
customComponent: PekTablesDropdown
|
||||
},
|
||||
{
|
||||
id: 'pek-tables-command-input',
|
||||
type: 'inputForm',
|
||||
inputFormConfig: {
|
||||
hookFactory: createPekTablesHook,
|
||||
placeholder: 'Befehl eingeben (z.B., "Erstelle ein neues Projekt namens \'Hauptstrasse 42\'")',
|
||||
buttonLabel: 'Senden',
|
||||
buttonIcon: IoMdSend,
|
||||
buttonVariant: 'primary',
|
||||
buttonSize: 'md',
|
||||
textFieldSize: 'md'
|
||||
id: 'projektmanagement-layout',
|
||||
type: 'columns',
|
||||
columnsConfig: {
|
||||
columns: [
|
||||
{
|
||||
id: 'main-column',
|
||||
width: '3fr',
|
||||
content: [
|
||||
{
|
||||
id: 'tables-tabs',
|
||||
type: 'tabs',
|
||||
tabsConfig: {
|
||||
tabs: [
|
||||
{
|
||||
id: 'projects',
|
||||
label: 'Projekte',
|
||||
content: [
|
||||
{
|
||||
id: 'projects-table',
|
||||
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: 'Noch keine Befehle ausgeführt. Senden Sie einen Befehl, um Ergebnisse hier zu sehen.'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'pek-tables-table',
|
||||
type: 'custom',
|
||||
customComponent: PekTablesTable
|
||||
}
|
||||
],
|
||||
|
||||
|
|
@ -112,10 +188,7 @@ export const pekTablesPageData: GenericPageData = {
|
|||
// Sidebar
|
||||
order: 11,
|
||||
showInSidebar: true,
|
||||
|
||||
// Custom component wrapper with PekTablesProvider
|
||||
customComponent: PekTablesPageWrapper,
|
||||
|
||||
|
||||
// Lifecycle hooks
|
||||
onActivate: async () => {
|
||||
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,
|
||||
handleMapClick,
|
||||
handleParcelClick,
|
||||
selectedParcel,
|
||||
selectedParcels,
|
||||
removeParcel,
|
||||
isPanelOpen,
|
||||
setIsPanelOpen
|
||||
} = usePekContext();
|
||||
|
||||
// Aggregate all adjacent parcels from all selected parcels
|
||||
const allAdjacentParcels = React.useMemo(() => {
|
||||
const adjacentSet = new Map<string, any>();
|
||||
selectedParcels.forEach(parcel => {
|
||||
if (parcel.adjacent_parcels) {
|
||||
parcel.adjacent_parcels.forEach(adj => {
|
||||
if (!adjacentSet.has(adj.id)) {
|
||||
adjacentSet.set(adj.id, adj);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return Array.from(adjacentSet.values());
|
||||
}, [selectedParcels]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
|
|
@ -31,8 +47,9 @@ const PekMapView: React.FC = () => {
|
|||
<ParcelInfoPanel
|
||||
isOpen={isPanelOpen}
|
||||
onClose={() => setIsPanelOpen(false)}
|
||||
parcelData={selectedParcel}
|
||||
adjacentParcels={selectedParcel?.adjacent_parcels || []}
|
||||
parcels={selectedParcels}
|
||||
onRemoveParcel={removeParcel}
|
||||
adjacentParcels={allAdjacentParcels}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ export interface PageButton {
|
|||
createOperationName?: string; // Name of the create operation in hookData (e.g., 'handlePromptCreate')
|
||||
successMessage?: string | LanguageText;
|
||||
errorMessage?: string | LanguageText;
|
||||
multiStep?: boolean; // Enable multi-step form mode
|
||||
};
|
||||
// Dropdown configuration for dropdown selection buttons
|
||||
dropdownConfig?: DropdownConfig;
|
||||
|
|
@ -123,7 +124,7 @@ export interface SettingsConfig {
|
|||
// Content section for paragraphs
|
||||
export interface PageContent {
|
||||
id: string;
|
||||
type: 'paragraph' | 'heading' | 'list' | 'code' | 'divider' | 'custom' | 'table' | 'inputForm' | 'messages' | 'settings' | 'log';
|
||||
type: 'paragraph' | 'heading' | 'list' | 'code' | 'divider' | 'custom' | 'table' | 'inputForm' | 'messages' | 'settings' | 'log' | 'tabs' | 'columns';
|
||||
content?: string | LanguageText; // Optional for dividers
|
||||
level?: number; // For headings (1-6)
|
||||
items?: (string | LanguageText)[]; // For lists
|
||||
|
|
@ -148,6 +149,24 @@ export interface PageContent {
|
|||
logConfig?: {
|
||||
emptyMessage?: string | LanguageText;
|
||||
};
|
||||
// Tabs-specific properties
|
||||
tabsConfig?: {
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
// Generic hook interface for data fetching
|
||||
|
|
@ -156,7 +175,15 @@ export interface GenericDataHook {
|
|||
loading: boolean;
|
||||
isRefetching?: boolean; // True when refetching data (keeps existing data visible)
|
||||
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
|
||||
columns?: any[]; // Optional columns configuration
|
||||
// File operations
|
||||
|
|
@ -267,6 +294,7 @@ export interface TableContentConfig {
|
|||
pagination?: boolean;
|
||||
pageSize?: number;
|
||||
className?: string;
|
||||
emptyMessage?: string; // Custom message to display when table is empty
|
||||
}
|
||||
|
||||
// Language-aware text interface
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import api from '../api';
|
||||
import type { MapPoint, ParcelGeometry } from '../components/UiComponents/MapView';
|
||||
import { wgs84ToLV95 } from '../components/UiComponents/MapView/LV95Converter';
|
||||
|
|
@ -134,7 +134,7 @@ export function usePek() {
|
|||
};
|
||||
|
||||
// Parcel search state
|
||||
const [selectedParcel, setSelectedParcel] = useState<ParcelSearchResponse | null>(null);
|
||||
const [selectedParcels, setSelectedParcels] = useState<ParcelSearchResponse[]>([]);
|
||||
const [isSearchingParcel, setIsSearchingParcel] = useState(false);
|
||||
const [parcelSearchError, setParcelSearchError] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -163,6 +163,19 @@ export function usePek() {
|
|||
// Panel state
|
||||
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
|
||||
* Does not fill input fields, directly makes the request
|
||||
|
|
@ -259,166 +272,160 @@ export function usePek() {
|
|||
});
|
||||
}
|
||||
|
||||
// Update selected parcel
|
||||
setSelectedParcel(data);
|
||||
|
||||
// Open panel when parcel is found
|
||||
setIsPanelOpen(true);
|
||||
|
||||
// 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
|
||||
}));
|
||||
// Add parcel to selected parcels array if not already selected
|
||||
// Update geometries within the callback to have access to updated selectedParcels
|
||||
setSelectedParcels(prev => {
|
||||
const exists = prev.some(p => p.parcel.id === data.parcel.id);
|
||||
if (exists) {
|
||||
return prev; // Already selected, don't add again
|
||||
}
|
||||
|
||||
if (mainParcelCoordinates.length > 0) {
|
||||
geometries.push({
|
||||
id: data.parcel.id,
|
||||
egrid: data.parcel.egrid,
|
||||
number: data.parcel.number,
|
||||
coordinates: mainParcelCoordinates,
|
||||
isSelected: true,
|
||||
isAdjacent: false
|
||||
|
||||
const updatedSelectedParcels = [...prev, data];
|
||||
const selectedParcelIds = new Set(updatedSelectedParcels.map(p => p.parcel.id));
|
||||
|
||||
// Update geometries
|
||||
setParcelGeometries(currentGeometries => {
|
||||
const geometryMap = new Map<string, ParcelGeometry>();
|
||||
|
||||
// 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)
|
||||
// Use geometries from the response (no need to fetch separately)
|
||||
if (data.adjacent_parcels && includeAdjacent && data.adjacent_parcels.length > 0) {
|
||||
const adjacentGeometries: ParcelGeometry[] = [];
|
||||
// Main parcel - use geometry_geojson if available, otherwise use perimeter.punkte
|
||||
let mainParcelCoordinates: MapPoint[] = [];
|
||||
|
||||
data.adjacent_parcels.forEach((adjacent) => {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`🔍 Processing adjacent parcel ${adjacent.id}:`, {
|
||||
hasGeometryGeoJson: !!adjacent.geometry_geojson,
|
||||
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[]) => ({
|
||||
if (data.map_view.geometry_geojson?.geometry?.coordinates) {
|
||||
const coords = data.map_view.geometry_geojson.geometry.coordinates[0];
|
||||
if (Array.isArray(coords)) {
|
||||
mainParcelCoordinates = coords.map((coord: number[]) => ({
|
||||
x: coord[0],
|
||||
y: coord[1]
|
||||
}));
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`✅ Extracted ${adjCoordinates.length} coordinates from geometry_geojson for ${adjacent.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback to perimeter.punkte if available
|
||||
else if (adjacent.perimeter?.punkte) {
|
||||
adjCoordinates = adjacent.perimeter.punkte.map((p) => ({
|
||||
} else if (data.parcel.perimeter?.punkte) {
|
||||
mainParcelCoordinates = data.parcel.perimeter.punkte.map((p) => ({
|
||||
x: p.x,
|
||||
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) {
|
||||
adjacentGeometries.push({
|
||||
id: adjacent.id,
|
||||
egrid: adjacent.egrid,
|
||||
number: adjacent.number,
|
||||
coordinates: adjCoordinates,
|
||||
isSelected: false,
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update all geometries: mark selected ones and unmark adjacent for selected ones
|
||||
const updatedGeometries = Array.from(geometryMap.values()).map(geo => {
|
||||
const isSelected = selectedParcelIds.has(geo.id);
|
||||
return {
|
||||
...geo,
|
||||
isSelected,
|
||||
isAdjacent: isSelected ? false : geo.isAdjacent
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`📦 Adjacent parcels summary:`, {
|
||||
requested: data.adjacent_parcels.length,
|
||||
valid: adjacentGeometries.length,
|
||||
geometries: adjacentGeometries.map(g => ({
|
||||
id: g.id,
|
||||
number: g.number,
|
||||
coordCount: g.coordinates.length
|
||||
}))
|
||||
console.log(`🗺️ Total geometries to display: ${updatedGeometries.length}`, {
|
||||
selected: updatedGeometries.filter(g => g.isSelected).length,
|
||||
adjacent: updatedGeometries.filter(g => g.isAdjacent).length
|
||||
});
|
||||
}
|
||||
|
||||
// Add adjacent parcels to geometries array
|
||||
geometries.push(...adjacentGeometries);
|
||||
}
|
||||
|
||||
// Update parcel geometries with all parcels (main + adjacent)
|
||||
setParcelGeometries(geometries);
|
||||
|
||||
return updatedGeometries;
|
||||
});
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`🗺️ Total geometries to display: ${geometries.length}`, {
|
||||
main: geometries.filter(g => g.isSelected).length,
|
||||
adjacent: geometries.filter(g => g.isAdjacent).length
|
||||
});
|
||||
}
|
||||
} 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 updatedSelectedParcels;
|
||||
});
|
||||
|
||||
// Open panel when parcel is found
|
||||
setIsPanelOpen(true);
|
||||
|
||||
return { success: true, data };
|
||||
} 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) => {
|
||||
// Find the clicked parcel in the geometries
|
||||
const clickedParcel = parcelGeometries.find(p => p.id === parcelId);
|
||||
// Check if parcel is already selected
|
||||
const isSelected = isParcelSelected(parcelId);
|
||||
|
||||
if (clickedParcel && clickedParcel.coordinates.length > 0) {
|
||||
// Use a point inside the parcel (first coordinate is always on the boundary, which is inside)
|
||||
// For better accuracy, use a point slightly inside the boundary
|
||||
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
|
||||
if (isSelected) {
|
||||
// Remove from selection
|
||||
removeParcel(parcelId);
|
||||
} else {
|
||||
// Fallback: try to search by parcel ID/EGRID if available
|
||||
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);
|
||||
} else if (adjacentParcel?.number) {
|
||||
// Try searching by number (might need address context)
|
||||
await searchParcel(adjacentParcel.number, true);
|
||||
} else if (adjacentParcel?.id) {
|
||||
// Last resort: try searching by ID
|
||||
await searchParcel(adjacentParcel.id, true);
|
||||
// Find the clicked parcel in the geometries
|
||||
const clickedParcel = parcelGeometries.find(p => p.id === parcelId);
|
||||
|
||||
if (clickedParcel && clickedParcel.coordinates.length > 0) {
|
||||
// Use a point inside the parcel (first coordinate is always on the boundary, which is inside)
|
||||
const firstCoord = clickedParcel.coordinates[0];
|
||||
|
||||
// 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 {
|
||||
// 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
|
||||
|
|
@ -510,26 +551,46 @@ export function usePek() {
|
|||
userInput: userInput.trim()
|
||||
};
|
||||
|
||||
// Always include the currently selected parcel if available
|
||||
if (selectedParcel) {
|
||||
// Always include the currently selected parcels if available
|
||||
if (selectedParcels.length > 0) {
|
||||
// Use first selected parcel for backward compatibility
|
||||
const firstParcel = selectedParcels[0];
|
||||
requestBody.selectedParcel = {
|
||||
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,
|
||||
id: firstParcel.parcel.id,
|
||||
egrid: firstParcel.parcel.egrid,
|
||||
number: firstParcel.parcel.number,
|
||||
name: firstParcel.parcel.name,
|
||||
identnd: firstParcel.parcel.identnd,
|
||||
canton: firstParcel.parcel.canton,
|
||||
municipality_code: firstParcel.parcel.municipality_code,
|
||||
municipality_name: firstParcel.parcel.municipality_name,
|
||||
address: firstParcel.parcel.address,
|
||||
area_m2: firstParcel.parcel.area_m2,
|
||||
centroid: firstParcel.parcel.centroid,
|
||||
geoportal_url: firstParcel.parcel.geoportal_url,
|
||||
realestate_type: firstParcel.parcel.realestate_type,
|
||||
// Include geometry data if available
|
||||
geometry_geojson: selectedParcel.map_view?.geometry_geojson,
|
||||
perimeter: selectedParcel.parcel.perimeter
|
||||
geometry_geojson: firstParcel.map_view?.geometry_geojson,
|
||||
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);
|
||||
|
|
@ -560,8 +621,8 @@ export function usePek() {
|
|||
};
|
||||
setCommandResults((prev) => [...prev, assistantMessage]);
|
||||
|
||||
// If a project was created and there's a selected parcel, automatically add it
|
||||
if (data.success && data.intent === 'CREATE' && data.entity === 'Projekt' && selectedParcel) {
|
||||
// If a project was created and there are selected parcels, automatically add them
|
||||
if (data.success && data.intent === 'CREATE' && data.entity === 'Projekt' && selectedParcels.length > 0) {
|
||||
try {
|
||||
// Extract projekt from result
|
||||
const projektResult = data.result?.result || data.result;
|
||||
|
|
@ -569,42 +630,51 @@ export function usePek() {
|
|||
// Set as current projekt
|
||||
setCurrentProjekt(projektResult);
|
||||
|
||||
// Add the selected parcel to the newly created project via direct API call
|
||||
const addParcelRequestBody: any = {
|
||||
parcelId: selectedParcel.parcel.id,
|
||||
parcelData: {
|
||||
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,
|
||||
geometry_geojson: selectedParcel.map_view?.geometry_geojson,
|
||||
perimeter: selectedParcel.parcel.perimeter
|
||||
// Add all selected parcels to the newly created project via direct API call
|
||||
let addedCount = 0;
|
||||
for (const selectedParcel of selectedParcels) {
|
||||
try {
|
||||
const addParcelRequestBody: any = {
|
||||
parcelId: selectedParcel.parcel.id,
|
||||
parcelData: {
|
||||
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,
|
||||
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(
|
||||
`/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);
|
||||
|
||||
// Update the assistant message to indicate parcel was added
|
||||
// Update the assistant message to indicate parcels were added
|
||||
const parcelText = addedCount === 1 ? 'Parzelle' : 'Parzellen';
|
||||
const updateMessage = {
|
||||
...assistantMessage,
|
||||
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) => {
|
||||
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 (data.success && data.intent === 'CREATE' && data.entity === 'Parzelle' && selectedParcel) {
|
||||
// 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' && selectedParcels.length > 0) {
|
||||
const selectedParcel = selectedParcels[0]; // Use first selected parcel
|
||||
try {
|
||||
// Extract parzelle from result
|
||||
const parzelleResult = data.result?.result || data.result;
|
||||
|
|
@ -747,7 +818,7 @@ export function usePek() {
|
|||
} finally {
|
||||
setIsProcessingCommand(false);
|
||||
}
|
||||
}, [selectedParcel]);
|
||||
}, [selectedParcels]);
|
||||
|
||||
/**
|
||||
* Create a new project
|
||||
|
|
@ -872,10 +943,13 @@ export function usePek() {
|
|||
locationError,
|
||||
|
||||
// Parcel search
|
||||
selectedParcel,
|
||||
selectedParcels,
|
||||
searchParcel,
|
||||
isSearchingParcel,
|
||||
parcelSearchError,
|
||||
removeParcel,
|
||||
clearSelectedParcels,
|
||||
isParcelSelected,
|
||||
|
||||
// Map view
|
||||
mapCenter,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,17 @@
|
|||
import { useState, useCallback, useEffect } from 'react';
|
||||
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
|
||||
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'
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -479,4 +479,18 @@
|
|||
.pageTitle {
|
||||
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