403 lines
15 KiB
TypeScript
403 lines
15 KiB
TypeScript
/**
|
|
* Real Estate Hooks
|
|
*
|
|
* Hooks für das Real Estate/PEK-Feature mit Instanz-Kontext.
|
|
* Die instanceId wird automatisch aus der URL gelesen (Feature-Instanz-Route).
|
|
* Analog zu useTrustee.ts für backend-driven FormGeneratorTable.
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { useApiRequest } from './useApi';
|
|
import api from '../api';
|
|
import { usePermissions, type UserPermissions } from './usePermissions';
|
|
import { useInstanceId } from './useCurrentInstance';
|
|
import {
|
|
type RealEstateProject,
|
|
type RealEstateParcel,
|
|
type PaginationParams,
|
|
fetchProjects as fetchProjectsApi,
|
|
fetchProjectById as fetchProjectByIdApi,
|
|
createProject as createProjectApi,
|
|
updateProject as updateProjectApi,
|
|
deleteProject as deleteProjectApi,
|
|
fetchParcels as fetchParcelsApi,
|
|
fetchParcelById as fetchParcelByIdApi,
|
|
createParcel as createParcelApi,
|
|
updateParcel as updateParcelApi,
|
|
deleteParcel as deleteParcelApi,
|
|
} from '../api/realEstateApi';
|
|
|
|
export type { RealEstateProject, RealEstateParcel, PaginationParams };
|
|
|
|
export interface AttributeDefinition {
|
|
name: string;
|
|
type: 'text' | 'email' | 'date' | 'checkbox' | 'select' | 'multiselect' | 'number' | 'textarea' | 'timestamp' | 'file';
|
|
label: string;
|
|
description?: string;
|
|
required?: boolean;
|
|
default?: any;
|
|
options?: any[] | string;
|
|
readonly?: boolean;
|
|
editable?: boolean;
|
|
visible?: boolean;
|
|
order?: number;
|
|
sortable?: boolean;
|
|
filterable?: boolean;
|
|
searchable?: boolean;
|
|
width?: number;
|
|
minWidth?: number;
|
|
maxWidth?: number;
|
|
filterOptions?: string[];
|
|
dependsOn?: string;
|
|
}
|
|
|
|
// ============================================================================
|
|
// GENERIC REAL ESTATE ENTITY HOOK FACTORY
|
|
// ============================================================================
|
|
|
|
interface RealEstateEntityConfig<T> {
|
|
entityName: string;
|
|
fetchAll: (request: any, instanceId: string, params?: PaginationParams) => Promise<any>;
|
|
fetchById: (request: any, instanceId: string, id: string) => Promise<T | null>;
|
|
create: (request: any, instanceId: string, data: Partial<T>) => Promise<T>;
|
|
update: (request: any, instanceId: string, id: string, data: Partial<T>) => Promise<T>;
|
|
deleteItem: (request: any, instanceId: string, id: string) => Promise<void>;
|
|
}
|
|
|
|
function _createRealEstateEntityHook<T extends { id: string }>(config: RealEstateEntityConfig<T>) {
|
|
return function useRealEstateEntity() {
|
|
const instanceId = useInstanceId();
|
|
const [items, setItems] = useState<T[]>([]);
|
|
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
|
|
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
|
|
const [pagination, setPagination] = useState<{
|
|
currentPage: number;
|
|
pageSize: number;
|
|
totalItems: number;
|
|
totalPages: number;
|
|
} | null>(null);
|
|
const { request, isLoading: loading, error } = useApiRequest<null, T[]>();
|
|
const { checkPermission } = usePermissions();
|
|
|
|
const fetchAttributes = useCallback(async () => {
|
|
if (!instanceId) return [];
|
|
try {
|
|
const response = await api.get(`/api/realestate/${instanceId}/attributes/${config.entityName}`);
|
|
let attrs: AttributeDefinition[] = [];
|
|
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
|
|
attrs = response.data.attributes;
|
|
} else if (Array.isArray(response.data)) {
|
|
attrs = response.data;
|
|
}
|
|
setAttributes(attrs);
|
|
return attrs;
|
|
} catch (err: any) {
|
|
console.error(`Error fetching ${config.entityName} attributes:`, err);
|
|
setAttributes([]);
|
|
return [];
|
|
}
|
|
}, [instanceId]);
|
|
|
|
const fetchPermissions = useCallback(async () => {
|
|
try {
|
|
const objectKey = `data.feature.realestate.${config.entityName}`;
|
|
const perms = await checkPermission('DATA', objectKey);
|
|
setPermissions(perms);
|
|
return perms;
|
|
} catch (err: any) {
|
|
console.error(`Error fetching ${config.entityName} permissions:`, err);
|
|
const defaultPerms: UserPermissions = {
|
|
view: false,
|
|
read: 'n',
|
|
create: 'n',
|
|
update: 'n',
|
|
delete: 'n',
|
|
};
|
|
setPermissions(defaultPerms);
|
|
return defaultPerms;
|
|
}
|
|
}, [checkPermission]);
|
|
|
|
const fetchItems = useCallback(async (params?: PaginationParams) => {
|
|
if (!instanceId) {
|
|
setItems([]);
|
|
return;
|
|
}
|
|
try {
|
|
const data = await config.fetchAll(request, instanceId, params);
|
|
if (data && typeof data === 'object' && 'items' in data) {
|
|
const fetchedItems = Array.isArray(data.items) ? data.items : [];
|
|
setItems(fetchedItems);
|
|
if (data.pagination) {
|
|
setPagination(data.pagination);
|
|
}
|
|
} else {
|
|
const fetchedItems = Array.isArray(data) ? data : [];
|
|
setItems(fetchedItems);
|
|
setPagination(null);
|
|
}
|
|
} catch {
|
|
setItems([]);
|
|
setPagination(null);
|
|
}
|
|
}, [request, instanceId]);
|
|
|
|
const removeOptimistically = (itemId: string) => {
|
|
setItems(prev => prev.filter(item => item.id !== itemId));
|
|
};
|
|
|
|
const updateOptimistically = (itemId: string, updateData: Partial<T>) => {
|
|
setItems(prev =>
|
|
prev.map(item =>
|
|
item.id === itemId ? { ...item, ...updateData } : item
|
|
)
|
|
);
|
|
};
|
|
|
|
const fetchById = useCallback(async (itemId: string): Promise<T | null> => {
|
|
if (!instanceId) return null;
|
|
return await config.fetchById(request, instanceId, itemId);
|
|
}, [request, instanceId]);
|
|
|
|
const generateEditFieldsFromAttributes = useCallback(() => {
|
|
if (!attributes || attributes.length === 0) return [];
|
|
return attributes
|
|
.filter(attr => {
|
|
if (attr.readonly === true || attr.editable === false) return false;
|
|
if (attr.name === 'id') return false;
|
|
const nonEditable = ['sysCreatedBy', 'sysCreatedAt', 'sysModifiedBy', 'sysModifiedAt'];
|
|
return !nonEditable.includes(attr.name);
|
|
})
|
|
.map(attr => {
|
|
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' | 'number' = 'string';
|
|
let options: Array<{ value: string | number; label: string }> | undefined;
|
|
let optionsReference: string | undefined;
|
|
if (attr.type === 'checkbox') fieldType = 'boolean';
|
|
else if (attr.type === 'email') fieldType = 'email';
|
|
else if (attr.type === 'date') fieldType = 'date';
|
|
else if (attr.type === 'number') fieldType = 'number';
|
|
else if (attr.type === 'select') {
|
|
fieldType = 'enum';
|
|
if (Array.isArray(attr.options)) {
|
|
options = (attr.options as any[]).map((opt: any) => ({
|
|
value: opt.value,
|
|
label: opt.label || String(opt.value),
|
|
}));
|
|
} else if (typeof attr.options === 'string') optionsReference = attr.options;
|
|
} else if (attr.type === 'multiselect') {
|
|
fieldType = 'multiselect';
|
|
if (Array.isArray(attr.options)) {
|
|
options = (attr.options as any[]).map((opt: any) => ({
|
|
value: opt.value,
|
|
label: opt.label || String(opt.value),
|
|
}));
|
|
} else if (typeof attr.options === 'string') optionsReference = attr.options;
|
|
} else if (attr.type === 'textarea') fieldType = 'textarea';
|
|
else if (attr.type === 'timestamp') fieldType = 'readonly';
|
|
return {
|
|
key: attr.name,
|
|
label: attr.label || attr.name,
|
|
type: fieldType,
|
|
editable: attr.editable !== false && attr.readonly !== true,
|
|
required: attr.required === true,
|
|
options,
|
|
optionsReference,
|
|
dependsOn: attr.dependsOn,
|
|
};
|
|
});
|
|
}, [attributes]);
|
|
|
|
const generateCreateFieldsFromAttributes = useCallback(() => {
|
|
if (!attributes || attributes.length === 0) return [];
|
|
return attributes
|
|
.filter(attr => !['sysCreatedBy', 'sysCreatedAt', 'sysModifiedBy', 'sysModifiedAt', 'mandateId', 'featureInstanceId'].includes(attr.name))
|
|
.map(attr => {
|
|
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' | 'number' = 'string';
|
|
let options: Array<{ value: string | number; label: string }> | undefined;
|
|
let optionsReference: string | undefined;
|
|
if (attr.type === 'checkbox') fieldType = 'boolean';
|
|
else if (attr.type === 'email') fieldType = 'email';
|
|
else if (attr.type === 'date') fieldType = 'date';
|
|
else if (attr.type === 'number') fieldType = 'number';
|
|
else if (attr.type === 'select') {
|
|
fieldType = 'enum';
|
|
if (Array.isArray(attr.options)) {
|
|
options = (attr.options as any[]).map((opt: any) => ({
|
|
value: opt.value,
|
|
label: opt.label || String(opt.value),
|
|
}));
|
|
} else if (typeof attr.options === 'string') optionsReference = attr.options;
|
|
} else if (attr.type === 'multiselect') {
|
|
fieldType = 'multiselect';
|
|
if (Array.isArray(attr.options)) {
|
|
options = (attr.options as any[]).map((opt: any) => ({
|
|
value: opt.value,
|
|
label: opt.label || String(opt.value),
|
|
}));
|
|
} else if (typeof attr.options === 'string') optionsReference = attr.options;
|
|
} else if (attr.type === 'textarea') fieldType = 'textarea';
|
|
else if (attr.type === 'timestamp') fieldType = 'readonly';
|
|
return {
|
|
key: attr.name,
|
|
label: attr.label || attr.name,
|
|
type: fieldType,
|
|
editable: true,
|
|
required: attr.required === true,
|
|
options,
|
|
optionsReference,
|
|
dependsOn: attr.dependsOn,
|
|
};
|
|
});
|
|
}, [attributes]);
|
|
|
|
const ensureAttributesLoaded = useCallback(async () => {
|
|
if (attributes && attributes.length > 0) return attributes;
|
|
return await fetchAttributes();
|
|
}, [attributes, fetchAttributes]);
|
|
|
|
useEffect(() => {
|
|
if (instanceId) {
|
|
fetchAttributes();
|
|
fetchPermissions();
|
|
fetchItems();
|
|
}
|
|
}, [instanceId, fetchAttributes, fetchPermissions, fetchItems]);
|
|
|
|
return {
|
|
items,
|
|
loading,
|
|
error,
|
|
refetch: fetchItems,
|
|
removeOptimistically,
|
|
updateOptimistically,
|
|
attributes,
|
|
permissions,
|
|
pagination,
|
|
fetchById,
|
|
generateEditFieldsFromAttributes,
|
|
generateCreateFieldsFromAttributes,
|
|
ensureAttributesLoaded,
|
|
instanceId,
|
|
};
|
|
};
|
|
}
|
|
|
|
function _createRealEstateOperationsHook<T extends { id: string }>(config: RealEstateEntityConfig<T>) {
|
|
return function useRealEstateEntityOperations() {
|
|
const instanceId = useInstanceId();
|
|
const [deletingItems, setDeletingItems] = useState<Set<string>>(new Set());
|
|
const [creatingItem, setCreatingItem] = useState(false);
|
|
const { request } = useApiRequest();
|
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
const [createError, setCreateError] = useState<string | null>(null);
|
|
const [updateError, setUpdateError] = useState<string | null>(null);
|
|
|
|
const handleDelete = useCallback(async (itemId: string) => {
|
|
if (!instanceId) {
|
|
setDeleteError('No instance context');
|
|
return false;
|
|
}
|
|
setDeleteError(null);
|
|
setDeletingItems(prev => new Set(prev).add(itemId));
|
|
try {
|
|
await config.deleteItem(request, instanceId, itemId);
|
|
await new Promise(resolve => setTimeout(resolve, 300));
|
|
return true;
|
|
} catch (err: any) {
|
|
setDeleteError(err.message);
|
|
return false;
|
|
} finally {
|
|
setDeletingItems(prev => {
|
|
const next = new Set(prev);
|
|
next.delete(itemId);
|
|
return next;
|
|
});
|
|
}
|
|
}, [request, instanceId]);
|
|
|
|
const handleCreate = useCallback(async (itemData: Partial<T>) => {
|
|
if (!instanceId) {
|
|
setCreateError('No instance context');
|
|
return { success: false, error: 'No instance context' };
|
|
}
|
|
setCreateError(null);
|
|
setCreatingItem(true);
|
|
try {
|
|
const newItem = await config.create(request, instanceId, itemData);
|
|
return { success: true, data: newItem };
|
|
} catch (err: any) {
|
|
const errorMessage = err.response?.data?.detail || err.message;
|
|
setCreateError(errorMessage);
|
|
return { success: false, error: errorMessage };
|
|
} finally {
|
|
setCreatingItem(false);
|
|
}
|
|
}, [request, instanceId]);
|
|
|
|
const handleUpdate = useCallback(async (itemId: string, updateData: Partial<T>) => {
|
|
if (!instanceId) {
|
|
setUpdateError('No instance context');
|
|
return { success: false, error: 'No instance context' };
|
|
}
|
|
setUpdateError(null);
|
|
try {
|
|
const updatedItem = await config.update(request, instanceId, itemId, updateData);
|
|
return { success: true, data: updatedItem };
|
|
} catch (err: any) {
|
|
const errorMessage = err.response?.data?.message || err.message || 'Failed to update';
|
|
setUpdateError(errorMessage);
|
|
return {
|
|
success: false,
|
|
error: errorMessage,
|
|
statusCode: err.response?.status,
|
|
isPermissionError: err.response?.status === 403,
|
|
isValidationError: err.response?.status === 400,
|
|
};
|
|
}
|
|
}, [request, instanceId]);
|
|
|
|
return {
|
|
deletingItems,
|
|
creatingItem,
|
|
deleteError,
|
|
createError,
|
|
updateError,
|
|
handleDelete,
|
|
handleCreate,
|
|
handleUpdate,
|
|
instanceId,
|
|
};
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// PROJECT HOOKS
|
|
// ============================================================================
|
|
|
|
const projectConfig: RealEstateEntityConfig<RealEstateProject> = {
|
|
entityName: 'Projekt',
|
|
fetchAll: fetchProjectsApi,
|
|
fetchById: fetchProjectByIdApi,
|
|
create: createProjectApi,
|
|
update: updateProjectApi,
|
|
deleteItem: deleteProjectApi,
|
|
};
|
|
|
|
export const useRealEstateProjects = _createRealEstateEntityHook(projectConfig);
|
|
export const useRealEstateProjectOperations = _createRealEstateOperationsHook(projectConfig);
|
|
|
|
// ============================================================================
|
|
// PARCEL HOOKS
|
|
// ============================================================================
|
|
|
|
const parcelConfig: RealEstateEntityConfig<RealEstateParcel> = {
|
|
entityName: 'Parzelle',
|
|
fetchAll: fetchParcelsApi,
|
|
fetchById: fetchParcelByIdApi,
|
|
create: createParcelApi,
|
|
update: updateParcelApi,
|
|
deleteItem: deleteParcelApi,
|
|
};
|
|
|
|
export const useRealEstateParcels = _createRealEstateEntityHook(parcelConfig);
|
|
export const useRealEstateParcelOperations = _createRealEstateOperationsHook(parcelConfig);
|