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';
// 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
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
@ -38,32 +44,47 @@ export async function fetchPermissions(
params.item = item;
}
console.log('📡 fetchPermissions: Requesting permissions:', {
context,
item,
params,
url: '/api/rbac/permissions'
});
const data = await request({
url: '/api/rbac/permissions',
method: 'get',
params
});
console.log('📥 fetchPermissions: Received permissions response:', {
context,
item,
response: data,
view: data?.view,
read: data?.read,
create: data?.create,
update: data?.update,
delete: data?.delete,
type: typeof data,
isArray: Array.isArray(data),
keys: data ? Object.keys(data) : [],
fullResponse: JSON.stringify(data, null, 2)
return data;
}
/**
* Fetch all permissions for a given context (UI or RESOURCE)
* Endpoint: GET /api/rbac/permissions/all
* Query params: context (optional - if not provided, returns both UI and RESOURCE)
*
* This is optimized for UI initialization to avoid multiple API calls.
* Returns a dictionary of item paths to their permissions.
*/
export async function fetchAllPermissions(
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;

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);
}
/* 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 */
@media (max-width: 768px) {
.actionButtons {
@ -274,4 +304,12 @@
.actionButton.refresh: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 { DeleteActionButton } from './DeleteActionButton';
export { DownloadActionButton } from './DownloadActionButton';
export { ViewActionButton } from './ViewActionButton';
export { CopyActionButton } from './CopyActionButton';
export { ConnectActionButton } from './ConnectActionButton';
export { PlayActionButton } from './PlayActionButton';
export { RemoveActionButton } from './RemoveActionButton';
// Generic Custom Action Button (for entity-specific actions)
export { CustomActionButton } from './CustomActionButton';
// Action Button Types
export type { EditActionButtonProps } from './EditActionButton';
export type { DeleteActionButtonProps } from './DeleteActionButton';
export type { DownloadActionButtonProps } from './DownloadActionButton';
export type { ViewActionButtonProps } from './ViewActionButton';
export type { CopyActionButtonProps } from './CopyActionButton';
export type { ConnectActionButtonProps } from './ConnectActionButton';
export type { PlayActionButtonProps } from './PlayActionButton';
export type { RemoveActionButtonProps } from './RemoveActionButton';
export type { CustomActionButtonProps } from './CustomActionButton';

View file

@ -26,6 +26,15 @@
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 {
display: flex;
align-items: center;

View file

@ -4,7 +4,6 @@ import styles from './FormGeneratorControls.module.css';
import { Button } from '../../UiComponents/Button';
import { IoIosRefresh } from "react-icons/io";
import { FaTrash } from "react-icons/fa";
import { isCheckboxType } from '../../../utils/attributeTypeMapper';
import type { AttributeType } from '../../../utils/attributeTypeMapper';
// Generic field/column config interface
@ -26,7 +25,7 @@ export interface FormGeneratorControlsProps {
searchFocused: boolean;
onSearchFocus: (focused: boolean) => void;
// Filter state
// Filter state (kept for compatibility but not used in this component)
filters: Record<string, any>;
onFilterChange: (key: string, value: any) => void;
filterFocused: Record<string, boolean>;
@ -49,113 +48,29 @@ export interface FormGeneratorControlsProps {
selectable?: boolean;
loading?: boolean;
// Special date filter handler (for FormGenerator date formatting)
onDateFilterChange?: (key: string, value: string) => void;
// Active filters count for display
activeFiltersCount?: number;
}
export function FormGeneratorControls({
fields,
searchTerm,
onSearchChange,
searchFocused,
onSearchFocus,
filters,
onFilterChange,
filterFocused,
onFilterFocus,
selectedCount,
displayData,
onDeleteSingle,
onDeleteMultiple,
onRefresh,
searchable = true,
filterable = true,
selectable = true,
loading = false,
onDateFilterChange
activeFiltersCount = 0
}: FormGeneratorControlsProps) {
const { t } = useLanguage();
// Check if all items are selected
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 (
<div className={styles.controls}>
@ -204,6 +119,11 @@ export function FormGeneratorControls({
{t('formgen.search.placeholder')}
</label>
</div>
{activeFiltersCount > 0 && (
<span className={styles.activeFiltersCount}>
{activeFiltersCount} {t('formgen.filter.active', 'filter(s)')}
</span>
)}
{onRefresh && (
<button
onClick={onRefresh}
@ -216,93 +136,6 @@ export function FormGeneratorControls({
)}
</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>
);
}

View file

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

View file

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

View file

@ -1,12 +1,13 @@
.formGeneratorTable {
display: flex;
flex-direction: column;
gap: 20px;
gap: 10px;
width: 100%;
font-family: var(--font-family);
/* Ensure proper height constraints for scrolling */
min-height: 0;
max-height: 100%;
flex: 1;
overflow: hidden;
}
.title {
@ -24,10 +25,11 @@
border: 1px solid var(--color-primary);
border-radius: 25px;
background: var(--color-bg);
/* Use calc to account for controls, pagination, and spacing */
max-height: calc(100vh - 400px);
/* No min-height - let it shrink to fit content */
/* When empty, it will only show header */
/* Fill available space in flex container */
flex: 1;
min-height: 0;
/* Ensure scrolling within container */
max-height: 100%;
}
/* Empty table styling - no extra space, just header */
@ -104,7 +106,6 @@
color: var(--color-text);
white-space: nowrap;
user-select: none;
position: relative;
z-index: 10;
}
@ -125,27 +126,159 @@
display: flex;
align-items: center;
justify-content: left;
gap: 8px;
gap: 4px;
}
.columnLabel {
cursor: pointer;
flex: 1;
}
.sortIcon {
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);
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 {
position: absolute;
top: 0;
right: 0;
width: 4px;
right: -3px;
width: 8px;
height: 100%;
cursor: col-resize;
z-index: 11;
z-index: 20;
background: transparent;
}
.resizeHandle:hover {
background: var(--color-secondary);
opacity: 0.5;
}
.resizeHandle:active {
background: var(--color-secondary);
opacity: 0.8;
}
.td {
@ -312,15 +445,14 @@ tbody .actionsColumn {
.pagination {
display: flex;
justify-content: space-between;
justify-content: flex-end;
align-items: center;
gap: 10px;
padding: 15px;
border-top: 1px solid var(--color-primary);
padding: 8px 0;
/* Ensure pagination stays visible and doesn't get cut off */
flex-shrink: 0;
flex-wrap: wrap;
background: var(--color-bg);
border-radius: 0 0 8px 8px;
}
.pageSizeSelector {
@ -388,11 +520,90 @@ tbody .actionsColumn {
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 */
@media (max-width: 768px) {
.tableContainer {
max-height: calc(100vh - 350px);
/* No min-height on mobile - let it shrink to fit content */
flex: 1;
min-height: 0;
max-height: 100%;
}
/* Empty table styling - no extra space */
@ -433,6 +644,17 @@ tbody .actionsColumn {
margin: 0;
font-size: 13px;
}
.pageNumbers {
max-width: 100%;
justify-content: center;
}
.pageNumber {
min-width: 24px;
height: 24px;
font-size: 11px;
}
}
/* Dark theme support */
@ -448,6 +670,15 @@ tbody .actionsColumn {
.tr.selected {
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 */
@ -502,3 +733,83 @@ tbody .actionsColumn {
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;
visibility: visible !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 { Link } from 'react-router-dom';
import { Link, useLocation } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { useRef, useEffect, useState } from 'react';
import { SidebarSubmenuProps } from './sidebarTypes';
import React, { useRef, useEffect, useState } from 'react';
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 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;
return (
@ -42,13 +127,14 @@ const SidebarSubmenu: React.FC<SidebarSubmenuProps> = ({ item, isOpen, isMinimiz
<ul className={styles.submenuHorizontalList}>
{item.submenu.map(subitem => {
const SubIcon = subitem.icon as React.ComponentType<React.SVGProps<SVGSVGElement>>;
const isActive = isSubmenuItemActive(subitem.link);
return (
<li key={subitem.id} className={styles.submenuHorizontalItem}>
<li key={subitem.id} className={`${styles.submenuHorizontalItem} ${isActive ? styles.active : ''}`}>
<Link
to={subitem.link || '#'}
title={subitem.name}
className={styles.submenuHorizontalLink}
className={`${styles.submenuHorizontalLink} ${isActive ? styles.activeLink : ''}`}
>
{SubIcon && (
<SubIcon
@ -56,7 +142,7 @@ const SidebarSubmenu: React.FC<SidebarSubmenuProps> = ({ item, isOpen, isMinimiz
style={{
width: '16px',
height: '16px',
color: '#181818',
color: isActive ? 'white' : '#181818',
display: 'block'
}}
/>
@ -77,68 +163,13 @@ const SidebarSubmenu: React.FC<SidebarSubmenuProps> = ({ item, isOpen, isMinimiz
exit={{ opacity: 0, transition: { duration: 0.25, delay: 0 } }}
>
<ul className={styles.submenuList}>
{item.submenu.map(subitem => {
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 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>
);
})}
{item.submenu.map(subitem => (
<SubmenuItem
key={subitem.id}
subitem={subitem}
isActive={isSubmenuItemActive(subitem.link)}
/>
))}
</ul>
</motion.div>
)}

View file

@ -34,9 +34,27 @@ export const useSidebarLogic = (): SidebarContextType => {
}, [state.openItemId]);
// 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) => {
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]);
// Minimize sidebar

View file

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

View file

@ -924,7 +924,7 @@ const PageRenderer: React.FC<PageRendererProps> = ({
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)
// 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
// Only standard action types: edit, delete, view, copy
let requiredPermission: 'read' | 'create' | 'update' | 'delete' | null = null;
if (action.type === 'view' || action.type === 'play') {
if (action.type === 'view') {
requiredPermission = 'read';
} else if (action.type === 'edit') {
requiredPermission = 'update';
@ -1055,9 +1056,7 @@ const PageRenderer: React.FC<PageRendererProps> = ({
contentField: action.contentField,
operationName: action.operationName,
loadingStateName: action.loadingStateName,
// Navigation and behavior (for play button)
navigateTo: action.navigateTo,
mode: action.mode
fetchItemFunctionName: action.fetchItemFunctionName
};
}) || [];
@ -1085,6 +1084,21 @@ const PageRenderer: React.FC<PageRendererProps> = ({
columns={resolvedColumns}
loading={showLoadingSpinner}
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}
onDelete={currentTableHookData.onDelete}
onDeleteMultiple={currentTableHookData.onDeleteMultiple}

View file

@ -3,8 +3,7 @@ import { allPageData, SidebarItem } from './data';
import { useLanguage } from '../../providers/language/LanguageContext';
import { resolveLanguageText } from './pageInterface';
import { usePermissions } from '../../hooks/usePermissions';
import { getUserDataCache } from '../../utils/userCache';
import { FaHome, FaHatWizard } from 'react-icons/fa';
import { FaHome, FaHatWizard, FaBriefcase } from 'react-icons/fa';
import { RiFolderSettingsFill } from 'react-icons/ri';
// Configuration for parent groups that don't have a page definition
@ -17,13 +16,17 @@ const parentGroupConfig: Record<string, {
icon: FaHome,
defaultOrder: 1
},
'trustee': {
icon: FaBriefcase,
defaultOrder: 2
},
'administration': {
icon: RiFolderSettingsFill,
defaultOrder: 2
defaultOrder: 3
},
'admin': {
icon: FaHatWizard,
defaultOrder: 3
defaultOrder: 4
}
};
@ -55,7 +58,7 @@ export const SidebarProvider: React.FC<SidebarProviderProps> = ({ children }) =>
// Get translation function from language context
const { t } = useLanguage();
const { canView } = usePermissions();
const { canView, preloadUiPermissions } = usePermissions();
// Get sidebar items from page data
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)
.sort((a, b) => (a.order || 0) - (b.order || 0));
// Log user info for debugging
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 }> = [];
// Process each main page (permissions already bulk-loaded)
for (const pageData of mainPages) {
console.log('🔍 SidebarProvider: Checking access for page:', {
path: pageData.path,
name: pageData.name,
hasSubpages: pageData.hasSubpages
});
// Check RBAC permissions
// Check RBAC permissions (from cache - no API call)
try {
const hasRBACAccess = await canView('UI', pageData.path);
console.log('🔍 SidebarProvider: RBAC check result:', {
path: pageData.path,
hasAccess: hasRBACAccess
});
if (!hasRBACAccess) {
console.log('⛔ SidebarProvider: Page hidden due to RBAC:', pageData.path);
continue;
}
@ -217,16 +199,15 @@ export const SidebarProvider: React.FC<SidebarProviderProps> = ({ children }) =>
try {
const hasPrivilege = await pageData.privilegeChecker();
if (!hasPrivilege) {
console.log('⛔ SidebarProvider: Page hidden due to privilegeChecker:', pageData.path);
continue;
}
} catch (error) {
console.error(`❌ SidebarProvider: Error checking privilegeChecker for ${pageData.path}:`, error);
console.error(`Error checking privilegeChecker for ${pageData.path}:`, error);
continue;
}
}
} catch (error) {
console.error(`❌ SidebarProvider: Error checking RBAC access for ${pageData.path}:`, error);
console.error(`Error checking RBAC access for ${pageData.path}:`, error);
continue;
}
@ -349,30 +330,6 @@ export const SidebarProvider: React.FC<SidebarProviderProps> = ({ children }) =>
// Sort all items by order
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;
};
@ -383,6 +340,10 @@ export const SidebarProvider: React.FC<SidebarProviderProps> = ({ children }) =>
setError(null);
try {
// Preload all UI permissions in a single API call
// This caches all permissions before iterating through pages
await preloadUiPermissions();
const items = await getSidebarItems();
console.log('✅ SidebarProvider: Setting sidebar items:', {
count: items.length,

View file

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

View file

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

View file

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

View file

@ -1,6 +1,7 @@
import { useCallback } from 'react';
import React, { useCallback } from 'react';
import { GenericPageData } from '../../../pageInterface';
import { FaUsers, FaPlus } from 'react-icons/fa';
import { IoMailOutline } from 'react-icons/io5';
import { useOrgUsers, useUserOperations } from '../../../../../hooks/useUsers';
import { getUserDataCache } from '../../../../../utils/userCache';
@ -39,20 +40,26 @@ const createUsersHook = () => {
updateOptimistically,
attributes,
permissions,
pagination,
fetchUserById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded
} = useOrgUsers();
const {
handleUserDelete,
handleUserCreate,
handleUserUpdate,
handleInlineUpdate,
handleSendPasswordLink,
deletingUsers,
editingUsers,
sendingPasswordLink,
creatingUser,
deleteError,
createError,
updateError
updateError,
passwordLinkError
} = useUserOperations();
const generatedColumns = attributes && attributes.length > 0
@ -99,24 +106,30 @@ const createUsersHook = () => {
handleDeleteMultiple,
handleUserCreate: wrappedHandleUserCreate,
handleUserUpdate,
handleInlineUpdate, // For inline boolean editing in table
handleSendPasswordLink, // Send password setup link to user
// FormGenerator specific handlers
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
// Loading states
deletingUsers,
editingUsers,
sendingPasswordLink,
creatingUser,
// Error states
deleteError,
createError,
updateError,
passwordLinkError,
// Attributes and permissions for dynamic column/button generation
attributes,
permissions,
pagination, // Pagination metadata from backend
columns: generatedColumns,
// Functions for EditActionButton
fetchUserById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded
};
};
@ -145,72 +158,9 @@ export const teamMembersPageData: GenericPageData = {
size: 'md',
icon: FaPlus,
formConfig: {
fields: [
{
key: 'username',
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'
}
],
// Fields will be generated dynamically from attributes via generateCreateFieldsFromAttributes
// PageRenderer will use generateCreateFieldsFromAttributes if available, otherwise generateEditFieldsFromAttributes
fields: [], // Empty array - fields will be generated dynamically from attributes
popupTitle: 'team-members.modal.create.title',
popupSize: 'medium',
createOperationName: 'handleUserCreate',
@ -228,6 +178,7 @@ export const teamMembersPageData: GenericPageData = {
tableConfig: {
hookFactory: createUsersHook,
// Columns are generated dynamically from attributes via hookData.columns
// Standard action buttons (built-in: edit, delete, view, copy)
actionButtons: [
{
type: 'edit',
@ -249,7 +200,6 @@ export const teamMembersPageData: GenericPageData = {
idField: 'id',
operationName: 'handleDelete',
loadingStateName: 'deletingUsers',
// Only show if user has delete permission (permissions.delete !== 'n')
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
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,
filterable: true,
sortable: true,

View file

@ -1,6 +1,6 @@
import { useCallback } from 'react';
import React, { useCallback } from 'react';
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';
// Helper function to convert attribute definitions to column config
@ -66,6 +66,7 @@ const createFilesHook = () => {
updateFileOptimistically,
attributes,
permissions,
pagination,
fetchFileById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
@ -152,6 +153,7 @@ const createFilesHook = () => {
// Attributes and permissions for dynamic column/button generation
attributes,
permissions,
pagination, // Pagination metadata from backend
columns: generatedColumns, // Return generated columns
// Functions for EditActionButton
fetchFileById, // Fetch single file by ID
@ -201,18 +203,16 @@ export const filesPageData: GenericPageData = {
tableConfig: {
hookFactory: createFilesHook,
// Columns are generated dynamically from attributes via hookData.columns
// Standard action buttons (built-in: edit, delete, view, copy)
actionButtons: [
{
type: 'view',
title: 'files.action.preview',
idField: 'id',
// nameField and typeField will be determined from attributes dynamically
// For now, use common backend field names
nameField: 'fileName',
typeField: 'mimeType',
operationName: 'handlePreview',
loadingStateName: 'previewingFiles',
// 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;
@ -233,26 +233,12 @@ export const filesPageData: GenericPageData = {
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',
title: 'files.action.delete',
idField: 'id',
operationName: 'handleDelete',
loadingStateName: 'deletingFiles',
// Only show if user has delete permission (permissions.delete !== 'n')
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
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,
filterable: true,
sortable: true,

View file

@ -13,6 +13,16 @@ export { chatbotPageData } from './chatbot';
export { mandatesPageData } from './admin/mandates';
export { rbacRulesPageData } from './admin/rbac-rules';
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 { dashboardPageData } from './dashboard';
@ -29,6 +39,7 @@ import { chatbotPageData } from './chatbot';
import { mandatesPageData } from './admin/mandates';
import { rbacRulesPageData } from './admin/rbac-rules';
import { rbacRolePageData } from './admin/rbac-role';
import { trusteePages } from './trustee';
// Array of all page data
export const allPageData = [
@ -36,17 +47,19 @@ export const allPageData = [
filesPageData,
workflowsPageData,
connectionsPageData,
teamMembersPageData,
promptsPageData,
speechPageData,
settingsPageData,
pekPageData,
pekTablesPageData,
chatbotPageData,
// Trustee pages (before Administration)
...trusteePages,
// Administration pages
teamMembersPageData,
mandatesPageData,
rbacRulesPageData,
rbacRolePageData,
];
// Helper function to get page data by path

View file

@ -38,14 +38,17 @@ const createPromptsHook = () => {
updateOptimistically,
attributes,
permissions,
pagination,
fetchPromptById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded
} = usePrompts();
const {
handlePromptDelete,
handlePromptCreate,
handlePromptUpdate,
handleInlineUpdate,
deletingPrompts,
creatingPrompt,
deleteError,
@ -98,6 +101,7 @@ const createPromptsHook = () => {
handleDeleteMultiple,
handlePromptCreate: wrappedHandlePromptCreate, // Use wrapped version
handlePromptUpdate,
handleInlineUpdate, // For inline boolean editing in table
// FormGenerator specific handlers
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
@ -111,10 +115,12 @@ const createPromptsHook = () => {
// Attributes and permissions for dynamic column/button generation
attributes,
permissions,
pagination, // Pagination metadata from backend
columns: generatedColumns, // Return generated columns
// Functions for EditActionButton
fetchPromptById, // Fetch single prompt by ID
generateEditFieldsFromAttributes, // Generate edit fields from attributes
generateCreateFieldsFromAttributes, // Generate create fields from attributes
ensureAttributesLoaded // Generic function to ensure attributes are loaded
};
};
@ -143,42 +149,9 @@ export const promptsPageData: GenericPageData = {
icon: FaPlus,
variant: 'primary',
formConfig: {
fields: [
{
key: 'name',
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;
}
}
],
// Fields will be generated dynamically from attributes via generateCreateFieldsFromAttributes
// PageRenderer will use generateCreateFieldsFromAttributes if available, otherwise generateEditFieldsFromAttributes
fields: [], // Empty array - fields will be generated dynamically from attributes
popupTitle: 'prompts.modal.create.title',
popupSize: 'medium',
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,
attributes,
permissions,
pagination,
fetchWorkflowById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
@ -133,6 +134,7 @@ const createWorkflowsHook = () => {
// Attributes and permissions for dynamic column/button generation
attributes,
permissions,
pagination, // Pagination metadata from backend
columns: generatedColumns, // Return generated columns
// Functions for EditActionButton
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
}
// Action button configuration
// Standard action button configuration (built-in actions: edit, delete, view, copy)
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
title?: string | LanguageText;
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')
typeField?: string; // Field name for type/mime type (default: 'type' or 'mime_type')
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
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
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
export interface TableContentConfig {
hookFactory: () => () => GenericDataHook; // Hook factory that returns a hook function
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;
filterable?: boolean;
sortable?: boolean;

View file

@ -235,11 +235,11 @@ export function useMandates() {
// Email validation
if (fieldType === 'email') {
validator = (value: string) => {
if (required && (!value || value.trim() === '')) {
validator = (value: any) => {
if (required && (!value || (typeof value === 'string' && value.trim() === ''))) {
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 null;
@ -369,11 +369,11 @@ export function useMandates() {
// Email validation
if (fieldType === 'email') {
validator = (value: string) => {
if (required && (!value || value.trim() === '')) {
validator = (value: any) => {
if (required && (!value || (typeof value === 'string' && value.trim() === ''))) {
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 null;
@ -399,8 +399,8 @@ export function useMandates() {
}
// String validation for required fields
else if (fieldType === 'string' && required) {
validator = (value: string) => {
if (!value || value.trim() === '') {
validator = (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
return `${attr.label} is required`;
}
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 {
deletingMandates,
editingMandates,
@ -558,6 +567,7 @@ export function useMandateOperations() {
handleMandateDelete,
handleMandateCreate,
handleMandateUpdate,
handleInlineUpdate,
isLoading
};
}

View file

@ -276,11 +276,11 @@ export function useRbacRoles() {
// Email validation
if (fieldType === 'email') {
validator = (value: string) => {
if (required && (!value || value.trim() === '')) {
validator = (value: any) => {
if (required && (!value || (typeof value === 'string' && value.trim() === ''))) {
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 null;
@ -410,11 +410,11 @@ export function useRbacRoles() {
// Email validation
if (fieldType === 'email') {
validator = (value: string) => {
if (required && (!value || value.trim() === '')) {
validator = (value: any) => {
if (required && (!value || (typeof value === 'string' && value.trim() === ''))) {
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 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 {
deletingRoles,
editingRoles,
@ -660,6 +669,7 @@ export function useRbacRoleOperations() {
handleRoleDelete,
handleRoleCreate,
handleRoleUpdate,
handleInlineUpdate,
isLoading
};
}

View file

@ -252,11 +252,11 @@ export function useRbacRules() {
// Email validation
if (fieldType === 'email') {
validator = (value: string) => {
if (required && (!value || value.trim() === '')) {
validator = (value: any) => {
if (required && (!value || (typeof value === 'string' && value.trim() === ''))) {
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 null;
@ -386,11 +386,11 @@ export function useRbacRules() {
// Email validation
if (fieldType === 'email') {
validator = (value: string) => {
if (required && (!value || value.trim() === '')) {
validator = (value: any) => {
if (required && (!value || (typeof value === 'string' && value.trim() === ''))) {
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 null;
@ -416,8 +416,8 @@ export function useRbacRules() {
}
// String validation for required fields
else if (fieldType === 'string' && required) {
validator = (value: string) => {
if (!value || value.trim() === '') {
validator = (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
return `${attr.label} is required`;
}
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 {
deletingRbacRules,
editingRbacRules,
@ -575,6 +584,7 @@ export function useRbacRuleOperations() {
handleRbacRuleDelete,
handleRbacRuleCreate,
handleRbacRuleUpdate,
handleInlineUpdate,
isLoading
};
}

View file

@ -247,11 +247,11 @@ export function useUserFiles() {
if (attr.name === 'fileName' || attr.name === 'file_name') {
required = true;
validator = (value: string) => {
if (!value || value.trim() === '') {
validator = (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
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 null;
@ -284,16 +284,12 @@ export function useUserFiles() {
}, [attributes, fetchAttributes]);
// Fetch attributes and permissions on mount
// Note: Do NOT fetch files here - let the table component control pagination
useEffect(() => {
fetchAttributes();
fetchPermissions();
}, [fetchAttributes, fetchPermissions]);
// Initial fetch
useEffect(() => {
fetchFiles();
}, [fetchFiles]);
return {
data: files,
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 {
fetchPermissions as fetchPermissionsApi,
fetchAllPermissions as fetchAllPermissionsApi,
type PermissionLevel,
type UserPermissions,
type PermissionContext
@ -18,22 +19,30 @@ interface PermissionCache {
// Operation type for permission checks
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
* 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 = () => {
const [cache, setCache] = useState<PermissionCache>({});
const cacheRef = useRef<PermissionCache>({});
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 { request } = useApiRequest();
// Keep cacheRef in sync with cache state
useEffect(() => {
cacheRef.current = cache;
}, [cache]);
/**
* 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 (
fn: () => Promise<any>,
maxRetries = 3,
baseDelay = 1000
): 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;
}
const loadBulkPermissions = useCallback(async (context: 'UI' | 'RESOURCE'): Promise<void> => {
// Skip if already loaded for this context
if (bulkLoadedContextsRef.current.has(context)) {
return;
}
};
// 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
* Returns full UserPermissions object
* Checks cache first, then fetches from backend if not cached
* Fetch individual permission (used for DATA context and fallback)
*/
const checkPermission = useCallback(async (
const fetchIndividualPermission = useCallback(async (
context: PermissionContext,
item?: string
): Promise<UserPermissions> => {
const key = getPermissionKey(context, item);
// Check cache first using ref to avoid stale closures
// Check cache first
if (cacheRef.current[key]) {
return cacheRef.current[key];
}
@ -84,76 +121,21 @@ export const usePermissions = () => {
return pendingRequests.current.get(key)!;
}
// Create new request
// Fetch individual permission
const requestPromise = (async () => {
setLoading(true);
try {
// Use retry logic for 429 errors
// 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 });
const permissions = await fetchPermissionsApi(request, context, item);
// 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;
} catch (error: any) {
// Only log non-429 errors to avoid spam
if (error.response?.status !== 429) {
console.error('Error checking permissions:', error);
}
console.error('Error checking permissions:', error);
// Return cached value if available, otherwise default (no access)
const cached = cacheRef.current[key];
if (cached) {
return cached;
}
return {
view: false,
read: 'n' as PermissionLevel,
create: 'n' as PermissionLevel,
update: 'n' as PermissionLevel,
delete: 'n' as PermissionLevel,
};
return cacheRef.current[key] || DEFAULT_NO_ACCESS;
} finally {
pendingRequests.current.delete(key);
setLoading(false);
@ -166,6 +148,39 @@ export const usePermissions = () => {
return requestPromise;
}, [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
* Returns true if user has any level of permission (not 'n')
@ -197,35 +212,25 @@ export const usePermissions = () => {
context: PermissionContext,
item: string
): Promise<boolean> => {
console.log('👁️ canView: Checking view access for:', { context, item });
const permissions = await checkPermission(context, item);
const hasAccess = 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;
return permissions.view === true;
}, [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
* Useful when user permissions change or after logout
*/
const clearCache = useCallback(() => {
setCache({});
cacheRef.current = {};
bulkLoadedContextsRef.current.clear();
pendingRequests.current.clear();
}, []);
@ -233,6 +238,7 @@ export const usePermissions = () => {
checkPermission,
hasPermission,
canView,
preloadUiPermissions,
loading,
clearCache,
};

View file

@ -235,11 +235,11 @@ export function usePrompts() {
// Match create button configuration for prompts
if (attr.name === 'name') {
required = true;
validator = (value: string) => {
if (!value || value.trim() === '') {
validator = (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
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 null;
@ -248,11 +248,11 @@ export function usePrompts() {
required = true;
minRows = 6; // Match create button: minRows: 6
maxRows = 12; // Match create button: maxRows: 12
validator = (value: string) => {
if (!value || value.trim() === '') {
validator = (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
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 null;
@ -302,11 +302,11 @@ export function usePrompts() {
if (attr.name === 'name') {
required = true;
validator = (value: string) => {
if (!value || value.trim() === '') {
validator = (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
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 null;
@ -315,11 +315,11 @@ export function usePrompts() {
required = true;
minRows = 6; // Match create button
maxRows = 12; // Match create button
validator = (value: string) => {
if (!value || value.trim() === '') {
validator = (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
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 null;
@ -342,6 +342,95 @@ export function usePrompts() {
return editableFields;
}, [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
const ensureAttributesLoaded = useCallback(async () => {
// If attributes are already loaded, return them
@ -377,6 +466,7 @@ export function usePrompts() {
pagination,
fetchPromptById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
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 {
deletingPrompts,
creatingPrompt,
@ -483,6 +582,7 @@ export function usePromptOperations() {
handlePromptDelete,
handlePromptCreate,
handlePromptUpdate,
handleInlineUpdate,
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,
updateUser as updateUserApi,
deleteUser as deleteUserApi,
sendPasswordLink as sendPasswordLinkApi,
type User,
type UserUpdateData,
type AttributeDefinition,
@ -579,11 +580,11 @@ export function useOrgUsers() {
// Email validation
if (fieldType === 'email') {
validator = (value: string) => {
if (required && (!value || value.trim() === '')) {
validator = (value: any) => {
if (required && (!value || (typeof value === 'string' && value.trim() === ''))) {
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 null;
@ -625,6 +626,135 @@ export function useOrgUsers() {
return editableFields;
}, [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
const ensureAttributesLoaded = useCallback(async () => {
// Don't fetch attributes if user is not authenticated (prevents 401 errors)
@ -667,6 +797,7 @@ export function useOrgUsers() {
pagination,
fetchUserById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded
};
}
@ -675,11 +806,13 @@ export function useOrgUsers() {
export function useUserOperations() {
const [deletingUsers, setDeletingUsers] = 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 { 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 [passwordLinkError, setPasswordLinkError] = useState<string | null>(null);
const handleUserDelete = async (userId: string) => {
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);
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) => {
setUpdateError(null);
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 {
deletingUsers,
editingUsers,
sendingPasswordLink,
creatingUser,
deleteError,
createError,
updateError,
passwordLinkError,
handleUserDelete,
handleUserCreate,
handleUserUpdate,
handleInlineUpdate,
handleSendPasswordLink,
isLoading
};
}

View file

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

View file

@ -535,6 +535,9 @@ export default {
'team-members.new_button': 'Mitglied hinzufügen',
'team-members.action.edit': 'Bearbeiten',
'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.email': 'E-Mail',
'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.',
// Administration
'administration.title': 'Verwaltung',
'administration.description': 'Verwaltungs- und Management-Tools',
'administration.title': 'Werkzeuge',
'administration.description': 'Werkzeuge und Hilfsmittel',
'administration.subtitle': 'Verwaltungs- und Management-Tools',
'administration.intro.description': 'Dieser Bereich enthält alle Verwaltungs- und Management-Tools für Ihren Arbeitsbereich.',
'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.processing': 'Dateien werden verarbeitet...',
'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.action.edit': 'Edit',
'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.email': 'Email',
'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.',
// Administration
'administration.title': 'Administration',
'administration.description': 'Administration and management tools',
'administration.title': 'Utils',
'administration.description': 'Utilities and tools',
'administration.subtitle': 'Administration and management tools',
'administration.intro.description': 'This section contains all administration and management tools for your workspace.',
'administration.features.title': 'Available Tools',
@ -799,4 +802,111 @@ export default {
'dragdrop.overlay.default_subtext': 'You can also click the upload button',
'dragdrop.overlay.processing': '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.action.edit': 'Modifier',
'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.email': 'E-mail',
'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é.',
// Administration
'administration.title': 'Administration',
'administration.description': 'Outils d\'administration et de gestion',
'administration.title': 'Outils',
'administration.description': 'Outils et utilitaires',
'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.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.processing': '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 */
.pageCard {
display: flex;
padding: 25px 25px 0 25px;
padding: 25px;
flex-direction: column;
align-self: top;
background: var(--color-bg);
gap: 20px;
gap: 15px;
height: 100%;
overflow: hidden; /* Prevent card from expanding beyond viewport */
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
@ -149,9 +149,14 @@
}
.tableContainer {
margin: 1.5rem 0;
margin: 0;
width: 100%;
position: relative;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.refetchingIndicator {

View file

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