Fixed UI issues:

Data: roles & rules: fixed id and pydantic handling ui+gateway
Table generic: Fixed column width changing with persistent user width not overwritten by default in the session
Table generic: fixed active item in navbar working with subpaths
Table generic: fixed sort and pagination with modified routes
Table generic: enhanced logic to select page directly and bar on top of the table
Table generic: Always load data with pagination, fixed page counter (not updated), enhanced pagination to access pages directly
Table generic: Filter function enhanced directly as dropdown in column headers, sorting enhanced to have cascading sorting
Table generic: Added multilingual to AttributeType and handle it in the form renderer, removed explicit field definitions from team-members.ts and prompts.ts to use dynamic generation from backend attributes strictly
Table generic: Inline editting of checkboxes and boolean values
Table generig: Only generic logic, no explicit logic (e.g. id's)
Table generic: Removed all specific parts like action icons. To e a parameter by calling instance.
This commit is contained in:
ValueOn AG 2026-01-13 20:01:27 +01:00
parent 7d2808d22e
commit b2c38e75bf
55 changed files with 4975 additions and 1274 deletions

View file

@ -16,6 +16,12 @@ export interface UserPermissions {
export type PermissionContext = 'DATA' | 'UI' | 'RESOURCE'; export type PermissionContext = 'DATA' | 'UI' | 'RESOURCE';
// Response type for bulk permissions fetch
export interface BulkPermissionsResponse {
ui?: Record<string, UserPermissions>;
resource?: Record<string, UserPermissions>;
}
// Type for the request function passed to API functions // Type for the request function passed to API functions
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>; export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
@ -38,32 +44,47 @@ export async function fetchPermissions(
params.item = item; params.item = item;
} }
console.log('📡 fetchPermissions: Requesting permissions:', {
context,
item,
params,
url: '/api/rbac/permissions'
});
const data = await request({ const data = await request({
url: '/api/rbac/permissions', url: '/api/rbac/permissions',
method: 'get', method: 'get',
params params
}); });
console.log('📥 fetchPermissions: Received permissions response:', { return data;
context, }
item,
response: data, /**
view: data?.view, * Fetch all permissions for a given context (UI or RESOURCE)
read: data?.read, * Endpoint: GET /api/rbac/permissions/all
create: data?.create, * Query params: context (optional - if not provided, returns both UI and RESOURCE)
update: data?.update, *
delete: data?.delete, * This is optimized for UI initialization to avoid multiple API calls.
type: typeof data, * Returns a dictionary of item paths to their permissions.
isArray: Array.isArray(data), */
keys: data ? Object.keys(data) : [], export async function fetchAllPermissions(
fullResponse: JSON.stringify(data, null, 2) request: ApiRequestFunction,
context?: 'UI' | 'RESOURCE'
): Promise<BulkPermissionsResponse> {
const params: Record<string, string> = {};
if (context) {
params.context = context;
}
console.log('📡 fetchAllPermissions: Fetching all permissions:', {
context: context || 'all',
url: '/api/rbac/permissions/all'
});
const data = await request({
url: '/api/rbac/permissions/all',
method: 'get',
params
});
console.log('📥 fetchAllPermissions: Received bulk permissions:', {
context: context || 'all',
uiItemCount: data?.ui ? Object.keys(data.ui).length : 0,
resourceItemCount: data?.resource ? Object.keys(data.resource).length : 0
}); });
return data; return data;

660
src/api/trusteeApi.ts Normal file
View file

@ -0,0 +1,660 @@
import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================
// TYPES & INTERFACES
// ============================================================================
export interface TrusteeOrganisation {
id: string;
label: string;
enabled: boolean;
mandateId?: string;
_createdAt?: number;
_modifiedAt?: number;
_createdBy?: string;
_modifiedBy?: string;
[key: string]: any;
}
export interface TrusteeRole {
id: string;
desc: string;
mandateId?: string;
_createdAt?: number;
_modifiedAt?: number;
_createdBy?: string;
_modifiedBy?: string;
[key: string]: any;
}
export interface TrusteeAccess {
id: string;
organisationId: string;
roleId: string;
userId: string;
contractId?: string | null;
mandateId?: string;
_createdAt?: number;
_modifiedAt?: number;
_createdBy?: string;
_modifiedBy?: string;
[key: string]: any;
}
export interface TrusteeContract {
id: string;
organisationId: string;
label: string;
enabled: boolean;
mandateId?: string;
_createdAt?: number;
_modifiedAt?: number;
_createdBy?: string;
_modifiedBy?: string;
[key: string]: any;
}
export interface TrusteeDocument {
id: string;
organisationId: string;
contractId: string;
documentName: string;
documentMimeType: string;
documentData?: any; // Binary data, typically not included in list responses
mandateId?: string;
_createdAt?: number;
_modifiedAt?: number;
_createdBy?: string;
_modifiedBy?: string;
[key: string]: any;
}
export interface TrusteePosition {
id: string;
organisationId: string;
contractId: string;
valuta?: string;
transactionDateTime?: number;
company: string;
desc: string;
tags: string;
bookingCurrency: string;
bookingAmount: number;
originalCurrency: string;
originalAmount: number;
vatPercentage: number;
vatAmount: number;
mandateId?: string;
_createdAt?: number;
_modifiedAt?: number;
_createdBy?: string;
_modifiedBy?: string;
[key: string]: any;
}
export interface TrusteePositionDocument {
id: string;
organisationId: string;
contractId: string;
documentId: string;
positionId: string;
mandateId?: string;
_createdAt?: number;
_modifiedAt?: number;
_createdBy?: string;
_modifiedBy?: string;
[key: string]: any;
}
export interface PaginationParams {
page?: number;
pageSize?: number;
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>;
search?: string;
}
export interface PaginatedResponse<T> {
items: T[];
pagination?: {
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
};
}
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
function _buildPaginationParams(params?: PaginationParams): Record<string, any> {
const requestParams: any = {};
if (params) {
const paginationObj: any = {};
if (params.page !== undefined) paginationObj.page = params.page;
if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search;
if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj);
}
}
return requestParams;
}
// ============================================================================
// ORGANISATION API
// ============================================================================
export async function fetchOrganisations(
request: ApiRequestFunction,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteeOrganisation> | TrusteeOrganisation[]> {
return await request({
url: '/api/trustee/organisations',
method: 'get',
params: _buildPaginationParams(params)
});
}
export async function fetchOrganisationById(
request: ApiRequestFunction,
orgId: string
): Promise<TrusteeOrganisation | null> {
try {
return await request({
url: `/api/trustee/organisations/${orgId}`,
method: 'get'
});
} catch (error: any) {
console.error('Error fetching organisation by ID:', error);
return null;
}
}
export async function createOrganisation(
request: ApiRequestFunction,
data: Partial<TrusteeOrganisation>
): Promise<TrusteeOrganisation> {
return await request({
url: '/api/trustee/organisations',
method: 'post',
data
});
}
export async function updateOrganisation(
request: ApiRequestFunction,
orgId: string,
data: Partial<TrusteeOrganisation>
): Promise<TrusteeOrganisation> {
return await request({
url: `/api/trustee/organisations/${orgId}`,
method: 'put',
data
});
}
export async function deleteOrganisation(
request: ApiRequestFunction,
orgId: string
): Promise<void> {
await request({
url: `/api/trustee/organisations/${orgId}`,
method: 'delete'
});
}
// ============================================================================
// ROLE API
// ============================================================================
export async function fetchRoles(
request: ApiRequestFunction,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteeRole> | TrusteeRole[]> {
return await request({
url: '/api/trustee/roles',
method: 'get',
params: _buildPaginationParams(params)
});
}
export async function fetchRoleById(
request: ApiRequestFunction,
roleId: string
): Promise<TrusteeRole | null> {
try {
return await request({
url: `/api/trustee/roles/${roleId}`,
method: 'get'
});
} catch (error: any) {
console.error('Error fetching role by ID:', error);
return null;
}
}
export async function createRole(
request: ApiRequestFunction,
data: Partial<TrusteeRole>
): Promise<TrusteeRole> {
return await request({
url: '/api/trustee/roles',
method: 'post',
data
});
}
export async function updateRole(
request: ApiRequestFunction,
roleId: string,
data: Partial<TrusteeRole>
): Promise<TrusteeRole> {
return await request({
url: `/api/trustee/roles/${roleId}`,
method: 'put',
data
});
}
export async function deleteRole(
request: ApiRequestFunction,
roleId: string
): Promise<void> {
await request({
url: `/api/trustee/roles/${roleId}`,
method: 'delete'
});
}
// ============================================================================
// ACCESS API
// ============================================================================
export async function fetchAccess(
request: ApiRequestFunction,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteeAccess> | TrusteeAccess[]> {
return await request({
url: '/api/trustee/access',
method: 'get',
params: _buildPaginationParams(params)
});
}
export async function fetchAccessById(
request: ApiRequestFunction,
accessId: string
): Promise<TrusteeAccess | null> {
try {
return await request({
url: `/api/trustee/access/${accessId}`,
method: 'get'
});
} catch (error: any) {
console.error('Error fetching access by ID:', error);
return null;
}
}
export async function fetchAccessByOrganisation(
request: ApiRequestFunction,
orgId: string
): Promise<TrusteeAccess[]> {
return await request({
url: `/api/trustee/access/organisation/${orgId}`,
method: 'get'
});
}
export async function fetchAccessByUser(
request: ApiRequestFunction,
userId: string
): Promise<TrusteeAccess[]> {
return await request({
url: `/api/trustee/access/user/${userId}`,
method: 'get'
});
}
export async function createAccess(
request: ApiRequestFunction,
data: Partial<TrusteeAccess>
): Promise<TrusteeAccess> {
return await request({
url: '/api/trustee/access',
method: 'post',
data
});
}
export async function updateAccess(
request: ApiRequestFunction,
accessId: string,
data: Partial<TrusteeAccess>
): Promise<TrusteeAccess> {
return await request({
url: `/api/trustee/access/${accessId}`,
method: 'put',
data
});
}
export async function deleteAccess(
request: ApiRequestFunction,
accessId: string
): Promise<void> {
await request({
url: `/api/trustee/access/${accessId}`,
method: 'delete'
});
}
// ============================================================================
// CONTRACT API
// ============================================================================
export async function fetchContracts(
request: ApiRequestFunction,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteeContract> | TrusteeContract[]> {
return await request({
url: '/api/trustee/contracts',
method: 'get',
params: _buildPaginationParams(params)
});
}
export async function fetchContractById(
request: ApiRequestFunction,
contractId: string
): Promise<TrusteeContract | null> {
try {
return await request({
url: `/api/trustee/contracts/${contractId}`,
method: 'get'
});
} catch (error: any) {
console.error('Error fetching contract by ID:', error);
return null;
}
}
export async function fetchContractsByOrganisation(
request: ApiRequestFunction,
orgId: string
): Promise<TrusteeContract[]> {
return await request({
url: `/api/trustee/contracts/organisation/${orgId}`,
method: 'get'
});
}
export async function createContract(
request: ApiRequestFunction,
data: Partial<TrusteeContract>
): Promise<TrusteeContract> {
return await request({
url: '/api/trustee/contracts',
method: 'post',
data
});
}
export async function updateContract(
request: ApiRequestFunction,
contractId: string,
data: Partial<TrusteeContract>
): Promise<TrusteeContract> {
return await request({
url: `/api/trustee/contracts/${contractId}`,
method: 'put',
data
});
}
export async function deleteContract(
request: ApiRequestFunction,
contractId: string
): Promise<void> {
await request({
url: `/api/trustee/contracts/${contractId}`,
method: 'delete'
});
}
// ============================================================================
// DOCUMENT API
// ============================================================================
export async function fetchDocuments(
request: ApiRequestFunction,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteeDocument> | TrusteeDocument[]> {
return await request({
url: '/api/trustee/documents',
method: 'get',
params: _buildPaginationParams(params)
});
}
export async function fetchDocumentById(
request: ApiRequestFunction,
documentId: string
): Promise<TrusteeDocument | null> {
try {
return await request({
url: `/api/trustee/documents/${documentId}`,
method: 'get'
});
} catch (error: any) {
console.error('Error fetching document by ID:', error);
return null;
}
}
export async function fetchDocumentsByContract(
request: ApiRequestFunction,
contractId: string
): Promise<TrusteeDocument[]> {
return await request({
url: `/api/trustee/documents/contract/${contractId}`,
method: 'get'
});
}
export async function createDocument(
request: ApiRequestFunction,
data: Partial<TrusteeDocument>
): Promise<TrusteeDocument> {
return await request({
url: '/api/trustee/documents',
method: 'post',
data
});
}
export async function updateDocument(
request: ApiRequestFunction,
documentId: string,
data: Partial<TrusteeDocument>
): Promise<TrusteeDocument> {
return await request({
url: `/api/trustee/documents/${documentId}`,
method: 'put',
data
});
}
export async function deleteDocument(
request: ApiRequestFunction,
documentId: string
): Promise<void> {
await request({
url: `/api/trustee/documents/${documentId}`,
method: 'delete'
});
}
// ============================================================================
// POSITION API
// ============================================================================
export async function fetchPositions(
request: ApiRequestFunction,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteePosition> | TrusteePosition[]> {
return await request({
url: '/api/trustee/positions',
method: 'get',
params: _buildPaginationParams(params)
});
}
export async function fetchPositionById(
request: ApiRequestFunction,
positionId: string
): Promise<TrusteePosition | null> {
try {
return await request({
url: `/api/trustee/positions/${positionId}`,
method: 'get'
});
} catch (error: any) {
console.error('Error fetching position by ID:', error);
return null;
}
}
export async function fetchPositionsByContract(
request: ApiRequestFunction,
contractId: string
): Promise<TrusteePosition[]> {
return await request({
url: `/api/trustee/positions/contract/${contractId}`,
method: 'get'
});
}
export async function fetchPositionsByOrganisation(
request: ApiRequestFunction,
orgId: string
): Promise<TrusteePosition[]> {
return await request({
url: `/api/trustee/positions/organisation/${orgId}`,
method: 'get'
});
}
export async function createPosition(
request: ApiRequestFunction,
data: Partial<TrusteePosition>
): Promise<TrusteePosition> {
return await request({
url: '/api/trustee/positions',
method: 'post',
data
});
}
export async function updatePosition(
request: ApiRequestFunction,
positionId: string,
data: Partial<TrusteePosition>
): Promise<TrusteePosition> {
return await request({
url: `/api/trustee/positions/${positionId}`,
method: 'put',
data
});
}
export async function deletePosition(
request: ApiRequestFunction,
positionId: string
): Promise<void> {
await request({
url: `/api/trustee/positions/${positionId}`,
method: 'delete'
});
}
// ============================================================================
// POSITION-DOCUMENT API
// ============================================================================
export async function fetchPositionDocuments(
request: ApiRequestFunction,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteePositionDocument> | TrusteePositionDocument[]> {
return await request({
url: '/api/trustee/position-documents',
method: 'get',
params: _buildPaginationParams(params)
});
}
export async function fetchPositionDocumentById(
request: ApiRequestFunction,
linkId: string
): Promise<TrusteePositionDocument | null> {
try {
return await request({
url: `/api/trustee/position-documents/${linkId}`,
method: 'get'
});
} catch (error: any) {
console.error('Error fetching position-document link by ID:', error);
return null;
}
}
export async function fetchDocumentsForPosition(
request: ApiRequestFunction,
positionId: string
): Promise<TrusteePositionDocument[]> {
return await request({
url: `/api/trustee/position-documents/position/${positionId}`,
method: 'get'
});
}
export async function fetchPositionsForDocument(
request: ApiRequestFunction,
documentId: string
): Promise<TrusteePositionDocument[]> {
return await request({
url: `/api/trustee/position-documents/document/${documentId}`,
method: 'get'
});
}
export async function createPositionDocument(
request: ApiRequestFunction,
data: Partial<TrusteePositionDocument>
): Promise<TrusteePositionDocument> {
return await request({
url: '/api/trustee/position-documents',
method: 'post',
data
});
}
export async function deletePositionDocument(
request: ApiRequestFunction,
linkId: string
): Promise<void> {
await request({
url: `/api/trustee/position-documents/${linkId}`,
method: 'delete'
});
}

View file

@ -227,3 +227,19 @@ export async function deleteUser(
}); });
} }
/**
* Send password setup link to a user
* Endpoint: POST /api/users/{userId}/send-password-link
*/
export async function sendPasswordLink(
request: ApiRequestFunction,
userId: string,
frontendUrl: string
): Promise<{ message: string; userId: string; email: string }> {
return await request({
url: `/api/users/${userId}/send-password-link`,
method: 'post',
data: { frontendUrl }
});
}

View file

@ -196,6 +196,36 @@
background: var(--color-secondary-hover); background: var(--color-secondary-hover);
} }
/* Generic Custom Action Button */
.actionButton.custom {
background: var(--color-secondary);
color: white;
}
.actionButton.custom:hover {
background: var(--color-secondary-hover);
}
/* Success State */
.actionButton.success {
background: #28a745 !important;
color: white !important;
}
.actionButton.success:hover {
background: #218838 !important;
}
/* Error State */
.actionButton.error {
background: #dc3545 !important;
color: white !important;
}
.actionButton.error:hover {
background: #c82333 !important;
}
/* Responsive Design */ /* Responsive Design */
@media (max-width: 768px) { @media (max-width: 768px) {
.actionButtons { .actionButtons {
@ -274,4 +304,12 @@
.actionButton.refresh:hover { .actionButton.refresh:hover {
background: var(--color-secondary-hover); background: var(--color-secondary-hover);
} }
.actionButton.custom {
background: var(--color-secondary);
}
.actionButton.custom:hover {
background: var(--color-secondary-hover);
}
} }

View file

@ -1,119 +0,0 @@
import React, { useState } from 'react';
import { IoIosLink } from 'react-icons/io';
import { IoIosRefresh } from 'react-icons/io';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import styles from '../ActionButton.module.css';
export interface ConnectActionButtonProps<T = any> {
row: T;
disabled?: boolean | { disabled: boolean; message?: string };
loading?: boolean;
className?: string;
title?: string;
connectTitle?: string;
refreshTitle?: string;
hookData: any; // REQUIRED: Contains all hook data including operations
// Field mappings
idField?: string; // Field name for the unique identifier
statusField?: string; // Field name for the status field
operationName?: string; // Name of the connect operation in hookData
loadingStateName?: string; // Name of the loading state in hookData
}
export function ConnectActionButton<T = any>({
row,
disabled = false,
loading = false,
className = '',
title,
connectTitle,
refreshTitle,
hookData,
idField = 'id',
statusField = 'status',
operationName = 'connectWithPopup',
loadingStateName = 'isConnecting'
}: ConnectActionButtonProps<T>) {
const { t } = useLanguage();
const [isProcessing, setIsProcessing] = useState(false);
// Extract disabled state and tooltip message
const isDisabled = typeof disabled === 'boolean' ? disabled : disabled?.disabled || false;
const disabledMessage = typeof disabled === 'object' ? disabled?.message : undefined;
// Validate that hookData is provided with required operations
if (!hookData) {
throw new Error('ConnectActionButton requires hookData to be provided');
}
// Get the connection data from the row
const connectionStatus = (row as any)[statusField];
const connectionId = (row as any)[idField];
const isActive = connectionStatus === 'active';
// Extract operations from hookData
const handleConnect = hookData[operationName];
const refetch = hookData.refetch;
const loadingState = hookData[loadingStateName];
// Validate required operations exist
if (!handleConnect) {
throw new Error(`ConnectActionButton requires hookData.${operationName} to be defined`);
}
if (!refetch) {
throw new Error('ConnectActionButton requires hookData.refetch to be defined');
}
const handleClick = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!isDisabled && !loading && !isProcessing) {
setIsProcessing(true);
try {
// Always use the connect operation for both active and inactive connections
// The backend will handle refreshing tokens for active connections
if (handleConnect) {
await handleConnect(connectionId);
}
// Refetch to update the connection status
if (refetch) {
await refetch();
}
} catch (error: any) {
console.error('Connection operation failed:', error);
} finally {
setIsProcessing(false);
}
}
};
// Determine button title and icon based on connection status
const defaultTitle = isActive
? (refreshTitle || t('connections.action.refresh', 'Refresh'))
: (connectTitle || t('connections.action.connect', 'Connect'));
const buttonTitle = title || defaultTitle;
const buttonIcon = isActive ? <IoIosRefresh /> : <IoIosLink />;
// Check if this specific connection is being processed
const isLoadingState = loadingState === true || isProcessing;
// Determine the final button title (tooltip)
const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle;
return (
<button
onClick={handleClick}
className={`${styles.actionButton} ${isActive ? styles.refresh : styles.connect} ${isLoadingState ? styles.loading : ''} ${isDisabled ? styles.disabled : ''} ${className}`}
title={finalTitle}
disabled={isDisabled || loading || isLoadingState}
>
<span className={styles.actionIcon}>
{isLoadingState ? <IoIosRefresh /> : buttonIcon}
</span>
</button>
);
}
export default ConnectActionButton;

View file

@ -1,3 +0,0 @@
export { default as ConnectActionButton } from './ConnectActionButton';
export type { ConnectActionButtonProps } from './ConnectActionButton';

View file

@ -0,0 +1,108 @@
import React, { useState } from 'react';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import styles from '../ActionButton.module.css';
export interface CustomActionButtonProps<T = any> {
row: T;
id: string; // Unique identifier for the action
icon: React.ReactNode; // Icon component to display
onClick: (row: T, hookData?: any) => Promise<void> | void; // Handler function
visible?: (row: T, hookData?: any) => boolean; // Show/hide based on row data
disabled?: (row: T, hookData?: any) => boolean | { disabled: boolean; message?: string };
loading?: (row: T, hookData?: any) => boolean;
title?: string | ((row: T) => string); // Tooltip text or translation key
className?: string; // Optional custom CSS class
hookData?: any; // Hook data passed through for context
idField?: string; // Field name for unique identifier (default: 'id')
}
export function CustomActionButton<T = any>({
row,
id,
icon,
onClick,
visible,
disabled,
loading,
title,
className = '',
hookData,
idField: _idField = 'id' // Available for future use, kept for API consistency
}: CustomActionButtonProps<T>) {
const { t } = useLanguage();
const [internalLoading, setInternalLoading] = useState(false);
const [showSuccessFeedback, setShowSuccessFeedback] = useState(false);
const [showErrorFeedback, setShowErrorFeedback] = useState(false);
// Check visibility - if not visible, don't render
if (visible && !visible(row, hookData)) {
return null;
}
// Extract disabled state and tooltip message
const disabledResult = disabled ? disabled(row, hookData) : false;
const isDisabled = typeof disabledResult === 'boolean' ? disabledResult : disabledResult?.disabled || false;
const disabledMessage = typeof disabledResult === 'object' ? disabledResult?.message : undefined;
// Check loading state
const isLoadingFromProp = loading ? loading(row, hookData) : false;
const isLoading = isLoadingFromProp || internalLoading;
// Resolve title
let buttonTitle = '';
if (typeof title === 'function') {
buttonTitle = title(row);
} else if (typeof title === 'string') {
buttonTitle = t(title, title); // Try to translate, fallback to original
}
// Determine the final tooltip
const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle;
const handleClick = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!isDisabled && !isLoading) {
setInternalLoading(true);
setShowErrorFeedback(false);
setShowSuccessFeedback(false);
try {
await onClick(row, hookData);
// Show brief success feedback
setShowSuccessFeedback(true);
setTimeout(() => setShowSuccessFeedback(false), 2000);
} catch (error) {
console.error(`CustomActionButton (${id}): Action failed:`, error);
setShowErrorFeedback(true);
setTimeout(() => setShowErrorFeedback(false), 3000);
} finally {
setInternalLoading(false);
}
}
};
// Determine icon to show based on state
let displayIcon: React.ReactNode = icon;
if (isLoading) {
displayIcon = '⏳';
} else if (showSuccessFeedback) {
displayIcon = '✓';
} else if (showErrorFeedback) {
displayIcon = '✗';
}
return (
<button
onClick={handleClick}
className={`${styles.actionButton} ${styles.custom} ${isLoading ? styles.loading : ''} ${isDisabled ? styles.disabled : ''} ${showSuccessFeedback ? styles.success : ''} ${showErrorFeedback ? styles.error : ''} ${className}`}
title={finalTitle}
disabled={isDisabled || isLoading}
data-action-id={id}
>
<span className={styles.actionIcon}>
{displayIcon}
</span>
</button>
);
}
export default CustomActionButton;

View file

@ -0,0 +1,2 @@
export { CustomActionButton } from './CustomActionButton';
export type { CustomActionButtonProps } from './CustomActionButton';

View file

