feat:multiselect parcels and create projects

This commit is contained in:
Ida Dittrich 2026-01-05 18:04:36 +01:00
parent 407a3c4b82
commit c5a82dd518
27 changed files with 2763 additions and 850 deletions

View file

@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { IoIosEye } from 'react-icons/io'; import { IoIosEye } from 'react-icons/io';
import { useLanguage } from '../../../../providers/language/LanguageContext'; import { useLanguage } from '../../../../providers/language/LanguageContext';
import { ContentPreview } from '../../../ContentPreview'; import { ContentPreview } from '../../../ContentPreview';
import { Popup } from '../../../UiComponents/Popup';
import styles from '../ActionButton.module.css'; import styles from '../ActionButton.module.css';
export interface ViewActionButtonProps<T = any> { export interface ViewActionButtonProps<T = any> {
@ -69,6 +70,17 @@ export function ViewActionButton<T = any>({
// Determine the final button title (tooltip) // Determine the final button title (tooltip)
const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle; const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle;
// Check if this is a file (has mimeType) or a regular entity (show details)
const mimeType = (row as any)[typeField];
const isFile = mimeType && typeof mimeType === 'string' && mimeType.length > 0;
// Format row data for display
const formatValue = (value: any): string => {
if (value === null || value === undefined) return '-';
if (typeof value === 'object') return JSON.stringify(value, null, 2);
return String(value);
};
return ( return (
<> <>
<button <button
@ -82,14 +94,53 @@ export function ViewActionButton<T = any>({
</span> </span>
</button> </button>
{/* Content Preview Component */} {/* Content Preview Component for files */}
{isFile && (
<ContentPreview <ContentPreview
isOpen={isPopupOpen} isOpen={isPopupOpen}
onClose={() => setIsPopupOpen(false)} onClose={() => setIsPopupOpen(false)}
fileId={(row as any)[idField]} fileId={(row as any)[idField]}
fileName={(row as any)[nameField] || 'Unknown Item'} fileName={(row as any)[nameField] || 'Unknown Item'}
mimeType={(row as any)[typeField]} mimeType={mimeType}
/> />
)}
{/* Details Popup for non-file entities */}
{!isFile && (
<Popup
isOpen={isPopupOpen}
title={t('common.details', 'Details')}
onClose={() => setIsPopupOpen(false)}
size="large"
>
<div style={{ padding: '20px' }}>
<h3 style={{ marginBottom: '20px', fontSize: '1.2rem', fontWeight: 'bold' }}>
{(row as any)[nameField] || (row as any)[idField] || 'Details'}
</h3>
<div style={{ display: 'grid', gap: '15px' }}>
{Object.entries(row as Record<string, any>)
.filter(([key]) => !key.startsWith('_') && key !== 'id')
.map(([key, value]) => (
<div key={key} style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
<strong style={{ fontSize: '0.9rem', color: 'var(--color-text-secondary, #666)' }}>
{key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1')}
</strong>
<div style={{
padding: '8px 12px',
backgroundColor: 'var(--color-background-secondary, #f5f5f5)',
borderRadius: '4px',
fontSize: '0.95rem',
wordBreak: 'break-word',
whiteSpace: 'pre-wrap'
}}>
{formatValue(value)}
</div>
</div>
))}
</div>
</div>
</Popup>
)}
</> </>
); );
} }

View file

@ -159,7 +159,7 @@ export function FormGeneratorControls({
{/* Delete Controls - Show when items are selected */} {/* Delete Controls - Show when items are selected */}
{selectable && selectedCount > 0 && ( {selectable && selectedCount > 0 && (
<div className={styles.deleteControlsIntegrated}> <div className={styles.deleteControlsIntegrated}>
{selectedCount === 1 && onDeleteSingle && ( {selectedCount === 1 && onDeleteSingle && !(selectedCount === _displayData.length && _displayData.length > 0) && (
<Button <Button
onClick={onDeleteSingle} onClick={onDeleteSingle}
variant="primary" variant="primary"
@ -169,7 +169,7 @@ export function FormGeneratorControls({
{t('formgen.delete.single', 'Delete')} {t('formgen.delete.single', 'Delete')}
</Button> </Button>
)} )}
{selectedCount > 1 && onDeleteMultiple && ( {(selectedCount > 1 || (selectedCount === _displayData.length && _displayData.length > 0)) && onDeleteMultiple && (
<Button <Button
onClick={onDeleteMultiple} onClick={onDeleteMultiple}
variant="primary" variant="primary"

View file

@ -37,12 +37,23 @@
max-height: none; max-height: none;
} }
/* Empty state styling */
.emptyState {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
padding: 40px 20px;
}
/* Empty message styling */ /* Empty message styling */
.emptyMessage { .emptyMessage {
text-align: center; text-align: center;
padding: 20px; padding: 20px;
color: var(--color-text); color: var(--color-text);
opacity: 0.6; opacity: 0.7;
font-size: 1rem;
line-height: 1.5;
} }
.loading { .loading {

View file

@ -81,6 +81,8 @@ export interface FormGeneratorTableProps<T = any> {
getRowDataAttributes?: (row: T, index: number) => Record<string, string>; getRowDataAttributes?: (row: T, index: number) => Record<string, string>;
// For passing hook data to action buttons // For passing hook data to action buttons
hookData?: any; // Contains all hook data: refetch, operations, loading states, etc. hookData?: any; // Contains all hook data: refetch, operations, loading states, etc.
// Custom empty message when table is empty
emptyMessage?: string;
} }
export function FormGeneratorTable<T extends Record<string, any>>({ export function FormGeneratorTable<T extends Record<string, any>>({
@ -105,7 +107,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
onRefresh, onRefresh,
className = '', className = '',
getRowDataAttributes, getRowDataAttributes,
hookData hookData,
emptyMessage
}: FormGeneratorTableProps<T>) { }: FormGeneratorTableProps<T>) {
const { t } = useLanguage(); const { t } = useLanguage();
// Use provided columns (from attributes) if available, otherwise auto-detect from data // Use provided columns (from attributes) if available, otherwise auto-detect from data
@ -520,6 +523,66 @@ export function FormGeneratorTable<T extends Record<string, any>>({
} }
} }
// Handle objects/arrays (e.g., references to other entities)
// Check if value is an object (but not Date, Array, or null)
if (typeof value === 'object' && value !== null && !(value instanceof Date) && !Array.isArray(value)) {
// Try to find a display field in common order: label, name, title, id
const displayFields = ['label', 'name', 'title', 'id', 'value', 'text'];
for (const field of displayFields) {
if (value[field] !== undefined && value[field] !== null) {
const displayValue = value[field];
// If the display value is itself an object, try to stringify it nicely
if (typeof displayValue === 'object' && displayValue !== null) {
try {
return JSON.stringify(displayValue);
} catch {
return String(displayValue);
}
}
return String(displayValue);
}
}
// If no display field found, try to stringify the object (limited to avoid huge output)
try {
const stringified = JSON.stringify(value);
// Truncate if too long
if (stringified.length > 100) {
return stringified.substring(0, 97) + '...';
}
return stringified;
} catch {
// If stringification fails, show object type
return `[${value.constructor?.name || 'Object'}]`;
}
}
// Handle arrays (e.g., multiselect values)
if (Array.isArray(value)) {
if (value.length === 0) {
return '-';
}
// If array contains objects, try to extract display values
const displayValues = value.map(item => {
if (typeof item === 'object' && item !== null) {
// Try to find a display field
const displayFields = ['label', 'name', 'title', 'id', 'value', 'text'];
for (const field of displayFields) {
if (item[field] !== undefined && item[field] !== null) {
return String(item[field]);
}
}
// Fallback to stringified object
try {
return JSON.stringify(item);
} catch {
return String(item);
}
}
return String(item);
});
return displayValues.join(', ');
}
// Use custom formatter if provided (but only if not an ID/hash field) // Use custom formatter if provided (but only if not an ID/hash field)
if (column.formatter) { if (column.formatter) {
return column.formatter(value, row); return column.formatter(value, row);
@ -656,7 +719,18 @@ export function FormGeneratorTable<T extends Record<string, any>>({
const selectedRow = displayData[selectedIndex]; const selectedRow = displayData[selectedIndex];
handleDeleteSingle(selectedRow, selectedIndex); handleDeleteSingle(selectedRow, selectedIndex);
} : undefined} } : undefined}
onDeleteMultiple={selectedRows.size > 1 && onDeleteMultiple ? handleDeleteMultiple : undefined} onDeleteMultiple={(() => {
if (!onDeleteMultiple) return undefined;
// Show delete multiple button if:
// 1. More than 1 item is selected, OR
// 2. All selectable items are selected (even if it's just 1)
const selectableIndices = displayData
.map((row, index) => ({ row, index }))
.filter(({ row }) => !isRowSelectable || isRowSelectable(row))
.map(({ index }) => index);
const allSelected = selectedRows.size === selectableIndices.length && selectableIndices.length > 0;
return (selectedRows.size > 1 || allSelected) ? handleDeleteMultiple : undefined;
})()}
onRefresh={onRefresh} onRefresh={onRefresh}
searchable={searchable} searchable={searchable}
filterable={filterable} filterable={filterable}
@ -673,6 +747,10 @@ export function FormGeneratorTable<T extends Record<string, any>>({
<div className={styles.loadingSpinner}></div> <div className={styles.loadingSpinner}></div>
<p>{t('common.loading', 'Loading...')}</p> <p>{t('common.loading', 'Loading...')}</p>
</div> </div>
) : displayData.length === 0 ? (
<div className={styles.emptyState}>
<p className={styles.emptyMessage}>{emptyMessage || t('formgen.empty', 'No data available')}</p>
</div>
) : ( ) : (
<table ref={tableRef} className={styles.table}> <table ref={tableRef} className={styles.table}>
<thead> <thead>
@ -732,15 +810,9 @@ export function FormGeneratorTable<T extends Record<string, any>>({
))} ))}
</tr> </tr>
</thead> </thead>
{displayData.length > 0 && (
<tbody> <tbody>
{displayData.length === 0 ? ( {displayData.map((row, index) => {
<tr>
<td colSpan={detectedColumns.length + (selectable ? 1 : 0) + (actionButtons.length > 0 ? 1 : 0)} className={styles.emptyMessage}>
{t('formgen.empty', 'No data available')}
</td>
</tr>
) : (
displayData.map((row, index) => {
const dataAttributes = getRowDataAttributes ? getRowDataAttributes(row, index) : {}; const dataAttributes = getRowDataAttributes ? getRowDataAttributes(row, index) : {};
return ( return (
<tr <tr
@ -870,9 +942,9 @@ export function FormGeneratorTable<T extends Record<string, any>>({
</tr> </tr>
); );
}) })}
)}
</tbody> </tbody>
)}
</table> </table>
)} )}
</div> </div>

View file

@ -31,8 +31,9 @@ const Button: React.FC<ButtonProps> = ({
].filter(Boolean).join(' '); ].filter(Boolean).join(' ');
// Handle click // Handle click
const handleClick = () => { const handleClick = (e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
if (!disabled && !loading && onClick) { if (!disabled && !loading && onClick) {
e.preventDefault();
onClick(); onClick();
} }
}; };

View file

@ -52,4 +52,5 @@ export interface CreateButtonProps extends BaseButtonProps {
iconPosition?: 'left' | 'right'; iconPosition?: 'left' | 'right';
onSuccess?: (result: any) => void; onSuccess?: (result: any) => void;
onError?: (error: string) => void; onError?: (error: string) => void;
multiStep?: boolean; // Enable multi-step form mode
} }

View file

@ -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;
}
}

View file

@ -4,6 +4,276 @@ import Button from '../Button';
import { Popup } from '../../Popup'; import { Popup } from '../../Popup';
import { FormGeneratorForm, AttributeDefinition } from '../../../FormGenerator/FormGeneratorForm'; import { FormGeneratorForm, AttributeDefinition } from '../../../FormGenerator/FormGeneratorForm';
import { useLanguage } from '../../../../providers/language/LanguageContext'; import { useLanguage } from '../../../../providers/language/LanguageContext';
import { TextField } from '../../TextField';
import { PekProvider } from '../../../../contexts/PekContext';
import { MapView } from '../../MapView';
import { usePekContext } from '../../../../contexts/PekContext';
import { FaLocationArrow, FaTimes } from 'react-icons/fa';
import { IoMdSend } from 'react-icons/io';
import styles from './CreateButton.module.css';
// Step 2 component for parcel selection (must be inside PekProvider)
const Step2Content: React.FC<{
onNext: (data: any) => void;
onBack: () => void;
addressData: {
street: string;
postalCode: string;
city: string;
};
onAddressChange: (field: string, value: string) => void;
}> = ({ onNext, onBack, addressData, onAddressChange }) => {
const { t } = useLanguage();
const {
selectedParcels,
searchParcel,
useCurrentLocation,
isGettingLocation,
isSearchingParcel,
setAdresse,
mapCenter,
mapZoomBounds,
parcelGeometries,
handleMapClick,
handleParcelClick,
removeParcel,
setIsPanelOpen
} = usePekContext();
const [step2Errors, setStep2Errors] = useState<Record<string, string>>({});
// Prevent panel from opening when parcel is clicked
React.useEffect(() => {
setIsPanelOpen(false);
}, [selectedParcels, setIsPanelOpen]);
// Build location string from address fields
const buildLocationString = () => {
const parts = [];
if (addressData.street.trim()) parts.push(addressData.street.trim());
if (addressData.postalCode.trim()) parts.push(addressData.postalCode.trim());
if (addressData.city.trim()) parts.push(addressData.city.trim());
return parts.join(', ');
};
const handleSearch = async () => {
const locationString = buildLocationString();
if (locationString.trim()) {
// Update the adresse field in context for consistency
setAdresse(locationString);
await searchParcel(locationString.trim(), true);
}
};
const handleUseCurrentLocation = async () => {
await useCurrentLocation();
};
const handleNext = () => {
const newErrors: Record<string, string> = {};
if (!addressData.street.trim()) {
newErrors.street = t('formgen.form.required', 'Strasse und Hausnummer ist erforderlich');
}
if (!addressData.postalCode.trim()) {
newErrors.postalCode = t('formgen.form.required', 'Postleitzahl ist erforderlich');
}
if (!addressData.city.trim()) {
newErrors.city = t('formgen.form.required', 'Stadt ist erforderlich');
}
if (!selectedParcels || selectedParcels.length === 0) {
newErrors.parcel = t('formgen.form.required', 'Bitte wählen Sie mindestens eine Parzelle aus');
}
setStep2Errors(newErrors);
if (Object.keys(newErrors).length === 0) {
onNext({
address: addressData,
parzellen: selectedParcels
});
}
};
return (
<div className={styles.step2Container}>
<div className={styles.stepIndicator}>
<span className={styles.stepNumber}>2</span>
<span className={styles.stepLabel}>Parzelle hinzufügen</span>
</div>
<div className={styles.addressFields}>
<TextField
value={addressData.street}
onChange={(value) => onAddressChange('street', value)}
label="Strasse und Hausnummer"
placeholder="z.B. Bundesplatz 3"
required
error={step2Errors.street}
size="md"
type="text"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSearch();
}
}}
/>
<div className={styles.addressRow}>
<TextField
value={addressData.postalCode}
onChange={(value) => onAddressChange('postalCode', value)}
label="Postleitzahl"
placeholder="z.B. 3000"
required
error={step2Errors.postalCode}
size="md"
type="text"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSearch();
}
}}
/>
<TextField
value={addressData.city}
onChange={(value) => onAddressChange('city', value)}
label="Stadt"
placeholder="z.B. Bern"
required
error={step2Errors.city}
size="md"
type="text"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSearch();
}
}}
/>
</div>
</div>
{/* Search buttons */}
<div className={styles.searchButtons}>
<Button
variant="primary"
size="md"
icon={IoMdSend}
onClick={handleSearch}
disabled={!buildLocationString().trim() || isGettingLocation || isSearchingParcel}
loading={isSearchingParcel}
>
Suchen
</Button>
<Button
variant="secondary"
size="md"
icon={FaLocationArrow}
onClick={handleUseCurrentLocation}
disabled={isGettingLocation || isSearchingParcel}
loading={isGettingLocation}
>
Meine Position
</Button>
</div>
<div className={styles.mapSection}>
<div className={styles.mapContainer}>
<MapView
parcels={parcelGeometries}
center={mapCenter || undefined}
zoomBounds={mapZoomBounds || undefined}
onMapClick={handleMapClick}
onParcelClick={handleParcelClick}
height="400px"
emptyMessage="Klicken Sie auf die Karte, um einen Standort auszuwählen, oder suchen Sie nach einer Adresse oben."
/>
</div>
{/* Selected parcels list displayed below map */}
{selectedParcels && selectedParcels.length > 0 && (
<div className={styles.parcelInfo}>
<h3 className={styles.parcelInfoTitle}>Ausgewählte Parzellen ({selectedParcels.length})</h3>
<div className={styles.selectedParcelsList}>
{selectedParcels.map((selectedParcel, index) => (
<div key={selectedParcel.parcel.id || index} className={styles.selectedParcelCard}>
<div className={styles.selectedParcelHeader}>
<h4 className={styles.selectedParcelTitle}>
Parzelle {index + 1}: {selectedParcel.parcel.number || selectedParcel.parcel.id || 'Unbekannt'}
</h4>
<button
className={styles.removeParcelButton}
onClick={() => removeParcel(selectedParcel.parcel.id)}
title="Parzelle entfernen"
>
<FaTimes />
</button>
</div>
<div className={styles.parcelInfoGrid}>
{selectedParcel.parcel.id && (
<div className={styles.parcelInfoItem}>
<span className={styles.parcelInfoLabel}>ID:</span>
<span className={styles.parcelInfoValue}>{selectedParcel.parcel.id}</span>
</div>
)}
{selectedParcel.parcel.number && (
<div className={styles.parcelInfoItem}>
<span className={styles.parcelInfoLabel}>Nummer:</span>
<span className={styles.parcelInfoValue}>{selectedParcel.parcel.number}</span>
</div>
)}
{selectedParcel.parcel.egrid && (
<div className={styles.parcelInfoItem}>
<span className={styles.parcelInfoLabel}>EGRID:</span>
<span className={styles.parcelInfoValue}>{selectedParcel.parcel.egrid}</span>
</div>
)}
{selectedParcel.parcel.address && (
<div className={styles.parcelInfoItem}>
<span className={styles.parcelInfoLabel}>Adresse:</span>
<span className={styles.parcelInfoValue}>{selectedParcel.parcel.address}</span>
</div>
)}
{selectedParcel.parcel.area_m2 && (
<div className={styles.parcelInfoItem}>
<span className={styles.parcelInfoLabel}>Fläche (m²):</span>
<span className={styles.parcelInfoValue}>{selectedParcel.parcel.area_m2}</span>
</div>
)}
</div>
</div>
))}
</div>
</div>
)}
{step2Errors.parcel && (
<span className={styles.errorText}>{step2Errors.parcel}</span>
)}
</div>
<div className={styles.buttonGroup}>
<Button
variant="secondary"
size="md"
onClick={onBack}
>
{t('common.back', 'Zurück')}
</Button>
<Button
variant="primary"
size="md"
onClick={handleNext}
>
{t('common.finish', 'Abschließen')}
</Button>
</div>
</div>
);
};
const CreateButton: React.FC<CreateButtonProps> = ({ const CreateButton: React.FC<CreateButtonProps> = ({
onCreate, onCreate,
@ -20,15 +290,32 @@ const CreateButton: React.FC<CreateButtonProps> = ({
size = 'md', size = 'md',
onSuccess, onSuccess,
onError, onError,
multiStep = false,
...props ...props
}) => { }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [isPopupOpen, setIsPopupOpen] = useState(false); const [isPopupOpen, setIsPopupOpen] = useState(false);
const [currentStep, setCurrentStep] = useState<1 | 2>(1);
const [step1Data, setStep1Data] = useState<any>({});
const [addressData, setAddressData] = useState({
street: '',
postalCode: '',
city: ''
});
// Filter fields for multi-step: Step 1 only shows "label" field
const step1Fields = useMemo(() => {
if (multiStep) {
return fields.filter(field => field.key === 'label');
}
return fields;
}, [fields, multiStep]);
// Convert CreateButtonFieldConfig to AttributeDefinition format // Convert CreateButtonFieldConfig to AttributeDefinition format
const attributes: AttributeDefinition[] = useMemo(() => { const attributes: AttributeDefinition[] = useMemo(() => {
return fields.map(field => { const fieldsToUse = multiStep ? step1Fields : fields;
return fieldsToUse.map(field => {
// Convert options to AttributeOption[] format // Convert options to AttributeOption[] format
let options: AttributeDefinition['options'] = undefined; let options: AttributeDefinition['options'] = undefined;
@ -84,12 +371,13 @@ const CreateButton: React.FC<CreateButtonProps> = ({
options: options options: options
}; };
}); });
}, [fields]); }, [fields, multiStep, step1Fields]);
// Initialize form data with default values // Initialize form data with default values
const initialFormData = useMemo(() => { const initialFormData = useMemo(() => {
const data: any = {}; const data: any = {};
fields.forEach(field => { const fieldsToUse = multiStep ? step1Fields : fields;
fieldsToUse.forEach(field => {
if (field.type === 'multiselect') { if (field.type === 'multiselect') {
// Multiselect fields should default to empty array // Multiselect fields should default to empty array
data[field.key] = field.defaultValue || []; data[field.key] = field.defaultValue || [];
@ -102,15 +390,116 @@ const CreateButton: React.FC<CreateButtonProps> = ({
} }
}); });
return data; return data;
}, [fields]); }, [fields, multiStep, step1Fields]);
const handleButtonClick = () => { const handleButtonClick = () => {
if (!disabled && !loading && !isCreating) { if (!disabled && !loading && !isCreating) {
setIsPopupOpen(true); setIsPopupOpen(true);
// Reset to step 1 when opening popup
if (multiStep) {
setCurrentStep(1);
setStep1Data({});
setAddressData({ street: '', postalCode: '', city: '' });
}
}
};
const handleStep1Next = (formData: any) => {
// Validate label is present
if (!formData.label || !formData.label.trim()) {
return; // FormGeneratorForm will show validation error
}
setStep1Data(formData);
setCurrentStep(2);
};
const handleStep2Back = () => {
setCurrentStep(1);
};
const handleStep2Finish = async (step2FormData: any) => {
// Combine step 1 and step 2 data
const selectedParcels = step2FormData.parzellen || [];
const completeData: any = {
label: step1Data.label,
// mandateId is NOT included - will be set by backend
};
// Add parzellen array if parcels were selected - include ALL parcel information for each
if (selectedParcels && selectedParcels.length > 0) {
completeData.parzellen = selectedParcels.map((selectedParcel: any) => ({
// Basic parcel info
id: selectedParcel.parcel.id,
egrid: selectedParcel.parcel.egrid,
number: selectedParcel.parcel.number,
name: selectedParcel.parcel.name,
identnd: selectedParcel.parcel.identnd,
canton: selectedParcel.parcel.canton,
municipality_code: selectedParcel.parcel.municipality_code,
municipality_name: selectedParcel.parcel.municipality_name,
address: selectedParcel.parcel.address,
area_m2: selectedParcel.parcel.area_m2,
centroid: selectedParcel.parcel.centroid,
geoportal_url: selectedParcel.parcel.geoportal_url,
realestate_type: selectedParcel.parcel.realestate_type,
// User-entered address fields (from step 2)
userAddress: {
street: step2FormData.address.street,
postalCode: step2FormData.address.postalCode,
city: step2FormData.address.city
},
// Geometry and map data
geometry: selectedParcel.map_view?.geometry_geojson,
perimeter: selectedParcel.parcel.perimeter,
map_view: selectedParcel.map_view,
// Adjacent parcels
adjacent_parcels: selectedParcel.adjacent_parcels || [],
// Include any other parcel properties that might exist
...selectedParcel.parcel
}));
}
// Send request to backend via onCreate handler
setIsCreating(true);
try {
const result = await onCreate(completeData);
if (result?.success !== false) {
// Success - close popup
setIsPopupOpen(false);
setCurrentStep(1);
setStep1Data({});
setAddressData({ street: '', postalCode: '', city: '' });
if (onSuccess) {
onSuccess(result);
}
} else {
// Handle error
if (onError) {
onError(result?.error || 'Projekt konnte nicht erstellt werden');
}
}
} catch (error: any) {
console.error('Project creation failed:', error);
if (onError) {
onError(error.message || 'Projekt konnte nicht erstellt werden');
}
} finally {
setIsCreating(false);
} }
}; };
const handleSave = async (updatedData: any) => { const handleSave = async (updatedData: any) => {
if (multiStep && currentStep === 1) {
handleStep1Next(updatedData);
return;
}
setIsCreating(true); setIsCreating(true);
try { try {
@ -119,6 +508,11 @@ const CreateButton: React.FC<CreateButtonProps> = ({
if (result?.success !== false) { if (result?.success !== false) {
// Success // Success
setIsPopupOpen(false); setIsPopupOpen(false);
if (multiStep) {
setCurrentStep(1);
setStep1Data({});
setAddressData({ street: '', postalCode: '', city: '' });
}
if (onSuccess) { if (onSuccess) {
onSuccess(result); onSuccess(result);
} }
@ -140,6 +534,18 @@ const CreateButton: React.FC<CreateButtonProps> = ({
const handleCancel = () => { const handleCancel = () => {
setIsPopupOpen(false); setIsPopupOpen(false);
if (multiStep) {
setCurrentStep(1);
setStep1Data({});
setAddressData({ street: '', postalCode: '', city: '' });
}
};
const handleAddressChange = (field: string, value: string) => {
setAddressData(prev => ({
...prev,
[field]: value
}));
}; };
const isDisabled = disabled || loading || isCreating; const isDisabled = disabled || loading || isCreating;
@ -184,9 +590,37 @@ const CreateButton: React.FC<CreateButtonProps> = ({
isOpen={isPopupOpen} isOpen={isPopupOpen}
title={resolvedPopupTitle} title={resolvedPopupTitle}
onClose={handleCancel} onClose={handleCancel}
size={popupSize} size={multiStep ? 'large' : popupSize}
closable={!isCreating} closable={!isCreating}
> >
{multiStep ? (
currentStep === 1 ? (
<div className={styles.step1Container}>
<div className={styles.stepIndicator}>
<span className={styles.stepNumber}>1</span>
<span className={styles.stepLabel}>Titel festlegen</span>
</div>
<FormGeneratorForm
attributes={resolvedAttributes}
data={initialFormData}
mode="create"
onSubmit={handleSave}
onCancel={handleCancel}
submitButtonText={t('common.next', 'Weiter')}
cancelButtonText={t('common.cancel', 'Abbrechen')}
/>
</div>
) : (
<PekProvider>
<Step2Content
onNext={handleStep2Finish}
onBack={handleStep2Back}
addressData={addressData}
onAddressChange={handleAddressChange}
/>
</PekProvider>
)
) : (
<FormGeneratorForm <FormGeneratorForm
attributes={resolvedAttributes} attributes={resolvedAttributes}
data={initialFormData} data={initialFormData}
@ -196,6 +630,7 @@ const CreateButton: React.FC<CreateButtonProps> = ({
submitButtonText={t('common.create', 'Create')} submitButtonText={t('common.create', 'Create')}
cancelButtonText={t('common.cancel', 'Cancel')} cancelButtonText={t('common.cancel', 'Cancel')}
/> />
)}
</Popup> </Popup>
</> </>
); );

View file

@ -72,13 +72,57 @@
margin-bottom: 0; margin-bottom: 0;
} }
.sectionHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--color-primary, #3b82f6);
}
.sectionTitle { .sectionTitle {
margin: 0 0 1rem 0; margin: 0;
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
color: var(--color-text, #111827); color: var(--color-text, #111827);
padding-bottom: 0.5rem; }
border-bottom: 2px solid var(--color-primary, #3b82f6);
.removeButton {
background: none;
border: none;
cursor: pointer;
padding: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-error, #ef4444);
border-radius: 4px;
transition: background-color 0.2s;
}
.removeButton:hover {
background-color: var(--color-error-light, #fee2e2);
color: var(--color-error-dark, #dc2626);
}
.parcelsList {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.mapViewSection {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border, #e5e7eb);
}
.subSectionTitle {
margin: 0 0 0.75rem 0;
font-size: 0.95rem;
font-weight: 600;
color: var(--color-text-secondary, #6b7280);
} }
.infoGrid { .infoGrid {

View file

@ -1,22 +1,24 @@
import React from 'react'; import React from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { FaTimes } from 'react-icons/fa'; import { FaTimes, FaTrash } from 'react-icons/fa';
import styles from './ParcelInfoPanel.module.css'; import styles from './ParcelInfoPanel.module.css';
export interface ParcelInfoPanelProps { export interface ParcelInfoPanelProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
parcelData: any; parcels: any[];
onRemoveParcel?: (parcelId: string) => void;
adjacentParcels?: any[]; adjacentParcels?: any[];
} }
const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
isOpen, isOpen,
onClose, onClose,
parcelData, parcels,
onRemoveParcel,
adjacentParcels = [] adjacentParcels = []
}) => { }) => {
if (!parcelData) return null; if (!parcels || parcels.length === 0) return null;
return ( return (
<AnimatePresence> <AnimatePresence>
@ -41,16 +43,31 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
className={styles.panel} className={styles.panel}
> >
<div className={styles.header}> <div className={styles.header}>
<h2 className={styles.title}>Parzellen-Informationen</h2> <h2 className={styles.title}>Parzellen-Informationen ({parcels.length})</h2>
<button className={styles.closeButton} onClick={onClose}> <button className={styles.closeButton} onClick={onClose}>
<FaTimes /> <FaTimes />
</button> </button>
</div> </div>
<div className={styles.content}> <div className={styles.content}>
{/* Main Parcel */} {/* Selected Parcels List */}
<section className={styles.section}> <div className={styles.parcelsList}>
<h3 className={styles.sectionTitle}>Ausgewählte Parzelle</h3> {parcels.map((parcelData, index) => (
<section key={parcelData.parcel.id || index} className={styles.section}>
<div className={styles.sectionHeader}>
<h3 className={styles.sectionTitle}>
Parzelle {index + 1}: {parcelData.parcel.number || parcelData.parcel.id || 'Unbekannt'}
</h3>
{onRemoveParcel && (
<button
className={styles.removeButton}
onClick={() => onRemoveParcel(parcelData.parcel.id)}
title="Parzelle entfernen"
>
<FaTrash />
</button>
)}
</div>
<div className={styles.infoGrid}> <div className={styles.infoGrid}>
{parcelData.parcel.id && ( {parcelData.parcel.id && (
<div className={styles.infoItem}> <div className={styles.infoItem}>
@ -147,12 +164,11 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
</div> </div>
)} )}
</div> </div>
</section>
{/* Map View Info */} {/* Map View Info for this parcel */}
{parcelData.map_view && ( {parcelData.map_view && (
<section className={styles.section}> <div className={styles.mapViewSection}>
<h3 className={styles.sectionTitle}>Kartenansicht</h3> <h4 className={styles.subSectionTitle}>Kartenansicht</h4>
<div className={styles.infoGrid}> <div className={styles.infoGrid}>
{parcelData.map_view.center && ( {parcelData.map_view.center && (
<div className={styles.infoItem}> <div className={styles.infoItem}>
@ -179,8 +195,11 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
</> </>
)} )}
</div> </div>
</section> </div>
)} )}
</section>
))}
</div>
{/* Adjacent Parcels */} {/* Adjacent Parcels */}
{adjacentParcels.length > 0 && ( {adjacentParcels.length > 0 && (

View file

@ -0,0 +1,44 @@
.tabsContainer {
display: flex;
flex-direction: column;
width: 100%;
gap: 0;
}
.tabsHeader {
display: flex;
gap: 0;
border-bottom: 2px solid var(--color-border, #e0e0e0);
margin-bottom: 1rem;
}
.tabButton {
padding: 0.75rem 1.5rem;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
color: var(--color-text, #666);
transition: all 0.2s ease;
margin-bottom: -2px;
font-family: var(--font-family);
}
.tabButton:hover {
color: var(--color-text, #333);
background: var(--color-bg-hover, rgba(0, 0, 0, 0.02));
}
.tabButtonActive {
color: var(--color-secondary, #007bff);
border-bottom-color: var(--color-primary, #007bff);
font-weight: 600;
}
.tabsContent {
flex: 1;
width: 100%;
}

View file

@ -0,0 +1,55 @@
import React, { useState } from 'react';
import styles from './Tabs.module.css';
export interface Tab {
id: string;
label: string;
content: React.ReactNode;
}
export interface TabsProps {
tabs: Tab[];
defaultTabId?: string;
onTabChange?: (tabId: string) => void;
className?: string;
}
export function Tabs({ tabs, defaultTabId, onTabChange, className = '' }: TabsProps) {
const [activeTabId, setActiveTabId] = useState<string>(
defaultTabId || tabs[0]?.id || ''
);
const handleTabClick = (tabId: string) => {
setActiveTabId(tabId);
onTabChange?.(tabId);
};
const activeTab = tabs.find(tab => tab.id === activeTabId) || tabs[0];
if (!tabs || tabs.length === 0) {
return null;
}
return (
<div className={`${styles.tabsContainer} ${className}`}>
<div className={styles.tabsHeader}>
{tabs.map(tab => (
<button
key={tab.id}
className={`${styles.tabButton} ${activeTabId === tab.id ? styles.tabButtonActive : ''}`}
onClick={() => handleTabClick(tab.id)}
type="button"
>
{tab.label}
</button>
))}
</div>
<div className={styles.tabsContent}>
{activeTab && activeTab.content}
</div>
</div>
);
}
export default Tabs;

View file

@ -0,0 +1,3 @@
export { Tabs, default } from './Tabs';
export type { TabsProps, Tab } from './Tabs';

View file

@ -17,3 +17,5 @@ export type { LogMessageProps } from './Log/LogMessage';
export { WorkflowStatus } from './WorkflowStatus'; export { WorkflowStatus } from './WorkflowStatus';
export type { WorkflowStatusProps } from './WorkflowStatus/WorkflowStatusTypes'; export type { WorkflowStatusProps } from './WorkflowStatus/WorkflowStatusTypes';
export * from './AutoScroll'; export * from './AutoScroll';
export * from './Tabs';
export type { TabsProps, Tab } from './Tabs';

View file

@ -18,10 +18,13 @@ interface PekContextType {
locationError: string | null; locationError: string | null;
// Parcel search // Parcel search
selectedParcel: any; selectedParcels: any[];
searchParcel: (location: string, includeAdjacent?: boolean) => Promise<any>; searchParcel: (location: string, includeAdjacent?: boolean) => Promise<any>;
isSearchingParcel: boolean; isSearchingParcel: boolean;
parcelSearchError: string | null; parcelSearchError: string | null;
removeParcel: (parcelId: string) => void;
clearSelectedParcels: () => void;
isParcelSelected: (parcelId: string) => boolean;
// Map view // Map view
mapCenter: any; mapCenter: any;

View file

@ -1,8 +1,8 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { GenericPageData, PageButton, PageContent, resolveLanguageText, SettingsFieldConfig, SettingsSectionConfig } from './pageInterface'; import { GenericPageData, PageButton, PageContent, resolveLanguageText, SettingsFieldConfig, SettingsSectionConfig, GenericDataHook } from './pageInterface';
import { FormGenerator } from '../../components/FormGenerator'; import { FormGenerator } from '../../components/FormGenerator';
import { FormGeneratorForm, AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; import { FormGeneratorForm, AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { Button, UploadButton, CreateButton, TextField, Messages, ChatMessage, LogMessage, DropdownSelect, Log, WorkflowStatus } from '../../components/UiComponents'; import { Button, UploadButton, CreateButton, TextField, Messages, ChatMessage, LogMessage, DropdownSelect, Log, WorkflowStatus, Tabs } from '../../components/UiComponents';
import { Popup } from '../../components/UiComponents/Popup'; import { Popup } from '../../components/UiComponents/Popup';
import { ConnectedFilesList } from '../../components/UiComponents/ConnectedFilesList'; import { ConnectedFilesList } from '../../components/UiComponents/ConnectedFilesList';
import type { DropdownSelectItem } from '../../components/UiComponents/DropdownSelect'; import type { DropdownSelectItem } from '../../components/UiComponents/DropdownSelect';
@ -155,6 +155,38 @@ const FixedHeightTextField: React.FC<{
); );
}; };
// Component for rendering tabs from page content
const TabsRenderer: React.FC<{
content: PageContent;
renderContent: (content: PageContent, key?: string | number) => React.ReactNode;
t: (key: string, fallback?: string) => string;
}> = ({ content, renderContent, t }) => {
const tabsConfig = content.tabsConfig;
if (!tabsConfig || !tabsConfig.tabs || tabsConfig.tabs.length === 0) {
return null;
}
// Convert PageContent tabs to Tabs component format
const tabs = tabsConfig.tabs.map(tab => ({
id: tab.id,
label: resolveLanguageText(tab.label, t),
content: (
<>
{tab.content.map((nestedContent, index) =>
renderContent(nestedContent, index)
)}
</>
)
}));
return (
<Tabs
tabs={tabs}
defaultTabId={tabsConfig.defaultTabId}
/>
);
};
// Component to handle async permission checks for content // Component to handle async permission checks for content
const ContentRenderer: React.FC<{ const ContentRenderer: React.FC<{
contents: PageContent[]; contents: PageContent[];
@ -259,6 +291,34 @@ const ContentRenderer: React.FC<{
); );
}; };
// Helper function to recursively find all table content sections
const findAllTableContents = (contents: PageContent[]): PageContent[] => {
const tableContents: PageContent[] = [];
const traverse = (contentList: PageContent[]) => {
for (const content of contentList) {
if (content.type === 'table') {
tableContents.push(content);
}
// Check nested content in tabs
if (content.type === 'tabs' && content.tabsConfig) {
for (const tab of content.tabsConfig.tabs) {
traverse(tab.content);
}
}
// Check nested content in columns
if (content.type === 'columns' && content.columnsConfig) {
for (const column of content.columnsConfig.columns) {
traverse(column.content);
}
}
}
};
traverse(contents);
return tableContents;
};
const PageRenderer: React.FC<PageRendererProps> = ({ const PageRenderer: React.FC<PageRendererProps> = ({
pageData, pageData,
onButtonClick onButtonClick
@ -267,27 +327,65 @@ const PageRenderer: React.FC<PageRendererProps> = ({
const { t } = useLanguage(); const { t } = useLanguage();
const { hasPermission } = usePermissions(); const { hasPermission } = usePermissions();
// Call the hook at the top level to ensure it persists across renders // Find all table content sections (including nested ones)
// This is CRITICAL - hooks must be called in the same order on every render const allTableContents = React.useMemo(() =>
const tableContent = pageData.content?.find(content => content.type === 'table'); findAllTableContents(pageData.content || []),
const inputFormContent = pageData.content?.find(content => content.type === 'inputForm'); [pageData.content]
const settingsContent = pageData.content?.find(content => content.type === 'settings'); );
const hookFactory = tableContent?.tableConfig?.hookFactory
|| inputFormContent?.inputFormConfig?.hookFactory
|| settingsContent?.settingsConfig?.hookFactory;
// Create a stable hook instance using React.useMemo // Create hook instances for all table contents - MUST be at top level
// This ensures the same hook instance is used across re-renders // We need to call hookFactory() at top level, but the actual hook() calls happen below
const useTableData = React.useMemo(() => { const tableHookFactories = React.useMemo(() => {
return allTableContents.map((tableContent, index) => {
const hookFactory = tableContent.tableConfig?.hookFactory;
if (hookFactory) { if (hookFactory) {
return hookFactory(); const key = tableContent.id || `table-${index}`;
return { key, hookFactory };
} }
return null; return null;
}, [hookFactory]); }).filter((item): item is { key: string; hookFactory: () => () => GenericDataHook } => item !== null);
}, [allTableContents]);
// Call the hook to get the current data // Call all hook factories at top level to create hook instances
// This will be called on every render, but it's the SAME hook instance // This must happen unconditionally and in the same order every render
const hookData = useTableData ? useTableData() : null; const tableHookInstances = tableHookFactories.map(({ key, hookFactory }) => ({
key,
hook: hookFactory() // This creates the hook function, doesn't call it yet
}));
// Call all hooks at top level - MUST be unconditional
// All hooks are called in the same order every render
const tableHookDataArray = tableHookInstances.map(({ key, hook }) => ({
key,
data: hook() // This is the actual hook call - must be at top level
}));
// Convert to Map for easy lookup
const tableHookData = React.useMemo(() => {
const dataMap = new Map<string, GenericDataHook | null>();
tableHookDataArray.forEach(({ key, data }) => {
dataMap.set(key, data);
});
return dataMap;
}, [tableHookDataArray]);
// Also check for top-level inputForm and settings (for backward compatibility)
const inputFormContent = pageData.content?.find(content => content.type === 'inputForm');
const settingsContent = pageData.content?.find(content => content.type === 'settings');
const hookFactory = inputFormContent?.inputFormConfig?.hookFactory
|| settingsContent?.settingsConfig?.hookFactory;
// Create hook instance at top level
const useTableData = hookFactory ? hookFactory() : null;
// Call the hook to get the current data (for backward compatibility)
// If no inputForm/settings hook, try to use the first table hook for header buttons
let hookData = useTableData ? useTableData() : null;
if (!hookData && tableHookData.size > 0) {
// Use the first table hook data for header buttons
const firstTableHook = Array.from(tableHookData.values())[0];
hookData = firstTableHook;
}
@ -770,21 +868,27 @@ const PageRenderer: React.FC<PageRendererProps> = ({
return null; return null;
case 'table': case 'table':
if (content.tableConfig && hookData) { // Get hookData for this specific table (nested tables use their own hooks)
const { columns: configColumns, actionButtons, ...tableProps } = content.tableConfig; const currentTableHookData = content.id && tableHookData.has(content.id)
? tableHookData.get(content.id)!
: hookData; // Fallback to top-level hookData for backward compatibility
if (content.tableConfig && currentTableHookData) {
const { columns: configColumns, actionButtons, emptyMessage, ...tableProps } = content.tableConfig;
// Only show loading spinner on initial load (when there's no data yet) // Only show loading spinner on initial load (when there's no data yet)
// During refetch, keep the existing data visible // During refetch, keep the existing data visible
const showLoadingSpinner = hookData.loading && hookData.data.length === 0; const showLoadingSpinner = currentTableHookData.loading && currentTableHookData.data.length === 0;
// Show error state if there's an error // Show error state if there's an error
if (hookData.error) { if (currentTableHookData.error) {
return ( return (
<div key={content.id} className={styles.tableContainer}> <div key={content.id} className={styles.tableContainer}>
<div className={styles.errorState}> <div className={styles.errorState}>
<p>Error loading data: {hookData.error}</p> <p>Error loading data: {currentTableHookData.error}</p>
{hookData.refetch && ( {currentTableHookData.refetch && (
<button onClick={hookData.refetch} className={styles.retryButton}> <button onClick={() => currentTableHookData.refetch?.()} className={styles.retryButton}>
Retry Retry
</button> </button>
)} )}
@ -796,7 +900,7 @@ const PageRenderer: React.FC<PageRendererProps> = ({
// Use columns from hook data if available, otherwise use config columns // Use columns from hook data if available, otherwise use config columns
// CRITICAL: Preserve columns even when data is empty (e.g., after filtering) // CRITICAL: Preserve columns even when data is empty (e.g., after filtering)
// Columns from attributes should persist regardless of data state // Columns from attributes should persist regardless of data state
const hookColumns = hookData.columns && hookData.columns.length > 0 ? hookData.columns : undefined; const hookColumns = currentTableHookData.columns && currentTableHookData.columns.length > 0 ? currentTableHookData.columns : undefined;
const configCols = configColumns && configColumns.length > 0 ? configColumns : undefined; const configCols = configColumns && configColumns.length > 0 ? configColumns : undefined;
// Prioritize hookColumns (from attributes) over configColumns to ensure persistence // Prioritize hookColumns (from attributes) over configColumns to ensure persistence
const columns = hookColumns || configCols; const columns = hookColumns || configCols;
@ -911,30 +1015,31 @@ const PageRenderer: React.FC<PageRendererProps> = ({
// Debug logging for table rendering // Debug logging for table rendering
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
console.log('🔍 Rendering FormGenerator:', { console.log('🔍 Rendering FormGenerator:', {
dataLength: hookData.data?.length || 0, dataLength: currentTableHookData.data?.length || 0,
columnsCount: resolvedColumns?.length || 0, columnsCount: resolvedColumns?.length || 0,
loading: showLoadingSpinner, loading: showLoadingSpinner,
hasError: !!hookData.error, hasError: !!currentTableHookData.error,
data: hookData.data, data: currentTableHookData.data,
willAutoDetect: !resolvedColumns willAutoDetect: !resolvedColumns
}); });
} }
return ( return (
<div key={content.id} className={styles.tableContainer}> <div key={content.id} className={styles.tableContainer}>
{hookData.isRefetching && ( {currentTableHookData.isRefetching && (
<div className={styles.refetchingIndicator}> <div className={styles.refetchingIndicator}>
Refreshing... Refreshing...
</div> </div>
)} )}
<FormGenerator <FormGenerator
data={hookData.data || []} data={currentTableHookData.data || []}
columns={resolvedColumns} columns={resolvedColumns}
loading={showLoadingSpinner} loading={showLoadingSpinner}
actionButtons={formGeneratorActions} actionButtons={formGeneratorActions}
hookData={hookData} hookData={currentTableHookData}
onDelete={hookData.onDelete} onDelete={currentTableHookData.onDelete}
onDeleteMultiple={hookData.onDeleteMultiple} onDeleteMultiple={currentTableHookData.onDeleteMultiple}
emptyMessage={emptyMessage}
{...tableProps} {...tableProps}
/> />
</div> </div>
@ -1465,6 +1570,50 @@ const PageRenderer: React.FC<PageRendererProps> = ({
); );
} }
case 'tabs': {
return (
<TabsRenderer
key={content.id}
content={content}
renderContent={renderContent}
t={t}
/>
);
}
case 'columns': {
const columnsConfig = content.columnsConfig;
if (!columnsConfig || !columnsConfig.columns || columnsConfig.columns.length === 0) {
return null;
}
// Build grid template columns
const gridTemplateColumns = columnsConfig.columns
.map(col => col.width || '1fr')
.join(' ');
const gap = columnsConfig.gap || '1rem';
return (
<div
key={content.id}
className={styles.columnsContainer}
style={{
display: 'grid',
gridTemplateColumns,
gap
}}
>
{columnsConfig.columns.map((column, colIndex) => (
<div key={column.id} className={styles.column}>
{column.content.map((nestedContent, index) =>
renderContent(nestedContent, `${colIndex}-${index}`)
)}
</div>
))}
</div>
);
}
default: default:
return null; return null;
} }
@ -1738,6 +1887,7 @@ const PageRenderer: React.FC<PageRendererProps> = ({
size={button.size || 'md'} size={button.size || 'md'}
icon={button.icon} icon={button.icon}
disabled={disabledValue} disabled={disabledValue}
multiStep={button.formConfig.multiStep || false}
onSuccess={() => { onSuccess={() => {
// Refetch data after successful creation // Refetch data after successful creation
if (hookData.refetch) { if (hookData.refetch) {

View file

@ -1,105 +1,181 @@
import { GenericPageData } from '../../pageInterface'; import { GenericPageData } from '../../pageInterface';
import { FaTable } from 'react-icons/fa'; import { FaTable, FaPlus } from 'react-icons/fa';
import { IoMdSend } from 'react-icons/io'; import { createProjectsTableHook, createParzellenTableHook } from '../../../../hooks/usePekTables';
import { usePekTablesContext } from '../../../../contexts/PekTablesContext';
import PekTablesDropdown from './pek-tables/PekTablesDropdown';
import PekTablesPageWrapper from './pek-tables/PekTablesPageWrapper';
import PekTablesTable from './pek-tables/PekTablesTable';
// Hook factory for PEK Tables page
const createPekTablesHook = () => {
return () => {
const pekTablesData = usePekTablesContext();
const handleSubmit = async () => {
await pekTablesData.processCommand(pekTablesData.commandInput);
};
return {
// Table data
data: pekTablesData.tableData,
loading: pekTablesData.isLoadingTableData || pekTablesData.isLoadingTables,
error: pekTablesData.tableDataError || pekTablesData.tablesError,
// Messages for command results
messages: pekTablesData.messages.map(msg => ({
id: msg.id,
role: msg.role,
message: msg.message,
publishedAt: msg.timestamp.getTime(),
...(msg.data && { data: msg.data })
})),
// Input form properties (for command input)
inputValue: pekTablesData.commandInput,
onInputChange: pekTablesData.setCommandInput,
handleSubmit,
isSubmitting: pekTablesData.isProcessingCommand,
// Refresh function
onRefresh: pekTablesData.refreshTableData,
isRefetching: false
};
};
};
export const pekTablesPageData: GenericPageData = { export const pekTablesPageData: GenericPageData = {
id: 'pek-tables', id: 'pek-tables',
path: 'start/pek-tables', path: 'start/pek-tables',
name: 'PEK Tabellen', name: 'Projektmanagement',
description: 'PEK Datenverwaltung mit Tabellen', description: 'Projektmanagement mit Tabellen',
// Parent page // Parent page
parentPath: 'start', parentPath: 'start',
// Visual // Visual
icon: FaTable, icon: FaTable,
title: 'PEK Tabellen', title: 'Projektmanagement',
subtitle: 'Datenverwaltung', subtitle: 'Datenverwaltung',
// Header buttons // Header buttons
headerButtons: [], headerButtons: [
{
id: 'create-project',
label: 'Neues Projekt',
variant: 'primary',
size: 'lg',
icon: FaPlus,
formConfig: {
fields: [], // Will be generated from attributes via generateEditFieldsFromAttributes
popupTitle: 'Neues Projekt erstellen',
popupSize: 'large',
createOperationName: 'handleProjectCreate',
multiStep: true // Enable multi-step form with Step 1 (label) and Step 2 (parcel selection)
},
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasCreate = hookData.permissions.create !== 'n' && hookData.permissions.view;
return { disabled: !hasCreate, message: 'No permission to create projects' };
}
}
],
// Content sections // Content sections
content: [ content: [
{ {
id: 'pek-tables-description', id: 'projektmanagement-layout',
type: 'paragraph', type: 'columns',
content: 'Verwalten Sie PEK-Daten über Tabellen. Wählen Sie eine Tabelle aus oder verwenden Sie natürliche Sprache, um Befehle auszuführen.' columnsConfig: {
}, columns: [
{ {
id: 'pek-tables-dropdown', id: 'main-column',
type: 'custom', width: '3fr',
customComponent: PekTablesDropdown content: [
},
{ {
id: 'pek-tables-command-input', id: 'tables-tabs',
type: 'inputForm', type: 'tabs',
inputFormConfig: { tabsConfig: {
hookFactory: createPekTablesHook, tabs: [
placeholder: 'Befehl eingeben (z.B., "Erstelle ein neues Projekt namens \'Hauptstrasse 42\'")', {
buttonLabel: 'Senden', id: 'projects',
buttonIcon: IoMdSend, label: 'Projekte',
buttonVariant: 'primary', content: [
buttonSize: 'md', {
textFieldSize: 'md' 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' };
} }
}, },
{ {
id: 'pek-tables-command-results', type: 'delete',
type: 'messages', title: 'common.delete',
messagesConfig: { idField: 'id',
variant: 'chat', operationName: 'handleDelete',
showDocuments: false, loadingStateName: 'deletingProjects',
showMetadata: false, disabled: (hookData: any) => {
showProgress: false, if (!hookData?.permissions) return { disabled: false };
emptyMessage: 'Noch keine Befehle ausgeführt. Senden Sie einen Befehl, um Ergebnisse hier zu sehen.' 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' };
} }
}, },
{ {
id: 'pek-tables-table', type: 'edit',
type: 'custom', title: 'common.edit',
customComponent: PekTablesTable 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'
}
} }
], ],
@ -113,9 +189,6 @@ export const pekTablesPageData: GenericPageData = {
order: 11, order: 11,
showInSidebar: true, showInSidebar: true,
// Custom component wrapper with PekTablesProvider
customComponent: PekTablesPageWrapper,
// Lifecycle hooks // Lifecycle hooks
onActivate: async () => { onActivate: async () => {
if (import.meta.env.DEV) console.log('PEK Tables page activated'); if (import.meta.env.DEV) console.log('PEK Tables page activated');

View file

@ -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;
}
}

View file

@ -1,47 +0,0 @@
import React, { useState } from 'react';
import { FaChevronDown, FaChevronUp } from 'react-icons/fa';
import styles from './PekTablesCollapsable.module.css';
interface PekTablesCollapsableProps {
title?: string;
children: React.ReactNode;
defaultCollapsed?: boolean;
}
const PekTablesCollapsable: React.FC<PekTablesCollapsableProps> = ({
title = 'Tabellenansicht',
children,
defaultCollapsed = false
}) => {
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
const toggleCollapse = () => {
setIsCollapsed(!isCollapsed);
};
return (
<div className={styles.collapsableContainer}>
<button
type="button"
className={styles.collapseButton}
onClick={toggleCollapse}
aria-expanded={!isCollapsed}
>
<span className={styles.collapseButtonText}>{title}</span>
{isCollapsed ? (
<FaChevronDown className={styles.collapseIcon} />
) : (
<FaChevronUp className={styles.collapseIcon} />
)}
</button>
{!isCollapsed && (
<div className={styles.collapsableContent}>
{children}
</div>
)}
</div>
);
};
export default PekTablesCollapsable;

View file

@ -1,71 +0,0 @@
import React from 'react';
import { DropdownSelect } from '../../../../../components/UiComponents';
import { usePekTablesContext } from '../../../../../contexts/PekTablesContext';
const PekTablesDropdown: React.FC = () => {
const {
tables,
isLoadingTables,
tablesError,
selectedTable,
setSelectedTable
} = usePekTablesContext();
// Ensure tables is always an array
const safeTables = tables || [];
// Convert tables to DropdownSelectItem format
const tableItems = safeTables.map((table, index) => ({
id: table.model || `table-${index}`,
label: `${table.name}${table.description ? ` - ${table.description}` : ''}`,
value: table.model
}));
// Find selected item ID
const selectedItemId = selectedTable
? tableItems.find(item => item.value === selectedTable)?.id || null
: null;
const handleSelect = (item: { id: string | number; label: string; value: any } | null) => {
if (item) {
setSelectedTable(item.value);
} else {
setSelectedTable('');
}
};
return (
<div style={{ marginBottom: '1.5rem' }}>
<label style={{
display: 'block',
fontSize: '0.875rem',
fontWeight: 500,
color: 'var(--color-text)',
marginBottom: '0.5rem'
}}>
Tabelle
</label>
<DropdownSelect
items={tableItems}
selectedItemId={selectedItemId}
onSelect={handleSelect}
placeholder={isLoadingTables ? 'Lade Tabellen...' : 'Tabelle auswählen'}
emptyMessage="Keine Tabellen verfügbar"
disabled={isLoadingTables || safeTables.length === 0}
loading={isLoadingTables}
size="md"
variant="primary"
minWidth="100%"
showClearButton={false}
/>
{tablesError && (
<div style={{ marginTop: '0.5rem', color: '#ef4444', fontSize: '0.875rem' }}>
{tablesError}
</div>
)}
</div>
);
};
export default PekTablesDropdown;

View file

@ -1,18 +0,0 @@
import React from 'react';
import { PekTablesProvider } from '../../../../../contexts/PekTablesContext';
import PageRenderer from '../../../PageRenderer';
import { pekTablesPageData } from '../pek-tables';
const PekTablesPageWrapper: React.FC = () => {
// Create a version of pageData without customComponent to avoid infinite loop
const { customComponent, ...pageDataWithoutCustom } = pekTablesPageData;
return (
<PekTablesProvider>
<PageRenderer pageData={pageDataWithoutCustom} />
</PekTablesProvider>
);
};
export default PekTablesPageWrapper;

View file

@ -1,115 +0,0 @@
import React, { useState } from 'react';
import { FormGenerator } from '../../../../../components/FormGenerator';
import { usePekTablesContext } from '../../../../../contexts/PekTablesContext';
import { FaChevronDown, FaChevronUp } from 'react-icons/fa';
import styles from './PekTablesCollapsable.module.css';
const PekTablesTable: React.FC = () => {
const {
tableData,
isLoadingTableData,
tableDataError,
refreshTableData,
selectedTable
} = usePekTablesContext();
const [isCollapsed, setIsCollapsed] = useState(true); // Default collapsed to show chat window
// Debug logging
if (import.meta.env.DEV) {
console.log('📊 PekTablesTable render:', {
selectedTable,
tableDataLength: tableData?.length || 0,
isLoadingTableData,
tableDataError
});
}
const toggleCollapse = () => {
setIsCollapsed(!isCollapsed);
};
// Show error state if there's an error
if (tableDataError) {
return (
<div className={styles.collapsableContainer}>
<button
type="button"
className={styles.collapseButton}
onClick={toggleCollapse}
aria-expanded={!isCollapsed}
>
<span className={styles.collapseButtonText}>Tabellenansicht</span>
{isCollapsed ? (
<FaChevronDown className={styles.collapseIcon} />
) : (
<FaChevronUp className={styles.collapseIcon} />
)}
</button>
{!isCollapsed && (
<div className={styles.collapsableContent}>
<div style={{ padding: '1rem', color: '#ef4444' }}>
<p>Fehler beim Laden der Daten: {tableDataError}</p>
{refreshTableData && (
<button onClick={refreshTableData} style={{ marginTop: '0.5rem', padding: '0.5rem 1rem', backgroundColor: '#3b82f6', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
Erneut versuchen
</button>
)}
</div>
</div>
)}
</div>
);
}
// Only show loading spinner on initial load (when there's no data yet)
const showLoadingSpinner = isLoadingTableData && (!tableData || tableData.length === 0);
return (
<div className={styles.collapsableContainer}>
<button
type="button"
className={styles.collapseButton}
onClick={toggleCollapse}
aria-expanded={!isCollapsed}
>
<span className={styles.collapseButtonText}>
Tabellenansicht {selectedTable && `(${selectedTable})`} {tableData && tableData.length > 0 && `- ${tableData.length} Einträge`}
</span>
{isCollapsed ? (
<FaChevronDown className={styles.collapseIcon} />
) : (
<FaChevronUp className={styles.collapseIcon} />
)}
</button>
{!isCollapsed && (
<div className={styles.collapsableContent}>
{tableData && tableData.length > 0 ? (
<FormGenerator
data={tableData}
columns={undefined} // Auto-detect columns - undefined triggers auto-detection
loading={showLoadingSpinner}
searchable={true}
filterable={true}
sortable={true}
pagination={true}
pageSize={10}
selectable={true}
/>
) : isLoadingTableData ? (
<div style={{ padding: '1rem', textAlign: 'center', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div>Lade Daten...</div>
</div>
) : (
<div style={{ padding: '1rem', textAlign: 'center', color: '#6b7280', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
Keine Daten verfügbar. Bitte wählen Sie eine Tabelle aus.
</div>
)}
</div>
)}
</div>
);
};
export default PekTablesTable;

View file

@ -9,11 +9,27 @@ const PekMapView: React.FC = () => {
parcelGeometries, parcelGeometries,
handleMapClick, handleMapClick,
handleParcelClick, handleParcelClick,
selectedParcel, selectedParcels,
removeParcel,
isPanelOpen, isPanelOpen,
setIsPanelOpen setIsPanelOpen
} = usePekContext(); } = usePekContext();
// Aggregate all adjacent parcels from all selected parcels
const allAdjacentParcels = React.useMemo(() => {
const adjacentSet = new Map<string, any>();
selectedParcels.forEach(parcel => {
if (parcel.adjacent_parcels) {
parcel.adjacent_parcels.forEach(adj => {
if (!adjacentSet.has(adj.id)) {
adjacentSet.set(adj.id, adj);
}
});
}
});
return Array.from(adjacentSet.values());
}, [selectedParcels]);
return ( return (
<> <>
<div style={{ marginBottom: '1.5rem' }}> <div style={{ marginBottom: '1.5rem' }}>
@ -31,8 +47,9 @@ const PekMapView: React.FC = () => {
<ParcelInfoPanel <ParcelInfoPanel
isOpen={isPanelOpen} isOpen={isPanelOpen}
onClose={() => setIsPanelOpen(false)} onClose={() => setIsPanelOpen(false)}
parcelData={selectedParcel} parcels={selectedParcels}
adjacentParcels={selectedParcel?.adjacent_parcels || []} onRemoveParcel={removeParcel}
adjacentParcels={allAdjacentParcels}
/> />
</> </>
); );

View file

@ -59,6 +59,7 @@ export interface PageButton {
createOperationName?: string; // Name of the create operation in hookData (e.g., 'handlePromptCreate') createOperationName?: string; // Name of the create operation in hookData (e.g., 'handlePromptCreate')
successMessage?: string | LanguageText; successMessage?: string | LanguageText;
errorMessage?: string | LanguageText; errorMessage?: string | LanguageText;
multiStep?: boolean; // Enable multi-step form mode
}; };
// Dropdown configuration for dropdown selection buttons // Dropdown configuration for dropdown selection buttons
dropdownConfig?: DropdownConfig; dropdownConfig?: DropdownConfig;
@ -123,7 +124,7 @@ export interface SettingsConfig {
// Content section for paragraphs // Content section for paragraphs
export interface PageContent { export interface PageContent {
id: string; id: string;
type: 'paragraph' | 'heading' | 'list' | 'code' | 'divider' | 'custom' | 'table' | 'inputForm' | 'messages' | 'settings' | 'log'; type: 'paragraph' | 'heading' | 'list' | 'code' | 'divider' | 'custom' | 'table' | 'inputForm' | 'messages' | 'settings' | 'log' | 'tabs' | 'columns';
content?: string | LanguageText; // Optional for dividers content?: string | LanguageText; // Optional for dividers
level?: number; // For headings (1-6) level?: number; // For headings (1-6)
items?: (string | LanguageText)[]; // For lists items?: (string | LanguageText)[]; // For lists
@ -148,6 +149,24 @@ export interface PageContent {
logConfig?: { logConfig?: {
emptyMessage?: string | LanguageText; 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 // Generic hook interface for data fetching
@ -156,7 +175,15 @@ export interface GenericDataHook {
loading: boolean; loading: boolean;
isRefetching?: boolean; // True when refetching data (keeps existing data visible) isRefetching?: boolean; // True when refetching data (keeps existing data visible)
error: string | null; error: string | null;
refetch?: () => Promise<void>; refetch?: (params?: { page?: number; pageSize?: number; sort?: Array<{field: string; direction: 'asc' | 'desc'}>; filters?: any; search?: string }) => Promise<void>;
pagination?: {
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: any;
} | null;
removeFileOptimistically?: (fileId: string) => void; // For optimistic updates removeFileOptimistically?: (fileId: string) => void; // For optimistic updates
columns?: any[]; // Optional columns configuration columns?: any[]; // Optional columns configuration
// File operations // File operations
@ -267,6 +294,7 @@ export interface TableContentConfig {
pagination?: boolean; pagination?: boolean;
pageSize?: number; pageSize?: number;
className?: string; className?: string;
emptyMessage?: string; // Custom message to display when table is empty
} }
// Language-aware text interface // Language-aware text interface

View file

@ -1,4 +1,4 @@
import { useState, useCallback } from 'react'; import { useState, useCallback, useEffect } from 'react';
import api from '../api'; import api from '../api';
import type { MapPoint, ParcelGeometry } from '../components/UiComponents/MapView'; import type { MapPoint, ParcelGeometry } from '../components/UiComponents/MapView';
import { wgs84ToLV95 } from '../components/UiComponents/MapView/LV95Converter'; import { wgs84ToLV95 } from '../components/UiComponents/MapView/LV95Converter';
@ -134,7 +134,7 @@ export function usePek() {
}; };
// Parcel search state // Parcel search state
const [selectedParcel, setSelectedParcel] = useState<ParcelSearchResponse | null>(null); const [selectedParcels, setSelectedParcels] = useState<ParcelSearchResponse[]>([]);
const [isSearchingParcel, setIsSearchingParcel] = useState(false); const [isSearchingParcel, setIsSearchingParcel] = useState(false);
const [parcelSearchError, setParcelSearchError] = useState<string | null>(null); const [parcelSearchError, setParcelSearchError] = useState<string | null>(null);
@ -163,6 +163,19 @@ export function usePek() {
// Panel state // Panel state
const [isPanelOpen, setIsPanelOpen] = useState(false); const [isPanelOpen, setIsPanelOpen] = useState(false);
// Update parcel geometries when selected parcels change
// Ensure all selected parcels are marked as selected and not as adjacent
useEffect(() => {
const selectedParcelIds = new Set(selectedParcels.map(p => p.parcel.id));
setParcelGeometries(prev => prev.map(geo => {
const isSelected = selectedParcelIds.has(geo.id);
// If parcel is selected, it should not be marked as adjacent
const isAdjacent = isSelected ? false : geo.isAdjacent;
return { ...geo, isSelected, isAdjacent };
}));
}, [selectedParcels]);
/** /**
* Get current geolocation and directly search for parcel * Get current geolocation and directly search for parcel
* Does not fill input fields, directly makes the request * Does not fill input fields, directly makes the request
@ -259,35 +272,43 @@ export function usePek() {
}); });
} }
// Update selected parcel // Add parcel to selected parcels array if not already selected
setSelectedParcel(data); // Update geometries within the callback to have access to updated selectedParcels
setSelectedParcels(prev => {
const exists = prev.some(p => p.parcel.id === data.parcel.id);
if (exists) {
return prev; // Already selected, don't add again
}
// Open panel when parcel is found const updatedSelectedParcels = [...prev, data];
setIsPanelOpen(true); const selectedParcelIds = new Set(updatedSelectedParcels.map(p => p.parcel.id));
// Update geometries
setParcelGeometries(currentGeometries => {
const geometryMap = new Map<string, ParcelGeometry>();
// Keep existing geometries
currentGeometries.forEach(geo => {
geometryMap.set(geo.id, geo);
});
// Update map center and zoom bounds // Update map center and zoom bounds
if (data.map_view) { if (data.map_view) {
setMapCenter(data.map_view.center); setMapCenter(data.map_view.center);
setMapZoomBounds(data.map_view.zoom_bounds); 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 // Main parcel - use geometry_geojson if available, otherwise use perimeter.punkte
let mainParcelCoordinates: MapPoint[] = []; let mainParcelCoordinates: MapPoint[] = [];
if (data.map_view.geometry_geojson?.geometry?.coordinates) { 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]; const coords = data.map_view.geometry_geojson.geometry.coordinates[0];
if (Array.isArray(coords)) { if (Array.isArray(coords)) {
mainParcelCoordinates = coords.map((coord: number[]) => ({ mainParcelCoordinates = coords.map((coord: number[]) => ({
x: coord[0], // Longitude/X in LV95 x: coord[0],
y: coord[1] // Latitude/Y in LV95 y: coord[1]
})); }));
} }
} else if (data.parcel.perimeter?.punkte) { } else if (data.parcel.perimeter?.punkte) {
// Fallback to perimeter.punkte
mainParcelCoordinates = data.parcel.perimeter.punkte.map((p) => ({ mainParcelCoordinates = data.parcel.perimeter.punkte.map((p) => ({
x: p.x, x: p.x,
y: p.y y: p.y
@ -295,7 +316,7 @@ export function usePek() {
} }
if (mainParcelCoordinates.length > 0) { if (mainParcelCoordinates.length > 0) {
geometries.push({ geometryMap.set(data.parcel.id, {
id: data.parcel.id, id: data.parcel.id,
egrid: data.parcel.egrid, egrid: data.parcel.egrid,
number: data.parcel.number, number: data.parcel.number,
@ -305,24 +326,30 @@ export function usePek() {
}); });
} }
// Adjacent parcels (if available) // Add adjacent parcels, but skip if already selected
// Use geometries from the response (no need to fetch separately)
if (data.adjacent_parcels && includeAdjacent && data.adjacent_parcels.length > 0) { if (data.adjacent_parcels && includeAdjacent && data.adjacent_parcels.length > 0) {
const adjacentGeometries: ParcelGeometry[] = [];
data.adjacent_parcels.forEach((adjacent) => { data.adjacent_parcels.forEach((adjacent) => {
if (import.meta.env.DEV) { // Skip if this adjacent parcel is already selected
console.log(`🔍 Processing adjacent parcel ${adjacent.id}:`, { if (selectedParcelIds.has(adjacent.id)) {
hasGeometryGeoJson: !!adjacent.geometry_geojson, // If it exists, mark as selected, not adjacent
hasPerimeter: !!adjacent.perimeter, const existingGeo = geometryMap.get(adjacent.id);
geometryGeoJson: adjacent.geometry_geojson, if (existingGeo) {
perimeter: adjacent.perimeter 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[] = []; let adjCoordinates: MapPoint[] = [];
// Extract coordinates from geometry_geojson if available
if (adjacent.geometry_geojson?.geometry?.coordinates) { if (adjacent.geometry_geojson?.geometry?.coordinates) {
const coords = adjacent.geometry_geojson.geometry.coordinates[0]; const coords = adjacent.geometry_geojson.geometry.coordinates[0];
if (Array.isArray(coords) && coords.length > 0) { if (Array.isArray(coords) && coords.length > 0) {
@ -330,25 +357,16 @@ export function usePek() {
x: coord[0], x: coord[0],
y: coord[1] y: coord[1]
})); }));
if (import.meta.env.DEV) {
console.log(`✅ Extracted ${adjCoordinates.length} coordinates from geometry_geojson for ${adjacent.id}`);
} }
} } else if (adjacent.perimeter?.punkte) {
}
// Fallback to perimeter.punkte if available
else if (adjacent.perimeter?.punkte) {
adjCoordinates = adjacent.perimeter.punkte.map((p) => ({ adjCoordinates = adjacent.perimeter.punkte.map((p) => ({
x: p.x, x: p.x,
y: p.y y: p.y
})); }));
if (import.meta.env.DEV) {
console.log(`✅ Extracted ${adjCoordinates.length} coordinates from perimeter for ${adjacent.id}`);
}
} }
// Only add if we have valid coordinates
if (adjCoordinates.length >= 3) { if (adjCoordinates.length >= 3) {
adjacentGeometries.push({ geometryMap.set(adjacent.id, {
id: adjacent.id, id: adjacent.id,
egrid: adjacent.egrid, egrid: adjacent.egrid,
number: adjacent.number, number: adjacent.number,
@ -356,44 +374,8 @@ export function usePek() {
isSelected: false, isSelected: false,
isAdjacent: true isAdjacent: true
}); });
} else if (import.meta.env.DEV) {
console.warn(`⚠️ Adjacent parcel ${adjacent.id} has insufficient geometry data:`, {
coordCount: adjCoordinates.length,
hasGeometryGeoJson: !!adjacent.geometry_geojson,
hasPerimeter: !!adjacent.perimeter,
geometryGeoJsonStructure: adjacent.geometry_geojson ? {
hasGeometry: !!adjacent.geometry_geojson.geometry,
hasCoordinates: !!adjacent.geometry_geojson.geometry?.coordinates,
coordinatesLength: adjacent.geometry_geojson.geometry?.coordinates?.length,
firstCoordLength: adjacent.geometry_geojson.geometry?.coordinates?.[0]?.length
} : null
});
} }
});
if (import.meta.env.DEV) {
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
}))
});
} }
// Add adjacent parcels to geometries array
geometries.push(...adjacentGeometries);
}
// Update parcel geometries with all parcels (main + adjacent)
setParcelGeometries(geometries);
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 { } else {
@ -404,22 +386,47 @@ export function usePek() {
y: p.y y: p.y
})); }));
setParcelGeometries([{ geometryMap.set(data.parcel.id, {
id: data.parcel.id, id: data.parcel.id,
egrid: data.parcel.egrid, egrid: data.parcel.egrid,
number: data.parcel.number, number: data.parcel.number,
coordinates, coordinates,
isSelected: true, isSelected: true,
isAdjacent: false isAdjacent: false
}]); });
// Set center from centroid if available
if (data.parcel.centroid) { if (data.parcel.centroid) {
setMapCenter(data.parcel.centroid); setMapCenter(data.parcel.centroid);
} }
} }
} }
// 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(`🗺️ Total geometries to display: ${updatedGeometries.length}`, {
selected: updatedGeometries.filter(g => g.isSelected).length,
adjacent: updatedGeometries.filter(g => g.isAdjacent).length
});
}
return updatedGeometries;
});
return updatedSelectedParcels;
});
// Open panel when parcel is found
setIsPanelOpen(true);
return { success: true, data }; return { success: true, data };
} catch (err: any) { } catch (err: any) {
const errorMessage = const errorMessage =
@ -444,43 +451,77 @@ export function usePek() {
); );
/** /**
* Handle parcel click on map - select the clicked parcel * Check if a parcel is selected
*/
const isParcelSelected = useCallback((parcelId: string): boolean => {
return selectedParcels.some(p => p.parcel.id === parcelId);
}, [selectedParcels]);
/**
* Remove a parcel from selection
*/
const removeParcel = useCallback((parcelId: string) => {
setSelectedParcels(prev => prev.filter(p => p.parcel.id !== parcelId));
// Update geometries to reflect deselection
setParcelGeometries(prev => prev.map(geo =>
geo.id === parcelId ? { ...geo, isSelected: false } : geo
));
}, []);
/**
* Clear all selected parcels
*/
const clearSelectedParcels = useCallback(() => {
setSelectedParcels([]);
// Update geometries to reflect deselection
setParcelGeometries(prev => prev.map(geo => ({ ...geo, isSelected: false })));
}, []);
/**
* Handle parcel click on map - toggle parcel selection
*/ */
const handleParcelClick = useCallback(async (parcelId: string) => { const handleParcelClick = useCallback(async (parcelId: string) => {
// Check if parcel is already selected
const isSelected = isParcelSelected(parcelId);
if (isSelected) {
// Remove from selection
removeParcel(parcelId);
} else {
// Find the clicked parcel in the geometries // Find the clicked parcel in the geometries
const clickedParcel = parcelGeometries.find(p => p.id === parcelId); const clickedParcel = parcelGeometries.find(p => p.id === parcelId);
if (clickedParcel && clickedParcel.coordinates.length > 0) { if (clickedParcel && clickedParcel.coordinates.length > 0) {
// Use a point inside the parcel (first coordinate is always on the boundary, which is inside) // 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]; 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 // Use first coordinate (guaranteed to be on/in the parcel) for search
const locationString = `${firstCoord.x},${firstCoord.y}`; const locationString = `${firstCoord.x},${firstCoord.y}`;
await searchParcel(locationString, true); // Always include adjacent parcels await searchParcel(locationString, true); // Always include adjacent parcels
} else { } else {
// Fallback: try to search by parcel ID/EGRID if available // Fallback: try to search by parcel ID/EGRID if available
if (selectedParcel?.adjacent_parcels) { // 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); const adjacentParcel = selectedParcel.adjacent_parcels.find(p => p.id === parcelId);
if (adjacentParcel?.egrid) { if (adjacentParcel?.egrid) {
// Search by EGRID // Search by EGRID
await searchParcel(adjacentParcel.egrid, true); await searchParcel(adjacentParcel.egrid, true);
break;
} else if (adjacentParcel?.number) { } else if (adjacentParcel?.number) {
// Try searching by number (might need address context) // Try searching by number (might need address context)
await searchParcel(adjacentParcel.number, true); await searchParcel(adjacentParcel.number, true);
break;
} else if (adjacentParcel?.id) { } else if (adjacentParcel?.id) {
// Last resort: try searching by ID // Last resort: try searching by ID
await searchParcel(adjacentParcel.id, true); await searchParcel(adjacentParcel.id, true);
break;
} }
} }
} }
}, [parcelGeometries, selectedParcel, searchParcel]); }
}
}, [parcelGeometries, selectedParcels, isParcelSelected, removeParcel, searchParcel]);
/** /**
* Process natural language command * Process natural language command
@ -510,26 +551,46 @@ export function usePek() {
userInput: userInput.trim() userInput: userInput.trim()
}; };
// Always include the currently selected parcel if available // Always include the currently selected parcels if available
if (selectedParcel) { if (selectedParcels.length > 0) {
// Use first selected parcel for backward compatibility
const firstParcel = selectedParcels[0];
requestBody.selectedParcel = { requestBody.selectedParcel = {
id: selectedParcel.parcel.id, id: firstParcel.parcel.id,
egrid: selectedParcel.parcel.egrid, egrid: firstParcel.parcel.egrid,
number: selectedParcel.parcel.number, number: firstParcel.parcel.number,
name: selectedParcel.parcel.name, name: firstParcel.parcel.name,
identnd: selectedParcel.parcel.identnd, identnd: firstParcel.parcel.identnd,
canton: selectedParcel.parcel.canton, canton: firstParcel.parcel.canton,
municipality_code: selectedParcel.parcel.municipality_code, municipality_code: firstParcel.parcel.municipality_code,
municipality_name: selectedParcel.parcel.municipality_name, municipality_name: firstParcel.parcel.municipality_name,
address: selectedParcel.parcel.address, address: firstParcel.parcel.address,
area_m2: selectedParcel.parcel.area_m2, area_m2: firstParcel.parcel.area_m2,
centroid: selectedParcel.parcel.centroid, centroid: firstParcel.parcel.centroid,
geoportal_url: selectedParcel.parcel.geoportal_url, geoportal_url: firstParcel.parcel.geoportal_url,
realestate_type: selectedParcel.parcel.realestate_type, realestate_type: firstParcel.parcel.realestate_type,
// Include geometry data if available // Include geometry data if available
geometry_geojson: selectedParcel.map_view?.geometry_geojson, geometry_geojson: firstParcel.map_view?.geometry_geojson,
perimeter: selectedParcel.parcel.perimeter perimeter: firstParcel.parcel.perimeter
}; };
// Also include all selected parcels as array
requestBody.selectedParcels = selectedParcels.map(p => ({
id: p.parcel.id,
egrid: p.parcel.egrid,
number: p.parcel.number,
name: p.parcel.name,
identnd: p.parcel.identnd,
canton: p.parcel.canton,
municipality_code: p.parcel.municipality_code,
municipality_name: p.parcel.municipality_name,
address: p.parcel.address,
area_m2: p.parcel.area_m2,
centroid: p.parcel.centroid,
geoportal_url: p.parcel.geoportal_url,
realestate_type: p.parcel.realestate_type,
geometry_geojson: p.map_view?.geometry_geojson,
perimeter: p.parcel.perimeter
}));
} }
const response = await api.post('/api/realestate/command', requestBody); const response = await api.post('/api/realestate/command', requestBody);
@ -560,8 +621,8 @@ export function usePek() {
}; };
setCommandResults((prev) => [...prev, assistantMessage]); setCommandResults((prev) => [...prev, assistantMessage]);
// If a project was created and there's a selected parcel, automatically add it // If a project was created and there are selected parcels, automatically add them
if (data.success && data.intent === 'CREATE' && data.entity === 'Projekt' && selectedParcel) { if (data.success && data.intent === 'CREATE' && data.entity === 'Projekt' && selectedParcels.length > 0) {
try { try {
// Extract projekt from result // Extract projekt from result
const projektResult = data.result?.result || data.result; const projektResult = data.result?.result || data.result;
@ -569,7 +630,10 @@ export function usePek() {
// Set as current projekt // Set as current projekt
setCurrentProjekt(projektResult); setCurrentProjekt(projektResult);
// Add the selected parcel to the newly created project via direct API call // Add all selected parcels to the newly created project via direct API call
let addedCount = 0;
for (const selectedParcel of selectedParcels) {
try {
const addParcelRequestBody: any = { const addParcelRequestBody: any = {
parcelId: selectedParcel.parcel.id, parcelId: selectedParcel.parcel.id,
parcelData: { parcelData: {
@ -599,12 +663,18 @@ export function usePek() {
// Update current projekt with the updated version that includes the parcel // Update current projekt with the updated version that includes the parcel
setCurrentProjekt(addResult.projekt); setCurrentProjekt(addResult.projekt);
addedCount++;
} catch (addError: any) {
console.error(`Failed to add parcel ${selectedParcel.parcel.id} to project:`, addError);
}
}
// 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 = { const updateMessage = {
...assistantMessage, ...assistantMessage,
id: `assistant-update-${Date.now()}`, id: `assistant-update-${Date.now()}`,
message: `${responseMessage}\n\n✅ Parzelle wurde automatisch zum Projekt hinzugefügt.` message: `${responseMessage}\n\n✅ ${addedCount} ${parcelText} ${addedCount === 1 ? 'wurde' : 'wurden'} automatisch zum Projekt hinzugefügt.`
}; };
setCommandResults((prev) => { setCommandResults((prev) => {
const updated = [...prev]; const updated = [...prev];
@ -629,8 +699,9 @@ export function usePek() {
} }
} }
// If a parcel was created and there's a selected parcel, automatically populate it with the selected parcel data // If a parcel was created and there are selected parcels, automatically populate it with the first selected parcel data
if (data.success && data.intent === 'CREATE' && data.entity === 'Parzelle' && selectedParcel) { if (data.success && data.intent === 'CREATE' && data.entity === 'Parzelle' && selectedParcels.length > 0) {
const selectedParcel = selectedParcels[0]; // Use first selected parcel
try { try {
// Extract parzelle from result // Extract parzelle from result
const parzelleResult = data.result?.result || data.result; const parzelleResult = data.result?.result || data.result;
@ -747,7 +818,7 @@ export function usePek() {
} finally { } finally {
setIsProcessingCommand(false); setIsProcessingCommand(false);
} }
}, [selectedParcel]); }, [selectedParcels]);
/** /**
* Create a new project * Create a new project
@ -872,10 +943,13 @@ export function usePek() {
locationError, locationError,
// Parcel search // Parcel search
selectedParcel, selectedParcels,
searchParcel, searchParcel,
isSearchingParcel, isSearchingParcel,
parcelSearchError, parcelSearchError,
removeParcel,
clearSelectedParcels,
isParcelSelected,
// Map view // Map view
mapCenter, mapCenter,

View file

@ -1,5 +1,17 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import api from '../api'; import api from '../api';
import type { GenericDataHook } from '../core/PageManager/pageInterface';
import { fetchAttributes } from '../api/attributesApi';
import { useApiRequest } from './useApi';
import { usePermissions, type UserPermissions } from './usePermissions';
import {
isDateTimeType,
isSelectType,
isMultiselectType,
isCheckboxType,
isTextareaType,
type AttributeType
} from '../utils/attributeTypeMapper';
// Table list response interface // Table list response interface
export interface TableInfo { export interface TableInfo {
@ -262,3 +274,911 @@ export function usePekTables() {
}; };
} }
/**
* Hook factory that creates a hook for a specific table model
* Returns a hook function compatible with GenericDataHook interface
*/
export function createPekTableHook(tableModel: string): () => GenericDataHook {
return () => {
const [tableData, setTableData] = useState<any[]>([]);
const [isLoadingTableData, setIsLoadingTableData] = useState(false);
const [tableDataError, setTableDataError] = useState<string | null>(null);
const [pagination, setPagination] = useState<TableDataResponse['pagination']>(null);
/**
* Load data for the specific table
*/
const loadTableData = useCallback(async (
page?: number,
pageSize?: number,
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>,
filters?: any,
search?: string
) => {
setIsLoadingTableData(true);
setTableDataError(null);
try {
const params: any = {};
// Build pagination object
const paginationObj: any = {
page: page || 1,
pageSize: pageSize || 10,
sort: sort || []
};
if (filters) {
paginationObj.filters = filters;
}
if (search) {
paginationObj.search = search;
}
params.pagination = JSON.stringify(paginationObj);
const response = await api.get(`/api/realestate/table/${tableModel}`, { params });
const data: TableDataResponse = response.data;
setTableData(data.items || []);
setPagination(data.pagination);
} catch (err: any) {
const errorMessage =
err.response?.data?.detail || err.message || `Fehler beim Laden der Tabelle ${tableModel}`;
setTableDataError(errorMessage);
} finally {
setIsLoadingTableData(false);
}
}, [tableModel]);
// Load table data on mount
useEffect(() => {
loadTableData();
}, [loadTableData]);
/**
* Refetch function compatible with GenericDataHook interface
*/
const refetch = useCallback(async (params?: {
page?: number;
pageSize?: number;
sort?: Array<{field: string; direction: 'asc' | 'desc'}>;
filters?: any;
search?: string;
}) => {
await loadTableData(
params?.page,
params?.pageSize,
params?.sort,
params?.filters,
params?.search
);
}, [loadTableData]);
return {
data: tableData,
loading: isLoadingTableData,
error: tableDataError,
refetch,
pagination: pagination || null,
columns: undefined // Columns can be loaded from attributes API if needed
};
};
}
// Attribute definition interface
interface AttributeDefinition {
name: string;
label: string;
type: AttributeType;
sortable?: boolean;
filterable?: boolean;
searchable?: boolean;
width?: number;
minWidth?: number;
maxWidth?: number;
filterOptions?: string[];
editable?: boolean;
visible?: boolean;
}
// Helper function to convert attribute definitions to column config
const attributesToColumns = (attributes: AttributeDefinition[], hiddenColumns: string[] = []) => {
return attributes
.filter(attr => !hiddenColumns.includes(attr.name) && !hiddenColumns.includes(attr.label))
.map(attr => {
// Use attributeTypeMapper to check if this is a date/timestamp field - disable filtering for these
const isDateField = isDateTimeType(attr.type);
return {
key: attr.name,
label: attr.label || attr.name,
type: attr.type || 'string',
width: attr.width || 200,
minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400,
sortable: attr.sortable !== false,
// Disable filtering for date/timestamp fields
filterable: isDateField ? false : (attr.filterable !== false),
searchable: attr.searchable !== false,
filterOptions: attr.filterOptions
};
});
};
/**
* Hook factory for Projekte table with edit/delete support
*/
export function createProjectsTableHook(): () => GenericDataHook {
return () => {
const [tableData, setTableData] = useState<any[]>([]);
const [isLoadingTableData, setIsLoadingTableData] = useState(false);
const [tableDataError, setTableDataError] = useState<string | null>(null);
const [pagination, setPagination] = useState<TableDataResponse['pagination']>(null);
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
const [editingProjects, setEditingProjects] = useState<Set<string>>(new Set());
const [deletingProjects, setDeletingProjects] = useState<Set<string>>(new Set());
const { request } = useApiRequest();
const { checkPermission } = usePermissions();
// Columns to hide in Projekte table
const hiddenColumns = ['mandateId', 'Mandat Id', 'perimeter', 'Perimeter', 'dokumente', 'Dokumente', 'kontextInformationen', 'Kontext Informationen'];
// Fetch attributes from backend
const fetchAttributesData = useCallback(async () => {
try {
const attrs = await fetchAttributes(request, 'Projekt');
setAttributes(attrs);
return attrs;
} catch (error: any) {
console.error('Error fetching Projekt attributes:', error);
setAttributes([]);
return [];
}
}, [request]);
// Fetch permissions from backend
const fetchPermissionsData = useCallback(async () => {
try {
const perms = await checkPermission('DATA', 'Projekt');
setPermissions(perms);
return perms;
} catch (error: any) {
console.error('Error fetching permissions:', error);
const defaultPerms: UserPermissions = {
view: false,
read: 'n',
create: 'n',
update: 'n',
delete: 'n',
};
setPermissions(defaultPerms);
return defaultPerms;
}
}, [checkPermission]);
// Generate columns from attributes
const generatedColumns = attributes && attributes.length > 0
? attributesToColumns(attributes, hiddenColumns)
: undefined;
/**
* Load data for the specific table
*/
const loadTableData = useCallback(async (
page?: number,
pageSize?: number,
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>,
filters?: any,
search?: string
) => {
setIsLoadingTableData(true);
setTableDataError(null);
try {
const params: any = {};
// Build pagination object
const paginationObj: any = {
page: page || 1,
pageSize: pageSize || 10,
sort: sort || []
};
if (filters) {
paginationObj.filters = filters;
}
if (search) {
paginationObj.search = search;
}
params.pagination = JSON.stringify(paginationObj);
const response = await api.get(`/api/realestate/table/Projekt`, { params });
const data: TableDataResponse = response.data;
setTableData(data.items || []);
setPagination(data.pagination);
} catch (err: any) {
const errorMessage =
err.response?.data?.detail || err.message || `Fehler beim Laden der Tabelle Projekt`;
setTableDataError(errorMessage);
} finally {
setIsLoadingTableData(false);
}
}, []);
// Fetch a single project by ID
const fetchProjectById = useCallback(async (id: string): Promise<any | null> => {
try {
// Load all projects and find the one with matching ID
// Note: If backend supports GET /api/realestate/table/Projekt/{id}, use that instead
const response = await api.get(`/api/realestate/table/Projekt`);
const data: TableDataResponse = response.data;
const project = (data.items || []).find((item: any) => item.id === id || item.id?.toString() === id);
return project || null;
} catch (err: any) {
console.error('Error fetching project by ID:', err);
return null;
}
}, []);
// Update project
const handleProjectUpdate = useCallback(async (id: string, updateData: any, originalData?: any): Promise<{ success: boolean }> => {
try {
setEditingProjects(prev => new Set(prev).add(id));
// Use command API for update
const response = await api.post('/api/realestate/command', {
userInput: `UPDATE Projekt ${id} with ${JSON.stringify(updateData)}`
});
const data: CommandResponse = response.data;
if (data.success) {
// Refetch table data
await loadTableData();
return { success: true };
} else {
return { success: false };
}
} catch (err: any) {
console.error('Error updating project:', err);
return { success: false };
} finally {
setEditingProjects(prev => {
const next = new Set(prev);
next.delete(id);
return next;
});
}
}, [loadTableData]);
// Delete project
const handleProjectDelete = useCallback(async (id: string): Promise<boolean> => {
try {
setDeletingProjects(prev => new Set(prev).add(id));
// Use command API for delete
const response = await api.post('/api/realestate/command', {
userInput: `DELETE Projekt ${id}`
});
const data: CommandResponse = response.data;
if (data.success) {
// Refetch table data
await loadTableData();
return true;
} else {
return false;
}
} catch (err: any) {
console.error('Error deleting project:', err);
return false;
} finally {
setDeletingProjects(prev => {
const next = new Set(prev);
next.delete(id);
return next;
});
}
}, [loadTableData]);
// Create project
const handleProjectCreate = useCallback(async (projectData: any): Promise<{ success: boolean; data?: any; error?: string }> => {
try {
// The projectData now contains:
// - label: string
// - parzellen: Array<{ ...all parcel data including userAddress, geometry, map_view, adjacent_parcels, etc. }>
// - mandateId is NOT included (set by backend)
// Backward compatibility: if parzelle (singular) exists, convert to parzellen array
if (projectData.parzelle && !projectData.parzellen) {
projectData.parzellen = [projectData.parzelle];
delete projectData.parzelle;
}
// Clean and flatten parzellen data: Extract parcel data from ParcelSearchResponse structure
// Each parcel should use its own address data from Swiss Topo API
if (projectData.parzellen && Array.isArray(projectData.parzellen)) {
projectData.parzellen = projectData.parzellen.map((parzelleItem: any) => {
// Handle both structures:
// 1. ParcelSearchResponse structure: { parcel: {...}, map_view: {...}, adjacent_parcels: [...] }
// 2. Already flattened structure: { id, address, plz, ... }
let parcelData: any;
if (parzelleItem.parcel) {
// ParcelSearchResponse structure - extract parcel data and merge with map_view/adjacent_parcels
parcelData = {
...parzelleItem.parcel, // All parcel fields (id, address, plz, perimeter, etc.)
// Preserve map_view and adjacent_parcels if needed
map_view: parzelleItem.map_view,
adjacent_parcels: parzelleItem.adjacent_parcels
};
} else {
// Already flattened - use as-is
parcelData = { ...parzelleItem };
}
// Remove userAddress to ensure Swiss Topo API data is used
delete parcelData.userAddress;
// Ensure address and plz from Swiss Topo are preserved
// These come from the parcel search API response for THIS specific parcel
return parcelData;
});
}
// Send the complete project data structure to the backend
const response = await api.post('/api/realestate/table/Projekt', projectData);
// Refetch table data after successful creation
await loadTableData();
return { success: true, data: response.data };
} catch (err: any) {
console.error('Error creating project:', err);
const errorMessage = err.response?.data?.detail || err.message || 'Fehler beim Erstellen des Projekts';
return { success: false, error: errorMessage };
}
}, [loadTableData]);
// Handle single project deletion for FormGenerator
const handleDeleteSingle = useCallback(async (project: any) => {
const success = await handleProjectDelete(project.id);
if (success) {
await loadTableData();
}
}, [handleProjectDelete, loadTableData]);
// Handle multiple project deletion for FormGenerator
const handleDeleteMultiple = useCallback(async (selectedProjects: any[]) => {
const projectIds = selectedProjects.map(project => project.id);
// Delete all projects sequentially
const results = await Promise.all(
projectIds.map(id => handleProjectDelete(id))
);
const allSuccessful = results.every(result => result);
if (allSuccessful) {
await loadTableData();
}
}, [handleProjectDelete, loadTableData]);
// Optimistic update
const updateOptimistically = useCallback((id: string, updateData: any) => {
setTableData(prev => prev.map(item => {
if (item.id === id || item.id?.toString() === id) {
return { ...item, ...updateData };
}
return item;
}));
}, []);
// Optimistic remove
const removeOptimistically = useCallback((id: string) => {
setTableData(prev => prev.filter(item => item.id !== id && item.id?.toString() !== id));
}, []);
// Generate edit fields from attributes for create button
const generateEditFieldsFromAttributes = useCallback((): Array<{
key: string;
label: string;
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly';
editable?: boolean;
required?: boolean;
validator?: (value: any) => string | null;
minRows?: number;
maxRows?: number;
options?: Array<{ value: string | number; label: string }>;
optionsReference?: string;
}> => {
if (!attributes || attributes.length === 0) {
return [];
}
const editableFields = attributes
.filter(attr => {
// Show all fields from backend - filter out ID fields and mandateId for create forms
// mandateId is set by backend, not editable by user
const nonEditableFields = ['id', 'mandateId']; // Filter out ID fields and mandateId for create forms
return !nonEditableFields.includes(attr.name);
})
.map(attr => {
// Map backend attribute type to form field type using attributeTypeMapper utilities
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string';
let options: Array<{ value: string | number; label: string }> | undefined = undefined;
let optionsReference: string | undefined = undefined;
const attrType = attr.type as AttributeType;
// Use attributeTypeMapper utilities to determine field type
if (isCheckboxType(attrType)) {
fieldType = 'boolean';
} else if (attrType === 'email') {
fieldType = 'email';
} else if (isDateTimeType(attrType)) {
fieldType = 'date';
} else if (isSelectType(attrType)) {
fieldType = 'enum';
const attrOptions = (attr as any).options;
if (Array.isArray(attrOptions)) {
options = attrOptions.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value);
return {
value: opt.value,
label: labelValue
};
});
} else if (typeof attrOptions === 'string') {
optionsReference = attrOptions;
}
} else if (isMultiselectType(attrType)) {
fieldType = 'multiselect';
const attrOptions = (attr as any).options;
if (Array.isArray(attrOptions)) {
options = attrOptions.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value);
return {
value: opt.value,
label: labelValue
};
});
} else if (typeof attrOptions === 'string') {
optionsReference = attrOptions;
}
} else if (isTextareaType(attrType)) {
fieldType = 'textarea';
} else if (attrType === 'readonly') {
fieldType = 'readonly';
} else {
fieldType = 'string';
}
return {
key: attr.name,
label: attr.label || attr.name,
type: fieldType,
required: (attr as any).required || false,
placeholder: (attr as any).placeholder,
editable: true, // All fields from backend should be editable in create form
minRows: isTextareaType(attrType) ? 4 : undefined,
maxRows: isTextareaType(attrType) ? 8 : undefined,
options: options,
optionsReference: optionsReference
};
});
return editableFields;
}, [attributes, hiddenColumns]);
// Ensure attributes are loaded
const ensureAttributesLoaded = useCallback(async () => {
if (attributes.length === 0) {
await fetchAttributesData();
}
}, [attributes.length, fetchAttributesData]);
// Load attributes and permissions first, then table data
useEffect(() => {
const initializeData = async () => {
// Load attributes first to ensure columns are available
await fetchAttributesData();
await fetchPermissionsData();
// Then load table data
await loadTableData();
};
initializeData();
}, [fetchAttributesData, fetchPermissionsData, loadTableData]);
/**
* Refetch function compatible with GenericDataHook interface
*/
const refetch = useCallback(async (params?: {
page?: number;
pageSize?: number;
sort?: Array<{field: string; direction: 'asc' | 'desc'}>;
filters?: any;
search?: string;
}) => {
await loadTableData(
params?.page,
params?.pageSize,
params?.sort,
params?.filters,
params?.search
);
}, [loadTableData]);
return {
data: tableData,
loading: isLoadingTableData,
error: tableDataError,
refetch,
pagination: pagination || null,
columns: generatedColumns,
// Operations
handleProjectCreate,
handleProjectUpdate,
handleDelete: handleProjectDelete,
handleDeleteMultiple,
// FormGenerator specific handlers
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
// Loading states
editingProjects,
deletingProjects,
// Optimistic updates
updateOptimistically,
removeOptimistically,
// Attributes and permissions
attributes,
permissions,
// Functions for EditActionButton
fetchProjectById,
ensureAttributesLoaded,
// Functions for CreateButton
generateEditFieldsFromAttributes,
// Entity type
entityType: 'Projekt'
};
};
}
/**
* Hook factory for Parzellen table with edit/delete/view support
*/
export function createParzellenTableHook(): () => GenericDataHook {
return () => {
const [tableData, setTableData] = useState<any[]>([]);
const [isLoadingTableData, setIsLoadingTableData] = useState(false);
const [tableDataError, setTableDataError] = useState<string | null>(null);
const [pagination, setPagination] = useState<TableDataResponse['pagination']>(null);
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
const [editingParzellen, setEditingParzellen] = useState<Set<string>>(new Set());
const [deletingParzellen, setDeletingParzellen] = useState<Set<string>>(new Set());
const [viewingParzellen, setViewingParzellen] = useState<Set<string>>(new Set());
const { request } = useApiRequest();
const { checkPermission } = usePermissions();
// Columns to hide in Parzellen table
const hiddenColumns = [
'mandateId', 'Mandate ID', 'Mandat Id',
'aliasTags', 'Alias Tags',
'perimeter', 'Perimeter',
'baulinie', 'Baulinie',
'bauzone', 'Bauzone',
'az', 'Az', 'AZ',
'bz', 'Bz', 'BZ',
'vollgeschossZahl', 'Vollgeschoss Zahl',
'anrechenbarDachgeschoss', 'Anrechenbar Dachgeschoss',
'anrechenbarUntergeschoss', 'Anrechendbar Untergeschoss',
'gebaudehoheMax', 'Gebäudehöhe Max',
'regelnGrenzabstand', 'Regeln Grenzabstand',
'regelnMehrhoehenzuschlag', 'Regln Mehrhoehenzuschlag',
'parzelleBebaut', 'Parzelle Bebaut',
'parzelleErschlossen', 'Parzelle Erschlossen',
'parzelleHanglage', 'Parzelle Hanglage',
'laermschutzzone', 'Lärmschutzzone',
'gebaeudehoeheMax', 'Gebäudehöhe Max',
'regelnMehrlaengenzuschlag', 'Regeln Mehrlängenzuschlag',
'hochwasserschutzzone', 'Hochwasserschutzzone',
'grundwasserschutzzone', 'Grundwasserschutzzone',
'dokumente', 'Dokumente',
'kontextInformationen', 'Kontext Informationen'
];
// Fetch attributes from backend
const fetchAttributesData = useCallback(async () => {
try {
const attrs = await fetchAttributes(request, 'Parzelle');
setAttributes(attrs);
return attrs;
} catch (error: any) {
console.error('Error fetching Parzelle attributes:', error);
setAttributes([]);
return [];
}
}, [request]);
// Fetch permissions from backend
const fetchPermissionsData = useCallback(async () => {
try {
const perms = await checkPermission('DATA', 'Parzelle');
setPermissions(perms);
return perms;
} catch (error: any) {
console.error('Error fetching permissions:', error);
const defaultPerms: UserPermissions = {
view: false,
read: 'n',
create: 'n',
update: 'n',
delete: 'n',
};
setPermissions(defaultPerms);
return defaultPerms;
}
}, [checkPermission]);
// Generate columns from attributes
const generatedColumns = attributes && attributes.length > 0
? attributesToColumns(attributes, hiddenColumns)
: undefined;
/**
* Load data for the specific table
*/
const loadTableData = useCallback(async (
page?: number,
pageSize?: number,
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>,
filters?: any,
search?: string
) => {
setIsLoadingTableData(true);
setTableDataError(null);
try {
const params: any = {};
// Build pagination object
const paginationObj: any = {
page: page || 1,
pageSize: pageSize || 10,
sort: sort || []
};
if (filters) {
paginationObj.filters = filters;
}
if (search) {
paginationObj.search = search;
}
params.pagination = JSON.stringify(paginationObj);
const response = await api.get(`/api/realestate/table/Parzelle`, { params });
const data: TableDataResponse = response.data;
setTableData(data.items || []);
setPagination(data.pagination);
} catch (err: any) {
const errorMessage =
err.response?.data?.detail || err.message || `Fehler beim Laden der Tabelle Parzelle`;
setTableDataError(errorMessage);
} finally {
setIsLoadingTableData(false);
}
}, []);
// Fetch a single parzelle by ID
const fetchParzelleById = useCallback(async (id: string): Promise<any | null> => {
try {
// Load all parzellen and find the one with matching ID
const response = await api.get(`/api/realestate/table/Parzelle`);
const data: TableDataResponse = response.data;
const parzelle = (data.items || []).find((item: any) => item.id === id || item.id?.toString() === id);
return parzelle || null;
} catch (err: any) {
console.error('Error fetching parzelle by ID:', err);
return null;
}
}, []);
// Update parzelle
const handleParzelleUpdate = useCallback(async (id: string, updateData: any, originalData?: any): Promise<{ success: boolean }> => {
try {
setEditingParzellen(prev => new Set(prev).add(id));
// Use command API for update
const response = await api.post('/api/realestate/command', {
userInput: `UPDATE Parzelle ${id} with ${JSON.stringify(updateData)}`
});
const data: CommandResponse = response.data;
if (data.success) {
// Refetch table data
await loadTableData();
return { success: true };
} else {
return { success: false };
}
} catch (err: any) {
console.error('Error updating parzelle:', err);
return { success: false };
} finally {
setEditingParzellen(prev => {
const next = new Set(prev);
next.delete(id);
return next;
});
}
}, [loadTableData]);
// Delete parzelle
const handleParzelleDelete = useCallback(async (id: string): Promise<boolean> => {
try {
setDeletingParzellen(prev => new Set(prev).add(id));
// Use command API for delete
const response = await api.post('/api/realestate/command', {
userInput: `DELETE Parzelle ${id}`
});
const data: CommandResponse = response.data;
if (data.success) {
// Refetch table data
await loadTableData();
return true;
} else {
return false;
}
} catch (err: any) {
console.error('Error deleting parzelle:', err);
return false;
} finally {
setDeletingParzellen(prev => {
const next = new Set(prev);
next.delete(id);
return next;
});
}
}, [loadTableData]);
// View parzelle (for preview/details)
const handleParzelleView = useCallback(async (parzelle: any): Promise<void> => {
const id = parzelle.id || parzelle.id?.toString();
if (id) {
setViewingParzellen(prev => new Set(prev).add(id));
// The actual viewing is handled by the ViewActionButton component
// This is just for tracking loading state
}
}, []);
// Optimistic update
const updateOptimistically = useCallback((id: string, updateData: any) => {
setTableData(prev => prev.map(item => {
if (item.id === id || item.id?.toString() === id) {
return { ...item, ...updateData };
}
return item;
}));
}, []);
// Optimistic remove
const removeOptimistically = useCallback((id: string) => {
setTableData(prev => prev.filter(item => item.id !== id && item.id?.toString() !== id));
}, []);
// Ensure attributes are loaded
const ensureAttributesLoaded = useCallback(async () => {
if (attributes.length === 0) {
await fetchAttributesData();
}
}, [attributes.length, fetchAttributesData]);
// Load table data and attributes on mount
useEffect(() => {
const initializeData = async () => {
// Load attributes first to ensure columns are available
await fetchAttributesData();
await fetchPermissionsData();
// Then load table data
await loadTableData();
};
initializeData();
}, [fetchAttributesData, fetchPermissionsData, loadTableData]);
/**
* Refetch function compatible with GenericDataHook interface
*/
const refetch = useCallback(async (params?: {
page?: number;
pageSize?: number;
sort?: Array<{field: string; direction: 'asc' | 'desc'}>;
filters?: any;
search?: string;
}) => {
await loadTableData(
params?.page,
params?.pageSize,
params?.sort,
params?.filters,
params?.search
);
}, [loadTableData]);
// Handle single parzelle deletion for FormGenerator
const handleDeleteSingle = useCallback(async (parzelle: any) => {
const success = await handleParzelleDelete(parzelle.id);
if (success) {
await loadTableData();
}
}, [handleParzelleDelete, loadTableData]);
// Handle multiple parzelle deletion for FormGenerator
const handleDeleteMultiple = useCallback(async (selectedParzellen: any[]) => {
const parzelleIds = selectedParzellen.map(parzelle => parzelle.id);
// Delete all parzellen sequentially
const results = await Promise.all(
parzelleIds.map(id => handleParzelleDelete(id))
);
const allSuccessful = results.every(result => result);
if (allSuccessful) {
await loadTableData();
}
}, [handleParzelleDelete, loadTableData]);
return {
data: tableData,
loading: isLoadingTableData,
error: tableDataError,
refetch,
pagination: pagination || null,
columns: generatedColumns,
// Operations
handleParzelleUpdate,
handleDelete: handleParzelleDelete,
handleDeleteMultiple,
handleParzelleView,
// FormGenerator specific handlers
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
// Loading states
editingParzellen,
deletingParzellen,
viewingParzellen,
// Optimistic updates
updateOptimistically,
removeOptimistically,
// Attributes and permissions
attributes,
permissions,
// Functions for EditActionButton
fetchParzelleById,
ensureAttributesLoaded,
// Entity type
entityType: 'Parzelle'
};
};
}

View file

@ -480,3 +480,17 @@
font-size: 1.5rem; font-size: 1.5rem;
} }
} }
/* Columns container */
.columnsContainer {
display: grid;
width: 100%;
gap: 1rem;
}
.column {
display: flex;
flex-direction: column;
gap: 1rem;
min-width: 0; /* Prevent overflow */
}