443 lines
14 KiB
TypeScript
443 lines
14 KiB
TypeScript
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 {
|
|
fetchPositions as fetchPositionsApi,
|
|
fetchPositionById as fetchPositionByIdApi,
|
|
createPosition as createPositionApi,
|
|
updatePosition as updatePositionApi,
|
|
deletePosition as deletePositionApi,
|
|
type TrusteePosition,
|
|
type PaginationParams
|
|
} from '../api/trusteeApi';
|
|
|
|
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;
|
|
}
|
|
|
|
// Re-export types
|
|
export type { TrusteePosition, PaginationParams };
|
|
|
|
// Positions list hook
|
|
export function useTrusteePositions() {
|
|
const instanceId = useInstanceId();
|
|
|
|
const [positions, setPositions] = useState<TrusteePosition[]>([]);
|
|
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, TrusteePosition[]>();
|
|
const { checkPermission } = usePermissions();
|
|
|
|
// Fetch attributes from backend
|
|
const fetchAttributes = useCallback(async () => {
|
|
if (!instanceId) return [];
|
|
|
|
try {
|
|
const response = await api.get(`/api/trustee/${instanceId}/attributes/TrusteePosition`);
|
|
|
|
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;
|
|
} else if (response.data && typeof response.data === 'object') {
|
|
const keys = Object.keys(response.data);
|
|
for (const key of keys) {
|
|
if (Array.isArray(response.data[key])) {
|
|
attrs = response.data[key];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
setAttributes(attrs);
|
|
return attrs;
|
|
} catch (error: any) {
|
|
console.error('Error fetching attributes:', error);
|
|
setAttributes([]);
|
|
return [];
|
|
}
|
|
}, [instanceId]);
|
|
|
|
// Fetch permissions from backend
|
|
const fetchPermissions = useCallback(async () => {
|
|
try {
|
|
const objectKey = 'data.feature.trustee.TrusteePosition';
|
|
const perms = await checkPermission('DATA', objectKey);
|
|
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]);
|
|
|
|
const fetchPositions = useCallback(async (params?: PaginationParams) => {
|
|
if (!instanceId) {
|
|
setPositions([]);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const data = await fetchPositionsApi(request, instanceId, params);
|
|
|
|
if (data && typeof data === 'object' && 'items' in data) {
|
|
const items = Array.isArray(data.items) ? data.items : [];
|
|
setPositions(items);
|
|
if (data.pagination) {
|
|
setPagination(data.pagination);
|
|
}
|
|
} else {
|
|
const items = Array.isArray(data) ? data : [];
|
|
setPositions(items);
|
|
setPagination(null);
|
|
}
|
|
} catch (error: any) {
|
|
setPositions([]);
|
|
setPagination(null);
|
|
}
|
|
}, [request, instanceId]);
|
|
|
|
// Optimistically remove a position
|
|
const removeOptimistically = (positionId: string) => {
|
|
setPositions(prevPositions => prevPositions.filter(pos => pos.id !== positionId));
|
|
};
|
|
|
|
// Optimistically update a position
|
|
const updateOptimistically = (positionId: string, updateData: Partial<TrusteePosition>) => {
|
|
setPositions(prevPositions =>
|
|
prevPositions.map(pos =>
|
|
pos.id === positionId
|
|
? { ...pos, ...updateData }
|
|
: pos
|
|
)
|
|
);
|
|
};
|
|
|
|
// Fetch a single position by ID
|
|
const fetchPositionById = useCallback(async (positionId: string): Promise<TrusteePosition | null> => {
|
|
if (!instanceId) return null;
|
|
return await fetchPositionByIdApi(request, instanceId, positionId);
|
|
}, [request, instanceId]);
|
|
|
|
// Generate edit fields from attributes dynamically with MwSt calculation logic
|
|
const generateEditFieldsFromAttributes = useCallback((): Array<{
|
|
key: string;
|
|
label: string;
|
|
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' | 'number';
|
|
editable?: boolean;
|
|
required?: boolean;
|
|
validator?: (value: any, formData?: any) => string | null;
|
|
onChange?: (value: any, formData: any) => Partial<any>;
|
|
options?: Array<{ value: string | number; label: string }>;
|
|
optionsReference?: string;
|
|
dependsOn?: string;
|
|
minRows?: number;
|
|
maxRows?: number;
|
|
}> => {
|
|
if (!attributes || attributes.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const editableFields = attributes
|
|
.filter(attr => {
|
|
if (attr.readonly === true || attr.editable === false) {
|
|
return false;
|
|
}
|
|
const nonEditableFields = ['id', 'mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt'];
|
|
return !nonEditableFields.includes(attr.name);
|
|
})
|
|
.map(attr => {
|
|
const isDescField = attr.name === 'desc' || attr.name.toLowerCase().includes('description');
|
|
|
|
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' | 'number' = 'string';
|
|
let options: Array<{ value: string | number; label: string }> | undefined = undefined;
|
|
let optionsReference: string | undefined = undefined;
|
|
let dependsOn: string | undefined = undefined;
|
|
let minRows: number | undefined = undefined;
|
|
let maxRows: number | undefined = undefined;
|
|
let onChange: ((value: any, formData: any) => Partial<any>) | undefined = undefined;
|
|
|
|
if (isDescField) {
|
|
fieldType = 'textarea';
|
|
minRows = 3;
|
|
maxRows = 8;
|
|
} else 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.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';
|
|
minRows = minRows || 3;
|
|
maxRows = maxRows || 8;
|
|
}
|
|
|
|
// contractId depends on organisationId
|
|
if (attr.name === 'contractId') {
|
|
dependsOn = 'organisationId';
|
|
}
|
|
|
|
// CUSTOM LOGIC: MwSt-Berechnung
|
|
// When bookingAmount or vatPercentage changes, auto-calculate vatAmount
|
|
if (attr.name === 'bookingAmount') {
|
|
onChange = (value: number, formData: any) => {
|
|
const amount = parseFloat(String(value)) || 0;
|
|
const percentage = parseFloat(String(formData.vatPercentage)) || 0;
|
|
const calculatedVat = amount * (percentage / 100);
|
|
return { vatAmount: calculatedVat };
|
|
};
|
|
} else if (attr.name === 'vatPercentage') {
|
|
onChange = (value: number, formData: any) => {
|
|
const percentage = parseFloat(String(value)) || 0;
|
|
const amount = parseFloat(String(formData.bookingAmount)) || 0;
|
|
const calculatedVat = amount * (percentage / 100);
|
|
return { vatAmount: calculatedVat };
|
|
};
|
|
}
|
|
|
|
let required = attr.required === true;
|
|
let validator: ((value: any, formData?: any) => string | null) | undefined = undefined;
|
|
|
|
// CUSTOM LOGIC: vatAmount validator - warn if manually overridden
|
|
if (attr.name === 'vatAmount') {
|
|
validator = (value: any, formData?: any) => {
|
|
if (!formData) return null;
|
|
|
|
const vatAmount = parseFloat(String(value)) || 0;
|
|
const bookingAmount = parseFloat(String(formData.bookingAmount)) || 0;
|
|
const vatPercentage = parseFloat(String(formData.vatPercentage)) || 0;
|
|
const calculatedVat = bookingAmount * (vatPercentage / 100);
|
|
|
|
if (Math.abs(vatAmount - calculatedVat) > 0.01) {
|
|
return 'MwSt-Betrag weicht von Berechnung ab (manuell überschrieben)';
|
|
}
|
|
return null;
|
|
};
|
|
}
|
|
|
|
// Standard validators
|
|
if (attr.name === 'organisationId' || attr.name === 'contractId') {
|
|
required = true;
|
|
validator = (value: any) => {
|
|
if (!value || (typeof value === 'string' && value.trim() === '')) {
|
|
return `${attr.label || attr.name} is required`;
|
|
}
|
|
return null;
|
|
};
|
|
} else if (attr.name === 'valuta' || attr.name === 'transactionDateTime') {
|
|
required = true;
|
|
} else if (attr.name === 'bookingCurrency' || attr.name === 'originalCurrency') {
|
|
required = true;
|
|
} else if (attr.name === 'bookingAmount' || attr.name === 'originalAmount') {
|
|
required = true;
|
|
validator = (value: any) => {
|
|
const num = parseFloat(String(value));
|
|
if (isNaN(num)) {
|
|
return 'Must be a valid number';
|
|
}
|
|
return null;
|
|
};
|
|
}
|
|
|
|
return {
|
|
key: attr.name,
|
|
label: attr.label || attr.name,
|
|
type: fieldType,
|
|
editable: attr.editable !== false && attr.readonly !== true,
|
|
required,
|
|
validator,
|
|
onChange,
|
|
options,
|
|
optionsReference,
|
|
dependsOn,
|
|
minRows,
|
|
maxRows
|
|
};
|
|
});
|
|
|
|
return editableFields;
|
|
}, [attributes]);
|
|
|
|
// Ensure attributes are loaded
|
|
const ensureAttributesLoaded = useCallback(async () => {
|
|
if (attributes && attributes.length > 0) {
|
|
return attributes;
|
|
}
|
|
const fetchedAttributes = await fetchAttributes();
|
|
return fetchedAttributes;
|
|
}, [attributes, fetchAttributes]);
|
|
|
|
// Fetch data when instanceId is available
|
|
useEffect(() => {
|
|
if (instanceId) {
|
|
fetchAttributes();
|
|
fetchPermissions();
|
|
fetchPositions();
|
|
}
|
|
}, [instanceId, fetchAttributes, fetchPermissions, fetchPositions]);
|
|
|
|
return {
|
|
positions,
|
|
loading,
|
|
error,
|
|
refetch: fetchPositions,
|
|
removeOptimistically,
|
|
updateOptimistically,
|
|
attributes,
|
|
permissions,
|
|
pagination,
|
|
fetchPositionById,
|
|
generateEditFieldsFromAttributes,
|
|
ensureAttributesLoaded,
|
|
instanceId
|
|
};
|
|
}
|
|
|
|
// Position operations hook
|
|
export function useTrusteePositionOperations() {
|
|
const instanceId = useInstanceId();
|
|
|
|
const [deletingPositions, setDeletingPositions] = useState<Set<string>>(new Set());
|
|
const [creatingPosition, setCreatingPosition] = useState(false);
|
|
const { request, isLoading } = useApiRequest();
|
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
const [createError, setCreateError] = useState<string | null>(null);
|
|
const [updateError, setUpdateError] = useState<string | null>(null);
|
|
|
|
const handlePositionDelete = async (positionId: string) => {
|
|
if (!instanceId) {
|
|
setDeleteError('No instance context');
|
|
return false;
|
|
}
|
|
|
|
setDeleteError(null);
|
|
setDeletingPositions(prev => new Set(prev).add(positionId));
|
|
|
|
try {
|
|
await deletePositionApi(request, instanceId, positionId);
|
|
await new Promise(resolve => setTimeout(resolve, 300));
|
|
return true;
|
|
} catch (error: any) {
|
|
setDeleteError(error.message);
|
|
return false;
|
|
} finally {
|
|
setDeletingPositions(prev => {
|
|
const newSet = new Set(prev);
|
|
newSet.delete(positionId);
|
|
return newSet;
|
|
});
|
|
}
|
|
};
|
|
|
|
const handlePositionCreate = async (positionData: Partial<TrusteePosition>) => {
|
|
if (!instanceId) {
|
|
setCreateError('No instance context');
|
|
return { success: false, error: 'No instance context' };
|
|
}
|
|
|
|
setCreateError(null);
|
|
setCreatingPosition(true);
|
|
|
|
try {
|
|
const newPosition = await createPositionApi(request, instanceId, positionData);
|
|
return { success: true, positionData: newPosition };
|
|
} catch (error: any) {
|
|
setCreateError(error.message);
|
|
return { success: false, error: error.message };
|
|
} finally {
|
|
setCreatingPosition(false);
|
|
}
|
|
};
|
|
|
|
const handlePositionUpdate = async (
|
|
positionId: string,
|
|
updateData: Partial<TrusteePosition>,
|
|
_originalData?: any
|
|
) => {
|
|
if (!instanceId) {
|
|
setUpdateError('No instance context');
|
|
return { success: false, error: 'No instance context' };
|
|
}
|
|
|
|
setUpdateError(null);
|
|
|
|
try {
|
|
const updatedPosition = await updatePositionApi(request, instanceId, positionId, updateData);
|
|
return { success: true, positionData: updatedPosition };
|
|
} catch (error: any) {
|
|
const errorMessage = error.response?.data?.message || error.message || 'Failed to update position';
|
|
const statusCode = error.response?.status;
|
|
|
|
setUpdateError(errorMessage);
|
|
|
|
return {
|
|
success: false,
|
|
error: errorMessage,
|
|
statusCode,
|
|
isPermissionError: statusCode === 403,
|
|
isValidationError: statusCode === 400
|
|
};
|
|
}
|
|
};
|
|
|
|
return {
|
|
deletingPositions,
|
|
creatingPosition,
|
|
deleteError,
|
|
createError,
|
|
updateError,
|
|
handlePositionDelete,
|
|
handlePositionCreate,
|
|
handlePositionUpdate,
|
|
isLoading,
|
|
instanceId
|
|
};
|
|
}
|