From c5a82dd51837966feb2bf0e44b634079687984a8 Mon Sep 17 00:00:00 2001 From: Ida Dittrich Date: Mon, 5 Jan 2026 18:04:36 +0100 Subject: [PATCH] feat:multiselect parcels and create projects --- .../ViewActionButton/ViewActionButton.tsx | 67 +- .../FormGeneratorControls.tsx | 4 +- .../FormGeneratorTable.module.css | 13 +- .../FormGeneratorTable/FormGeneratorTable.tsx | 100 +- src/components/UiComponents/Button/Button.tsx | 3 +- .../UiComponents/Button/ButtonTypes.ts | 1 + .../CreateButton/CreateButton.module.css | 216 ++++ .../Button/CreateButton/CreateButton.tsx | 463 ++++++++- .../ParcelInfoPanel.module.css | 50 +- .../ParcelInfoPanel/ParcelInfoPanel.tsx | 283 +++--- .../UiComponents/Tabs/Tabs.module.css | 44 + src/components/UiComponents/Tabs/Tabs.tsx | 55 ++ src/components/UiComponents/Tabs/index.ts | 3 + src/components/UiComponents/index.ts | 2 + src/contexts/PekContext.tsx | 5 +- src/core/PageManager/PageRenderer.tsx | 218 ++++- src/core/PageManager/data/pages/pek-tables.ts | 247 +++-- .../PekTablesCollapsable.module.css | 69 -- .../pages/pek-tables/PekTablesCollapsable.tsx | 47 - .../pages/pek-tables/PekTablesDropdown.tsx | 71 -- .../pages/pek-tables/PekTablesPageWrapper.tsx | 18 - .../data/pages/pek-tables/PekTablesTable.tsx | 115 --- .../PageManager/data/pages/pek/PekMapView.tsx | 23 +- src/core/PageManager/pageInterface.ts | 32 +- src/hooks/usePek.ts | 530 +++++----- src/hooks/usePekTables.ts | 920 ++++++++++++++++++ src/styles/pages.module.css | 14 + 27 files changed, 2763 insertions(+), 850 deletions(-) create mode 100644 src/components/UiComponents/Button/CreateButton/CreateButton.module.css create mode 100644 src/components/UiComponents/Tabs/Tabs.module.css create mode 100644 src/components/UiComponents/Tabs/Tabs.tsx create mode 100644 src/components/UiComponents/Tabs/index.ts delete mode 100644 src/core/PageManager/data/pages/pek-tables/PekTablesCollapsable.module.css delete mode 100644 src/core/PageManager/data/pages/pek-tables/PekTablesCollapsable.tsx delete mode 100644 src/core/PageManager/data/pages/pek-tables/PekTablesDropdown.tsx delete mode 100644 src/core/PageManager/data/pages/pek-tables/PekTablesPageWrapper.tsx delete mode 100644 src/core/PageManager/data/pages/pek-tables/PekTablesTable.tsx diff --git a/src/components/FormGenerator/ActionButtons/ViewActionButton/ViewActionButton.tsx b/src/components/FormGenerator/ActionButtons/ViewActionButton/ViewActionButton.tsx index bfc8b2f..b7d918c 100644 --- a/src/components/FormGenerator/ActionButtons/ViewActionButton/ViewActionButton.tsx +++ b/src/components/FormGenerator/ActionButtons/ViewActionButton/ViewActionButton.tsx @@ -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 { @@ -69,6 +70,17 @@ export function ViewActionButton({ // 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 ( <> - {/* Content Preview Component */} - 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 && ( + setIsPopupOpen(false)} + fileId={(row as any)[idField]} + fileName={(row as any)[nameField] || 'Unknown Item'} + mimeType={mimeType} + /> + )} + + {/* Details Popup for non-file entities */} + {!isFile && ( + setIsPopupOpen(false)} + size="large" + > +
+

+ {(row as any)[nameField] || (row as any)[idField] || 'Details'} +

+
+ {Object.entries(row as Record) + .filter(([key]) => !key.startsWith('_') && key !== 'id') + .map(([key, value]) => ( +
+ + {key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1')} + +
+ {formatValue(value)} +
+
+ ))} +
+
+
+ )} ); } diff --git a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx index b657fef..2a6b843 100644 --- a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx +++ b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx @@ -159,7 +159,7 @@ export function FormGeneratorControls({ {/* Delete Controls - Show when items are selected */} {selectable && selectedCount > 0 && (
- {selectedCount === 1 && onDeleteSingle && ( + {selectedCount === 1 && onDeleteSingle && !(selectedCount === _displayData.length && _displayData.length > 0) && (
+ ) : displayData.length === 0 ? ( +
+

{emptyMessage || t('formgen.empty', 'No data available')}

+
) : ( @@ -732,15 +810,9 @@ export function FormGeneratorTable>({ ))} - - {displayData.length === 0 ? ( - - - - ) : ( - displayData.map((row, index) => { + {displayData.length > 0 && ( + + {displayData.map((row, index) => { const dataAttributes = getRowDataAttributes ? getRowDataAttributes(row, index) : {}; return ( >({ ); - }) - )} - + })} + + )}
0 ? 1 : 0)} className={styles.emptyMessage}> - {t('formgen.empty', 'No data available')} -
)} diff --git a/src/components/UiComponents/Button/Button.tsx b/src/components/UiComponents/Button/Button.tsx index 683f528..615150a 100644 --- a/src/components/UiComponents/Button/Button.tsx +++ b/src/components/UiComponents/Button/Button.tsx @@ -31,8 +31,9 @@ const Button: React.FC = ({ ].filter(Boolean).join(' '); // Handle click - const handleClick = () => { + const handleClick = (e: React.MouseEvent) => { if (!disabled && !loading && onClick) { + e.preventDefault(); onClick(); } }; diff --git a/src/components/UiComponents/Button/ButtonTypes.ts b/src/components/UiComponents/Button/ButtonTypes.ts index 3bee1ee..807c9c4 100644 --- a/src/components/UiComponents/Button/ButtonTypes.ts +++ b/src/components/UiComponents/Button/ButtonTypes.ts @@ -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 } \ No newline at end of file diff --git a/src/components/UiComponents/Button/CreateButton/CreateButton.module.css b/src/components/UiComponents/Button/CreateButton/CreateButton.module.css new file mode 100644 index 0000000..9a8e396 --- /dev/null +++ b/src/components/UiComponents/Button/CreateButton/CreateButton.module.css @@ -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; + } +} + diff --git a/src/components/UiComponents/Button/CreateButton/CreateButton.tsx b/src/components/UiComponents/Button/CreateButton/CreateButton.tsx index 6eb81cc..d60dfd4 100644 --- a/src/components/UiComponents/Button/CreateButton/CreateButton.tsx +++ b/src/components/UiComponents/Button/CreateButton/CreateButton.tsx @@ -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>({}); + + // 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 = {}; + + 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 ( +
+
+ 2 + Parzelle hinzufügen +
+ +
+ 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(); + } + }} + /> + +
+ 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(); + } + }} + /> + + 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(); + } + }} + /> +
+
+ + {/* Search buttons */} +
+ + +
+ +
+
+ +
+ + {/* Selected parcels list displayed below map */} + {selectedParcels && selectedParcels.length > 0 && ( +
+

Ausgewählte Parzellen ({selectedParcels.length})

+
+ {selectedParcels.map((selectedParcel, index) => ( +
+
+

+ Parzelle {index + 1}: {selectedParcel.parcel.number || selectedParcel.parcel.id || 'Unbekannt'} +

+ +
+
+ {selectedParcel.parcel.id && ( +
+ ID: + {selectedParcel.parcel.id} +
+ )} + {selectedParcel.parcel.number && ( +
+ Nummer: + {selectedParcel.parcel.number} +
+ )} + {selectedParcel.parcel.egrid && ( +
+ EGRID: + {selectedParcel.parcel.egrid} +
+ )} + {selectedParcel.parcel.address && ( +
+ Adresse: + {selectedParcel.parcel.address} +
+ )} + {selectedParcel.parcel.area_m2 && ( +
+ Fläche (m²): + {selectedParcel.parcel.area_m2} +
+ )} +
+
+ ))} +
+
+ )} + + {step2Errors.parcel && ( + {step2Errors.parcel} + )} +
+ +
+ + +
+
+ ); +}; const CreateButton: React.FC = ({ onCreate, @@ -20,15 +290,32 @@ const CreateButton: React.FC = ({ 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({}); + 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 = ({ 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 = ({ } }); 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 = ({ 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 = ({ 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 = ({ isOpen={isPopupOpen} title={resolvedPopupTitle} onClose={handleCancel} - size={popupSize} + size={multiStep ? 'large' : popupSize} closable={!isCreating} > - + {multiStep ? ( + currentStep === 1 ? ( +
+
+ 1 + Titel festlegen +
+ +
+ ) : ( + + + + ) + ) : ( + + )} ); diff --git a/src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.module.css b/src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.module.css index 496cd69..356dee0 100644 --- a/src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.module.css +++ b/src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.module.css @@ -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 { diff --git a/src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx b/src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx index 8c813ab..0b5f159 100644 --- a/src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx +++ b/src/components/UiComponents/ParcelInfoPanel/ParcelInfoPanel.tsx @@ -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 = ({ isOpen, onClose, - parcelData, + parcels, + onRemoveParcel, adjacentParcels = [] }) => { - if (!parcelData) return null; + if (!parcels || parcels.length === 0) return null; return ( @@ -41,146 +43,163 @@ const ParcelInfoPanel: React.FC = ({ className={styles.panel} >
-

Parzellen-Informationen

+

Parzellen-Informationen ({parcels.length})

- {/* Main Parcel */} -
-

Ausgewählte Parzelle

-
- {parcelData.parcel.id && ( -
- ID: - {parcelData.parcel.id} + {/* Selected Parcels List */} +
+ {parcels.map((parcelData, index) => ( +
+
+

+ Parzelle {index + 1}: {parcelData.parcel.number || parcelData.parcel.id || 'Unbekannt'} +

+ {onRemoveParcel && ( + + )}
- )} - {parcelData.parcel.number && ( -
- Nummer: - {parcelData.parcel.number} -
- )} - {parcelData.parcel.name && ( -
- Name: - {parcelData.parcel.name} -
- )} - {parcelData.parcel.egrid && ( -
- EGRID: - {parcelData.parcel.egrid} -
- )} - {parcelData.parcel.identnd && ( -
- IdentND: - {parcelData.parcel.identnd} -
- )} - {parcelData.parcel.address && ( -
- Adresse: - {parcelData.parcel.address} -
- )} - {parcelData.parcel.canton && ( -
- Kanton: - {parcelData.parcel.canton} -
- )} - {parcelData.parcel.municipality_name && ( -
- Gemeinde: - {parcelData.parcel.municipality_name} -
- )} - {parcelData.parcel.municipality_code && ( -
- Gemeinde-Code: - {parcelData.parcel.municipality_code} -
- )} - {parcelData.parcel.area_m2 !== undefined && ( -
- Fläche: - - {parcelData.parcel.area_m2.toFixed(2)} m² - {parcelData.parcel.area_m2 >= 10000 && ( - - {' '}({(parcelData.parcel.area_m2 / 10000).toFixed(2)} ha) +
+ {parcelData.parcel.id && ( +
+ ID: + {parcelData.parcel.id} +
+ )} + {parcelData.parcel.number && ( +
+ Nummer: + {parcelData.parcel.number} +
+ )} + {parcelData.parcel.name && ( +
+ Name: + {parcelData.parcel.name} +
+ )} + {parcelData.parcel.egrid && ( +
+ EGRID: + {parcelData.parcel.egrid} +
+ )} + {parcelData.parcel.identnd && ( +
+ IdentND: + {parcelData.parcel.identnd} +
+ )} + {parcelData.parcel.address && ( +
+ Adresse: + {parcelData.parcel.address} +
+ )} + {parcelData.parcel.canton && ( +
+ Kanton: + {parcelData.parcel.canton} +
+ )} + {parcelData.parcel.municipality_name && ( +
+ Gemeinde: + {parcelData.parcel.municipality_name} +
+ )} + {parcelData.parcel.municipality_code && ( +
+ Gemeinde-Code: + {parcelData.parcel.municipality_code} +
+ )} + {parcelData.parcel.area_m2 !== undefined && ( +
+ Fläche: + + {parcelData.parcel.area_m2.toFixed(2)} m² + {parcelData.parcel.area_m2 >= 10000 && ( + + {' '}({(parcelData.parcel.area_m2 / 10000).toFixed(2)} ha) + + )} - )} - +
+ )} + {parcelData.parcel.realestate_type && ( +
+ Grundstückstyp: + {parcelData.parcel.realestate_type} +
+ )} + {parcelData.parcel.centroid && ( +
+ Zentrum (LV95): + + {parcelData.parcel.centroid.x.toFixed(2)}, {parcelData.parcel.centroid.y.toFixed(2)} + +
+ )} + {parcelData.parcel.geoportal_url && ( +
+ Geoportal: + + Link öffnen + +
+ )}
- )} - {parcelData.parcel.realestate_type && ( -
- Grundstückstyp: - {parcelData.parcel.realestate_type} -
- )} - {parcelData.parcel.centroid && ( -
- Zentrum (LV95): - - {parcelData.parcel.centroid.x.toFixed(2)}, {parcelData.parcel.centroid.y.toFixed(2)} - -
- )} - {parcelData.parcel.geoportal_url && ( -
- Geoportal: - - Link öffnen - -
- )} -
-
- {/* Map View Info */} - {parcelData.map_view && ( -
-

Kartenansicht

-
- {parcelData.map_view.center && ( -
- Zentrum: - - {parcelData.map_view.center.x.toFixed(2)}, {parcelData.map_view.center.y.toFixed(2)} - + {/* Map View Info for this parcel */} + {parcelData.map_view && ( +
+

Kartenansicht

+
+ {parcelData.map_view.center && ( +
+ Zentrum: + + {parcelData.map_view.center.x.toFixed(2)}, {parcelData.map_view.center.y.toFixed(2)} + +
+ )} + {parcelData.map_view.zoom_bounds && ( + <> +
+ Bounds Min: + + {parcelData.map_view.zoom_bounds.min_x.toFixed(2)}, {parcelData.map_view.zoom_bounds.min_y.toFixed(2)} + +
+
+ Bounds Max: + + {parcelData.map_view.zoom_bounds.max_x.toFixed(2)}, {parcelData.map_view.zoom_bounds.max_y.toFixed(2)} + +
+ + )} +
)} - {parcelData.map_view.zoom_bounds && ( - <> -
- Bounds Min: - - {parcelData.map_view.zoom_bounds.min_x.toFixed(2)}, {parcelData.map_view.zoom_bounds.min_y.toFixed(2)} - -
-
- Bounds Max: - - {parcelData.map_view.zoom_bounds.max_x.toFixed(2)}, {parcelData.map_view.zoom_bounds.max_y.toFixed(2)} - -
- - )} -
-
- )} +
+ ))} +
{/* Adjacent Parcels */} {adjacentParcels.length > 0 && ( diff --git a/src/components/UiComponents/Tabs/Tabs.module.css b/src/components/UiComponents/Tabs/Tabs.module.css new file mode 100644 index 0000000..9348d1e --- /dev/null +++ b/src/components/UiComponents/Tabs/Tabs.module.css @@ -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%; +} + diff --git a/src/components/UiComponents/Tabs/Tabs.tsx b/src/components/UiComponents/Tabs/Tabs.tsx new file mode 100644 index 0000000..8b30ffd --- /dev/null +++ b/src/components/UiComponents/Tabs/Tabs.tsx @@ -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( + 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 ( +
+
+ {tabs.map(tab => ( + + ))} +
+
+ {activeTab && activeTab.content} +
+
+ ); +} + +export default Tabs; + diff --git a/src/components/UiComponents/Tabs/index.ts b/src/components/UiComponents/Tabs/index.ts new file mode 100644 index 0000000..096ac2f --- /dev/null +++ b/src/components/UiComponents/Tabs/index.ts @@ -0,0 +1,3 @@ +export { Tabs, default } from './Tabs'; +export type { TabsProps, Tab } from './Tabs'; + diff --git a/src/components/UiComponents/index.ts b/src/components/UiComponents/index.ts index 51a83fc..816fc3e 100644 --- a/src/components/UiComponents/index.ts +++ b/src/components/UiComponents/index.ts @@ -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'; diff --git a/src/contexts/PekContext.tsx b/src/contexts/PekContext.tsx index 2249b7e..fbd3e06 100644 --- a/src/contexts/PekContext.tsx +++ b/src/contexts/PekContext.tsx @@ -18,10 +18,13 @@ interface PekContextType { locationError: string | null; // Parcel search - selectedParcel: any; + selectedParcels: any[]; searchParcel: (location: string, includeAdjacent?: boolean) => Promise; isSearchingParcel: boolean; parcelSearchError: string | null; + removeParcel: (parcelId: string) => void; + clearSelectedParcels: () => void; + isParcelSelected: (parcelId: string) => boolean; // Map view mapCenter: any; diff --git a/src/core/PageManager/PageRenderer.tsx b/src/core/PageManager/PageRenderer.tsx index aebb03d..10651d9 100644 --- a/src/core/PageManager/PageRenderer.tsx +++ b/src/core/PageManager/PageRenderer.tsx @@ -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 ( + + ); +}; + // 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 = ({ pageData, onButtonClick @@ -267,27 +327,65 @@ const PageRenderer: React.FC = ({ 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(); + 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 = ({ 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 (
-

Error loading data: {hookData.error}

- {hookData.refetch && ( - )} @@ -796,7 +900,7 @@ const PageRenderer: React.FC = ({ // 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 = ({ // 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 (
- {hookData.isRefetching && ( + {currentTableHookData.isRefetching && (
Refreshing...
)}
@@ -1465,6 +1570,50 @@ const PageRenderer: React.FC = ({ ); } + case 'tabs': { + return ( + + ); + } + + 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 ( +
+ {columnsConfig.columns.map((column, colIndex) => ( +
+ {column.content.map((nestedContent, index) => + renderContent(nestedContent, `${colIndex}-${index}`) + )} +
+ ))} +
+ ); + } + default: return null; } @@ -1738,6 +1887,7 @@ const PageRenderer: React.FC = ({ size={button.size || 'md'} icon={button.icon} disabled={disabledValue} + multiStep={button.formConfig.multiStep || false} onSuccess={() => { // Refetch data after successful creation if (hookData.refetch) { diff --git a/src/core/PageManager/data/pages/pek-tables.ts b/src/core/PageManager/data/pages/pek-tables.ts index f7ece04..205d08c 100644 --- a/src/core/PageManager/data/pages/pek-tables.ts +++ b/src/core/PageManager/data/pages/pek-tables.ts @@ -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'); diff --git a/src/core/PageManager/data/pages/pek-tables/PekTablesCollapsable.module.css b/src/core/PageManager/data/pages/pek-tables/PekTablesCollapsable.module.css deleted file mode 100644 index dec8245..0000000 --- a/src/core/PageManager/data/pages/pek-tables/PekTablesCollapsable.module.css +++ /dev/null @@ -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; - } -} - diff --git a/src/core/PageManager/data/pages/pek-tables/PekTablesCollapsable.tsx b/src/core/PageManager/data/pages/pek-tables/PekTablesCollapsable.tsx deleted file mode 100644 index 27e57ad..0000000 --- a/src/core/PageManager/data/pages/pek-tables/PekTablesCollapsable.tsx +++ /dev/null @@ -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 = ({ - title = 'Tabellenansicht', - children, - defaultCollapsed = false -}) => { - const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed); - - const toggleCollapse = () => { - setIsCollapsed(!isCollapsed); - }; - - return ( -
- - {!isCollapsed && ( -
- {children} -
- )} -
- ); -}; - -export default PekTablesCollapsable; - diff --git a/src/core/PageManager/data/pages/pek-tables/PekTablesDropdown.tsx b/src/core/PageManager/data/pages/pek-tables/PekTablesDropdown.tsx deleted file mode 100644 index a3687d6..0000000 --- a/src/core/PageManager/data/pages/pek-tables/PekTablesDropdown.tsx +++ /dev/null @@ -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 ( -
- - - {tablesError && ( -
- {tablesError} -
- )} -
- ); -}; - -export default PekTablesDropdown; - diff --git a/src/core/PageManager/data/pages/pek-tables/PekTablesPageWrapper.tsx b/src/core/PageManager/data/pages/pek-tables/PekTablesPageWrapper.tsx deleted file mode 100644 index e65e580..0000000 --- a/src/core/PageManager/data/pages/pek-tables/PekTablesPageWrapper.tsx +++ /dev/null @@ -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 ( - - - - ); -}; - -export default PekTablesPageWrapper; - diff --git a/src/core/PageManager/data/pages/pek-tables/PekTablesTable.tsx b/src/core/PageManager/data/pages/pek-tables/PekTablesTable.tsx deleted file mode 100644 index 067ac7b..0000000 --- a/src/core/PageManager/data/pages/pek-tables/PekTablesTable.tsx +++ /dev/null @@ -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 ( -
- - {!isCollapsed && ( -
-
-

Fehler beim Laden der Daten: {tableDataError}

- {refreshTableData && ( - - )} -
-
- )} -
- ); - } - - // Only show loading spinner on initial load (when there's no data yet) - const showLoadingSpinner = isLoadingTableData && (!tableData || tableData.length === 0); - - return ( -
- - {!isCollapsed && ( -
- {tableData && tableData.length > 0 ? ( - - ) : isLoadingTableData ? ( -
-
Lade Daten...
-
- ) : ( -
- Keine Daten verfügbar. Bitte wählen Sie eine Tabelle aus. -
- )} -
- )} -
- ); -}; - -export default PekTablesTable; - diff --git a/src/core/PageManager/data/pages/pek/PekMapView.tsx b/src/core/PageManager/data/pages/pek/PekMapView.tsx index e6b9a52..45663ab 100644 --- a/src/core/PageManager/data/pages/pek/PekMapView.tsx +++ b/src/core/PageManager/data/pages/pek/PekMapView.tsx @@ -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(); + 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 ( <>
@@ -31,8 +47,9 @@ const PekMapView: React.FC = () => { setIsPanelOpen(false)} - parcelData={selectedParcel} - adjacentParcels={selectedParcel?.adjacent_parcels || []} + parcels={selectedParcels} + onRemoveParcel={removeParcel} + adjacentParcels={allAdjacentParcels} /> ); diff --git a/src/core/PageManager/pageInterface.ts b/src/core/PageManager/pageInterface.ts index 5c8d9c9..e50cf26 100644 --- a/src/core/PageManager/pageInterface.ts +++ b/src/core/PageManager/pageInterface.ts @@ -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; + refetch?: (params?: { page?: number; pageSize?: number; sort?: Array<{field: string; direction: 'asc' | 'desc'}>; filters?: any; search?: string }) => Promise; + 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 diff --git a/src/hooks/usePek.ts b/src/hooks/usePek.ts index 3758336..10dfb7d 100644 --- a/src/hooks/usePek.ts +++ b/src/hooks/usePek.ts @@ -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(null); + const [selectedParcels, setSelectedParcels] = useState([]); const [isSearchingParcel, setIsSearchingParcel] = useState(false); const [parcelSearchError, setParcelSearchError] = useState(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(); + + // 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, diff --git a/src/hooks/usePekTables.ts b/src/hooks/usePekTables.ts index 7bd264c..9aa49f1 100644 --- a/src/hooks/usePekTables.ts +++ b/src/hooks/usePekTables.ts @@ -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([]); + const [isLoadingTableData, setIsLoadingTableData] = useState(false); + const [tableDataError, setTableDataError] = useState(null); + const [pagination, setPagination] = useState(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([]); + const [isLoadingTableData, setIsLoadingTableData] = useState(false); + const [tableDataError, setTableDataError] = useState(null); + const [pagination, setPagination] = useState(null); + const [attributes, setAttributes] = useState([]); + const [permissions, setPermissions] = useState(null); + const [editingProjects, setEditingProjects] = useState>(new Set()); + const [deletingProjects, setDeletingProjects] = useState>(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 => { + 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 => { + 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([]); + const [isLoadingTableData, setIsLoadingTableData] = useState(false); + const [tableDataError, setTableDataError] = useState(null); + const [pagination, setPagination] = useState(null); + const [attributes, setAttributes] = useState([]); + const [permissions, setPermissions] = useState(null); + const [editingParzellen, setEditingParzellen] = useState>(new Set()); + const [deletingParzellen, setDeletingParzellen] = useState>(new Set()); + const [viewingParzellen, setViewingParzellen] = useState>(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 => { + 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 => { + 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 => { + 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' + }; + }; +} + diff --git a/src/styles/pages.module.css b/src/styles/pages.module.css index fc40b69..0c52773 100644 --- a/src/styles/pages.module.css +++ b/src/styles/pages.module.css @@ -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 */ } \ No newline at end of file