@ -1,114 +0,0 @@
import React, { useState } from 'react';
import { IoIosDownload } from 'react-icons/io';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import styles from '../ActionButton.module.css';
export interface DownloadActionButtonProps<T = any> {
row: T;
onDownload: (row: T) => Promise<void> | void;
disabled?: boolean | { disabled: boolean; message?: string };
loading?: boolean;
className?: string;
title?: string;
isDownloading?: boolean;
hookData?: any; // Contains all hook data including operations
// Field mappings
idField?: string; // Field name for the unique identifier
nameField?: string; // Field name for file name (with extension)
loadingStateName?: string; // Name of the loading state in hookData
operationName?: string; // Name of the operation function in hookData
}
export function DownloadActionButton<T = any>({
row,
onDownload,
disabled = false,
loading = false,
className = '',
title,
isDownloading = false,
hookData,
idField = 'id',
nameField,
loadingStateName = 'downloadingFiles',
operationName
}: DownloadActionButtonProps<T>) {
const { t } = useLanguage();
const [internalLoading, setInternalLoading] = useState(false);
// Extract disabled state and tooltip message
const isDisabled = typeof disabled === 'boolean' ? disabled : disabled?.disabled || false;
const disabledMessage = typeof disabled === 'object' ? disabled?.message : undefined;
// Extract file name from row using nameField or fallback to common field names
const getFileName = (): string => {
const rowAny = row as any;
// If nameField is explicitly provided, use it
if (nameField && rowAny[nameField]) {
return rowAny[nameField];
}
// Try common field names in order of preference
if (rowAny.fileName) return rowAny.fileName;
if (rowAny.file_name) return rowAny.file_name;
if (rowAny.name) return rowAny.name;
// Fallback: try to find any field that might contain the file name
const possibleFields = ['fileName', 'file_name', 'name', 'filename'];
for (const field of possibleFields) {
if (rowAny[field]) {
return rowAny[field];
}
}
// Last resort: use id or a default
return rowAny[idField] || 'download';
};
const handleClick = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!isDisabled && !loading && !isDownloading && !internalLoading) {
setInternalLoading(true);
try {
// If operationName is provided and hookData is available, use the hook function
if (operationName && hookData && hookData[operationName]) {
const fileId = (row as any)[idField];
const fileName = getFileName();
await hookData[operationName](fileId, fileName);
} else if (onDownload) {
// Fallback to the provided onDownload function
await onDownload(row);
} else {
console.error('No download function available');
}
} finally {
setInternalLoading(false);
}
}
};
const buttonTitle = title || t('files.action.download', 'Download');
// Use hookData downloading state if available, otherwise use passed isDownloading
const loadingState = hookData?.[loadingStateName];
const actualIsDownloading = loadingState?.has((row as any)[idField]) || isDownloading;
const isLoading = loading || actualIsDownloading || internalLoading;
// Determine the final button title (tooltip)
const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle;
return (
<button
onClick={handleClick}
className={`${styles.actionButton} ${styles.download} ${isLoading ? styles.loading : ''} ${isDisabled ? styles.disabled : ''} ${className}`}
title={finalTitle}
disabled={isDisabled || isLoading}
>
<span className={styles.actionIcon}>
{isLoading ? '⏳' : <IoIosDownload />}
</span>
</button>
);
}
export default DownloadActionButton;

View file

@ -1,2 +0,0 @@
export { default as DownloadActionButton } from './DownloadActionButton';
export type { DownloadActionButtonProps } from './DownloadActionButton';

View file

@ -1,117 +0,0 @@
import React from 'react';
import { IoIosPlay } from 'react-icons/io';
import { useNavigate } from 'react-router-dom';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { useWorkflowSelection } from '../../../../contexts/WorkflowSelectionContext';
import styles from '../ActionButton.module.css';
export interface PlayActionButtonProps<T = any> {
row: T;
onPlay?: (row: T) => Promise<void> | void;
disabled?: boolean | { disabled: boolean; message?: string };
loading?: boolean;
className?: string;
title?: string;
hookData?: any; // Contains all hook data including operations
// Field mappings
idField?: string; // Field name for the unique identifier
nameField?: string; // Field name for display name
contentField?: string; // Field name for content (e.g., 'content' for prompts, 'prompt' for workflows)
// Navigation
navigateTo?: string; // Path to navigate to after selection (default: 'start/dashboard')
// Behavior
mode?: 'workflow' | 'prompt'; // 'workflow' selects workflow, 'prompt' sets input value
}
export function PlayActionButton<T = any>({
row,
onPlay,
disabled = false,
loading = false,
className = '',
title,
hookData: _hookData,
idField = 'id',
nameField: _nameField = 'name',
contentField = 'content',
navigateTo = 'start/dashboard',
mode = 'prompt'
}: PlayActionButtonProps<T>) {
const { t } = useLanguage();
const navigate = useNavigate();
const { selectWorkflow } = useWorkflowSelection();
// Extract disabled state and tooltip message
const isDisabled = typeof disabled === 'boolean' ? disabled : disabled?.disabled || false;
const disabledMessage = typeof disabled === 'object' ? disabled?.message : undefined;
const handleClick = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!isDisabled && !loading) {
try {
// Call the onPlay callback if provided
if (onPlay) {
await onPlay(row);
}
if (mode === 'workflow') {
// Workflow mode: reset workflow state and select workflow
const workflowId = (row as any)[idField];
if (!workflowId) {
console.error('Workflow ID not found in row');
return;
}
// Dispatch event to reset workflow state before selecting new one
// This ensures the dashboard resets and loads the selected workflow
window.dispatchEvent(new CustomEvent('workflowCleared', {
detail: { workflowId: null }
}));
// Select the workflow in context (this will trigger sync in dashboard)
selectWorkflow(workflowId);
// Also dispatch workflowSelected event for any other listeners
window.dispatchEvent(new CustomEvent('workflowSelected', {
detail: { workflowId }
}));
} else {
// Prompt mode: set input value in dashboard
const content = (row as any)[contentField];
if (content && typeof content === 'string') {
// Dispatch event to set dashboard input value
window.dispatchEvent(new CustomEvent('dashboardSetInput', {
detail: { value: content }
}));
}
}
// Navigate to dashboard (or specified path)
navigate(`/${navigateTo}`);
} catch (error: any) {
console.error('Error in PlayActionButton:', error);
}
}
};
const buttonTitle = title || (mode === 'workflow'
? t('workflows.action.play', 'Play')
: t('prompts.action.start', 'Start Prompt'));
const isLoading = loading;
const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle;
return (
<button
onClick={handleClick}
className={`${styles.actionButton} ${styles.play} ${isLoading ? styles.loading : ''} ${isDisabled ? styles.disabled : ''} ${className}`}
title={finalTitle}
disabled={isDisabled || isLoading}
>
<span className={styles.actionIcon}>
{isLoading ? '⏳' : <IoIosPlay />}
</span>
</button>
);
}
export default PlayActionButton;

View file

@ -1,2 +0,0 @@
export { PlayActionButton } from './PlayActionButton';
export type { PlayActionButtonProps } from './PlayActionButton';

View file

@ -1,19 +1,17 @@
// Action Button Components // Standard Action Button Components (built-in)
export { EditActionButton } from './EditActionButton'; export { EditActionButton } from './EditActionButton';
export { DeleteActionButton } from './DeleteActionButton'; export { DeleteActionButton } from './DeleteActionButton';
export { DownloadActionButton } from './DownloadActionButton';
export { ViewActionButton } from './ViewActionButton'; export { ViewActionButton } from './ViewActionButton';
export { CopyActionButton } from './CopyActionButton'; export { CopyActionButton } from './CopyActionButton';
export { ConnectActionButton } from './ConnectActionButton';
export { PlayActionButton } from './PlayActionButton';
export { RemoveActionButton } from './RemoveActionButton'; export { RemoveActionButton } from './RemoveActionButton';
// Generic Custom Action Button (for entity-specific actions)
export { CustomActionButton } from './CustomActionButton';
// Action Button Types // Action Button Types
export type { EditActionButtonProps } from './EditActionButton'; export type { EditActionButtonProps } from './EditActionButton';
export type { DeleteActionButtonProps } from './DeleteActionButton'; export type { DeleteActionButtonProps } from './DeleteActionButton';
export type { DownloadActionButtonProps } from './DownloadActionButton';
export type { ViewActionButtonProps } from './ViewActionButton'; export type { ViewActionButtonProps } from './ViewActionButton';
export type { CopyActionButtonProps } from './CopyActionButton'; export type { CopyActionButtonProps } from './CopyActionButton';
export type { ConnectActionButtonProps } from './ConnectActionButton';
export type { PlayActionButtonProps } from './PlayActionButton';
export type { RemoveActionButtonProps } from './RemoveActionButton'; export type { RemoveActionButtonProps } from './RemoveActionButton';
export type { CustomActionButtonProps } from './CustomActionButton';

View file

@ -26,6 +26,15 @@
flex-shrink: 0; flex-shrink: 0;
} }
.activeFiltersCount {
font-size: 12px;
color: var(--color-secondary);
background: rgba(var(--color-secondary-rgb), 0.1);
padding: 4px 8px;
border-radius: 12px;
white-space: nowrap;
}
.refreshButton { .refreshButton {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -4,7 +4,6 @@ import styles from './FormGeneratorControls.module.css';
import { Button } from '../../UiComponents/Button'; import { Button } from '../../UiComponents/Button';
import { IoIosRefresh } from "react-icons/io"; import { IoIosRefresh } from "react-icons/io";
import { FaTrash } from "react-icons/fa"; import { FaTrash } from "react-icons/fa";
import { isCheckboxType } from '../../../utils/attributeTypeMapper';
import type { AttributeType } from '../../../utils/attributeTypeMapper'; import type { AttributeType } from '../../../utils/attributeTypeMapper';
// Generic field/column config interface // Generic field/column config interface
@ -26,7 +25,7 @@ export interface FormGeneratorControlsProps {
searchFocused: boolean; searchFocused: boolean;
onSearchFocus: (focused: boolean) => void; onSearchFocus: (focused: boolean) => void;
// Filter state // Filter state (kept for compatibility but not used in this component)
filters: Record<string, any>; filters: Record<string, any>;
onFilterChange: (key: string, value: any) => void; onFilterChange: (key: string, value: any) => void;
filterFocused: Record<string, boolean>; filterFocused: Record<string, boolean>;
@ -49,113 +48,29 @@ export interface FormGeneratorControlsProps {
selectable?: boolean; selectable?: boolean;
loading?: boolean; loading?: boolean;
// Special date filter handler (for FormGenerator date formatting) // Active filters count for display
onDateFilterChange?: (key: string, value: string) => void; activeFiltersCount?: number;
} }
export function FormGeneratorControls({ export function FormGeneratorControls({
fields,
searchTerm, searchTerm,
onSearchChange, onSearchChange,
searchFocused, searchFocused,
onSearchFocus, onSearchFocus,
filters,
onFilterChange,
filterFocused,
onFilterFocus,
selectedCount, selectedCount,
displayData, displayData,
onDeleteSingle, onDeleteSingle,
onDeleteMultiple, onDeleteMultiple,
onRefresh, onRefresh,
searchable = true, searchable = true,
filterable = true,
selectable = true, selectable = true,
loading = false, loading = false,
onDateFilterChange activeFiltersCount = 0
}: FormGeneratorControlsProps) { }: FormGeneratorControlsProps) {
const { t } = useLanguage(); const { t } = useLanguage();
// Check if all items are selected // Check if all items are selected
const allItemsSelected = selectedCount > 0 && displayData.length > 0 && selectedCount === displayData.length; const allItemsSelected = selectedCount > 0 && displayData.length > 0 && selectedCount === displayData.length;
// Filter fields that are filterable
const filterableFields = fields.filter(field => {
if (field.type === 'readonly') return false;
return field.filterable !== false;
});
// Handle date filter with special formatting (for FormGenerator)
const handleDateFilterChange = (key: string, value: string) => {
if (onDateFilterChange) {
onDateFilterChange(key, value);
return;
}
// Default behavior for FormGeneratorList
onFilterChange(key, value);
};
// Date filter formatting logic (for FormGenerator)
const handleDateFilterInput = (key: string, e: React.ChangeEvent<HTMLInputElement>) => {
let value = e.target.value;
const currentValue = filters[key] || '';
// Check if user is deleting (new value is shorter)
const isDeleting = value.length < currentValue.length;
if (isDeleting) {
// When deleting, preserve the exact input without auto-formatting
handleDateFilterChange(key, value);
return;
}
// Auto-pad single digits followed by dot (e.g., "4." -> "04.")
value = value.replace(/^(\d)\./, '0$1.');
value = value.replace(/\.(\d)\./, '.0$1.');
// Allow typing and format as DD.MM.YYYY
const digitsOnly = value.replace(/\D/g, ''); // Remove non-digits
let formatted = '';
if (digitsOnly.length >= 8) {
// Full format: DDMMYYYY -> DD.MM.YYYY
const day = digitsOnly.slice(0, 2);
const month = digitsOnly.slice(2, 4);
const year = digitsOnly.slice(4, 8);
// Validate day (01-31) and month (01-12)
if (parseInt(day) > 31 || parseInt(month) > 12 || parseInt(day) === 0 || parseInt(month) === 0) {
return; // Don't update if invalid
}
formatted = `${day}.${month}.${year}`;
} else if (digitsOnly.length >= 4) {
// Partial format: DDMM -> DD.MM.
const day = digitsOnly.slice(0, 2);
const month = digitsOnly.slice(2, 4);
const remaining = digitsOnly.slice(4);
// Validate day (01-31) and month (01-12)
if (parseInt(day) > 31 || parseInt(month) > 12 || parseInt(day) === 0 || parseInt(month) === 0) {
return; // Don't update if invalid
}
formatted = `${day}.${month}.${remaining}`;
} else if (digitsOnly.length >= 2) {
// Start format: DD -> DD.
const day = digitsOnly.slice(0, 2);
const remaining = digitsOnly.slice(2);
// Validate day (01-31)
if (parseInt(day) > 31 || parseInt(day) === 0) {
return; // Don't update if invalid
}
formatted = `${day}.${remaining}`;
} else {
// Just digits
formatted = digitsOnly;
}
handleDateFilterChange(key, formatted);
};
return ( return (
<div className={styles.controls}> <div className={styles.controls}>
@ -204,6 +119,11 @@ export function FormGeneratorControls({
{t('formgen.search.placeholder')} {t('formgen.search.placeholder')}
</label> </label>
</div> </div>
{activeFiltersCount > 0 && (
<span className={styles.activeFiltersCount}>
{activeFiltersCount} {t('formgen.filter.active', 'filter(s)')}
</span>
)}
{onRefresh && ( {onRefresh && (
<button <button
onClick={onRefresh} onClick={onRefresh}
@ -216,93 +136,6 @@ export function FormGeneratorControls({
)} )}
</div> </div>
)} )}
{/* Filters */}
{filterable && (
<div className={styles.filtersContainer}>
{filterableFields.map(field => (
<div key={field.key} className={styles.filterGroup}>
{field.type && isCheckboxType(field.type) ? (
<div className={styles.customSelectContainer}>
<select
value={filters[field.key] || ''}
onChange={(e) => onFilterChange(field.key, e.target.value === '' ? undefined : e.target.value === 'true')}
className={`${styles.filterSelect} ${filters[field.key] ? styles.hasValue : ''}`}
>
<option value="" disabled hidden>{field.label}</option>
<option value="true">{t('formgen.filter.yes')}</option>
<option value="false">{t('formgen.filter.no')}</option>
</select>
{filters[field.key] && (
<button
type="button"
onClick={() => onFilterChange(field.key, '')}
className={styles.clearFilterButton}
title={t('formgen.filter.clear')}
>
</button>
)}
</div>
) : field.filterOptions ? (
<div className={styles.customSelectContainer}>
<select
value={filters[field.key] || ''}
onChange={(e) => onFilterChange(field.key, e.target.value)}
className={`${styles.filterSelect} ${filters[field.key] ? styles.hasValue : ''}`}
>
<option value="" disabled hidden>{field.label}</option>
{field.filterOptions.map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
{filters[field.key] && (
<button
type="button"
onClick={() => onFilterChange(field.key, '')}
className={styles.clearFilterButton}
title={t('formgen.filter.clear')}
>
</button>
)}
</div>
) : field.type === 'date' ? (
<div className={styles.floatingLabelInput}>
<input
type="text"
placeholder=" "
value={filters[field.key] || ''}
onChange={(e) => handleDateFilterInput(field.key, e)}
onFocus={() => onFilterFocus(field.key, true)}
onBlur={() => onFilterFocus(field.key, false)}
className={`${styles.filterInput} ${filterFocused[field.key] || filters[field.key] ? styles.focused : ''}`}
maxLength={10}
/>
<label className={filterFocused[field.key] || filters[field.key] ? styles.focusedLabel : styles.label}>
{field.label}
</label>
</div>
) : (
<div className={styles.floatingLabelInput}>
<input
type="text"
placeholder=" "
value={filters[field.key] || ''}
onChange={(e) => onFilterChange(field.key, e.target.value)}
onFocus={() => onFilterFocus(field.key, true)}
onBlur={() => onFilterFocus(field.key, false)}
className={`${styles.filterInput} ${filterFocused[field.key] || filters[field.key] ? styles.focused : ''}`}
/>
<label className={filterFocused[field.key] || filters[field.key] ? styles.focusedLabel : styles.label}>
{t('formgen.filter.placeholder').replace('{column}', field.label)}
</label>
</div>
)}
</div>
))}
</div>
)}
</div> </div>
); );
} }

View file

@ -7,6 +7,7 @@ import {
isTextareaType, isTextareaType,
isSelectType, isSelectType,
isMultiselectType, isMultiselectType,
isMultilingualType,
isCheckboxType, isCheckboxType,
isFileType, isFileType,
isNumberType, isNumberType,
@ -34,11 +35,11 @@ const isMultilingualFieldName = (fieldName: string): boolean => {
const exactMultilingualFields = ['description']; const exactMultilingualFields = ['description'];
// Fields that end with these patterns (but not roleLabel, etc.) // Fields that end with these patterns (but not roleLabel, etc.)
// Note: "name" is NOT multilingual - Mandate.name and other name fields are plain strings
const multilingualPatterns = [ const multilingualPatterns = [
/^description$/i, /^description$/i,
/^label$/i, // Only exact "label", not "roleLabel" /^label$/i, // Only exact "label", not "roleLabel"
/^title$/i, // Only exact "title" /^title$/i // Only exact "title"
/^name$/i // Only exact "name", not field names containing "name"
]; ];
// Check exact matches first // Check exact matches first
@ -220,12 +221,16 @@ export function FormGeneratorForm<T extends Record<string, any>>({
// Initialize form data with defaults // Initialize form data with defaults
useEffect(() => { useEffect(() => {
// Helper to check if a field should be treated as multilingual
const isMultilingual = (attr: AttributeDefinition) =>
isMultilingualType(attr.type as AttributeType) || isMultilingualFieldName(attr.name);
if (data) { if (data) {
// Ensure TextMultilingual fields are properly initialized // Ensure TextMultilingual fields are properly initialized
const processedData: any = { ...data }; const processedData: any = { ...data };
const filteredAttrs = getFilteredAttributes(); const filteredAttrs = getFilteredAttributes();
filteredAttrs.forEach(attr => { filteredAttrs.forEach(attr => {
if (isMultilingualFieldName(attr.name) && processedData[attr.name]) { if (isMultilingual(attr) && processedData[attr.name]) {
// If it's already a TextMultilingual object, keep it // If it's already a TextMultilingual object, keep it
if (!isTextMultilingual(processedData[attr.name])) { if (!isTextMultilingual(processedData[attr.name])) {
// If it's a string, convert to TextMultilingual // If it's a string, convert to TextMultilingual
@ -242,7 +247,7 @@ export function FormGeneratorForm<T extends Record<string, any>>({
filteredAttrs.forEach(attr => { filteredAttrs.forEach(attr => {
if (attr.default !== undefined) { if (attr.default !== undefined) {
initialData[attr.name] = attr.default; initialData[attr.name] = attr.default;
} else if (isMultilingualFieldName(attr.name)) { } else if (isMultilingual(attr)) {
// Initialize TextMultilingual fields with empty object // Initialize TextMultilingual fields with empty object
initialData[attr.name] = { en: '' }; initialData[attr.name] = { en: '' };
} else { } else {
@ -380,8 +385,9 @@ export function FormGeneratorForm<T extends Record<string, any>>({
// Check required fields // Check required fields
if (attr.required) { if (attr.required) {
// Special handling for TextMultilingual fields // Special handling for TextMultilingual fields (by type or field name)
if (isMultilingualFieldName(attr.name) && isTextMultilingual(value)) { const isMultilingual = isMultilingualType(attr.type as AttributeType) || isMultilingualFieldName(attr.name);
if (isMultilingual && isTextMultilingual(value)) {
if (!value.en || typeof value.en !== 'string' || value.en.trim() === '') { if (!value.en || typeof value.en !== 'string' || value.en.trim() === '') {
newErrors[attr.name] = t('formgen.form.required', `${attr.label} (English) is required`); newErrors[attr.name] = t('formgen.form.required', `${attr.label} (English) is required`);
return; return;
@ -631,8 +637,9 @@ export function FormGeneratorForm<T extends Record<string, any>>({
const hasError = errors[attr.name]; const hasError = errors[attr.name];
const isReadonly = mode === 'display' || attr.readonly || attr.editable === false; const isReadonly = mode === 'display' || attr.readonly || attr.editable === false;
// Check if this is a multilingual field // Check if this is a multilingual field - either by type or by field name convention
if (isMultilingualFieldName(attr.name) && (isTextMultilingual(value) || value === undefined || value === null || value === '')) { if ((isMultilingualType(attr.type as AttributeType) || isMultilingualFieldName(attr.name)) &&
(isTextMultilingual(value) || value === undefined || value === null || value === '')) {
return renderMultilingualField(attr); return renderMultilingualField(attr);
} }

View file

@ -5,12 +5,11 @@ import actionButtonStyles from '../ActionButtons/ActionButton.module.css';
import { import {
EditActionButton, EditActionButton,
DeleteActionButton, DeleteActionButton,
DownloadActionButton,
ViewActionButton, ViewActionButton,
CopyActionButton, CopyActionButton,
ConnectActionButton, CustomActionButton
PlayActionButton
} from '../ActionButtons'; } from '../ActionButtons';
import { FaDownload, FaLink, FaPlay } from 'react-icons/fa';
import { formatUnixTimestamp } from '../../../utils/time'; import { formatUnixTimestamp } from '../../../utils/time';
import TextField from '../../UiComponents/TextField/TextField'; import TextField from '../../UiComponents/TextField/TextField';
import { FormGeneratorControls } from '../FormGeneratorControls'; import { FormGeneratorControls } from '../FormGeneratorControls';
@ -884,22 +883,47 @@ export function FormGeneratorList<T extends Record<string, any>>({
case 'delete': case 'delete':
return <DeleteActionButton key={actionIndex} {...baseProps} containerRef={{ current: actionButtonsRefs.current.get(index) || null }} hookData={hookData} />; return <DeleteActionButton key={actionIndex} {...baseProps} containerRef={{ current: actionButtonsRefs.current.get(index) || null }} hookData={hookData} />;
case 'download': case 'download':
return <DownloadActionButton key={actionIndex} {...baseProps} onDownload={actionButton.onAction || (() => {})} isDownloading={isProcessing} hookData={hookData} operationName={actionButton.operationName} />; return <CustomActionButton
key={actionIndex}
row={row}
id="download"
icon={<FaDownload />}
onClick={actionButton.onAction || (() => {})}
disabled={() => disabledResult}
loading={() => isProcessing}
title={actionTitle}
className={actionButton.className}
hookData={hookData}
/>;
case 'view': case 'view':
return <ViewActionButton key={actionIndex} {...baseProps} onView={actionButton.onAction || (() => {})} isViewing={isProcessing} hookData={hookData} />; return <ViewActionButton key={actionIndex} {...baseProps} onView={actionButton.onAction || (() => {})} isViewing={isProcessing} hookData={hookData} />;
case 'copy': case 'copy':
return <CopyActionButton key={actionIndex} {...baseProps} onCopy={actionButton.onAction} isCopying={isProcessing} contentField={actionButton.contentField} />; return <CopyActionButton key={actionIndex} {...baseProps} onCopy={actionButton.onAction} isCopying={isProcessing} contentField={actionButton.contentField} />;
case 'connect': case 'connect':
return <ConnectActionButton key={actionIndex} {...baseProps} hookData={hookData} />; return <CustomActionButton
case 'play':
return <PlayActionButton
key={actionIndex} key={actionIndex}
{...baseProps} row={row}
onPlay={actionButton.onAction} id="connect"
hookData={hookData} icon={<FaLink />}
navigateTo={actionButton.navigateTo} onClick={actionButton.onAction || (() => {})}
contentField={actionButton.contentField} disabled={() => disabledResult}
mode={(actionButton as any).mode || 'prompt'} loading={() => isLoading}
title={actionTitle}
className={actionButton.className}
hookData={hookData}
/>;
case 'play':
return <CustomActionButton
key={actionIndex}
row={row}
id="play"
icon={<FaPlay />}
onClick={actionButton.onAction || (() => {})}
disabled={() => disabledResult}
loading={() => isLoading}
title={actionTitle}
className={actionButton.className}
hookData={hookData}
/>; />;
default: default:
return null; return null;

View file

@ -1,12 +1,13 @@
.formGeneratorTable { .formGeneratorTable {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px; gap: 10px;
width: 100%; width: 100%;
font-family: var(--font-family); font-family: var(--font-family);
/* Ensure proper height constraints for scrolling */ /* Ensure proper height constraints for scrolling */
min-height: 0; min-height: 0;
max-height: 100%; flex: 1;
overflow: hidden;
} }
.title { .title {
@ -24,10 +25,11 @@
border: 1px solid var(--color-primary); border: 1px solid var(--color-primary);
border-radius: 25px; border-radius: 25px;
background: var(--color-bg); background: var(--color-bg);
/* Use calc to account for controls, pagination, and spacing */ /* Fill available space in flex container */
max-height: calc(100vh - 400px); flex: 1;
/* No min-height - let it shrink to fit content */ min-height: 0;
/* When empty, it will only show header */ /* Ensure scrolling within container */
max-height: 100%;
} }
/* Empty table styling - no extra space, just header */ /* Empty table styling - no extra space, just header */
@ -104,7 +106,6 @@
color: var(--color-text); color: var(--color-text);
white-space: nowrap; white-space: nowrap;
user-select: none; user-select: none;
position: relative;
z-index: 10; z-index: 10;
} }
@ -125,27 +126,159 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: left; justify-content: left;
gap: 8px; gap: 4px;
}
.columnLabel {
cursor: pointer;
flex: 1;
} }
.sortIcon { .sortIcon {
font-size: 12px; font-size: 12px;
color: var(--color-text-secondary, #999);
cursor: pointer;
padding: 2px;
display: inline-flex;
align-items: baseline;
gap: 1px;
}
.sortIcon:hover {
color: var(--color-secondary); color: var(--color-secondary);
opacity: 1.; }
.sortIcon.sortActive {
color: var(--color-secondary);
font-weight: 600;
}
.sortIcon sub {
font-size: 9px;
font-weight: 500;
}
/* Filter icon in column header */
.filterIcon {
background: none;
border: none;
color: var(--color-text-secondary, #999);
cursor: pointer;
padding: 2px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
transition: all 0.15s ease;
}
.filterIcon:hover {
color: var(--color-secondary);
background: rgba(var(--color-secondary-rgb), 0.1);
}
.filterIcon.filterActive {
color: var(--color-secondary);
background: rgba(var(--color-secondary-rgb), 0.15);
}
/* Filter dropdown */
.filterDropdown {
position: absolute;
top: 100%;
left: 0;
min-width: 180px;
max-width: 300px;
background: var(--color-bg);
border: 1px solid var(--color-border, #ddd);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 100;
margin-top: 4px;
}
.filterDropdownHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid var(--color-border, #ddd);
font-size: 12px;
font-weight: 500;
color: var(--color-text);
}
.filterClearBtn {
background: none;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 14px;
padding: 2px 6px;
border-radius: 4px;
}
.filterClearBtn:hover {
background: rgba(255, 0, 0, 0.1);
color: #c00;
}
.filterDropdownOptions {
max-height: 250px;
overflow-y: auto;
padding: 4px 0;
}
.filterOption {
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.filterOption:hover {
background: var(--color-gray-disabled, #f5f5f5);
}
.filterOptionSelected {
background: rgba(var(--color-secondary-rgb), 0.15);
color: var(--color-secondary);
font-weight: 500;
}
.filterOptionSelected:hover {
background: rgba(var(--color-secondary-rgb), 0.2);
}
.filterOptionMore {
padding: 6px 12px;
font-size: 11px;
color: var(--color-text-secondary);
font-style: italic;
} }
.resizeHandle { .resizeHandle {
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: -3px;
width: 4px; width: 8px;
height: 100%; height: 100%;
cursor: col-resize; cursor: col-resize;
z-index: 11; z-index: 20;
background: transparent;
} }
.resizeHandle:hover { .resizeHandle:hover {
background: var(--color-secondary); background: var(--color-secondary);
opacity: 0.5;
}
.resizeHandle:active {
background: var(--color-secondary);
opacity: 0.8;
} }
.td { .td {
@ -312,15 +445,14 @@ tbody .actionsColumn {
.pagination { .pagination {
display: flex; display: flex;
justify-content: space-between; justify-content: flex-end;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 15px; padding: 8px 0;
border-top: 1px solid var(--color-primary);
/* Ensure pagination stays visible and doesn't get cut off */ /* Ensure pagination stays visible and doesn't get cut off */
flex-shrink: 0; flex-shrink: 0;
flex-wrap: wrap;
background: var(--color-bg); background: var(--color-bg);
border-radius: 0 0 8px 8px;
} }
.pageSizeSelector { .pageSizeSelector {
@ -388,11 +520,90 @@ tbody .actionsColumn {
white-space: nowrap; white-space: nowrap;
} }
/* Page numbers container */
.pageNumbers {
display: flex;
flex-wrap: wrap;
gap: 2px;
align-items: center;
justify-content: flex-start;
max-width: 60vw;
max-height: 120px;
overflow-y: auto;
padding: 4px;
}
/* Individual page number button */
.pageNumber {
min-width: 28px;
height: 28px;
padding: 0 6px;
border: 1px solid var(--color-border, #ddd);
background: var(--color-bg, #fff);
color: var(--color-text);
border-radius: 4px;
cursor: pointer;
font-family: var(--font-family);
font-size: 12px;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
}
.pageNumber:hover:not(:disabled) {
background: var(--color-secondary);
color: white;
border-color: var(--color-secondary);
}
.pageNumber:disabled {
cursor: default;
}
/* Active/current page number */
.pageNumberActive {
background: var(--color-secondary);
color: white;
border-color: var(--color-secondary);
font-weight: 600;
}
/* Ellipsis indicator */
.pageEllipsis {
padding: 0 8px;
color: var(--color-text-secondary, #666);
font-size: 14px;
}
/* Loading overlay */
.loadingOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.85);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 10;
border-radius: 8px;
}
.loadingOverlay p {
margin-top: 12px;
color: var(--color-text-secondary, #666);
font-size: 14px;
}
/* Responsive Design */ /* Responsive Design */
@media (max-width: 768px) { @media (max-width: 768px) {
.tableContainer { .tableContainer {
max-height: calc(100vh - 350px); flex: 1;
/* No min-height on mobile - let it shrink to fit content */ min-height: 0;
max-height: 100%;
} }
/* Empty table styling - no extra space */ /* Empty table styling - no extra space */
@ -433,6 +644,17 @@ tbody .actionsColumn {
margin: 0; margin: 0;
font-size: 13px; font-size: 13px;
} }
.pageNumbers {
max-width: 100%;
justify-content: center;
}
.pageNumber {
min-width: 24px;
height: 24px;
font-size: 11px;
}
} }
/* Dark theme support */ /* Dark theme support */
@ -448,6 +670,15 @@ tbody .actionsColumn {
.tr.selected { .tr.selected {
background: rgba(var(--color-secondary-rgb), 0.2); background: rgba(var(--color-secondary-rgb), 0.2);
} }
.loadingOverlay {
background: rgba(30, 30, 30, 0.9);
}
.pageNumber {
background: var(--color-bg, #2d2d2d);
border-color: var(--color-border, #444);
}
} }
/* Accessibility */ /* Accessibility */
@ -502,3 +733,83 @@ tbody .actionsColumn {
100% { transform: rotate(360deg); } 100% { transform: rotate(360deg); }
} }
/* Inline Editable Boolean Cells */
.booleanCell {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
font-size: 16px;
font-weight: bold;
border-radius: 4px;
transition: all 0.15s ease;
user-select: none;
}
.booleanEditable {
cursor: pointer;
background: transparent;
border: 2px solid var(--color-border, #dee2e6);
}
.booleanEditable:hover {
transform: scale(1.1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.booleanEditable:active {
transform: scale(0.95);
}
.booleanEditable.booleanTrue {
color: var(--color-success, #28a745);
border-color: var(--color-success, #28a745);
background: rgba(40, 167, 69, 0.1);
}
.booleanEditable.booleanTrue:hover {
background: rgba(40, 167, 69, 0.2);
}
.booleanEditable.booleanFalse {
color: var(--color-text-secondary, #6c757d);
border-color: var(--color-border, #dee2e6);
background: transparent;
}
.booleanEditable.booleanFalse:hover {
color: var(--color-danger, #dc3545);
border-color: var(--color-danger, #dc3545);
background: rgba(220, 53, 69, 0.1);
}
.booleanReadonly {
cursor: default;
background: transparent;
border: none;
opacity: 0.7;
}
.booleanReadonly.booleanTrue {
color: var(--color-success, #28a745);
}
.booleanReadonly.booleanFalse {
color: var(--color-text-secondary, #adb5bd);
}
.booleanLoading {
display: inline-flex;
align-items: center;
justify-content: center;
animation: booleanPulse 1s ease-in-out infinite;
color: var(--color-primary, #007bff);
font-size: 14px;
}
@keyframes booleanPulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}

View file

@ -221,4 +221,43 @@
opacity: 1 !important; opacity: 1 !important;
visibility: visible !important; visibility: visible !important;
color: #181818 !important; color: #181818 !important;
}
/* Active state for submenu items */
.submenuList li.active {
background-color: var(--color-secondary);
border-radius: 0 25px 25px 0;
}
.submenuList li.active a,
.submenuList li.active a span,
.submenuList li a.activeLink {
color: white !important;
}
.submenuList li.active .submenuIcon {
color: white !important;
}
/* Active state for horizontal (minimized) submenu items */
.submenuHorizontalItem.active .submenuHorizontalLink,
.submenuHorizontalLink.activeLink {
background-color: var(--color-secondary);
color: white !important;
}
.submenuHorizontalItem.active .submenuHorizontalIcon,
.submenuHorizontalLink.activeLink .submenuHorizontalIcon {
color: white !important;
}
.submenuHorizontalItem.active .submenuHorizontalIcon svg,
.submenuHorizontalLink.activeLink .submenuHorizontalIcon svg {
color: white !important;
}
.submenuHorizontalItem.active .submenuHorizontalIcon svg path,
.submenuHorizontalLink.activeLink .submenuHorizontalIcon svg path {
fill: white !important;
stroke: white !important;
} }

View file

@ -1,10 +1,95 @@
import styles from './SidebarStyles/SidebarSubmenu.module.css'; import styles from './SidebarStyles/SidebarSubmenu.module.css';
import { Link } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { useRef, useEffect, useState } from 'react'; import React, { useRef, useEffect, useState } from 'react';
import { SidebarSubmenuProps } from './sidebarTypes'; import { SidebarSubmenuProps, SidebarSubmenuItemData } from './sidebarTypes';
// Separate component for submenu item to properly use hooks
interface SubmenuItemProps {
subitem: SidebarSubmenuItemData;
isActive: boolean;
}
const SubmenuItem: React.FC<SubmenuItemProps> = ({ subitem, isActive }) => {
const textRef = useRef<HTMLSpanElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isOverflowing, setIsOverflowing] = useState(false);
useEffect(() => {
const checkOverflow = () => {
if (textRef.current && containerRef.current) {
const textWidth = textRef.current.scrollWidth;
const containerWidth = containerRef.current.clientWidth;
setIsOverflowing(textWidth > containerWidth);
}
};
checkOverflow();
// Also check on window resize
window.addEventListener('resize', checkOverflow);
return () => window.removeEventListener('resize', checkOverflow);
}, [subitem.name]);
const SubIcon = subitem.icon as React.ComponentType<React.SVGProps<SVGSVGElement>>;
return (
<li className={isActive ? styles.active : ''}>
<Link
to={subitem.link || '#'}
title={subitem.name}
className={isActive ? styles.activeLink : ''}
>
<div
ref={containerRef}
className={styles.textContainer}
>
<motion.span
ref={textRef}
style={{
display: 'block',
whiteSpace: 'nowrap',
}}
initial={{ x: 0 }}
animate={{ x: 0 }}
{...(isOverflowing && {
whileHover: {
x: -(textRef.current?.scrollWidth || 0) + (containerRef.current?.clientWidth || 153),
transition: {
duration: 2,
ease: "linear"
}
}
})}
>
<div style={{ display: 'flex', alignItems: 'center', paddingRight: '10px' }}>
{SubIcon && <SubIcon className={styles.submenuIcon} />}
<span style={{ marginLeft: SubIcon ? '8px' : '0' }}>
{subitem.name}
</span>
</div>
</motion.span>
</div>
</Link>
</li>
);
};
const SidebarSubmenu: React.FC<SidebarSubmenuProps> = ({ item, isOpen, isMinimized = false }) => { const SidebarSubmenu: React.FC<SidebarSubmenuProps> = ({ item, isOpen, isMinimized = false }) => {
const location = useLocation();
// Check if a submenu item is active
const isSubmenuItemActive = (itemPath?: string) => {
if (!itemPath) return false;
const currentPath = location.pathname;
// Exact match or prefix match at path segment boundary
if (currentPath === itemPath) return true;
if (currentPath.startsWith(itemPath)) {
const nextChar = currentPath[itemPath.length];
if (nextChar === '/' || nextChar === undefined) return true;
}
return false;
};
if (!item.submenu) return null; if (!item.submenu) return null;
return ( return (
@ -42,13 +127,14 @@ const SidebarSubmenu: React.FC<SidebarSubmenuProps> = ({ item, isOpen, isMinimiz
<ul className={styles.submenuHorizontalList}> <ul className={styles.submenuHorizontalList}>
{item.submenu.map(subitem => { {item.submenu.map(subitem => {
const SubIcon = subitem.icon as React.ComponentType<React.SVGProps<SVGSVGElement>>; const SubIcon = subitem.icon as React.ComponentType<React.SVGProps<SVGSVGElement>>;
const isActive = isSubmenuItemActive(subitem.link);
return ( return (
<li key={subitem.id} className={styles.submenuHorizontalItem}> <li key={subitem.id} className={`${styles.submenuHorizontalItem} ${isActive ? styles.active : ''}`}>
<Link <Link
to={subitem.link || '#'} to={subitem.link || '#'}
title={subitem.name} title={subitem.name}
className={styles.submenuHorizontalLink} className={`${styles.submenuHorizontalLink} ${isActive ? styles.activeLink : ''}`}
> >
{SubIcon && ( {SubIcon && (
<SubIcon <SubIcon
@ -56,7 +142,7 @@ const SidebarSubmenu: React.FC<SidebarSubmenuProps> = ({ item, isOpen, isMinimiz
style={{ style={{
width: '16px', width: '16px',
height: '16px', height: '16px',
color: '#181818', color: isActive ? 'white' : '#181818',
display: 'block' display: 'block'
}} }}
/> />
@ -77,68 +163,13 @@ const SidebarSubmenu: React.FC<SidebarSubmenuProps> = ({ item, isOpen, isMinimiz
exit={{ opacity: 0, transition: { duration: 0.25, delay: 0 } }} exit={{ opacity: 0, transition: { duration: 0.25, delay: 0 } }}
> >
<ul className={styles.submenuList}> <ul className={styles.submenuList}>
{item.submenu.map(subitem => { {item.submenu.map(subitem => (
const textRef = useRef<HTMLSpanElement>(null); <SubmenuItem
const containerRef = useRef<HTMLDivElement>(null); key={subitem.id}
const [isOverflowing, setIsOverflowing] = useState(false); subitem={subitem}
isActive={isSubmenuItemActive(subitem.link)}
useEffect(() => { />
const checkOverflow = () => { ))}
if (textRef.current && containerRef.current) {
const textWidth = textRef.current.scrollWidth;
const containerWidth = containerRef.current.clientWidth;
setIsOverflowing(textWidth > containerWidth);
}
};
checkOverflow();
// Also check on window resize
window.addEventListener('resize', checkOverflow);
return () => window.removeEventListener('resize', checkOverflow);
}, [subitem.name]);
const SubIcon = subitem.icon as React.ComponentType<React.SVGProps<SVGSVGElement>>;
return (
<li key={subitem.id}>
<Link
to={subitem.link || '#'}
title={subitem.name}
>
<div
ref={containerRef}
className={styles.textContainer}
>
<motion.span
ref={textRef}
style={{
display: 'block',
whiteSpace: 'nowrap',
}}
initial={{ x: 0 }}
animate={{ x: 0 }}
{...(isOverflowing && {
whileHover: {
x: -(textRef.current?.scrollWidth || 0) + (containerRef.current?.clientWidth || 153),
transition: {
duration: 2,
ease: "linear"
}
}
})}
>
<div style={{ display: 'flex', alignItems: 'center', paddingRight: '10px' }}>
{SubIcon && <SubIcon className={styles.submenuIcon} />}
<span style={{ marginLeft: SubIcon ? '8px' : '0' }}>
{subitem.name}
</span>
</div>
</motion.span>
</div>
</Link>
</li>
);
})}
</ul> </ul>
</motion.div> </motion.div>
)} )}

View file

@ -34,9 +34,27 @@ export const useSidebarLogic = (): SidebarContextType => {
}, [state.openItemId]); }, [state.openItemId]);
// Check if an item is the active route // Check if an item is the active route
// Supports exact match and prefix match (for parent items when child route is active)
const isItemActive = useCallback((itemPath?: string) => { const isItemActive = useCallback((itemPath?: string) => {
if (!itemPath) return false; if (!itemPath) return false;
return location.pathname === itemPath;
const currentPath = location.pathname;
// Exact match
if (currentPath === itemPath) return true;
// Prefix match: check if current path starts with the item path
// This highlights parent items when a child/subpage is active
// Ensure we match at path segment boundaries (e.g., /admin matches /admin/users but not /administrator)
if (currentPath.startsWith(itemPath)) {
// Check if the next character is either '/' or end of string
const nextChar = currentPath[itemPath.length];
if (nextChar === '/' || nextChar === undefined) {
return true;
}
}
return false;
}, [location.pathname]); }, [location.pathname]);
// Minimize sidebar // Minimize sidebar

View file

@ -4,11 +4,10 @@ import {
DeleteActionButton, DeleteActionButton,
RemoveActionButton, RemoveActionButton,
EditActionButton, EditActionButton,
DownloadActionButton,
CopyActionButton, CopyActionButton,
ConnectActionButton, CustomActionButton
PlayActionButton
} from '../../FormGenerator/ActionButtons'; } from '../../FormGenerator/ActionButtons';
import { FaDownload, FaLink, FaPlay } from 'react-icons/fa';
import { WorkflowFile } from '../../../hooks/usePlayground'; import { WorkflowFile } from '../../../hooks/usePlayground';
import styles from './ConnectedFilesList.module.css'; import styles from './ConnectedFilesList.module.css';
@ -303,12 +302,16 @@ export function ConnectedFilesList({
{...baseProps} {...baseProps}
/>; />;
case 'download': case 'download':
return <DownloadActionButton return <CustomActionButton
key={actionIndex} key={actionIndex}
{...baseProps} row={file}
onDownload={actionButton.onAction || (() => {})} id="download"
isDownloading={isProcessing} icon={<FaDownload />}
operationName={actionButton.operationName} onClick={actionButton.onAction || (() => {})}
disabled={() => disabledResult}
loading={() => isProcessing}
title={actionTitle}
className={actionButton.className}
/>; />;
case 'view': case 'view':
return <ViewActionButton return <ViewActionButton
@ -326,18 +329,28 @@ export function ConnectedFilesList({
contentField={actionButton.contentField} contentField={actionButton.contentField}
/>; />;
case 'connect': case 'connect':
return <ConnectActionButton return <CustomActionButton
key={actionIndex} key={actionIndex}
{...baseProps} row={file}
id="connect"
icon={<FaLink />}
onClick={actionButton.onAction || (() => {})}
disabled={() => disabledResult}
loading={() => isLoading}
title={actionTitle}
className={actionButton.className}
/>; />;
case 'play': case 'play':
return <PlayActionButton return <CustomActionButton
key={actionIndex} key={actionIndex}
{...baseProps} row={file}
onPlay={actionButton.onAction} id="play"
navigateTo={actionButton.navigateTo} icon={<FaPlay />}
contentField={actionButton.contentField} onClick={actionButton.onAction || (() => {})}
mode={(actionButton as any).mode || 'prompt'} disabled={() => disabledResult}
loading={() => isLoading}
title={actionTitle}
className={actionButton.className}
/>; />;
case 'remove': case 'remove':
return <RemoveActionButton return <RemoveActionButton

View file

@ -924,7 +924,7 @@ const PageRenderer: React.FC<PageRendererProps> = ({
if (content.tableConfig && currentTableHookData) { if (content.tableConfig && currentTableHookData) {
const { columns: configColumns, actionButtons, emptyMessage, ...tableProps } = content.tableConfig; const { columns: configColumns, actionButtons, customActions, 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
@ -972,8 +972,9 @@ const PageRenderer: React.FC<PageRendererProps> = ({
} }
// Determine which permission to check based on button type // Determine which permission to check based on button type
// Only standard action types: edit, delete, view, copy
let requiredPermission: 'read' | 'create' | 'update' | 'delete' | null = null; let requiredPermission: 'read' | 'create' | 'update' | 'delete' | null = null;
if (action.type === 'view' || action.type === 'play') { if (action.type === 'view') {
requiredPermission = 'read'; requiredPermission = 'read';
} else if (action.type === 'edit') { } else if (action.type === 'edit') {
requiredPermission = 'update'; requiredPermission = 'update';
@ -1055,9 +1056,7 @@ const PageRenderer: React.FC<PageRendererProps> = ({
contentField: action.contentField, contentField: action.contentField,
operationName: action.operationName, operationName: action.operationName,
loadingStateName: action.loadingStateName, loadingStateName: action.loadingStateName,
// Navigation and behavior (for play button) fetchItemFunctionName: action.fetchItemFunctionName
navigateTo: action.navigateTo,
mode: action.mode
}; };
}) || []; }) || [];
@ -1085,6 +1084,21 @@ const PageRenderer: React.FC<PageRendererProps> = ({
columns={resolvedColumns} columns={resolvedColumns}
loading={showLoadingSpinner} loading={showLoadingSpinner}
actionButtons={formGeneratorActions} actionButtons={formGeneratorActions}
customActions={customActions?.map(action => {
// Resolve LanguageText in title to string
let resolvedTitle: string | ((row: any) => string) | undefined = undefined;
if (typeof action.title === 'function') {
resolvedTitle = action.title;
} else if (typeof action.title === 'string') {
resolvedTitle = resolveLanguageText(action.title, t);
} else if (action.title && typeof action.title === 'object') {
resolvedTitle = resolveLanguageText(action.title as any, t);
}
return {
...action,
title: resolvedTitle
};
})}
hookData={currentTableHookData} hookData={currentTableHookData}
onDelete={currentTableHookData.onDelete} onDelete={currentTableHookData.onDelete}
onDeleteMultiple={currentTableHookData.onDeleteMultiple} onDeleteMultiple={currentTableHookData.onDeleteMultiple}

View file

@ -3,8 +3,7 @@ import { allPageData, SidebarItem } from './data';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
import { resolveLanguageText } from './pageInterface'; import { resolveLanguageText } from './pageInterface';
import { usePermissions } from '../../hooks/usePermissions'; import { usePermissions } from '../../hooks/usePermissions';
import { getUserDataCache } from '../../utils/userCache'; import { FaHome, FaHatWizard, FaBriefcase } from 'react-icons/fa';
import { FaHome, FaHatWizard } from 'react-icons/fa';
import { RiFolderSettingsFill } from 'react-icons/ri'; import { RiFolderSettingsFill } from 'react-icons/ri';
// Configuration for parent groups that don't have a page definition // Configuration for parent groups that don't have a page definition
@ -17,13 +16,17 @@ const parentGroupConfig: Record<string, {
icon: FaHome, icon: FaHome,
defaultOrder: 1 defaultOrder: 1
}, },
'trustee': {
icon: FaBriefcase,
defaultOrder: 2
},
'administration': { 'administration': {
icon: RiFolderSettingsFill, icon: RiFolderSettingsFill,
defaultOrder: 2 defaultOrder: 3
}, },
'admin': { 'admin': {
icon: FaHatWizard, icon: FaHatWizard,
defaultOrder: 3 defaultOrder: 4
} }
}; };
@ -55,7 +58,7 @@ export const SidebarProvider: React.FC<SidebarProviderProps> = ({ children }) =>
// Get translation function from language context // Get translation function from language context
const { t } = useLanguage(); const { t } = useLanguage();
const { canView } = usePermissions(); const { canView, preloadUiPermissions } = usePermissions();
// Get sidebar items from page data // Get sidebar items from page data
const getSidebarItems = async (): Promise<SidebarItem[]> => { const getSidebarItems = async (): Promise<SidebarItem[]> => {
@ -181,34 +184,13 @@ export const SidebarProvider: React.FC<SidebarProviderProps> = ({ children }) =>
.filter(page => !page.parentPath && !page.hide && page.showInSidebar !== false) .filter(page => !page.parentPath && !page.hide && page.showInSidebar !== false)
.sort((a, b) => (a.order || 0) - (b.order || 0)); .sort((a, b) => (a.order || 0) - (b.order || 0));
// Log user info for debugging // Process each main page (permissions already bulk-loaded)
const cachedUser = getUserDataCache();
console.log('👤 SidebarProvider: Current user info:', {
username: cachedUser?.username,
roleLabels: cachedUser?.roleLabels,
roleLabelsLength: Array.isArray(cachedUser?.roleLabels) ? cachedUser.roleLabels.length : 0
});
// Process each main page
console.log('📋 SidebarProvider: Processing pages, total:', mainPages.length, 'pages to check');
const pageAccessResults: Array<{ path: string; name: string; hasAccess: boolean }> = [];
for (const pageData of mainPages) { for (const pageData of mainPages) {
console.log('🔍 SidebarProvider: Checking access for page:', { // Check RBAC permissions (from cache - no API call)
path: pageData.path,
name: pageData.name,
hasSubpages: pageData.hasSubpages
});
// Check RBAC permissions
try { try {
const hasRBACAccess = await canView('UI', pageData.path); const hasRBACAccess = await canView('UI', pageData.path);
console.log('🔍 SidebarProvider: RBAC check result:', {
path: pageData.path,
hasAccess: hasRBACAccess
});
if (!hasRBACAccess) { if (!hasRBACAccess) {
console.log('⛔ SidebarProvider: Page hidden due to RBAC:', pageData.path);
continue; continue;
} }
@ -217,16 +199,15 @@ export const SidebarProvider: React.FC<SidebarProviderProps> = ({ children }) =>
try { try {
const hasPrivilege = await pageData.privilegeChecker(); const hasPrivilege = await pageData.privilegeChecker();
if (!hasPrivilege) { if (!hasPrivilege) {
console.log('⛔ SidebarProvider: Page hidden due to privilegeChecker:', pageData.path);
continue; continue;
} }
} catch (error) { } catch (error) {
console.error(`❌ SidebarProvider: Error checking privilegeChecker for ${pageData.path}:`, error); console.error(`Error checking privilegeChecker for ${pageData.path}:`, error);
continue; continue;
} }
} }
} catch (error) { } catch (error) {
console.error(`❌ SidebarProvider: Error checking RBAC access for ${pageData.path}:`, error); console.error(`Error checking RBAC access for ${pageData.path}:`, error);
continue; continue;
} }
@ -349,30 +330,6 @@ export const SidebarProvider: React.FC<SidebarProviderProps> = ({ children }) =>
// Sort all items by order // Sort all items by order
const sortedItems = items.sort((a, b) => (a.order || 0) - (b.order || 0)); const sortedItems = items.sort((a, b) => (a.order || 0) - (b.order || 0));
// Summary of page access checks
const accessiblePages = pageAccessResults.filter(r => r.hasAccess);
const deniedPages = pageAccessResults.filter(r => !r.hasAccess);
console.log('📊 SidebarProvider: Page access summary:', {
totalPagesChecked: pageAccessResults.length,
accessiblePages: accessiblePages.length,
deniedPages: deniedPages.length,
accessiblePagePaths: accessiblePages.map(p => p.path),
deniedPagePaths: deniedPages.map(p => p.path),
deniedPageDetails: deniedPages.map(p => ({ path: p.path, name: p.name }))
});
console.log('📊 SidebarProvider: Final sidebar items built and sorted:', {
totalItems: sortedItems.length,
sortedPaths: sortedItems.map(item => item.link),
items: sortedItems.map(item => ({
id: item.id,
link: item.link,
name: item.name,
hasSubmenu: !!item.submenu,
submenuCount: item.submenu?.length || 0
}))
});
return sortedItems; return sortedItems;
}; };
@ -383,6 +340,10 @@ export const SidebarProvider: React.FC<SidebarProviderProps> = ({ children }) =>
setError(null); setError(null);
try { try {
// Preload all UI permissions in a single API call
// This caches all permissions before iterating through pages
await preloadUiPermissions();
const items = await getSidebarItems(); const items = await getSidebarItems();
console.log('✅ SidebarProvider: Setting sidebar items:', { console.log('✅ SidebarProvider: Setting sidebar items:', {
count: items.length, count: items.length,

View file

@ -40,6 +40,7 @@ const createMandatesHook = () => {
updateOptimistically, updateOptimistically,
attributes, attributes,
permissions, permissions,
pagination,
fetchMandateById, fetchMandateById,
generateEditFieldsFromAttributes, generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes, generateCreateFieldsFromAttributes,
@ -49,6 +50,7 @@ const createMandatesHook = () => {
handleMandateDelete, handleMandateDelete,
handleMandateCreate, handleMandateCreate,
handleMandateUpdate, handleMandateUpdate,
handleInlineUpdate,
deletingMandates, deletingMandates,
editingMandates, editingMandates,
deleteError, deleteError,
@ -93,6 +95,7 @@ const createMandatesHook = () => {
handleDelete: handleMandateDelete, handleDelete: handleMandateDelete,
handleDeleteMultiple, handleDeleteMultiple,
handleMandateUpdate, handleMandateUpdate,
handleInlineUpdate, // For inline boolean editing in table
// FormGenerator specific handlers // FormGenerator specific handlers
onDelete: handleDeleteSingle, onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple, onDeleteMultiple: handleDeleteMultiple,
@ -105,6 +108,7 @@ const createMandatesHook = () => {
// Attributes and permissions for dynamic column/button generation // Attributes and permissions for dynamic column/button generation
attributes, attributes,
permissions, permissions,
pagination, // Pagination metadata from backend
columns: generatedColumns, columns: generatedColumns,
// Functions for EditActionButton // Functions for EditActionButton
fetchMandateById, fetchMandateById,

View file

@ -40,6 +40,7 @@ const createRbacRolesHook = () => {
updateOptimistically, updateOptimistically,
attributes, attributes,
permissions, permissions,
pagination,
fetchRoleById, fetchRoleById,
generateEditFieldsFromAttributes, generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes, generateCreateFieldsFromAttributes,
@ -114,6 +115,7 @@ const createRbacRolesHook = () => {
// Attributes and permissions for dynamic column/button generation // Attributes and permissions for dynamic column/button generation
attributes, attributes,
permissions, permissions,
pagination, // Pagination metadata from backend
columns: generatedColumns, columns: generatedColumns,
// Functions for EditActionButton // Functions for EditActionButton
fetchRoleById, fetchRoleById,

View file

@ -50,6 +50,7 @@ const createRbacRulesHook = () => {
handleRbacRuleDelete, handleRbacRuleDelete,
handleRbacRuleCreate, handleRbacRuleCreate,
handleRbacRuleUpdate, handleRbacRuleUpdate,
handleInlineUpdate,
deletingRbacRules, deletingRbacRules,
editingRbacRules, editingRbacRules,
deleteError, deleteError,
@ -94,6 +95,7 @@ const createRbacRulesHook = () => {
handleDelete: handleRbacRuleDelete, handleDelete: handleRbacRuleDelete,
handleDeleteMultiple, handleDeleteMultiple,
handleRbacRuleUpdate, handleRbacRuleUpdate,
handleInlineUpdate, // For inline boolean editing in table
// FormGenerator specific handlers // FormGenerator specific handlers
onDelete: handleDeleteSingle, onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple, onDeleteMultiple: handleDeleteMultiple,

View file

@ -1,6 +1,7 @@
import { useCallback } from 'react'; import React, { useCallback } from 'react';
import { GenericPageData } from '../../../pageInterface'; import { GenericPageData } from '../../../pageInterface';
import { FaUsers, FaPlus } from 'react-icons/fa'; import { FaUsers, FaPlus } from 'react-icons/fa';
import { IoMailOutline } from 'react-icons/io5';
import { useOrgUsers, useUserOperations } from '../../../../../hooks/useUsers'; import { useOrgUsers, useUserOperations } from '../../../../../hooks/useUsers';
import { getUserDataCache } from '../../../../../utils/userCache'; import { getUserDataCache } from '../../../../../utils/userCache';
@ -39,20 +40,26 @@ const createUsersHook = () => {
updateOptimistically, updateOptimistically,
attributes, attributes,
permissions, permissions,
pagination,
fetchUserById, fetchUserById,
generateEditFieldsFromAttributes, generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded ensureAttributesLoaded
} = useOrgUsers(); } = useOrgUsers();
const { const {
handleUserDelete, handleUserDelete,
handleUserCreate, handleUserCreate,
handleUserUpdate, handleUserUpdate,
handleInlineUpdate,
handleSendPasswordLink,
deletingUsers, deletingUsers,
editingUsers, editingUsers,
sendingPasswordLink,
creatingUser, creatingUser,
deleteError, deleteError,
createError, createError,
updateError updateError,
passwordLinkError
} = useUserOperations(); } = useUserOperations();
const generatedColumns = attributes && attributes.length > 0 const generatedColumns = attributes && attributes.length > 0
@ -99,24 +106,30 @@ const createUsersHook = () => {
handleDeleteMultiple, handleDeleteMultiple,
handleUserCreate: wrappedHandleUserCreate, handleUserCreate: wrappedHandleUserCreate,
handleUserUpdate, handleUserUpdate,
handleInlineUpdate, // For inline boolean editing in table
handleSendPasswordLink, // Send password setup link to user
// FormGenerator specific handlers // FormGenerator specific handlers
onDelete: handleDeleteSingle, onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple, onDeleteMultiple: handleDeleteMultiple,
// Loading states // Loading states
deletingUsers, deletingUsers,
editingUsers, editingUsers,
sendingPasswordLink,
creatingUser, creatingUser,
// Error states // Error states
deleteError, deleteError,
createError, createError,
updateError, updateError,
passwordLinkError,
// Attributes and permissions for dynamic column/button generation // Attributes and permissions for dynamic column/button generation
attributes, attributes,
permissions, permissions,
pagination, // Pagination metadata from backend
columns: generatedColumns, columns: generatedColumns,
// Functions for EditActionButton // Functions for EditActionButton
fetchUserById, fetchUserById,
generateEditFieldsFromAttributes, generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded ensureAttributesLoaded
}; };
}; };
@ -145,72 +158,9 @@ export const teamMembersPageData: GenericPageData = {
size: 'md', size: 'md',
icon: FaPlus, icon: FaPlus,
formConfig: { formConfig: {
fields: [ // Fields will be generated dynamically from attributes via generateCreateFieldsFromAttributes
{ // PageRenderer will use generateCreateFieldsFromAttributes if available, otherwise generateEditFieldsFromAttributes
key: 'username', fields: [], // Empty array - fields will be generated dynamically from attributes
label: 'team-members.field.username',
type: 'string',
required: true,
placeholder: 'team-members.field.username',
validator: (value: string) => {
if (!value || value.trim() === '') {
return 'Username cannot be empty';
}
if (value.length > 100) {
return 'Username cannot exceed 100 characters';
}
return null;
}
},
{
key: 'email',
label: 'team-members.field.email',
type: 'email',
required: true,
placeholder: 'team-members.field.email',
validator: (value: string) => {
if (!value || value.trim() === '') {
return 'Email cannot be empty';
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
return 'Invalid email format';
}
return null;
}
},
{
key: 'password',
label: 'team-members.field.password',
type: 'string',
required: true,
placeholder: 'team-members.field.password',
validator: (value: string) => {
if (!value || value.trim() === '') {
return 'Password cannot be empty';
}
if (value.length < 8) {
return 'Password must be at least 8 characters';
}
return null;
}
},
{
key: 'fullName',
label: 'team-members.field.fullName',
type: 'string',
required: false,
placeholder: 'team-members.field.fullName'
},
{
key: 'privilege',
label: 'team-members.field.privilege',
type: 'multiselect',
required: false,
options: ['viewer', 'editor', 'admin', 'sysadmin'],
placeholder: 'team-members.field.privilege'
}
],
popupTitle: 'team-members.modal.create.title', popupTitle: 'team-members.modal.create.title',
popupSize: 'medium', popupSize: 'medium',
createOperationName: 'handleUserCreate', createOperationName: 'handleUserCreate',
@ -228,6 +178,7 @@ export const teamMembersPageData: GenericPageData = {
tableConfig: { tableConfig: {
hookFactory: createUsersHook, hookFactory: createUsersHook,
// Columns are generated dynamically from attributes via hookData.columns // Columns are generated dynamically from attributes via hookData.columns
// Standard action buttons (built-in: edit, delete, view, copy)
actionButtons: [ actionButtons: [
{ {
type: 'edit', type: 'edit',
@ -249,7 +200,6 @@ export const teamMembersPageData: GenericPageData = {
idField: 'id', idField: 'id',
operationName: 'handleDelete', operationName: 'handleDelete',
loadingStateName: 'deletingUsers', loadingStateName: 'deletingUsers',
// Only show if user has delete permission (permissions.delete !== 'n')
disabled: (hookData: any) => { disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false }; if (!hookData?.permissions) return { disabled: false };
const hasDelete = hookData.permissions.delete !== 'n' && hookData.permissions.view; const hasDelete = hookData.permissions.delete !== 'n' && hookData.permissions.view;
@ -257,6 +207,27 @@ export const teamMembersPageData: GenericPageData = {
} }
} }
], ],
// Custom action buttons (entity-specific)
customActions: [
{
id: 'sendPasswordLink',
icon: React.createElement(IoMailOutline),
title: 'team-members.action.sendPasswordLink',
onClick: async (row: any, hookData: any) => {
if (hookData?.handleSendPasswordLink) {
await hookData.handleSendPasswordLink(row.id);
}
},
// Only show for users with local authentication (not msft/google)
visible: (row: any) => row.authenticationAuthority === 'local',
disabled: (_row: any, hookData: any) => {
if (!hookData?.permissions) return { disabled: false, message: '' };
const hasUpdate = hookData.permissions.update !== 'n' && hookData.permissions.view;
return { disabled: !hasUpdate, message: 'No permission to send password link' };
},
loading: (row: any, hookData: any) => hookData?.sendingPasswordLink?.has(row.id) || false
}
],
searchable: true, searchable: true,
filterable: true, filterable: true,
sortable: true, sortable: true,

View file

@ -1,6 +1,6 @@
import { useCallback } from 'react'; import React, { useCallback } from 'react';
import { GenericPageData } from '../../pageInterface'; import { GenericPageData } from '../../pageInterface';
import { FaRegFileAlt, FaUpload } from 'react-icons/fa'; import { FaRegFileAlt, FaUpload, FaDownload } from 'react-icons/fa';
import { useUserFiles, useFileOperations } from '../../../../hooks/useFiles'; import { useUserFiles, useFileOperations } from '../../../../hooks/useFiles';
// Helper function to convert attribute definitions to column config // Helper function to convert attribute definitions to column config
@ -66,6 +66,7 @@ const createFilesHook = () => {
updateFileOptimistically, updateFileOptimistically,
attributes, attributes,
permissions, permissions,
pagination,
fetchFileById, fetchFileById,
generateEditFieldsFromAttributes, generateEditFieldsFromAttributes,
ensureAttributesLoaded ensureAttributesLoaded
@ -152,6 +153,7 @@ const createFilesHook = () => {
// Attributes and permissions for dynamic column/button generation // Attributes and permissions for dynamic column/button generation
attributes, attributes,
permissions, permissions,
pagination, // Pagination metadata from backend
columns: generatedColumns, // Return generated columns columns: generatedColumns, // Return generated columns
// Functions for EditActionButton // Functions for EditActionButton
fetchFileById, // Fetch single file by ID fetchFileById, // Fetch single file by ID
@ -201,18 +203,16 @@ export const filesPageData: GenericPageData = {
tableConfig: { tableConfig: {
hookFactory: createFilesHook, hookFactory: createFilesHook,
// Columns are generated dynamically from attributes via hookData.columns // Columns are generated dynamically from attributes via hookData.columns
// Standard action buttons (built-in: edit, delete, view, copy)
actionButtons: [ actionButtons: [
{ {
type: 'view', type: 'view',
title: 'files.action.preview', title: 'files.action.preview',
idField: 'id', idField: 'id',
// nameField and typeField will be determined from attributes dynamically
// For now, use common backend field names
nameField: 'fileName', nameField: 'fileName',
typeField: 'mimeType', typeField: 'mimeType',
operationName: 'handlePreview', operationName: 'handlePreview',
loadingStateName: 'previewingFiles', loadingStateName: 'previewingFiles',
// Only show if user has read permission (permissions.read !== 'n')
disabled: (hookData: any) => { disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false }; if (!hookData?.permissions) return { disabled: false };
const hasRead = hookData.permissions.read !== 'n' && hookData.permissions.view; const hasRead = hookData.permissions.read !== 'n' && hookData.permissions.view;
@ -233,26 +233,12 @@ export const filesPageData: GenericPageData = {
return { disabled: !hasUpdate, message: 'No permission to edit files' }; return { disabled: !hasUpdate, message: 'No permission to edit files' };
} }
}, },
{
type: 'download',
title: 'files.action.download',
idField: 'id',
operationName: 'handleDownload',
loadingStateName: 'downloadingFiles',
// Only show if user has read permission (permissions.read !== 'n')
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 download files' };
}
},
{ {
type: 'delete', type: 'delete',
title: 'files.action.delete', title: 'files.action.delete',
idField: 'id', idField: 'id',
operationName: 'handleDelete', operationName: 'handleDelete',
loadingStateName: 'deletingFiles', loadingStateName: 'deletingFiles',
// Only show if user has delete permission (permissions.delete !== 'n')
disabled: (hookData: any) => { disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false }; if (!hookData?.permissions) return { disabled: false };
const hasDelete = hookData.permissions.delete !== 'n' && hookData.permissions.view; const hasDelete = hookData.permissions.delete !== 'n' && hookData.permissions.view;
@ -260,6 +246,25 @@ export const filesPageData: GenericPageData = {
} }
} }
], ],
// Custom action buttons (entity-specific)
customActions: [
{
id: 'download',
icon: React.createElement(FaDownload),
title: 'files.action.download',
onClick: async (row: any, hookData: any) => {
if (hookData?.handleDownload) {
await hookData.handleDownload(row.id, row.fileName, row.mimeType);
}
},
disabled: (row: any, hookData: any) => {
if (!hookData?.permissions) return { disabled: false, message: '' };
const hasRead = hookData.permissions.read !== 'n' && hookData.permissions.view;
return { disabled: !hasRead, message: 'No permission to download files' };
},
loading: (row: any, hookData: any) => hookData?.downloadingFiles?.has(row.id) || false
}
],
searchable: true, searchable: true,
filterable: true, filterable: true,
sortable: true, sortable: true,

View file

@ -13,6 +13,16 @@ export { chatbotPageData } from './chatbot';
export { mandatesPageData } from './admin/mandates'; export { mandatesPageData } from './admin/mandates';
export { rbacRulesPageData } from './admin/rbac-rules'; export { rbacRulesPageData } from './admin/rbac-rules';
export { rbacRolePageData } from './admin/rbac-role'; export { rbacRolePageData } from './admin/rbac-role';
// Trustee pages (no container - SidebarProvider creates virtual parent group)
export {
trusteeOrganisationsPageData,
trusteeRolesPageData,
trusteeAccessPageData,
trusteeContractsPageData,
trusteeDocumentsPageData,
trusteePositionsPageData,
trusteePages
} from './trustee';
// Import all page data // Import all page data
import { dashboardPageData } from './dashboard'; import { dashboardPageData } from './dashboard';
@ -29,6 +39,7 @@ import { chatbotPageData } from './chatbot';
import { mandatesPageData } from './admin/mandates'; import { mandatesPageData } from './admin/mandates';
import { rbacRulesPageData } from './admin/rbac-rules'; import { rbacRulesPageData } from './admin/rbac-rules';
import { rbacRolePageData } from './admin/rbac-role'; import { rbacRolePageData } from './admin/rbac-role';
import { trusteePages } from './trustee';
// Array of all page data // Array of all page data
export const allPageData = [ export const allPageData = [
@ -36,17 +47,19 @@ export const allPageData = [
filesPageData, filesPageData,
workflowsPageData, workflowsPageData,
connectionsPageData, connectionsPageData,
teamMembersPageData,
promptsPageData, promptsPageData,
speechPageData, speechPageData,
settingsPageData, settingsPageData,
pekPageData, pekPageData,
pekTablesPageData, pekTablesPageData,
chatbotPageData, chatbotPageData,
// Trustee pages (before Administration)
...trusteePages,
// Administration pages
teamMembersPageData,
mandatesPageData, mandatesPageData,
rbacRulesPageData, rbacRulesPageData,
rbacRolePageData, rbacRolePageData,
]; ];
// Helper function to get page data by path // Helper function to get page data by path

View file

@ -38,14 +38,17 @@ const createPromptsHook = () => {
updateOptimistically, updateOptimistically,
attributes, attributes,
permissions, permissions,
pagination,
fetchPromptById, fetchPromptById,
generateEditFieldsFromAttributes, generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded ensureAttributesLoaded
} = usePrompts(); } = usePrompts();
const { const {
handlePromptDelete, handlePromptDelete,
handlePromptCreate, handlePromptCreate,
handlePromptUpdate, handlePromptUpdate,
handleInlineUpdate,
deletingPrompts, deletingPrompts,
creatingPrompt, creatingPrompt,
deleteError, deleteError,
@ -98,6 +101,7 @@ const createPromptsHook = () => {
handleDeleteMultiple, handleDeleteMultiple,
handlePromptCreate: wrappedHandlePromptCreate, // Use wrapped version handlePromptCreate: wrappedHandlePromptCreate, // Use wrapped version
handlePromptUpdate, handlePromptUpdate,
handleInlineUpdate, // For inline boolean editing in table
// FormGenerator specific handlers // FormGenerator specific handlers
onDelete: handleDeleteSingle, onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple, onDeleteMultiple: handleDeleteMultiple,
@ -111,10 +115,12 @@ const createPromptsHook = () => {
// Attributes and permissions for dynamic column/button generation // Attributes and permissions for dynamic column/button generation
attributes, attributes,
permissions, permissions,
pagination, // Pagination metadata from backend
columns: generatedColumns, // Return generated columns columns: generatedColumns, // Return generated columns
// Functions for EditActionButton // Functions for EditActionButton
fetchPromptById, // Fetch single prompt by ID fetchPromptById, // Fetch single prompt by ID
generateEditFieldsFromAttributes, // Generate edit fields from attributes generateEditFieldsFromAttributes, // Generate edit fields from attributes
generateCreateFieldsFromAttributes, // Generate create fields from attributes
ensureAttributesLoaded // Generic function to ensure attributes are loaded ensureAttributesLoaded // Generic function to ensure attributes are loaded
}; };
}; };
@ -143,42 +149,9 @@ export const promptsPageData: GenericPageData = {
icon: FaPlus, icon: FaPlus,
variant: 'primary', variant: 'primary',
formConfig: { formConfig: {
fields: [ // Fields will be generated dynamically from attributes via generateCreateFieldsFromAttributes
{ // PageRenderer will use generateCreateFieldsFromAttributes if available, otherwise generateEditFieldsFromAttributes
key: 'name', fields: [], // Empty array - fields will be generated dynamically from attributes
label: 'prompts.field.name',
type: 'string',
required: true,
placeholder: 'prompts.field.name',
validator: (value: string) => {
if (!value || value.trim() === '') {
return 'Prompt name cannot be empty';
}
if (value.length > 100) {
return 'Prompt name cannot exceed 100 characters';
}
return null;
}
},
{
key: 'content',
label: 'prompts.field.content',
type: 'textarea',
required: true,
placeholder: 'prompts.field.content',
minRows: 6,
maxRows: 12,
validator: (value: string) => {
if (!value || value.trim() === '') {
return 'Prompt content cannot be empty';
}
if (value.length > 10000) {
return 'Prompt content cannot exceed 10,000 characters';
}
return null;
}
}
],
popupTitle: 'prompts.modal.create.title', popupTitle: 'prompts.modal.create.title',
popupSize: 'medium', popupSize: 'medium',
createOperationName: 'handlePromptCreate', createOperationName: 'handlePromptCreate',

View file

@ -0,0 +1,221 @@
import { useCallback } from 'react';
import { GenericPageData } from '../../../pageInterface';
import { FaKey, FaPlus } from 'react-icons/fa';
import { useTrusteeAccess, useTrusteeAccessOperations } from '../../../../../hooks/useTrustee';
const attributesToColumns = (attributes: any[]) => {
return attributes.map(attr => {
const isDateField = attr.type === 'date' || attr.type === 'timestamp' ||
/(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name);
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,
filterable: isDateField ? false : (attr.filterable !== false),
searchable: attr.searchable !== false,
filterOptions: attr.filterOptions
};
});
};
const createAccessHook = () => {
return () => {
const {
items: accessRecords,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
fetchById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
} = useTrusteeAccess();
const {
handleDelete,
handleCreate,
handleUpdate,
deletingItems,
creatingItem,
deleteError,
createError,
updateError
} = useTrusteeAccessOperations();
const generatedColumns = attributes && attributes.length > 0
? attributesToColumns(attributes)
: undefined;
const wrappedHandleCreate = useCallback(async (formData: any) => {
return await handleCreate(formData);
}, [handleCreate]);
const handleDeleteSingle = useCallback(async (item: any) => {
const success = await handleDelete(item.id);
if (success) {
refetch();
}
}, [handleDelete, refetch]);
const handleDeleteMultiple = useCallback(async (selectedItems: any[]) => {
const ids = selectedItems.map(item => item.id);
const results = await Promise.all(ids.map(id => handleDelete(id)));
const allSuccessful = results.every(result => result);
if (allSuccessful) {
refetch();
}
}, [handleDelete, refetch]);
return {
data: accessRecords,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
handleDelete,
handleDeleteMultiple,
handleCreate: wrappedHandleCreate,
handleUpdate,
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
deletingItems,
creatingItem,
deleteError,
createError,
updateError,
attributes,
permissions,
columns: generatedColumns,
fetchById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
};
};
};
export const trusteeAccessPageData: GenericPageData = {
id: 'trustee-access',
path: 'trustee/access',
name: 'trustee.access.title',
description: 'trustee.access.description',
parentPath: 'trustee',
icon: FaKey,
title: 'trustee.access.title',
subtitle: 'trustee.access.subtitle',
headerButtons: [
{
id: 'new-access',
label: 'trustee.access.new_button',
icon: FaPlus,
variant: 'primary',
formConfig: {
fields: [
{
key: 'organisationId',
label: 'trustee.access.field.organisationId',
type: 'enum',
required: true,
optionsReference: 'TrusteeOrganisation'
},
{
key: 'roleId',
label: 'trustee.access.field.roleId',
type: 'enum',
required: true,
optionsReference: 'TrusteeRole'
},
{
key: 'userId',
label: 'trustee.access.field.userId',
type: 'enum',
required: true,
optionsReference: 'User'
},
{
key: 'contractId',
label: 'trustee.access.field.contractId',
type: 'enum',
required: false,
optionsReference: 'TrusteeContract',
placeholder: 'trustee.access.field.contractId_placeholder'
}
],
popupTitle: 'trustee.access.modal.create.title',
popupSize: 'medium',
createOperationName: 'handleCreate',
successMessage: 'trustee.access.create.success',
errorMessage: 'trustee.access.create.error'
}
}
],
content: [
{
id: 'access-table',
type: 'table',
tableConfig: {
hookFactory: createAccessHook,
actionButtons: [
{
type: 'edit',
title: 'trustee.access.action.edit',
idField: 'id',
nameField: 'id',
operationName: 'handleUpdate',
loadingStateName: 'updatingItems',
fetchItemFunctionName: 'fetchById',
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 access' };
}
},
{
type: 'delete',
title: 'trustee.access.action.delete',
idField: 'id',
operationName: 'handleDelete',
loadingStateName: 'deletingItems',
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 access' };
}
}
],
searchable: true,
filterable: true,
sortable: true,
resizable: true,
pagination: true,
pageSize: 10,
className: 'trustee-access-table'
}
}
],
persistent: false,
preload: false,
preserveState: true,
moduleEnabled: true,
onActivate: async () => {
if (import.meta.env.DEV) console.log('Trustee Access activated');
},
onLoad: async () => {
if (import.meta.env.DEV) console.log('Trustee Access loaded');
},
onUnload: async () => {
if (import.meta.env.DEV) console.log('Trustee Access unloaded');
}
};

View file

@ -0,0 +1,212 @@
import { useCallback } from 'react';
import { GenericPageData } from '../../../pageInterface';
import { FaFileContract, FaPlus } from 'react-icons/fa';
import { useTrusteeContracts, useTrusteeContractOperations } from '../../../../../hooks/useTrustee';
const attributesToColumns = (attributes: any[]) => {
return attributes.map(attr => {
const isDateField = attr.type === 'date' || attr.type === 'timestamp' ||
/(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name);
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,
filterable: isDateField ? false : (attr.filterable !== false),
searchable: attr.searchable !== false,
filterOptions: attr.filterOptions
};
});
};
const createContractsHook = () => {
return () => {
const {
items: contracts,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
fetchById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
} = useTrusteeContracts();
const {
handleDelete,
handleCreate,
handleUpdate,
deletingItems,
creatingItem,
deleteError,
createError,
updateError
} = useTrusteeContractOperations();
const generatedColumns = attributes && attributes.length > 0
? attributesToColumns(attributes)
: undefined;
const wrappedHandleCreate = useCallback(async (formData: any) => {
return await handleCreate(formData);
}, [handleCreate]);
const handleDeleteSingle = useCallback(async (item: any) => {
const success = await handleDelete(item.id);
if (success) {
refetch();
}
}, [handleDelete, refetch]);
const handleDeleteMultiple = useCallback(async (selectedItems: any[]) => {
const ids = selectedItems.map(item => item.id);
const results = await Promise.all(ids.map(id => handleDelete(id)));
const allSuccessful = results.every(result => result);
if (allSuccessful) {
refetch();
}
}, [handleDelete, refetch]);
return {
data: contracts,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
handleDelete,
handleDeleteMultiple,
handleCreate: wrappedHandleCreate,
handleUpdate,
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
deletingItems,
creatingItem,
deleteError,
createError,
updateError,
attributes,
permissions,
columns: generatedColumns,
fetchById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
};
};
};
export const trusteeContractsPageData: GenericPageData = {
id: 'trustee-contracts',
path: 'trustee/contracts',
name: 'trustee.contracts.title',
description: 'trustee.contracts.description',
parentPath: 'trustee',
icon: FaFileContract,
title: 'trustee.contracts.title',
subtitle: 'trustee.contracts.subtitle',
headerButtons: [
{
id: 'new-contract',
label: 'trustee.contracts.new_button',
icon: FaPlus,
variant: 'primary',
formConfig: {
fields: [
{
key: 'organisationId',
label: 'trustee.contracts.field.organisationId',
type: 'enum',
required: true,
optionsReference: 'TrusteeOrganisation'
},
{
key: 'label',
label: 'trustee.contracts.field.label',
type: 'string',
required: true,
placeholder: 'trustee.contracts.field.label_placeholder'
},
{
key: 'enabled',
label: 'trustee.contracts.field.enabled',
type: 'boolean',
required: false
}
],
popupTitle: 'trustee.contracts.modal.create.title',
popupSize: 'medium',
createOperationName: 'handleCreate',
successMessage: 'trustee.contracts.create.success',
errorMessage: 'trustee.contracts.create.error'
}
}
],
content: [
{
id: 'contracts-table',
type: 'table',
tableConfig: {
hookFactory: createContractsHook,
actionButtons: [
{
type: 'edit',
title: 'trustee.contracts.action.edit',
idField: 'id',
nameField: 'label',
operationName: 'handleUpdate',
loadingStateName: 'updatingItems',
fetchItemFunctionName: 'fetchById',
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 contracts' };
}
},
{
type: 'delete',
title: 'trustee.contracts.action.delete',
idField: 'id',
operationName: 'handleDelete',
loadingStateName: 'deletingItems',
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 contracts' };
}
}
],
searchable: true,
filterable: true,
sortable: true,
resizable: true,
pagination: true,
pageSize: 10,
className: 'trustee-contracts-table'
}
}
],
persistent: false,
preload: false,
preserveState: true,
moduleEnabled: true,
onActivate: async () => {
if (import.meta.env.DEV) console.log('Trustee Contracts activated');
},
onLoad: async () => {
if (import.meta.env.DEV) console.log('Trustee Contracts loaded');
},
onUnload: async () => {
if (import.meta.env.DEV) console.log('Trustee Contracts unloaded');
}
};

View file

@ -0,0 +1,230 @@
import { useCallback } from 'react';
import { GenericPageData } from '../../../pageInterface';
import { FaFile, FaPlus } from 'react-icons/fa';
import { useTrusteeDocuments, useTrusteeDocumentOperations } from '../../../../../hooks/useTrustee';
const attributesToColumns = (attributes: any[]) => {
return attributes.map(attr => {
const isDateField = attr.type === 'date' || attr.type === 'timestamp' ||
/(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name);
// Hide binary data column
if (attr.name === 'documentData') {
return null;
}
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,
filterable: isDateField ? false : (attr.filterable !== false),
searchable: attr.searchable !== false,
filterOptions: attr.filterOptions
};
}).filter(Boolean);
};
const createDocumentsHook = () => {
return () => {
const {
items: documents,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
fetchById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
} = useTrusteeDocuments();
const {
handleDelete,
handleCreate,
handleUpdate,
deletingItems,
creatingItem,
deleteError,
createError,
updateError
} = useTrusteeDocumentOperations();
const generatedColumns = attributes && attributes.length > 0
? attributesToColumns(attributes)
: undefined;
const wrappedHandleCreate = useCallback(async (formData: any) => {
return await handleCreate(formData);
}, [handleCreate]);
const handleDeleteSingle = useCallback(async (item: any) => {
const success = await handleDelete(item.id);
if (success) {
refetch();
}
}, [handleDelete, refetch]);
const handleDeleteMultiple = useCallback(async (selectedItems: any[]) => {
const ids = selectedItems.map(item => item.id);
const results = await Promise.all(ids.map(id => handleDelete(id)));
const allSuccessful = results.every(result => result);
if (allSuccessful) {
refetch();
}
}, [handleDelete, refetch]);
return {
data: documents,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
handleDelete,
handleDeleteMultiple,
handleCreate: wrappedHandleCreate,
handleUpdate,
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
deletingItems,
creatingItem,
deleteError,
createError,
updateError,
attributes,
permissions,
columns: generatedColumns,
fetchById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
};
};
};
export const trusteeDocumentsPageData: GenericPageData = {
id: 'trustee-documents',
path: 'trustee/documents',
name: 'trustee.documents.title',
description: 'trustee.documents.description',
parentPath: 'trustee',
icon: FaFile,
title: 'trustee.documents.title',
subtitle: 'trustee.documents.subtitle',
headerButtons: [
{
id: 'new-document',
label: 'trustee.documents.new_button',
icon: FaPlus,
variant: 'primary',
formConfig: {
fields: [
{
key: 'organisationId',
label: 'trustee.documents.field.organisationId',
type: 'enum',
required: true,
optionsReference: 'TrusteeOrganisation'
},
{
key: 'contractId',
label: 'trustee.documents.field.contractId',
type: 'enum',
required: true,
optionsReference: 'TrusteeContract'
},
{
key: 'documentName',
label: 'trustee.documents.field.documentName',
type: 'string',
required: true,
placeholder: 'trustee.documents.field.documentName_placeholder'
},
{
key: 'documentMimeType',
label: 'trustee.documents.field.documentMimeType',
type: 'enum',
required: true,
options: [
{ value: 'application/pdf', label: 'PDF' },
{ value: 'image/jpeg', label: 'JPEG' },
{ value: 'image/png', label: 'PNG' },
{ value: 'application/octet-stream', label: 'Other' }
]
}
],
popupTitle: 'trustee.documents.modal.create.title',
popupSize: 'medium',
createOperationName: 'handleCreate',
successMessage: 'trustee.documents.create.success',
errorMessage: 'trustee.documents.create.error'
}
}
],
content: [
{
id: 'documents-table',
type: 'table',
tableConfig: {
hookFactory: createDocumentsHook,
actionButtons: [
{
type: 'edit',
title: 'trustee.documents.action.edit',
idField: 'id',
nameField: 'documentName',
operationName: 'handleUpdate',
loadingStateName: 'updatingItems',
fetchItemFunctionName: 'fetchById',
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 documents' };
}
},
{
type: 'delete',
title: 'trustee.documents.action.delete',
idField: 'id',
operationName: 'handleDelete',
loadingStateName: 'deletingItems',
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 documents' };
}
}
],
searchable: true,
filterable: true,
sortable: true,
resizable: true,
pagination: true,
pageSize: 10,
className: 'trustee-documents-table'
}
}
],
persistent: false,
preload: false,
preserveState: true,
moduleEnabled: true,
onActivate: async () => {
if (import.meta.env.DEV) console.log('Trustee Documents activated');
},
onLoad: async () => {
if (import.meta.env.DEV) console.log('Trustee Documents loaded');
},
onUnload: async () => {
if (import.meta.env.DEV) console.log('Trustee Documents unloaded');
}
};

View file

@ -0,0 +1,31 @@
import { GenericPageData } from '../../../pageInterface';
// Import all trustee page configurations
import { trusteeOrganisationsPageData } from './organisations';
import { trusteeRolesPageData } from './roles';
import { trusteeAccessPageData } from './access';
import { trusteeContractsPageData } from './contracts';
import { trusteeDocumentsPageData } from './documents';
import { trusteePositionsPageData } from './positions';
// Export all trustee pages
export {
trusteeOrganisationsPageData,
trusteeRolesPageData,
trusteeAccessPageData,
trusteeContractsPageData,
trusteeDocumentsPageData,
trusteePositionsPageData
};
// Export array of all trustee pages for registration
// No explicit container needed - SidebarProvider creates a virtual parent group
// based on parentPath: 'trustee' in the child pages
export const trusteePages: GenericPageData[] = [
trusteeOrganisationsPageData,
trusteeRolesPageData,
trusteeAccessPageData,
trusteeContractsPageData,
trusteeDocumentsPageData,
trusteePositionsPageData
];

View file

@ -0,0 +1,226 @@
import { useCallback } from 'react';
import { GenericPageData } from '../../../pageInterface';
import { FaBuilding, FaPlus } from 'react-icons/fa';
import { useTrusteeOrganisations, useTrusteeOrganisationOperations } from '../../../../../hooks/useTrustee';
// Helper function to convert attribute definitions to column config
const attributesToColumns = (attributes: any[]) => {
return attributes.map(attr => {
const isDateField = attr.type === 'date' || attr.type === 'timestamp' ||
/(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name);
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,
filterable: isDateField ? false : (attr.filterable !== false),
searchable: attr.searchable !== false,
filterOptions: attr.filterOptions
};
});
};
// Hook factory function for organisations data
const createOrganisationsHook = () => {
return () => {
const {
items: organisations,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
fetchById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
} = useTrusteeOrganisations();
const {
handleDelete,
handleCreate,
handleUpdate,
deletingItems,
creatingItem,
deleteError,
createError,
updateError
} = useTrusteeOrganisationOperations();
const generatedColumns = attributes && attributes.length > 0
? attributesToColumns(attributes)
: undefined;
const wrappedHandleCreate = useCallback(async (formData: any) => {
return await handleCreate(formData);
}, [handleCreate]);
const handleDeleteSingle = useCallback(async (item: any) => {
const success = await handleDelete(item.id);
if (success) {
refetch();
}
}, [handleDelete, refetch]);
const handleDeleteMultiple = useCallback(async (selectedItems: any[]) => {
const ids = selectedItems.map(item => item.id);
const results = await Promise.all(ids.map(id => handleDelete(id)));
const allSuccessful = results.every(result => result);
if (allSuccessful) {
refetch();
}
}, [handleDelete, refetch]);
return {
data: organisations,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
handleDelete,
handleDeleteMultiple,
handleCreate: wrappedHandleCreate,
handleUpdate,
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
deletingItems,
creatingItem,
deleteError,
createError,
updateError,
attributes,
permissions,
columns: generatedColumns,
fetchById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
};
};
};
export const trusteeOrganisationsPageData: GenericPageData = {
id: 'trustee-organisations',
path: 'trustee/organisations',
name: 'trustee.organisations.title',
description: 'trustee.organisations.description',
parentPath: 'trustee',
icon: FaBuilding,
title: 'trustee.organisations.title',
subtitle: 'trustee.organisations.subtitle',
headerButtons: [
{
id: 'new-organisation',
label: 'trustee.organisations.new_button',
icon: FaPlus,
variant: 'primary',
formConfig: {
fields: [
{
key: 'id',
label: 'trustee.organisations.field.id',
type: 'string',
required: true,
placeholder: 'trustee.organisations.field.id_placeholder',
validator: (value: string) => {
if (!value || value.trim() === '') {
return 'Organisation ID cannot be empty';
}
if (value.length < 3 || value.length > 50) {
return 'Organisation ID must be 3-50 characters';
}
if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
return 'Organisation ID can only contain letters, numbers, hyphens, and underscores';
}
return null;
}
},
{
key: 'label',
label: 'trustee.organisations.field.label',
type: 'string',
required: true,
placeholder: 'trustee.organisations.field.label_placeholder'
},
{
key: 'enabled',
label: 'trustee.organisations.field.enabled',
type: 'boolean',
required: false
}
],
popupTitle: 'trustee.organisations.modal.create.title',
popupSize: 'medium',
createOperationName: 'handleCreate',
successMessage: 'trustee.organisations.create.success',
errorMessage: 'trustee.organisations.create.error'
}
}
],
content: [
{
id: 'organisations-table',
type: 'table',
tableConfig: {
hookFactory: createOrganisationsHook,
actionButtons: [
{
type: 'edit',
title: 'trustee.organisations.action.edit',
idField: 'id',
nameField: 'label',
operationName: 'handleUpdate',
loadingStateName: 'updatingItems',
fetchItemFunctionName: 'fetchById',
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 organisations' };
}
},
{
type: 'delete',
title: 'trustee.organisations.action.delete',
idField: 'id',
operationName: 'handleDelete',
loadingStateName: 'deletingItems',
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 organisations' };
}
}
],
searchable: true,
filterable: true,
sortable: true,
resizable: true,
pagination: true,
pageSize: 10,
className: 'trustee-organisations-table'
}
}
],
persistent: false,
preload: false,
preserveState: true,
moduleEnabled: true,
onActivate: async () => {
if (import.meta.env.DEV) console.log('Trustee Organisations activated');
},
onLoad: async () => {
if (import.meta.env.DEV) console.log('Trustee Organisations loaded');
},
onUnload: async () => {
if (import.meta.env.DEV) console.log('Trustee Organisations unloaded');
}
};

View file

@ -0,0 +1,279 @@
import { useCallback } from 'react';
import { GenericPageData } from '../../../pageInterface';
import { FaReceipt, FaPlus } from 'react-icons/fa';
import { useTrusteePositions, useTrusteePositionOperations } from '../../../../../hooks/useTrustee';
const attributesToColumns = (attributes: any[]) => {
return attributes.map(attr => {
const isDateField = attr.type === 'date' || attr.type === 'timestamp' ||
/(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name);
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,
filterable: isDateField ? false : (attr.filterable !== false),
searchable: attr.searchable !== false,
filterOptions: attr.filterOptions
};
});
};
const createPositionsHook = () => {
return () => {
const {
items: positions,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
fetchById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
} = useTrusteePositions();
const {
handleDelete,
handleCreate,
handleUpdate,
deletingItems,
creatingItem,
deleteError,
createError,
updateError
} = useTrusteePositionOperations();
const generatedColumns = attributes && attributes.length > 0
? attributesToColumns(attributes)
: undefined;
const wrappedHandleCreate = useCallback(async (formData: any) => {
// Auto-calculate VAT amount if not provided
if (formData.bookingAmount && formData.vatPercentage && !formData.vatAmount) {
formData.vatAmount = formData.bookingAmount * formData.vatPercentage / 100;
}
return await handleCreate(formData);
}, [handleCreate]);
const handleDeleteSingle = useCallback(async (item: any) => {
const success = await handleDelete(item.id);
if (success) {
refetch();
}
}, [handleDelete, refetch]);
const handleDeleteMultiple = useCallback(async (selectedItems: any[]) => {
const ids = selectedItems.map(item => item.id);
const results = await Promise.all(ids.map(id => handleDelete(id)));
const allSuccessful = results.every(result => result);
if (allSuccessful) {
refetch();
}
}, [handleDelete, refetch]);
return {
data: positions,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
handleDelete,
handleDeleteMultiple,
handleCreate: wrappedHandleCreate,
handleUpdate,
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
deletingItems,
creatingItem,
deleteError,
createError,
updateError,
attributes,
permissions,
columns: generatedColumns,
fetchById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
};
};
};
export const trusteePositionsPageData: GenericPageData = {
id: 'trustee-positions',
path: 'trustee/positions',
name: 'trustee.positions.title',
description: 'trustee.positions.description',
parentPath: 'trustee',
icon: FaReceipt,
title: 'trustee.positions.title',
subtitle: 'trustee.positions.subtitle',
headerButtons: [
{
id: 'new-position',
label: 'trustee.positions.new_button',
icon: FaPlus,
variant: 'primary',
formConfig: {
fields: [
{
key: 'organisationId',
label: 'trustee.positions.field.organisationId',
type: 'enum',
required: true,
optionsReference: 'TrusteeOrganisation'
},
{
key: 'contractId',
label: 'trustee.positions.field.contractId',
type: 'enum',
required: true,
optionsReference: 'TrusteeContract'
},
{
key: 'valuta',
label: 'trustee.positions.field.valuta',
type: 'date',
required: true
},
{
key: 'company',
label: 'trustee.positions.field.company',
type: 'string',
required: false,
placeholder: 'trustee.positions.field.company_placeholder'
},
{
key: 'desc',
label: 'trustee.positions.field.desc',
type: 'textarea',
required: false,
minRows: 2,
maxRows: 4
},
{
key: 'bookingCurrency',
label: 'trustee.positions.field.bookingCurrency',
type: 'enum',
required: true,
options: [
{ value: 'CHF', label: 'CHF' },
{ value: 'EUR', label: 'EUR' },
{ value: 'USD', label: 'USD' },
{ value: 'GBP', label: 'GBP' }
]
},
{
key: 'bookingAmount',
label: 'trustee.positions.field.bookingAmount',
type: 'number',
required: true
},
{
key: 'originalCurrency',
label: 'trustee.positions.field.originalCurrency',
type: 'enum',
required: true,
options: [
{ value: 'CHF', label: 'CHF' },
{ value: 'EUR', label: 'EUR' },
{ value: 'USD', label: 'USD' },
{ value: 'GBP', label: 'GBP' }
]
},
{
key: 'originalAmount',
label: 'trustee.positions.field.originalAmount',
type: 'number',
required: true
},
{
key: 'vatPercentage',
label: 'trustee.positions.field.vatPercentage',
type: 'number',
required: false
},
{
key: 'vatAmount',
label: 'trustee.positions.field.vatAmount',
type: 'number',
required: false
}
],
popupTitle: 'trustee.positions.modal.create.title',
popupSize: 'large',
createOperationName: 'handleCreate',
successMessage: 'trustee.positions.create.success',
errorMessage: 'trustee.positions.create.error'
}
}
],
content: [
{
id: 'positions-table',
type: 'table',
tableConfig: {
hookFactory: createPositionsHook,
actionButtons: [
{
type: 'edit',
title: 'trustee.positions.action.edit',
idField: 'id',
nameField: 'desc',
operationName: 'handleUpdate',
loadingStateName: 'updatingItems',
fetchItemFunctionName: 'fetchById',
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 positions' };
}
},
{
type: 'delete',
title: 'trustee.positions.action.delete',
idField: 'id',
operationName: 'handleDelete',
loadingStateName: 'deletingItems',
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 positions' };
}
}
],
searchable: true,
filterable: true,
sortable: true,
resizable: true,
pagination: true,
pageSize: 10,
className: 'trustee-positions-table'
}
}
],
persistent: false,
preload: false,
preserveState: true,
moduleEnabled: true,
onActivate: async () => {
if (import.meta.env.DEV) console.log('Trustee Positions activated');
},
onLoad: async () => {
if (import.meta.env.DEV) console.log('Trustee Positions loaded');
},
onUnload: async () => {
if (import.meta.env.DEV) console.log('Trustee Positions unloaded');
}
};

View file

@ -0,0 +1,208 @@
import { useCallback } from 'react';
import { GenericPageData } from '../../../pageInterface';
import { FaUserTag, FaPlus } from 'react-icons/fa';
import { useTrusteeRoles, useTrusteeRoleOperations } from '../../../../../hooks/useTrustee';
const attributesToColumns = (attributes: any[]) => {
return attributes.map(attr => {
const isDateField = attr.type === 'date' || attr.type === 'timestamp' ||
/(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name);
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,
filterable: isDateField ? false : (attr.filterable !== false),
searchable: attr.searchable !== false,
filterOptions: attr.filterOptions
};
});
};
const createRolesHook = () => {
return () => {
const {
items: roles,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
fetchById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
} = useTrusteeRoles();
const {
handleDelete,
handleCreate,
handleUpdate,
deletingItems,
creatingItem,
deleteError,
createError,
updateError
} = useTrusteeRoleOperations();
const generatedColumns = attributes && attributes.length > 0
? attributesToColumns(attributes)
: undefined;
const wrappedHandleCreate = useCallback(async (formData: any) => {
return await handleCreate(formData);
}, [handleCreate]);
const handleDeleteSingle = useCallback(async (item: any) => {
const success = await handleDelete(item.id);
if (success) {
refetch();
}
}, [handleDelete, refetch]);
const handleDeleteMultiple = useCallback(async (selectedItems: any[]) => {
const ids = selectedItems.map(item => item.id);
const results = await Promise.all(ids.map(id => handleDelete(id)));
const allSuccessful = results.every(result => result);
if (allSuccessful) {
refetch();
}
}, [handleDelete, refetch]);
return {
data: roles,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
handleDelete,
handleDeleteMultiple,
handleCreate: wrappedHandleCreate,
handleUpdate,
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
deletingItems,
creatingItem,
deleteError,
createError,
updateError,
attributes,
permissions,
columns: generatedColumns,
fetchById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
};
};
};
export const trusteeRolesPageData: GenericPageData = {
id: 'trustee-roles',
path: 'trustee/roles',
name: 'trustee.roles.title',
description: 'trustee.roles.description',
parentPath: 'trustee',
icon: FaUserTag,
title: 'trustee.roles.title',
subtitle: 'trustee.roles.subtitle',
headerButtons: [
{
id: 'new-role',
label: 'trustee.roles.new_button',
icon: FaPlus,
variant: 'primary',
formConfig: {
fields: [
{
key: 'id',
label: 'trustee.roles.field.id',
type: 'string',
required: true,
placeholder: 'trustee.roles.field.id_placeholder'
},
{
key: 'desc',
label: 'trustee.roles.field.desc',
type: 'textarea',
required: true,
placeholder: 'trustee.roles.field.desc_placeholder',
minRows: 3,
maxRows: 6
}
],
popupTitle: 'trustee.roles.modal.create.title',
popupSize: 'medium',
createOperationName: 'handleCreate',
successMessage: 'trustee.roles.create.success',
errorMessage: 'trustee.roles.create.error'
}
}
],
content: [
{
id: 'roles-table',
type: 'table',
tableConfig: {
hookFactory: createRolesHook,
actionButtons: [
{
type: 'edit',
title: 'trustee.roles.action.edit',
idField: 'id',
nameField: 'id',
operationName: 'handleUpdate',
loadingStateName: 'updatingItems',
fetchItemFunctionName: 'fetchById',
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 roles' };
}
},
{
type: 'delete',
title: 'trustee.roles.action.delete',
idField: 'id',
operationName: 'handleDelete',
loadingStateName: 'deletingItems',
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 roles' };
}
}
],
searchable: true,
filterable: true,
sortable: true,
resizable: true,
pagination: true,
pageSize: 10,
className: 'trustee-roles-table'
}
}
],
persistent: false,
preload: false,
preserveState: true,
moduleEnabled: true,
onActivate: async () => {
if (import.meta.env.DEV) console.log('Trustee Roles activated');
},
onLoad: async () => {
if (import.meta.env.DEV) console.log('Trustee Roles loaded');
},
onUnload: async () => {
if (import.meta.env.DEV) console.log('Trustee Roles unloaded');
}
};

View file

@ -78,6 +78,7 @@ const createWorkflowsHook = () => {
updateOptimistically, updateOptimistically,
attributes, attributes,
permissions, permissions,
pagination,
fetchWorkflowById, fetchWorkflowById,
generateEditFieldsFromAttributes, generateEditFieldsFromAttributes,
ensureAttributesLoaded ensureAttributesLoaded
@ -133,6 +134,7 @@ const createWorkflowsHook = () => {
// Attributes and permissions for dynamic column/button generation // Attributes and permissions for dynamic column/button generation
attributes, attributes,
permissions, permissions,
pagination, // Pagination metadata from backend
columns: generatedColumns, // Return generated columns columns: generatedColumns, // Return generated columns
// Functions for EditActionButton // Functions for EditActionButton
fetchWorkflowById, // Fetch single workflow by ID fetchWorkflowById, // Fetch single workflow by ID

View file

@ -262,9 +262,9 @@ export interface GenericDataHook {
[key: string]: any; // Allow additional properties for dynamic data sources [key: string]: any; // Allow additional properties for dynamic data sources
} }
// Action button configuration // Standard action button configuration (built-in actions: edit, delete, view, copy)
export interface ActionButtonConfig { export interface ActionButtonConfig {
type: 'view' | 'edit' | 'download' | 'delete' | 'copy' | 'connect' | 'play'; type: 'view' | 'edit' | 'delete' | 'copy';
onAction?: (row: any) => Promise<void> | void; // Optional for delete buttons since they handle their own logic onAction?: (row: any) => Promise<void> | void; // Optional for delete buttons since they handle their own logic
title?: string | LanguageText; title?: string | LanguageText;
disabled?: (row: any) => boolean | { disabled: boolean; message?: string }; disabled?: (row: any) => boolean | { disabled: boolean; message?: string };
@ -274,24 +274,32 @@ export interface ActionButtonConfig {
nameField?: string; // Field name for display name (default: 'name' or 'file_name') nameField?: string; // Field name for display name (default: 'name' or 'file_name')
typeField?: string; // Field name for type/mime type (default: 'type' or 'mime_type') typeField?: string; // Field name for type/mime type (default: 'type' or 'mime_type')
contentField?: string; // Field name for content (default: 'content') contentField?: string; // Field name for content (default: 'content')
statusField?: string; // Field name for status (default: 'status')
authorityField?: string; // Field name for authority (msft/google) (default: 'authority')
// Operation and loading state names // Operation and loading state names
operationName?: string; // Name of the operation function in hookData operationName?: string; // Name of the operation function in hookData
disconnectOperationName?: string; // Name of the disconnect operation function in hookData (for connect button)
refreshOperationName?: string; // Name of the refresh operation function in hookData (for connect button)
loadingStateName?: string; // Name of the loading state in hookData loadingStateName?: string; // Name of the loading state in hookData
fetchItemFunctionName?: string; // Name of the function in hookData to fetch a single item by ID (for edit button) fetchItemFunctionName?: string; // Name of the function in hookData to fetch a single item by ID (for edit button)
// Navigation and behavior (for play button) }
navigateTo?: string; // Path to navigate to after action (default: 'start/dashboard')
mode?: 'workflow' | 'prompt'; // Behavior mode for play button: 'workflow' selects workflow, 'prompt' sets input value (default: 'prompt') // Custom action button configuration (for entity-specific actions like download, connect, play, sendPasswordLink)
export interface CustomActionConfig {
id: string; // Unique identifier for the action
icon: React.ReactNode; // Icon component to display
onClick: (row: any, hookData?: any) => Promise<void> | void; // Handler function
visible?: (row: any, hookData?: any) => boolean; // Show/hide based on row data (default: true)
disabled?: (row: any, hookData?: any) => boolean | { disabled: boolean; message?: string }; // Disable based on row data
loading?: (row: any, hookData?: any) => boolean; // Loading state based on row data
title?: string | LanguageText | ((row: any) => string); // Tooltip text
className?: string; // Optional custom CSS class
// Field mappings (optional, for convenience)
idField?: string; // Field name for the unique identifier (default: 'id')
} }
// Table content configuration // Table content configuration
export interface TableContentConfig { export interface TableContentConfig {
hookFactory: () => () => GenericDataHook; // Hook factory that returns a hook function hookFactory: () => () => GenericDataHook; // Hook factory that returns a hook function
columns?: any[]; // Column configuration (optional - can be generated dynamically from attributes via hookData.columns) columns?: any[]; // Column configuration (optional - can be generated dynamically from attributes via hookData.columns)
actionButtons?: ActionButtonConfig[]; // Action buttons configuration actionButtons?: ActionButtonConfig[]; // Standard action buttons configuration (edit, delete, view, copy)
customActions?: CustomActionConfig[]; // Custom action buttons (download, connect, play, sendPasswordLink, etc.)
searchable?: boolean; searchable?: boolean;
filterable?: boolean; filterable?: boolean;
sortable?: boolean; sortable?: boolean;

View file

@ -235,11 +235,11 @@ export function useMandates() {
// Email validation // Email validation
if (fieldType === 'email') { if (fieldType === 'email') {
validator = (value: string) => { validator = (value: any) => {
if (required && (!value || value.trim() === '')) { if (required && (!value || (typeof value === 'string' && value.trim() === ''))) {
return 'Email cannot be empty'; return 'Email cannot be empty';
} }
if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { if (value && typeof value === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return 'Invalid email format'; return 'Invalid email format';
} }
return null; return null;
@ -369,11 +369,11 @@ export function useMandates() {
// Email validation // Email validation
if (fieldType === 'email') { if (fieldType === 'email') {
validator = (value: string) => { validator = (value: any) => {
if (required && (!value || value.trim() === '')) { if (required && (!value || (typeof value === 'string' && value.trim() === ''))) {
return 'Email cannot be empty'; return 'Email cannot be empty';
} }
if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { if (value && typeof value === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return 'Invalid email format'; return 'Invalid email format';
} }
return null; return null;
@ -399,8 +399,8 @@ export function useMandates() {
} }
// String validation for required fields // String validation for required fields
else if (fieldType === 'string' && required) { else if (fieldType === 'string' && required) {
validator = (value: string) => { validator = (value: any) => {
if (!value || value.trim() === '') { if (!value || (typeof value === 'string' && value.trim() === '')) {
return `${attr.label} is required`; return `${attr.label} is required`;
} }
return null; return null;
@ -548,6 +548,15 @@ export function useMandateOperations() {
} }
}; };
// Generic inline update handler for FormGeneratorTable
const handleInlineUpdate = async (mandateId: string, changes: Partial<MandateUpdateData>) => {
const result = await handleMandateUpdate(mandateId, changes as MandateUpdateData);
if (!result.success) {
throw new Error(result.error || 'Failed to update');
}
return result;
};
return { return {
deletingMandates, deletingMandates,
editingMandates, editingMandates,
@ -558,6 +567,7 @@ export function useMandateOperations() {
handleMandateDelete, handleMandateDelete,
handleMandateCreate, handleMandateCreate,
handleMandateUpdate, handleMandateUpdate,
handleInlineUpdate,
isLoading isLoading
}; };
} }

View file

@ -276,11 +276,11 @@ export function useRbacRoles() {
// Email validation // Email validation
if (fieldType === 'email') { if (fieldType === 'email') {
validator = (value: string) => { validator = (value: any) => {
if (required && (!value || value.trim() === '')) { if (required && (!value || (typeof value === 'string' && value.trim() === ''))) {
return 'Email cannot be empty'; return 'Email cannot be empty';
} }
if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { if (value && typeof value === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return 'Invalid email format'; return 'Invalid email format';
} }
return null; return null;
@ -410,11 +410,11 @@ export function useRbacRoles() {
// Email validation // Email validation
if (fieldType === 'email') { if (fieldType === 'email') {
validator = (value: string) => { validator = (value: any) => {
if (required && (!value || value.trim() === '')) { if (required && (!value || (typeof value === 'string' && value.trim() === ''))) {
return 'Email cannot be empty'; return 'Email cannot be empty';
} }
if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { if (value && typeof value === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return 'Invalid email format'; return 'Invalid email format';
} }
return null; return null;
@ -650,6 +650,15 @@ export function useRbacRoleOperations() {
} }
}; };
// Generic inline update handler for FormGeneratorTable
const handleInlineUpdate = async (roleId: string, changes: Partial<RoleUpdateData>) => {
const result = await handleRoleUpdate(roleId, changes as RoleUpdateData);
if (!result.success) {
throw new Error(result.error || 'Failed to update');
}
return result;
};
return { return {
deletingRoles, deletingRoles,
editingRoles, editingRoles,
@ -660,6 +669,7 @@ export function useRbacRoleOperations() {
handleRoleDelete, handleRoleDelete,
handleRoleCreate, handleRoleCreate,
handleRoleUpdate, handleRoleUpdate,
handleInlineUpdate,
isLoading isLoading
}; };
} }

View file

@ -252,11 +252,11 @@ export function useRbacRules() {
// Email validation // Email validation
if (fieldType === 'email') { if (fieldType === 'email') {
validator = (value: string) => { validator = (value: any) => {
if (required && (!value || value.trim() === '')) { if (required && (!value || (typeof value === 'string' && value.trim() === ''))) {
return 'Email cannot be empty'; return 'Email cannot be empty';
} }
if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { if (value && typeof value === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return 'Invalid email format'; return 'Invalid email format';
} }
return null; return null;
@ -386,11 +386,11 @@ export function useRbacRules() {
// Email validation // Email validation
if (fieldType === 'email') { if (fieldType === 'email') {
validator = (value: string) => { validator = (value: any) => {
if (required && (!value || value.trim() === '')) { if (required && (!value || (typeof value === 'string' && value.trim() === ''))) {
return 'Email cannot be empty'; return 'Email cannot be empty';
} }
if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { if (value && typeof value === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return 'Invalid email format'; return 'Invalid email format';
} }
return null; return null;
@ -416,8 +416,8 @@ export function useRbacRules() {
} }
// String validation for required fields // String validation for required fields
else if (fieldType === 'string' && required) { else if (fieldType === 'string' && required) {
validator = (value: string) => { validator = (value: any) => {
if (!value || value.trim() === '') { if (!value || (typeof value === 'string' && value.trim() === '')) {
return `${attr.label} is required`; return `${attr.label} is required`;
} }
return null; return null;
@ -565,6 +565,15 @@ export function useRbacRuleOperations() {
} }
}; };
// Generic inline update handler for FormGeneratorTable
const handleInlineUpdate = async (ruleId: string, changes: Partial<RbacRuleUpdateData>) => {
const result = await handleRbacRuleUpdate(ruleId, changes as RbacRuleUpdateData);
if (!result.success) {
throw new Error(result.error || 'Failed to update');
}
return result;
};
return { return {
deletingRbacRules, deletingRbacRules,
editingRbacRules, editingRbacRules,
@ -575,6 +584,7 @@ export function useRbacRuleOperations() {
handleRbacRuleDelete, handleRbacRuleDelete,
handleRbacRuleCreate, handleRbacRuleCreate,
handleRbacRuleUpdate, handleRbacRuleUpdate,
handleInlineUpdate,
isLoading isLoading
}; };
} }

View file

@ -247,11 +247,11 @@ export function useUserFiles() {
if (attr.name === 'fileName' || attr.name === 'file_name') { if (attr.name === 'fileName' || attr.name === 'file_name') {
required = true; required = true;
validator = (value: string) => { validator = (value: any) => {
if (!value || value.trim() === '') { if (!value || (typeof value === 'string' && value.trim() === '')) {
return 'File name cannot be empty'; return 'File name cannot be empty';
} }
if (value.length > 255) { if (typeof value === 'string' && value.length > 255) {
return 'File name cannot exceed 255 characters'; return 'File name cannot exceed 255 characters';
} }
return null; return null;
@ -284,16 +284,12 @@ export function useUserFiles() {
}, [attributes, fetchAttributes]); }, [attributes, fetchAttributes]);
// Fetch attributes and permissions on mount // Fetch attributes and permissions on mount
// Note: Do NOT fetch files here - let the table component control pagination
useEffect(() => { useEffect(() => {
fetchAttributes(); fetchAttributes();
fetchPermissions(); fetchPermissions();
}, [fetchAttributes, fetchPermissions]); }, [fetchAttributes, fetchPermissions]);
// Initial fetch
useEffect(() => {
fetchFiles();
}, [fetchFiles]);
return { return {
data: files, data: files,
loading, loading,

View file

@ -1,7 +1,8 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useCallback, useRef } from 'react';
import { useApiRequest } from './useApi'; import { useApiRequest } from './useApi';
import { import {
fetchPermissions as fetchPermissionsApi, fetchPermissions as fetchPermissionsApi,
fetchAllPermissions as fetchAllPermissionsApi,
type PermissionLevel, type PermissionLevel,
type UserPermissions, type UserPermissions,
type PermissionContext type PermissionContext
@ -18,22 +19,30 @@ interface PermissionCache {
// Operation type for permission checks // Operation type for permission checks
export type PermissionOperation = 'read' | 'create' | 'update' | 'delete'; export type PermissionOperation = 'read' | 'create' | 'update' | 'delete';
// Default permission (no access)
const DEFAULT_NO_ACCESS: UserPermissions = {
view: false,
read: 'n' as PermissionLevel,
create: 'n' as PermissionLevel,
update: 'n' as PermissionLevel,
delete: 'n' as PermissionLevel,
};
/** /**
* Hook for managing RBAC permissions * Hook for managing RBAC permissions
* Provides centralized permission checking with caching * Provides centralized permission checking with caching
*
* Optimized to fetch all UI permissions in a single API call on first use,
* then serve subsequent requests from cache.
*/ */
export const usePermissions = () => { export const usePermissions = () => {
const [cache, setCache] = useState<PermissionCache>({});
const cacheRef = useRef<PermissionCache>({});
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const cacheRef = useRef<PermissionCache>({});
const bulkLoadPromiseRef = useRef<Promise<void> | null>(null);
const bulkLoadedContextsRef = useRef<Set<string>>(new Set());
const pendingRequests = useRef<Map<string, Promise<UserPermissions>>>(new Map()); const pendingRequests = useRef<Map<string, Promise<UserPermissions>>>(new Map());
const { request } = useApiRequest(); const { request } = useApiRequest();
// Keep cacheRef in sync with cache state
useEffect(() => {
cacheRef.current = cache;
}, [cache]);
/** /**
* Generate a cache key for a permission check * Generate a cache key for a permission check
*/ */
@ -42,39 +51,67 @@ export const usePermissions = () => {
}; };
/** /**
* Retry function with exponential backoff for 429 errors * Load all permissions for a context (UI or RESOURCE) in bulk
* This is called once per context and caches all permissions
*/ */
const retryWithBackoff = async ( const loadBulkPermissions = useCallback(async (context: 'UI' | 'RESOURCE'): Promise<void> => {
fn: () => Promise<any>, // Skip if already loaded for this context
maxRetries = 3, if (bulkLoadedContextsRef.current.has(context)) {
baseDelay = 1000 return;
): Promise<any> => {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (error: any) {
if (error.response?.status === 429 && attempt < maxRetries - 1) {
const delay = baseDelay * Math.pow(2, attempt);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw error;
}
} }
};
// Check if there's already a pending bulk load
if (bulkLoadPromiseRef.current) {
await bulkLoadPromiseRef.current;
return;
}
// Create the bulk load promise
bulkLoadPromiseRef.current = (async () => {
setLoading(true);
try {
console.log(`🔐 usePermissions: Bulk loading all ${context} permissions...`);
const response = await fetchAllPermissionsApi(request, context);
// Cache all permissions from the response
const contextKey = context.toLowerCase() as 'ui' | 'resource';
const permissions = response[contextKey] || {};
const newCache: PermissionCache = { ...cacheRef.current };
let count = 0;
for (const [item, perm] of Object.entries(permissions)) {
const key = getPermissionKey(context, item);
newCache[key] = perm;
count++;
}
cacheRef.current = newCache;
bulkLoadedContextsRef.current.add(context);
console.log(`✅ usePermissions: Bulk loaded ${count} ${context} permissions`);
} catch (error: any) {
console.error(`❌ usePermissions: Error bulk loading ${context} permissions:`, error);
// Don't mark as loaded on error - allow retry
} finally {
bulkLoadPromiseRef.current = null;
setLoading(false);
}
})();
await bulkLoadPromiseRef.current;
}, [request]);
/** /**
* Check permissions for a given context and item * Fetch individual permission (used for DATA context and fallback)
* Returns full UserPermissions object
* Checks cache first, then fetches from backend if not cached
*/ */
const checkPermission = useCallback(async ( const fetchIndividualPermission = useCallback(async (
context: PermissionContext, context: PermissionContext,
item?: string item?: string
): Promise<UserPermissions> => { ): Promise<UserPermissions> => {
const key = getPermissionKey(context, item); const key = getPermissionKey(context, item);
// Check cache first using ref to avoid stale closures // Check cache first
if (cacheRef.current[key]) { if (cacheRef.current[key]) {
return cacheRef.current[key]; return cacheRef.current[key];
} }
@ -84,76 +121,21 @@ export const usePermissions = () => {
return pendingRequests.current.get(key)!; return pendingRequests.current.get(key)!;
} }
// Create new request // Fetch individual permission
const requestPromise = (async () => { const requestPromise = (async () => {
setLoading(true); setLoading(true);
try { try {
// Use retry logic for 429 errors const permissions = await fetchPermissionsApi(request, context, item);
// Note: We wrap the API call in retry logic since useApiRequest doesn't handle 429 retries
console.log('🔐 usePermissions: Checking permissions for:', { context, item, cacheKey: key }); // Update cache
cacheRef.current = { ...cacheRef.current, [key]: permissions };
const permissions = await retryWithBackoff(async () => {
try {
const result = await fetchPermissionsApi(request, context, item);
console.log('✅ usePermissions: Received permissions response:', {
context,
item,
permissions: result,
view: result?.view,
viewType: typeof result?.view,
viewValue: result?.view,
read: result?.read,
create: result?.create,
update: result?.update,
delete: result?.delete,
isArray: Array.isArray(result),
keys: result ? Object.keys(result) : [],
fullResponse: JSON.stringify(result, null, 2)
});
return result;
} catch (error: any) {
console.error('❌ usePermissions: Error fetching permissions:', {
context,
item,
error: error.message,
status: error.response?.status,
statusText: error.response?.statusText,
fullError: error
});
// If useApiRequest throws, we need to check if it's a 429
// For now, we'll let the retry logic handle it
throw error;
}
});
// Update cache after fetching from backend
setCache(prev => {
const newCache = { ...prev, [key]: permissions };
cacheRef.current = newCache;
console.log('💾 usePermissions: Cached permissions:', { context, item, permissions });
return newCache;
});
return permissions; return permissions;
} catch (error: any) { } catch (error: any) {
// Only log non-429 errors to avoid spam console.error('Error checking permissions:', error);
if (error.response?.status !== 429) {
console.error('Error checking permissions:', error);
}
// Return cached value if available, otherwise default (no access) // Return cached value if available, otherwise default (no access)
const cached = cacheRef.current[key]; return cacheRef.current[key] || DEFAULT_NO_ACCESS;
if (cached) {
return cached;
}
return {
view: false,
read: 'n' as PermissionLevel,
create: 'n' as PermissionLevel,
update: 'n' as PermissionLevel,
delete: 'n' as PermissionLevel,
};
} finally { } finally {
pendingRequests.current.delete(key); pendingRequests.current.delete(key);
setLoading(false); setLoading(false);
@ -166,6 +148,39 @@ export const usePermissions = () => {
return requestPromise; return requestPromise;
}, [request]); }, [request]);
/**
* Check permissions for a given context and item
* Returns full UserPermissions object
*
* For UI/RESOURCE contexts: Uses bulk-loaded cache, falls back to individual fetch
* For DATA context: Fetches individually (as items are dynamic)
*/
const checkPermission = useCallback(async (
context: PermissionContext,
item?: string
): Promise<UserPermissions> => {
const key = getPermissionKey(context, item);
// For UI and RESOURCE contexts, try bulk loading first
if (context === 'UI' || context === 'RESOURCE') {
// Ensure bulk permissions are loaded
await loadBulkPermissions(context);
// Check cache after bulk load
if (cacheRef.current[key]) {
return cacheRef.current[key];
}
// If not in bulk cache, fall back to individual fetch
// (item may not have explicit rule, but backend will calculate effective permissions)
console.log(`⚠️ usePermissions: ${context}:${item} not in bulk cache, fetching individually`);
return fetchIndividualPermission(context, item);
}
// For DATA context, fetch individually
return fetchIndividualPermission(context, item);
}, [loadBulkPermissions, fetchIndividualPermission]);
/** /**
* Check if user has permission for a specific operation * Check if user has permission for a specific operation
* Returns true if user has any level of permission (not 'n') * Returns true if user has any level of permission (not 'n')
@ -197,35 +212,25 @@ export const usePermissions = () => {
context: PermissionContext, context: PermissionContext,
item: string item: string
): Promise<boolean> => { ): Promise<boolean> => {
console.log('👁️ canView: Checking view access for:', { context, item });
const permissions = await checkPermission(context, item); const permissions = await checkPermission(context, item);
const hasAccess = permissions.view === true; return permissions.view === true;
console.log('👁️ canView: Result:', {
context,
item,
hasAccess,
viewPermission: permissions.view,
viewPermissionType: typeof permissions.view,
viewPermissionValue: permissions.view,
allPermissions: {
view: permissions.view,
read: permissions.read,
create: permissions.create,
update: permissions.update,
delete: permissions.delete
},
fullPermissionsObject: JSON.stringify(permissions, null, 2)
});
return hasAccess;
}, [checkPermission]); }, [checkPermission]);
/**
* Preload all permissions for UI context
* Call this early in the app lifecycle to warm the cache
*/
const preloadUiPermissions = useCallback(async (): Promise<void> => {
await loadBulkPermissions('UI');
}, [loadBulkPermissions]);
/** /**
* Clear the permission cache * Clear the permission cache
* Useful when user permissions change or after logout * Useful when user permissions change or after logout
*/ */
const clearCache = useCallback(() => { const clearCache = useCallback(() => {
setCache({});
cacheRef.current = {}; cacheRef.current = {};
bulkLoadedContextsRef.current.clear();
pendingRequests.current.clear(); pendingRequests.current.clear();
}, []); }, []);
@ -233,6 +238,7 @@ export const usePermissions = () => {
checkPermission, checkPermission,
hasPermission, hasPermission,
canView, canView,
preloadUiPermissions,
loading, loading,
clearCache, clearCache,
}; };

View file

@ -235,11 +235,11 @@ export function usePrompts() {
// Match create button configuration for prompts // Match create button configuration for prompts
if (attr.name === 'name') { if (attr.name === 'name') {
required = true; required = true;
validator = (value: string) => { validator = (value: any) => {
if (!value || value.trim() === '') { if (!value || (typeof value === 'string' && value.trim() === '')) {
return 'Prompt name cannot be empty'; return 'Prompt name cannot be empty';
} }
if (value.length > 100) { if (typeof value === 'string' && value.length > 100) {
return 'Prompt name cannot exceed 100 characters'; return 'Prompt name cannot exceed 100 characters';
} }
return null; return null;
@ -248,11 +248,11 @@ export function usePrompts() {
required = true; required = true;
minRows = 6; // Match create button: minRows: 6 minRows = 6; // Match create button: minRows: 6
maxRows = 12; // Match create button: maxRows: 12 maxRows = 12; // Match create button: maxRows: 12
validator = (value: string) => { validator = (value: any) => {
if (!value || value.trim() === '') { if (!value || (typeof value === 'string' && value.trim() === '')) {
return 'Prompt content cannot be empty'; return 'Prompt content cannot be empty';
} }
if (value.length > 10000) { if (typeof value === 'string' && value.length > 10000) {
return 'Prompt content cannot exceed 10,000 characters'; return 'Prompt content cannot exceed 10,000 characters';
} }
return null; return null;
@ -302,11 +302,11 @@ export function usePrompts() {
if (attr.name === 'name') { if (attr.name === 'name') {
required = true; required = true;
validator = (value: string) => { validator = (value: any) => {
if (!value || value.trim() === '') { if (!value || (typeof value === 'string' && value.trim() === '')) {
return 'Prompt name cannot be empty'; return 'Prompt name cannot be empty';
} }
if (value.length > 100) { if (typeof value === 'string' && value.length > 100) {
return 'Prompt name cannot exceed 100 characters'; return 'Prompt name cannot exceed 100 characters';
} }
return null; return null;
@ -315,11 +315,11 @@ export function usePrompts() {
required = true; required = true;
minRows = 6; // Match create button minRows = 6; // Match create button
maxRows = 12; // Match create button maxRows = 12; // Match create button
validator = (value: string) => { validator = (value: any) => {
if (!value || value.trim() === '') { if (!value || (typeof value === 'string' && value.trim() === '')) {
return 'Prompt content cannot be empty'; return 'Prompt content cannot be empty';
} }
if (value.length > 10000) { if (typeof value === 'string' && value.length > 10000) {
return 'Prompt content cannot exceed 10,000 characters'; return 'Prompt content cannot exceed 10,000 characters';
} }
return null; return null;
@ -342,6 +342,95 @@ export function usePrompts() {
return editableFields; return editableFields;
}, [attributes]); }, [attributes]);
// Generate create fields from attributes dynamically
// For prompts, the create form is essentially the same as edit form
const generateCreateFieldsFromAttributes = useCallback((): Array<{
key: string;
label: string;
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly';
required?: boolean;
validator?: (value: any) => string | null;
minRows?: number;
maxRows?: number;
options?: Array<{ value: string | number; label: string }>;
optionsReference?: string;
placeholder?: string;
}> => {
if (!attributes || attributes.length === 0) {
return [];
}
const createFields = attributes
.filter(attr => {
// Filter out non-editable fields and auto-generated fields for create forms
if (attr.readonly === true || attr.editable === false) {
return false;
}
// Filter out ID fields and other auto-generated fields
const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {
// Map backend attribute type to form field type
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string';
let minRows: number | undefined = undefined;
let maxRows: number | undefined = undefined;
// Map backend types to form field types
// Cast to string to handle all possible backend type values
const attrType = attr.type as string;
if (attrType === 'checkbox' || attrType === 'boolean') {
fieldType = 'boolean';
} else if (attrType === 'email') {
fieldType = 'email';
} else if (attrType === 'timestamp' || attrType === 'date' || attrType === 'time') {
fieldType = 'date';
} else if (attrType === 'textarea') {
fieldType = 'textarea';
// Set default rows for textarea fields
minRows = 6;
maxRows = 12;
} else if (attr.name === 'content' || attr.name.toLowerCase().includes('content')) {
// Content fields should be textarea
fieldType = 'textarea';
minRows = 6;
maxRows = 12;
}
// Determine if required and build validator
const required = attr.required === true;
let validator: ((value: any) => string | null) | undefined = undefined;
// Required string validation
if (required && (fieldType === 'string' || fieldType === 'textarea')) {
validator = (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
return `${attr.label} is required`;
}
if (attr.name === 'name' && typeof value === 'string' && value.length > 100) {
return 'Prompt name cannot exceed 100 characters';
}
if (attr.name === 'content' && typeof value === 'string' && value.length > 10000) {
return 'Prompt content cannot exceed 10,000 characters';
}
return null;
};
}
return {
key: attr.name,
label: attr.label || attr.name,
type: fieldType,
required,
validator,
minRows,
maxRows
};
});
return createFields;
}, [attributes]);
// Ensure attributes are loaded - can be called by EditActionButton // Ensure attributes are loaded - can be called by EditActionButton
const ensureAttributesLoaded = useCallback(async () => { const ensureAttributesLoaded = useCallback(async () => {
// If attributes are already loaded, return them // If attributes are already loaded, return them
@ -377,6 +466,7 @@ export function usePrompts() {
pagination, pagination,
fetchPromptById, fetchPromptById,
generateEditFieldsFromAttributes, generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded // Generic function to ensure attributes are loaded ensureAttributesLoaded // Generic function to ensure attributes are loaded
}; };
} }
@ -474,6 +564,15 @@ export function usePromptOperations() {
} }
}; };
// Generic inline update handler for FormGeneratorTable
const handleInlineUpdate = async (promptId: string, changes: Partial<{ name: string; content: string }>) => {
const result = await handlePromptUpdate(promptId, changes as { name: string; content: string });
if (!result.success) {
throw new Error(result.error || 'Failed to update');
}
return result;
};
return { return {
deletingPrompts, deletingPrompts,
creatingPrompt, creatingPrompt,
@ -483,6 +582,7 @@ export function usePromptOperations() {
handlePromptDelete, handlePromptDelete,
handlePromptCreate, handlePromptCreate,
handlePromptUpdate, handlePromptUpdate,
handleInlineUpdate,
isLoading isLoading
}; };
} }

484
src/hooks/useTrustee.ts Normal file
View file

@ -0,0 +1,484 @@
import { useState, useEffect, useCallback } from 'react';
import { useApiRequest } from './useApi';
import { getUserDataCache } from '../utils/userCache';
import api from '../api';
import { usePermissions, type UserPermissions } from './usePermissions';
import {
// Types
type TrusteeOrganisation,
type TrusteeRole,
type TrusteeAccess,
type TrusteeContract,
type TrusteeDocument,
type TrusteePosition,
type TrusteePositionDocument,
type PaginationParams,
// Organisation API
fetchOrganisations as fetchOrganisationsApi,
fetchOrganisationById as fetchOrganisationByIdApi,
createOrganisation as createOrganisationApi,
updateOrganisation as updateOrganisationApi,
deleteOrganisation as deleteOrganisationApi,
// Role API
fetchRoles as fetchRolesApi,
fetchRoleById as fetchRoleByIdApi,
createRole as createRoleApi,
updateRole as updateRoleApi,
deleteRole as deleteRoleApi,
// Access API
fetchAccess as fetchAccessApi,
fetchAccessById as fetchAccessByIdApi,
createAccess as createAccessApi,
updateAccess as updateAccessApi,
deleteAccess as deleteAccessApi,
// Contract API
fetchContracts as fetchContractsApi,
fetchContractById as fetchContractByIdApi,
createContract as createContractApi,
updateContract as updateContractApi,
deleteContract as deleteContractApi,
// Document API
fetchDocuments as fetchDocumentsApi,
fetchDocumentById as fetchDocumentByIdApi,
createDocument as createDocumentApi,
updateDocument as updateDocumentApi,
deleteDocument as deleteDocumentApi,
// Position API
fetchPositions as fetchPositionsApi,
fetchPositionById as fetchPositionByIdApi,
createPosition as createPositionApi,
updatePosition as updatePositionApi,
deletePosition as deletePositionApi,
// Position-Document API
fetchPositionDocuments as fetchPositionDocumentsApi,
createPositionDocument as createPositionDocumentApi,
deletePositionDocument as deletePositionDocumentApi,
} from '../api/trusteeApi';
// Re-export types
export type {
TrusteeOrganisation,
TrusteeRole,
TrusteeAccess,
TrusteeContract,
TrusteeDocument,
TrusteePosition,
TrusteePositionDocument,
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 TRUSTEE ENTITY HOOK FACTORY
// ============================================================================
interface TrusteeEntityConfig<T> {
entityName: string;
fetchAll: (request: any, params?: PaginationParams) => Promise<any>;
fetchById: (request: any, id: string) => Promise<T | null>;
create: (request: any, data: Partial<T>) => Promise<T>;
update: (request: any, id: string, data: Partial<T>) => Promise<T>;
deleteItem: (request: any, id: string) => Promise<void>;
}
function _createTrusteeEntityHook<T extends { id: string }>(config: TrusteeEntityConfig<T>) {
return function useTrusteeEntity() {
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 () => {
try {
const response = await api.get(`/api/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 (error: any) {
console.error(`Error fetching ${config.entityName} attributes:`, error);
setAttributes([]);
return [];
}
}, []);
const fetchPermissions = useCallback(async () => {
try {
const perms = await checkPermission('DATA', config.entityName);
setPermissions(perms);
return perms;
} catch (error: any) {
console.error(`Error fetching ${config.entityName} permissions:`, error);
const defaultPerms: UserPermissions = {
view: false,
read: 'n',
create: 'n',
update: 'n',
delete: 'n',
};
setPermissions(defaultPerms);
return defaultPerms;
}
}, [checkPermission]);
const fetchItems = useCallback(async (params?: PaginationParams) => {
try {
const data = await config.fetchAll(request, 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 (error: any) {
setItems([]);
setPagination(null);
}
}, [request]);
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> => {
return await config.fetchById(request, itemId);
}, [request]);
const generateEditFieldsFromAttributes = useCallback(() => {
if (!attributes || attributes.length === 0) {
return [];
}
return attributes
.filter(attr => {
if (attr.readonly === true || attr.editable === false) {
return false;
}
const nonEditableFields = ['_createdBy', '_createdAt', '_modifiedBy', '_modifiedAt'];
return !nonEditableFields.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 = undefined;
let optionsReference: string | undefined = 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.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 attr.options === 'string') {
optionsReference = attr.options;
}
} else if (attr.type === 'multiselect') {
fieldType = 'multiselect';
if (Array.isArray(attr.options)) {
options = attr.options.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 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 ensureAttributesLoaded = useCallback(async () => {
if (attributes && attributes.length > 0) {
return attributes;
}
return await fetchAttributes();
}, [attributes, fetchAttributes]);
useEffect(() => {
fetchAttributes();
fetchPermissions();
}, [fetchAttributes, fetchPermissions]);
useEffect(() => {
fetchItems();
}, [fetchItems]);
return {
items,
loading,
error,
refetch: fetchItems,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
pagination,
fetchById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
};
};
}
function _createTrusteeOperationsHook<T extends { id: string }>(config: TrusteeEntityConfig<T>) {
return function useTrusteeEntityOperations() {
const [deletingItems, setDeletingItems] = useState<Set<string>>(new Set());
const [creatingItem, setCreatingItem] = 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 handleDelete = async (itemId: string) => {
setDeleteError(null);
setDeletingItems(prev => new Set(prev).add(itemId));
try {
await config.deleteItem(request, itemId);
await new Promise(resolve => setTimeout(resolve, 300));
return true;
} catch (error: any) {
setDeleteError(error.message);
return false;
} finally {
setDeletingItems(prev => {
const newSet = new Set(prev);
newSet.delete(itemId);
return newSet;
});
}
};
const handleCreate = async (itemData: Partial<T>) => {
setCreateError(null);
setCreatingItem(true);
try {
const newItem = await config.create(request, itemData);
return { success: true, data: newItem };
} catch (error: any) {
setCreateError(error.message);
return { success: false, error: error.message };
} finally {
setCreatingItem(false);
}
};
const handleUpdate = async (itemId: string, updateData: Partial<T>) => {
setUpdateError(null);
try {
const updatedItem = await config.update(request, itemId, updateData);
return { success: true, data: updatedItem };
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.message || 'Failed to update';
setUpdateError(errorMessage);
return {
success: false,
error: errorMessage,
statusCode: error.response?.status,
isPermissionError: error.response?.status === 403,
isValidationError: error.response?.status === 400
};
}
};
return {
deletingItems,
creatingItem,
deleteError,
createError,
updateError,
handleDelete,
handleCreate,
handleUpdate,
isLoading
};
};
}
// ============================================================================
// ORGANISATION HOOKS
// ============================================================================
const organisationConfig: TrusteeEntityConfig<TrusteeOrganisation> = {
entityName: 'TrusteeOrganisation',
fetchAll: fetchOrganisationsApi,
fetchById: fetchOrganisationByIdApi,
create: createOrganisationApi,
update: updateOrganisationApi,
deleteItem: deleteOrganisationApi
};
export const useTrusteeOrganisations = _createTrusteeEntityHook(organisationConfig);
export const useTrusteeOrganisationOperations = _createTrusteeOperationsHook(organisationConfig);
// ============================================================================
// ROLE HOOKS
// ============================================================================
const roleConfig: TrusteeEntityConfig<TrusteeRole> = {
entityName: 'TrusteeRole',
fetchAll: fetchRolesApi,
fetchById: fetchRoleByIdApi,
create: createRoleApi,
update: updateRoleApi,
deleteItem: deleteRoleApi
};
export const useTrusteeRoles = _createTrusteeEntityHook(roleConfig);
export const useTrusteeRoleOperations = _createTrusteeOperationsHook(roleConfig);
// ============================================================================
// ACCESS HOOKS
// ============================================================================
const accessConfig: TrusteeEntityConfig<TrusteeAccess> = {
entityName: 'TrusteeAccess',
fetchAll: fetchAccessApi,
fetchById: fetchAccessByIdApi,
create: createAccessApi,
update: updateAccessApi,
deleteItem: deleteAccessApi
};
export const useTrusteeAccess = _createTrusteeEntityHook(accessConfig);
export const useTrusteeAccessOperations = _createTrusteeOperationsHook(accessConfig);
// ============================================================================
// CONTRACT HOOKS
// ============================================================================
const contractConfig: TrusteeEntityConfig<TrusteeContract> = {
entityName: 'TrusteeContract',
fetchAll: fetchContractsApi,
fetchById: fetchContractByIdApi,
create: createContractApi,
update: updateContractApi,
deleteItem: deleteContractApi
};
export const useTrusteeContracts = _createTrusteeEntityHook(contractConfig);
export const useTrusteeContractOperations = _createTrusteeOperationsHook(contractConfig);
// ============================================================================
// DOCUMENT HOOKS
// ============================================================================
const documentConfig: TrusteeEntityConfig<TrusteeDocument> = {
entityName: 'TrusteeDocument',
fetchAll: fetchDocumentsApi,
fetchById: fetchDocumentByIdApi,
create: createDocumentApi,
update: updateDocumentApi,
deleteItem: deleteDocumentApi
};
export const useTrusteeDocuments = _createTrusteeEntityHook(documentConfig);
export const useTrusteeDocumentOperations = _createTrusteeOperationsHook(documentConfig);
// ============================================================================
// POSITION HOOKS
// ============================================================================
const positionConfig: TrusteeEntityConfig<TrusteePosition> = {
entityName: 'TrusteePosition',
fetchAll: fetchPositionsApi,
fetchById: fetchPositionByIdApi,
create: createPositionApi,
update: updatePositionApi,
deleteItem: deletePositionApi
};
export const useTrusteePositions = _createTrusteeEntityHook(positionConfig);
export const useTrusteePositionOperations = _createTrusteeOperationsHook(positionConfig);
// ============================================================================
// POSITION-DOCUMENT HOOKS
// ============================================================================
const positionDocumentConfig: TrusteeEntityConfig<TrusteePositionDocument> = {
entityName: 'TrusteePositionDocument',
fetchAll: fetchPositionDocumentsApi,
fetchById: async () => null, // Not typically needed
create: createPositionDocumentApi,
update: async () => { throw new Error('Update not supported for position-document links'); },
deleteItem: deletePositionDocumentApi
};
export const useTrusteePositionDocuments = _createTrusteeEntityHook(positionDocumentConfig);
export const useTrusteePositionDocumentOperations = _createTrusteeOperationsHook(positionDocumentConfig);

View file

@ -11,6 +11,7 @@ import {
createUser as createUserApi, createUser as createUserApi,
updateUser as updateUserApi, updateUser as updateUserApi,
deleteUser as deleteUserApi, deleteUser as deleteUserApi,
sendPasswordLink as sendPasswordLinkApi,
type User, type User,
type UserUpdateData, type UserUpdateData,
type AttributeDefinition, type AttributeDefinition,
@ -579,11 +580,11 @@ export function useOrgUsers() {
// Email validation // Email validation
if (fieldType === 'email') { if (fieldType === 'email') {
validator = (value: string) => { validator = (value: any) => {
if (required && (!value || value.trim() === '')) { if (required && (!value || (typeof value === 'string' && value.trim() === ''))) {
return 'Email cannot be empty'; return 'Email cannot be empty';
} }
if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { if (value && typeof value === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return 'Invalid email format'; return 'Invalid email format';
} }
return null; return null;
@ -625,6 +626,135 @@ export function useOrgUsers() {
return editableFields; return editableFields;
}, [attributes]); }, [attributes]);
// Generate create fields from attributes dynamically
// For users, we add a password field that's not in the backend attributes (since passwords are hashed)
const generateCreateFieldsFromAttributes = useCallback((): Array<{
key: string;
label: string;
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly';
required?: boolean;
validator?: (value: any) => string | null;
minRows?: number;
maxRows?: number;
options?: Array<{ value: string | number; label: string }>;
optionsReference?: string;
placeholder?: string;
}> => {
if (!attributes || attributes.length === 0) {
return [];
}
const createFields = attributes
.filter(attr => {
// Filter out non-editable fields and auto-generated fields for create forms
if (attr.readonly === true || attr.editable === false) {
return false;
}
// Filter out ID fields and other auto-generated fields
const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete', 'authenticationAuthority'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {
// Map backend attribute type to form field type
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;
// Map backend types to form field types
// Cast to string to handle all possible backend type values
const attrType = attr.type as string;
if (attrType === 'checkbox' || attrType === 'boolean') {
fieldType = 'boolean';
} else if (attrType === 'email') {
fieldType = 'email';
} else if (attrType === 'timestamp' || attrType === 'date' || attrType === 'time') {
fieldType = 'date';
} else if (attrType === 'select' || attrType === 'enum') {
fieldType = 'enum';
if (Array.isArray(attr.options)) {
options = attr.options.map(opt => {
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 attr.options === 'string') {
optionsReference = attr.options;
}
} else if (attrType === 'multiselect') {
fieldType = 'multiselect';
if (Array.isArray(attr.options)) {
options = attr.options.map(opt => {
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 attr.options === 'string') {
optionsReference = attr.options;
}
} else if (attrType === 'textarea') {
fieldType = 'textarea';
}
// Determine if required and build validator
const required = attr.required === true;
let validator: ((value: any) => string | null) | undefined = undefined;
// Email validation
if (attr.type === 'email') {
validator = (value: any) => {
if (required && (!value || (typeof value === 'string' && value.trim() === ''))) {
return 'Email cannot be empty';
}
if (value && typeof value === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return 'Invalid email format';
}
return null;
};
}
// Required string validation
else if (required && fieldType === 'string') {
validator = (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
return `${attr.label} is required`;
}
return null;
};
}
// Multiselect validation
else if (fieldType === 'multiselect' && required) {
validator = (value: any[]) => {
if (!value || !Array.isArray(value) || value.length === 0) {
return `${attr.label} is required`;
}
return null;
};
}
return {
key: attr.name,
label: attr.label || attr.name,
type: fieldType,
required,
validator,
options,
optionsReference
};
});
// Note: Password field removed - users are created without password
// Admin can send password setup link after user creation using handleSendPasswordLink
return createFields;
}, [attributes]);
// Ensure attributes are loaded - can be called by EditActionButton // Ensure attributes are loaded - can be called by EditActionButton
const ensureAttributesLoaded = useCallback(async () => { const ensureAttributesLoaded = useCallback(async () => {
// Don't fetch attributes if user is not authenticated (prevents 401 errors) // Don't fetch attributes if user is not authenticated (prevents 401 errors)
@ -667,6 +797,7 @@ export function useOrgUsers() {
pagination, pagination,
fetchUserById, fetchUserById,
generateEditFieldsFromAttributes, generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded ensureAttributesLoaded
}; };
} }
@ -675,11 +806,13 @@ export function useOrgUsers() {
export function useUserOperations() { export function useUserOperations() {
const [deletingUsers, setDeletingUsers] = useState<Set<string>>(new Set()); const [deletingUsers, setDeletingUsers] = useState<Set<string>>(new Set());
const [editingUsers, setEditingUsers] = useState<Set<string>>(new Set()); const [editingUsers, setEditingUsers] = useState<Set<string>>(new Set());
const [sendingPasswordLink, setSendingPasswordLink] = useState<Set<string>>(new Set());
const [creatingUser, setCreatingUser] = useState(false); const [creatingUser, setCreatingUser] = useState(false);
const { request, isLoading } = useApiRequest(); const { request, isLoading } = useApiRequest();
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
const [createError, setCreateError] = useState<string | null>(null); const [createError, setCreateError] = useState<string | null>(null);
const [updateError, setUpdateError] = useState<string | null>(null); const [updateError, setUpdateError] = useState<string | null>(null);
const [passwordLinkError, setPasswordLinkError] = useState<string | null>(null);
const handleUserDelete = async (userId: string) => { const handleUserDelete = async (userId: string) => {
setDeleteError(null); setDeleteError(null);
@ -702,7 +835,7 @@ export function useUserOperations() {
} }
}; };
const handleUserCreate = async (userData: Omit<User, 'id' | 'mandateId'> & { password: string }) => { const handleUserCreate = async (userData: Omit<User, 'id' | 'mandateId'>) => {
setCreateError(null); setCreateError(null);
setCreatingUser(true); setCreatingUser(true);
@ -726,6 +859,31 @@ export function useUserOperations() {
} }
}; };
// Send password setup link to a user (admin function)
const handleSendPasswordLink = async (userId: string) => {
setPasswordLinkError(null);
setSendingPasswordLink(prev => new Set(prev).add(userId));
try {
// Get frontend URL from current window location
const frontendUrl = `${window.location.protocol}//${window.location.host}`;
const result = await sendPasswordLinkApi(request, userId, frontendUrl);
return { success: true, message: result.message, email: result.email };
} catch (error: any) {
const errorMessage = error.response?.data?.detail || error.message || 'Failed to send password link';
setPasswordLinkError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setSendingPasswordLink(prev => {
const newSet = new Set(prev);
newSet.delete(userId);
return newSet;
});
}
};
const handleUserUpdate = async (userId: string, updateData: UserUpdateData, _originalData?: any) => { const handleUserUpdate = async (userId: string, updateData: UserUpdateData, _originalData?: any) => {
setUpdateError(null); setUpdateError(null);
setEditingUsers(prev => new Set(prev).add(userId)); setEditingUsers(prev => new Set(prev).add(userId));
@ -764,16 +922,29 @@ export function useUserOperations() {
} }
}; };
// Generic inline update handler for FormGeneratorTable
const handleInlineUpdate = async (userId: string, changes: Partial<UserUpdateData>) => {
const result = await handleUserUpdate(userId, changes as UserUpdateData);
if (!result.success) {
throw new Error(result.error || 'Failed to update');
}
return result;
};
return { return {
deletingUsers, deletingUsers,
editingUsers, editingUsers,
sendingPasswordLink,
creatingUser, creatingUser,
deleteError, deleteError,
createError, createError,
updateError, updateError,
passwordLinkError,
handleUserDelete, handleUserDelete,
handleUserCreate, handleUserCreate,
handleUserUpdate, handleUserUpdate,
handleInlineUpdate,
handleSendPasswordLink,
isLoading isLoading
}; };
} }

View file

@ -290,11 +290,11 @@ export function useUserWorkflows() {
if (attr.name === 'name') { if (attr.name === 'name') {
required = true; required = true;
validator = (value: string) => { validator = (value: any) => {
if (!value || value.trim() === '') { if (!value || (typeof value === 'string' && value.trim() === '')) {
return 'Workflow name cannot be empty'; return 'Workflow name cannot be empty';
} }
if (value.length > 100) { if (typeof value === 'string' && value.length > 100) {
return 'Workflow name cannot exceed 100 characters'; return 'Workflow name cannot exceed 100 characters';
} }
return null; return null;
@ -343,16 +343,12 @@ export function useUserWorkflows() {
}, [attributes, fetchAttributes]); }, [attributes, fetchAttributes]);
// Fetch attributes and permissions on mount // Fetch attributes and permissions on mount
// Note: Do NOT fetch workflows here - let the table component control pagination
useEffect(() => { useEffect(() => {
fetchAttributes(); fetchAttributes();
fetchPermissions(); fetchPermissions();
}, [fetchAttributes, fetchPermissions]); }, [fetchAttributes, fetchPermissions]);
// Initial fetch
useEffect(() => {
fetchWorkflowsData();
}, [fetchWorkflowsData]);
// Listen for workflow creation events to refetch workflows list // Listen for workflow creation events to refetch workflows list
useEffect(() => { useEffect(() => {
const handleWorkflowCreated = (_event: CustomEvent<{ workflow: UserWorkflow }>) => { const handleWorkflowCreated = (_event: CustomEvent<{ workflow: UserWorkflow }>) => {

View file

@ -535,6 +535,9 @@ export default {
'team-members.new_button': 'Mitglied hinzufügen', 'team-members.new_button': 'Mitglied hinzufügen',
'team-members.action.edit': 'Bearbeiten', 'team-members.action.edit': 'Bearbeiten',
'team-members.action.delete': 'Löschen', 'team-members.action.delete': 'Löschen',
'team-members.action.sendPasswordLink': 'Passwort-Link senden',
'team-members.action.passwordLinkSent': 'Passwort-Link gesendet!',
'team-members.action.passwordLinkFailed': 'Link konnte nicht gesendet werden',
'team-members.field.username': 'Benutzername', 'team-members.field.username': 'Benutzername',
'team-members.field.email': 'E-Mail', 'team-members.field.email': 'E-Mail',
'team-members.field.password': 'Passwort', 'team-members.field.password': 'Passwort',
@ -716,8 +719,8 @@ export default {
'warning.duplicate_file.message': 'Die Datei "{fileName}" existiert bereits mit identischem Inhalt. Die vorhandene Datei wird wiederverwendet.', 'warning.duplicate_file.message': 'Die Datei "{fileName}" existiert bereits mit identischem Inhalt. Die vorhandene Datei wird wiederverwendet.',
// Administration // Administration
'administration.title': 'Verwaltung', 'administration.title': 'Werkzeuge',
'administration.description': 'Verwaltungs- und Management-Tools', 'administration.description': 'Werkzeuge und Hilfsmittel',
'administration.subtitle': 'Verwaltungs- und Management-Tools', 'administration.subtitle': 'Verwaltungs- und Management-Tools',
'administration.intro.description': 'Dieser Bereich enthält alle Verwaltungs- und Management-Tools für Ihren Arbeitsbereich.', 'administration.intro.description': 'Dieser Bereich enthält alle Verwaltungs- und Management-Tools für Ihren Arbeitsbereich.',
'administration.features.title': 'Verfügbare Tools', 'administration.features.title': 'Verfügbare Tools',
@ -799,4 +802,111 @@ export default {
'dragdrop.overlay.default_subtext': 'Sie können auch auf den Upload-Button klicken', 'dragdrop.overlay.default_subtext': 'Sie können auch auf den Upload-Button klicken',
'dragdrop.overlay.processing': 'Dateien werden verarbeitet...', 'dragdrop.overlay.processing': 'Dateien werden verarbeitet...',
'dragdrop.overlay.error': 'Fehler beim Verarbeiten der Dateien', 'dragdrop.overlay.error': 'Fehler beim Verarbeiten der Dateien',
// Trustee Feature
'trustee.title': 'Treuhand',
'trustee.subtitle': 'Treuhandverwaltung',
'trustee.description': 'Verwaltung von Treuhand-Organisationen, Verträgen und Buchungen',
// Trustee Organisations
'trustee.organisations.title': 'Organisationen',
'trustee.organisations.subtitle': 'Trustee-Organisationen verwalten',
'trustee.organisations.description': 'Verwaltung der Treuhand-Organisationen',
'trustee.organisations.new_button': 'Neue Organisation',
'trustee.organisations.field.id': 'ID',
'trustee.organisations.field.id_placeholder': 'z.B. treuhand-ag-zuerich',
'trustee.organisations.field.label': 'Bezeichnung',
'trustee.organisations.field.label_placeholder': 'z.B. Treuhand AG Zürich',
'trustee.organisations.field.enabled': 'Aktiviert',
'trustee.organisations.modal.create.title': 'Neue Organisation erstellen',
'trustee.organisations.create.success': 'Organisation erfolgreich erstellt',
'trustee.organisations.create.error': 'Fehler beim Erstellen der Organisation',
'trustee.organisations.action.edit': 'Bearbeiten',
'trustee.organisations.action.delete': 'Löschen',
// Trustee Roles
'trustee.roles.title': 'Rollen',
'trustee.roles.subtitle': 'Trustee-Rollen verwalten',
'trustee.roles.description': 'Verwaltung der Feature-spezifischen Rollen',
'trustee.roles.new_button': 'Neue Rolle',
'trustee.roles.field.id': 'Rollen-ID',
'trustee.roles.field.id_placeholder': 'z.B. admin, operate, userreport',
'trustee.roles.field.desc': 'Beschreibung',
'trustee.roles.field.desc_placeholder': 'Beschreibung der Rolle',
'trustee.roles.modal.create.title': 'Neue Rolle erstellen',
'trustee.roles.create.success': 'Rolle erfolgreich erstellt',
'trustee.roles.create.error': 'Fehler beim Erstellen der Rolle',
'trustee.roles.action.edit': 'Bearbeiten',
'trustee.roles.action.delete': 'Löschen',
// Trustee Access
'trustee.access.title': 'Zugriff',
'trustee.access.subtitle': 'Benutzer-Zugriff verwalten',
'trustee.access.description': 'Verwaltung der Benutzerzugriffe auf Organisationen',
'trustee.access.new_button': 'Neuer Zugriff',
'trustee.access.field.organisationId': 'Organisation',
'trustee.access.field.roleId': 'Rolle',
'trustee.access.field.userId': 'Benutzer',
'trustee.access.field.contractId': 'Vertrag (optional)',
'trustee.access.field.contractId_placeholder': 'Leer = Zugriff auf alle Verträge',
'trustee.access.modal.create.title': 'Neuen Zugriff erstellen',
'trustee.access.create.success': 'Zugriff erfolgreich erstellt',
'trustee.access.create.error': 'Fehler beim Erstellen des Zugriffs',
'trustee.access.action.edit': 'Bearbeiten',
'trustee.access.action.delete': 'Löschen',
// Trustee Contracts
'trustee.contracts.title': 'Verträge',
'trustee.contracts.subtitle': 'Kundenverträge verwalten',
'trustee.contracts.description': 'Verwaltung der Kundenverträge',
'trustee.contracts.new_button': 'Neuer Vertrag',
'trustee.contracts.field.organisationId': 'Organisation',
'trustee.contracts.field.label': 'Bezeichnung',
'trustee.contracts.field.label_placeholder': 'z.B. Muster AG 2026',
'trustee.contracts.field.enabled': 'Aktiviert',
'trustee.contracts.modal.create.title': 'Neuen Vertrag erstellen',
'trustee.contracts.create.success': 'Vertrag erfolgreich erstellt',
'trustee.contracts.create.error': 'Fehler beim Erstellen des Vertrags',
'trustee.contracts.action.edit': 'Bearbeiten',
'trustee.contracts.action.delete': 'Löschen',
// Trustee Documents
'trustee.documents.title': 'Dokumente',
'trustee.documents.subtitle': 'Belege verwalten',
'trustee.documents.description': 'Verwaltung der Dokumente und Belege',
'trustee.documents.new_button': 'Neues Dokument',
'trustee.documents.field.organisationId': 'Organisation',
'trustee.documents.field.contractId': 'Vertrag',
'trustee.documents.field.documentName': 'Dateiname',
'trustee.documents.field.documentName_placeholder': 'z.B. Beleg.pdf',
'trustee.documents.field.documentMimeType': 'Dateityp',
'trustee.documents.modal.create.title': 'Neues Dokument erstellen',
'trustee.documents.create.success': 'Dokument erfolgreich erstellt',
'trustee.documents.create.error': 'Fehler beim Erstellen des Dokuments',
'trustee.documents.action.edit': 'Bearbeiten',
'trustee.documents.action.delete': 'Löschen',
'trustee.documents.action.download': 'Herunterladen',
// Trustee Positions
'trustee.positions.title': 'Positionen',
'trustee.positions.subtitle': 'Buchungspositionen verwalten',
'trustee.positions.description': 'Verwaltung der Buchungspositionen (Speseneinträge)',
'trustee.positions.new_button': 'Neue Position',
'trustee.positions.field.organisationId': 'Organisation',
'trustee.positions.field.contractId': 'Vertrag',
'trustee.positions.field.valuta': 'Valutadatum',
'trustee.positions.field.company': 'Firma',
'trustee.positions.field.company_placeholder': 'Name des Unternehmens',
'trustee.positions.field.desc': 'Beschreibung',
'trustee.positions.field.bookingCurrency': 'Buchungswährung',
'trustee.positions.field.bookingAmount': 'Buchungsbetrag',
'trustee.positions.field.originalCurrency': 'Originalwährung',
'trustee.positions.field.originalAmount': 'Originalbetrag',
'trustee.positions.field.vatPercentage': 'MwSt %',
'trustee.positions.field.vatAmount': 'MwSt Betrag',
'trustee.positions.modal.create.title': 'Neue Position erstellen',
'trustee.positions.create.success': 'Position erfolgreich erstellt',
'trustee.positions.create.error': 'Fehler beim Erstellen der Position',
'trustee.positions.action.edit': 'Bearbeiten',
'trustee.positions.action.delete': 'Löschen',
}; };

View file

@ -535,6 +535,9 @@ export default {
'team-members.new_button': 'Add Member', 'team-members.new_button': 'Add Member',
'team-members.action.edit': 'Edit', 'team-members.action.edit': 'Edit',
'team-members.action.delete': 'Delete', 'team-members.action.delete': 'Delete',
'team-members.action.sendPasswordLink': 'Send password setup link',
'team-members.action.passwordLinkSent': 'Password link sent!',
'team-members.action.passwordLinkFailed': 'Failed to send link',
'team-members.field.username': 'Username', 'team-members.field.username': 'Username',
'team-members.field.email': 'Email', 'team-members.field.email': 'Email',
'team-members.field.password': 'Password', 'team-members.field.password': 'Password',
@ -716,8 +719,8 @@ export default {
'warning.duplicate_file.message': 'The file "{fileName}" already exists with identical content. The existing file will be reused.', 'warning.duplicate_file.message': 'The file "{fileName}" already exists with identical content. The existing file will be reused.',
// Administration // Administration
'administration.title': 'Administration', 'administration.title': 'Utils',
'administration.description': 'Administration and management tools', 'administration.description': 'Utilities and tools',
'administration.subtitle': 'Administration and management tools', 'administration.subtitle': 'Administration and management tools',
'administration.intro.description': 'This section contains all administration and management tools for your workspace.', 'administration.intro.description': 'This section contains all administration and management tools for your workspace.',
'administration.features.title': 'Available Tools', 'administration.features.title': 'Available Tools',
@ -799,4 +802,111 @@ export default {
'dragdrop.overlay.default_subtext': 'You can also click the upload button', 'dragdrop.overlay.default_subtext': 'You can also click the upload button',
'dragdrop.overlay.processing': 'Processing files...', 'dragdrop.overlay.processing': 'Processing files...',
'dragdrop.overlay.error': 'Error processing files', 'dragdrop.overlay.error': 'Error processing files',
// Trustee Feature
'trustee.title': 'Trustee',
'trustee.subtitle': 'Trustee Management',
'trustee.description': 'Manage trustee organisations, contracts, and bookings',
// Trustee Organisations
'trustee.organisations.title': 'Organisations',
'trustee.organisations.subtitle': 'Manage trustee organisations',
'trustee.organisations.description': 'Management of trustee organisations',
'trustee.organisations.new_button': 'New Organisation',
'trustee.organisations.field.id': 'ID',
'trustee.organisations.field.id_placeholder': 'e.g. trustee-ag-zurich',
'trustee.organisations.field.label': 'Label',
'trustee.organisations.field.label_placeholder': 'e.g. Trustee AG Zurich',
'trustee.organisations.field.enabled': 'Enabled',
'trustee.organisations.modal.create.title': 'Create New Organisation',
'trustee.organisations.create.success': 'Organisation created successfully',
'trustee.organisations.create.error': 'Error creating organisation',
'trustee.organisations.action.edit': 'Edit',
'trustee.organisations.action.delete': 'Delete',
// Trustee Roles
'trustee.roles.title': 'Roles',
'trustee.roles.subtitle': 'Manage trustee roles',
'trustee.roles.description': 'Management of feature-specific roles',
'trustee.roles.new_button': 'New Role',
'trustee.roles.field.id': 'Role ID',
'trustee.roles.field.id_placeholder': 'e.g. admin, operate, userreport',
'trustee.roles.field.desc': 'Description',
'trustee.roles.field.desc_placeholder': 'Role description',
'trustee.roles.modal.create.title': 'Create New Role',
'trustee.roles.create.success': 'Role created successfully',
'trustee.roles.create.error': 'Error creating role',
'trustee.roles.action.edit': 'Edit',
'trustee.roles.action.delete': 'Delete',
// Trustee Access
'trustee.access.title': 'Access',
'trustee.access.subtitle': 'Manage user access',
'trustee.access.description': 'Management of user access to organisations',
'trustee.access.new_button': 'New Access',
'trustee.access.field.organisationId': 'Organisation',
'trustee.access.field.roleId': 'Role',
'trustee.access.field.userId': 'User',
'trustee.access.field.contractId': 'Contract (optional)',
'trustee.access.field.contractId_placeholder': 'Empty = Access to all contracts',
'trustee.access.modal.create.title': 'Create New Access',
'trustee.access.create.success': 'Access created successfully',
'trustee.access.create.error': 'Error creating access',
'trustee.access.action.edit': 'Edit',
'trustee.access.action.delete': 'Delete',
// Trustee Contracts
'trustee.contracts.title': 'Contracts',
'trustee.contracts.subtitle': 'Manage customer contracts',
'trustee.contracts.description': 'Management of customer contracts',
'trustee.contracts.new_button': 'New Contract',
'trustee.contracts.field.organisationId': 'Organisation',
'trustee.contracts.field.label': 'Label',
'trustee.contracts.field.label_placeholder': 'e.g. Muster AG 2026',
'trustee.contracts.field.enabled': 'Enabled',
'trustee.contracts.modal.create.title': 'Create New Contract',
'trustee.contracts.create.success': 'Contract created successfully',
'trustee.contracts.create.error': 'Error creating contract',
'trustee.contracts.action.edit': 'Edit',
'trustee.contracts.action.delete': 'Delete',
// Trustee Documents
'trustee.documents.title': 'Documents',
'trustee.documents.subtitle': 'Manage receipts',
'trustee.documents.description': 'Management of documents and receipts',
'trustee.documents.new_button': 'New Document',
'trustee.documents.field.organisationId': 'Organisation',
'trustee.documents.field.contractId': 'Contract',
'trustee.documents.field.documentName': 'File Name',
'trustee.documents.field.documentName_placeholder': 'e.g. Receipt.pdf',
'trustee.documents.field.documentMimeType': 'File Type',
'trustee.documents.modal.create.title': 'Create New Document',
'trustee.documents.create.success': 'Document created successfully',
'trustee.documents.create.error': 'Error creating document',
'trustee.documents.action.edit': 'Edit',
'trustee.documents.action.delete': 'Delete',
'trustee.documents.action.download': 'Download',
// Trustee Positions
'trustee.positions.title': 'Positions',
'trustee.positions.subtitle': 'Manage booking positions',
'trustee.positions.description': 'Management of booking positions (expense entries)',
'trustee.positions.new_button': 'New Position',
'trustee.positions.field.organisationId': 'Organisation',
'trustee.positions.field.contractId': 'Contract',
'trustee.positions.field.valuta': 'Value Date',
'trustee.positions.field.company': 'Company',
'trustee.positions.field.company_placeholder': 'Company name',
'trustee.positions.field.desc': 'Description',
'trustee.positions.field.bookingCurrency': 'Booking Currency',
'trustee.positions.field.bookingAmount': 'Booking Amount',
'trustee.positions.field.originalCurrency': 'Original Currency',
'trustee.positions.field.originalAmount': 'Original Amount',
'trustee.positions.field.vatPercentage': 'VAT %',
'trustee.positions.field.vatAmount': 'VAT Amount',
'trustee.positions.modal.create.title': 'Create New Position',
'trustee.positions.create.success': 'Position created successfully',
'trustee.positions.create.error': 'Error creating position',
'trustee.positions.action.edit': 'Edit',
'trustee.positions.action.delete': 'Delete',
}; };

View file

@ -535,6 +535,9 @@ export default {
'team-members.new_button': 'Ajouter un membre', 'team-members.new_button': 'Ajouter un membre',
'team-members.action.edit': 'Modifier', 'team-members.action.edit': 'Modifier',
'team-members.action.delete': 'Supprimer', 'team-members.action.delete': 'Supprimer',
'team-members.action.sendPasswordLink': 'Envoyer le lien de mot de passe',
'team-members.action.passwordLinkSent': 'Lien de mot de passe envoyé!',
'team-members.action.passwordLinkFailed': 'Échec de l\'envoi du lien',
'team-members.field.username': 'Nom d\'utilisateur', 'team-members.field.username': 'Nom d\'utilisateur',
'team-members.field.email': 'E-mail', 'team-members.field.email': 'E-mail',
'team-members.field.password': 'Mot de passe', 'team-members.field.password': 'Mot de passe',
@ -716,8 +719,8 @@ export default {
'warning.duplicate_file.message': 'Le fichier "{fileName}" existe déjà avec un contenu identique. Le fichier existant sera réutilisé.', 'warning.duplicate_file.message': 'Le fichier "{fileName}" existe déjà avec un contenu identique. Le fichier existant sera réutilisé.',
// Administration // Administration
'administration.title': 'Administration', 'administration.title': 'Outils',
'administration.description': 'Outils d\'administration et de gestion', 'administration.description': 'Outils et utilitaires',
'administration.subtitle': 'Outils d\'administration et de gestion', 'administration.subtitle': 'Outils d\'administration et de gestion',
'administration.intro.description': 'Cette section contient tous les outils d\'administration et de gestion pour votre espace de travail.', 'administration.intro.description': 'Cette section contient tous les outils d\'administration et de gestion pour votre espace de travail.',
'administration.features.title': 'Outils Disponibles', 'administration.features.title': 'Outils Disponibles',
@ -799,4 +802,111 @@ export default {
'dragdrop.overlay.default_subtext': 'Vous pouvez aussi cliquer sur le bouton de téléchargement', 'dragdrop.overlay.default_subtext': 'Vous pouvez aussi cliquer sur le bouton de téléchargement',
'dragdrop.overlay.processing': 'Traitement des fichiers...', 'dragdrop.overlay.processing': 'Traitement des fichiers...',
'dragdrop.overlay.error': 'Erreur lors du traitement des fichiers', 'dragdrop.overlay.error': 'Erreur lors du traitement des fichiers',
// Trustee Feature
'trustee.title': 'Fiduciaire',
'trustee.subtitle': 'Gestion Fiduciaire',
'trustee.description': 'Gestion des organisations fiduciaires, contrats et réservations',
// Trustee Organisations
'trustee.organisations.title': 'Organisations',
'trustee.organisations.subtitle': 'Gérer les organisations fiduciaires',
'trustee.organisations.description': 'Gestion des organisations fiduciaires',
'trustee.organisations.new_button': 'Nouvelle Organisation',
'trustee.organisations.field.id': 'ID',
'trustee.organisations.field.id_placeholder': 'ex. fiduciaire-ag-zurich',
'trustee.organisations.field.label': 'Libellé',
'trustee.organisations.field.label_placeholder': 'ex. Fiduciaire AG Zurich',
'trustee.organisations.field.enabled': 'Activé',
'trustee.organisations.modal.create.title': 'Créer une nouvelle organisation',
'trustee.organisations.create.success': 'Organisation créée avec succès',
'trustee.organisations.create.error': 'Erreur lors de la création de l\'organisation',
'trustee.organisations.action.edit': 'Modifier',
'trustee.organisations.action.delete': 'Supprimer',
// Trustee Roles
'trustee.roles.title': 'Rôles',
'trustee.roles.subtitle': 'Gérer les rôles fiduciaires',
'trustee.roles.description': 'Gestion des rôles spécifiques à la fonctionnalité',
'trustee.roles.new_button': 'Nouveau Rôle',
'trustee.roles.field.id': 'ID du rôle',
'trustee.roles.field.id_placeholder': 'ex. admin, operate, userreport',
'trustee.roles.field.desc': 'Description',
'trustee.roles.field.desc_placeholder': 'Description du rôle',
'trustee.roles.modal.create.title': 'Créer un nouveau rôle',
'trustee.roles.create.success': 'Rôle créé avec succès',
'trustee.roles.create.error': 'Erreur lors de la création du rôle',
'trustee.roles.action.edit': 'Modifier',
'trustee.roles.action.delete': 'Supprimer',
// Trustee Access
'trustee.access.title': 'Accès',
'trustee.access.subtitle': 'Gérer les accès utilisateurs',
'trustee.access.description': 'Gestion des accès utilisateurs aux organisations',
'trustee.access.new_button': 'Nouvel Accès',
'trustee.access.field.organisationId': 'Organisation',
'trustee.access.field.roleId': 'Rôle',
'trustee.access.field.userId': 'Utilisateur',
'trustee.access.field.contractId': 'Contrat (optionnel)',
'trustee.access.field.contractId_placeholder': 'Vide = Accès à tous les contrats',
'trustee.access.modal.create.title': 'Créer un nouvel accès',
'trustee.access.create.success': 'Accès créé avec succès',
'trustee.access.create.error': 'Erreur lors de la création de l\'accès',
'trustee.access.action.edit': 'Modifier',
'trustee.access.action.delete': 'Supprimer',
// Trustee Contracts
'trustee.contracts.title': 'Contrats',
'trustee.contracts.subtitle': 'Gérer les contrats clients',
'trustee.contracts.description': 'Gestion des contrats clients',
'trustee.contracts.new_button': 'Nouveau Contrat',
'trustee.contracts.field.organisationId': 'Organisation',
'trustee.contracts.field.label': 'Libellé',
'trustee.contracts.field.label_placeholder': 'ex. Muster AG 2026',
'trustee.contracts.field.enabled': 'Activé',
'trustee.contracts.modal.create.title': 'Créer un nouveau contrat',
'trustee.contracts.create.success': 'Contrat créé avec succès',
'trustee.contracts.create.error': 'Erreur lors de la création du contrat',
'trustee.contracts.action.edit': 'Modifier',
'trustee.contracts.action.delete': 'Supprimer',
// Trustee Documents
'trustee.documents.title': 'Documents',
'trustee.documents.subtitle': 'Gérer les pièces justificatives',
'trustee.documents.description': 'Gestion des documents et pièces justificatives',
'trustee.documents.new_button': 'Nouveau Document',
'trustee.documents.field.organisationId': 'Organisation',
'trustee.documents.field.contractId': 'Contrat',
'trustee.documents.field.documentName': 'Nom du fichier',
'trustee.documents.field.documentName_placeholder': 'ex. Justificatif.pdf',
'trustee.documents.field.documentMimeType': 'Type de fichier',
'trustee.documents.modal.create.title': 'Créer un nouveau document',
'trustee.documents.create.success': 'Document créé avec succès',
'trustee.documents.create.error': 'Erreur lors de la création du document',
'trustee.documents.action.edit': 'Modifier',
'trustee.documents.action.delete': 'Supprimer',
'trustee.documents.action.download': 'Télécharger',
// Trustee Positions
'trustee.positions.title': 'Positions',
'trustee.positions.subtitle': 'Gérer les positions de réservation',
'trustee.positions.description': 'Gestion des positions de réservation (entrées de dépenses)',
'trustee.positions.new_button': 'Nouvelle Position',
'trustee.positions.field.organisationId': 'Organisation',
'trustee.positions.field.contractId': 'Contrat',
'trustee.positions.field.valuta': 'Date de valeur',
'trustee.positions.field.company': 'Entreprise',
'trustee.positions.field.company_placeholder': 'Nom de l\'entreprise',
'trustee.positions.field.desc': 'Description',
'trustee.positions.field.bookingCurrency': 'Devise de comptabilisation',
'trustee.positions.field.bookingAmount': 'Montant de comptabilisation',
'trustee.positions.field.originalCurrency': 'Devise d\'origine',
'trustee.positions.field.originalAmount': 'Montant d\'origine',
'trustee.positions.field.vatPercentage': 'TVA %',
'trustee.positions.field.vatAmount': 'Montant TVA',
'trustee.positions.modal.create.title': 'Créer une nouvelle position',
'trustee.positions.create.success': 'Position créée avec succès',
'trustee.positions.create.error': 'Erreur lors de la création de la position',
'trustee.positions.action.edit': 'Modifier',
'trustee.positions.action.delete': 'Supprimer',
}; };

View file

@ -17,11 +17,11 @@
/* Card-style container with background and shadow */ /* Card-style container with background and shadow */
.pageCard { .pageCard {
display: flex; display: flex;
padding: 25px 25px 0 25px; padding: 25px;
flex-direction: column; flex-direction: column;
align-self: top; align-self: top;
background: var(--color-bg); background: var(--color-bg);
gap: 20px; gap: 15px;
height: 100%; height: 100%;
overflow: hidden; /* Prevent card from expanding beyond viewport */ overflow: hidden; /* Prevent card from expanding beyond viewport */
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
@ -149,9 +149,14 @@
} }
.tableContainer { .tableContainer {
margin: 1.5rem 0; margin: 0;
width: 100%; width: 100%;
position: relative; position: relative;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
} }
.refetchingIndicator { .refetchingIndicator {

View file

@ -7,6 +7,7 @@ export type AttributeType =
| 'textarea' | 'textarea'
| 'select' | 'select'
| 'multiselect' | 'multiselect'
| 'multilingual'
| 'integer' | 'integer'
| 'float' | 'float'
| 'number' | 'number'
@ -28,6 +29,7 @@ export type InputComponentType =
| 'textarea' | 'textarea'
| 'select' | 'select'
| 'multiselect' | 'multiselect'
| 'multilingual'
| 'checkbox' | 'checkbox'
| 'file' | 'file'
| 'email' | 'email'
@ -136,6 +138,13 @@ export function isMultiselectType(attributeType: AttributeType): boolean {
return attributeType === 'multiselect'; return attributeType === 'multiselect';
} }
/**
* Determines if an attribute type should render as a multilingual field
*/
export function isMultilingualType(attributeType: AttributeType): boolean {
return attributeType === 'multilingual';
}
/** /**
* Determines if an attribute type should render as a checkbox * Determines if an attribute type should render as a checkbox
*/ */
@ -174,6 +183,9 @@ export function getDefaultValueForType(attributeType: AttributeType): any {
if (isMultiselectType(attributeType)) { if (isMultiselectType(attributeType)) {
return []; return [];
} }
if (isMultilingualType(attributeType)) {
return { en: '' };
}
if (isNumberType(attributeType)) { if (isNumberType(attributeType)) {
return 0; return 0;
} }