diff --git a/src/api/permissionApi.ts b/src/api/permissionApi.ts index 81138ed..e2b6b53 100644 --- a/src/api/permissionApi.ts +++ b/src/api/permissionApi.ts @@ -16,6 +16,12 @@ export interface UserPermissions { export type PermissionContext = 'DATA' | 'UI' | 'RESOURCE'; +// Response type for bulk permissions fetch +export interface BulkPermissionsResponse { + ui?: Record; + resource?: Record; +} + // Type for the request function passed to API functions export type ApiRequestFunction = (options: ApiRequestOptions) => Promise; @@ -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 { + const params: Record = {}; + 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; diff --git a/src/api/trusteeApi.ts b/src/api/trusteeApi.ts new file mode 100644 index 0000000..013eeb6 --- /dev/null +++ b/src/api/trusteeApi.ts @@ -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; + search?: string; +} + +export interface PaginatedResponse { + items: T[]; + pagination?: { + currentPage: number; + pageSize: number; + totalItems: number; + totalPages: number; + }; +} + +export type ApiRequestFunction = (options: ApiRequestOptions) => Promise; + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +function _buildPaginationParams(params?: PaginationParams): Record { + 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 | TrusteeOrganisation[]> { + return await request({ + url: '/api/trustee/organisations', + method: 'get', + params: _buildPaginationParams(params) + }); +} + +export async function fetchOrganisationById( + request: ApiRequestFunction, + orgId: string +): Promise { + 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 +): Promise { + return await request({ + url: '/api/trustee/organisations', + method: 'post', + data + }); +} + +export async function updateOrganisation( + request: ApiRequestFunction, + orgId: string, + data: Partial +): Promise { + return await request({ + url: `/api/trustee/organisations/${orgId}`, + method: 'put', + data + }); +} + +export async function deleteOrganisation( + request: ApiRequestFunction, + orgId: string +): Promise { + await request({ + url: `/api/trustee/organisations/${orgId}`, + method: 'delete' + }); +} + +// ============================================================================ +// ROLE API +// ============================================================================ + +export async function fetchRoles( + request: ApiRequestFunction, + params?: PaginationParams +): Promise | TrusteeRole[]> { + return await request({ + url: '/api/trustee/roles', + method: 'get', + params: _buildPaginationParams(params) + }); +} + +export async function fetchRoleById( + request: ApiRequestFunction, + roleId: string +): Promise { + 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 +): Promise { + return await request({ + url: '/api/trustee/roles', + method: 'post', + data + }); +} + +export async function updateRole( + request: ApiRequestFunction, + roleId: string, + data: Partial +): Promise { + return await request({ + url: `/api/trustee/roles/${roleId}`, + method: 'put', + data + }); +} + +export async function deleteRole( + request: ApiRequestFunction, + roleId: string +): Promise { + await request({ + url: `/api/trustee/roles/${roleId}`, + method: 'delete' + }); +} + +// ============================================================================ +// ACCESS API +// ============================================================================ + +export async function fetchAccess( + request: ApiRequestFunction, + params?: PaginationParams +): Promise | TrusteeAccess[]> { + return await request({ + url: '/api/trustee/access', + method: 'get', + params: _buildPaginationParams(params) + }); +} + +export async function fetchAccessById( + request: ApiRequestFunction, + accessId: string +): Promise { + 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 { + return await request({ + url: `/api/trustee/access/organisation/${orgId}`, + method: 'get' + }); +} + +export async function fetchAccessByUser( + request: ApiRequestFunction, + userId: string +): Promise { + return await request({ + url: `/api/trustee/access/user/${userId}`, + method: 'get' + }); +} + +export async function createAccess( + request: ApiRequestFunction, + data: Partial +): Promise { + return await request({ + url: '/api/trustee/access', + method: 'post', + data + }); +} + +export async function updateAccess( + request: ApiRequestFunction, + accessId: string, + data: Partial +): Promise { + return await request({ + url: `/api/trustee/access/${accessId}`, + method: 'put', + data + }); +} + +export async function deleteAccess( + request: ApiRequestFunction, + accessId: string +): Promise { + await request({ + url: `/api/trustee/access/${accessId}`, + method: 'delete' + }); +} + +// ============================================================================ +// CONTRACT API +// ============================================================================ + +export async function fetchContracts( + request: ApiRequestFunction, + params?: PaginationParams +): Promise | TrusteeContract[]> { + return await request({ + url: '/api/trustee/contracts', + method: 'get', + params: _buildPaginationParams(params) + }); +} + +export async function fetchContractById( + request: ApiRequestFunction, + contractId: string +): Promise { + 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 { + return await request({ + url: `/api/trustee/contracts/organisation/${orgId}`, + method: 'get' + }); +} + +export async function createContract( + request: ApiRequestFunction, + data: Partial +): Promise { + return await request({ + url: '/api/trustee/contracts', + method: 'post', + data + }); +} + +export async function updateContract( + request: ApiRequestFunction, + contractId: string, + data: Partial +): Promise { + return await request({ + url: `/api/trustee/contracts/${contractId}`, + method: 'put', + data + }); +} + +export async function deleteContract( + request: ApiRequestFunction, + contractId: string +): Promise { + await request({ + url: `/api/trustee/contracts/${contractId}`, + method: 'delete' + }); +} + +// ============================================================================ +// DOCUMENT API +// ============================================================================ + +export async function fetchDocuments( + request: ApiRequestFunction, + params?: PaginationParams +): Promise | TrusteeDocument[]> { + return await request({ + url: '/api/trustee/documents', + method: 'get', + params: _buildPaginationParams(params) + }); +} + +export async function fetchDocumentById( + request: ApiRequestFunction, + documentId: string +): Promise { + 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 { + return await request({ + url: `/api/trustee/documents/contract/${contractId}`, + method: 'get' + }); +} + +export async function createDocument( + request: ApiRequestFunction, + data: Partial +): Promise { + return await request({ + url: '/api/trustee/documents', + method: 'post', + data + }); +} + +export async function updateDocument( + request: ApiRequestFunction, + documentId: string, + data: Partial +): Promise { + return await request({ + url: `/api/trustee/documents/${documentId}`, + method: 'put', + data + }); +} + +export async function deleteDocument( + request: ApiRequestFunction, + documentId: string +): Promise { + await request({ + url: `/api/trustee/documents/${documentId}`, + method: 'delete' + }); +} + +// ============================================================================ +// POSITION API +// ============================================================================ + +export async function fetchPositions( + request: ApiRequestFunction, + params?: PaginationParams +): Promise | TrusteePosition[]> { + return await request({ + url: '/api/trustee/positions', + method: 'get', + params: _buildPaginationParams(params) + }); +} + +export async function fetchPositionById( + request: ApiRequestFunction, + positionId: string +): Promise { + 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 { + return await request({ + url: `/api/trustee/positions/contract/${contractId}`, + method: 'get' + }); +} + +export async function fetchPositionsByOrganisation( + request: ApiRequestFunction, + orgId: string +): Promise { + return await request({ + url: `/api/trustee/positions/organisation/${orgId}`, + method: 'get' + }); +} + +export async function createPosition( + request: ApiRequestFunction, + data: Partial +): Promise { + return await request({ + url: '/api/trustee/positions', + method: 'post', + data + }); +} + +export async function updatePosition( + request: ApiRequestFunction, + positionId: string, + data: Partial +): Promise { + return await request({ + url: `/api/trustee/positions/${positionId}`, + method: 'put', + data + }); +} + +export async function deletePosition( + request: ApiRequestFunction, + positionId: string +): Promise { + await request({ + url: `/api/trustee/positions/${positionId}`, + method: 'delete' + }); +} + +// ============================================================================ +// POSITION-DOCUMENT API +// ============================================================================ + +export async function fetchPositionDocuments( + request: ApiRequestFunction, + params?: PaginationParams +): Promise | TrusteePositionDocument[]> { + return await request({ + url: '/api/trustee/position-documents', + method: 'get', + params: _buildPaginationParams(params) + }); +} + +export async function fetchPositionDocumentById( + request: ApiRequestFunction, + linkId: string +): Promise { + 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 { + return await request({ + url: `/api/trustee/position-documents/position/${positionId}`, + method: 'get' + }); +} + +export async function fetchPositionsForDocument( + request: ApiRequestFunction, + documentId: string +): Promise { + return await request({ + url: `/api/trustee/position-documents/document/${documentId}`, + method: 'get' + }); +} + +export async function createPositionDocument( + request: ApiRequestFunction, + data: Partial +): Promise { + return await request({ + url: '/api/trustee/position-documents', + method: 'post', + data + }); +} + +export async function deletePositionDocument( + request: ApiRequestFunction, + linkId: string +): Promise { + await request({ + url: `/api/trustee/position-documents/${linkId}`, + method: 'delete' + }); +} diff --git a/src/api/userApi.ts b/src/api/userApi.ts index 7c58d37..6cf36e6 100644 --- a/src/api/userApi.ts +++ b/src/api/userApi.ts @@ -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 } + }); +} + diff --git a/src/components/FormGenerator/ActionButtons/ActionButton.module.css b/src/components/FormGenerator/ActionButtons/ActionButton.module.css index d7d214e..58d1e7e 100644 --- a/src/components/FormGenerator/ActionButtons/ActionButton.module.css +++ b/src/components/FormGenerator/ActionButtons/ActionButton.module.css @@ -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); + } } diff --git a/src/components/FormGenerator/ActionButtons/ConnectActionButton/ConnectActionButton.tsx b/src/components/FormGenerator/ActionButtons/ConnectActionButton/ConnectActionButton.tsx deleted file mode 100644 index b2d6f0d..0000000 --- a/src/components/FormGenerator/ActionButtons/ConnectActionButton/ConnectActionButton.tsx +++ /dev/null @@ -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 { - 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({ - row, - disabled = false, - loading = false, - className = '', - title, - connectTitle, - refreshTitle, - hookData, - idField = 'id', - statusField = 'status', - operationName = 'connectWithPopup', - loadingStateName = 'isConnecting' -}: ConnectActionButtonProps) { - 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 ? : ; - - // 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 ( - - ); -} - -export default ConnectActionButton; - diff --git a/src/components/FormGenerator/ActionButtons/ConnectActionButton/index.ts b/src/components/FormGenerator/ActionButtons/ConnectActionButton/index.ts deleted file mode 100644 index d1991fc..0000000 --- a/src/components/FormGenerator/ActionButtons/ConnectActionButton/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { default as ConnectActionButton } from './ConnectActionButton'; -export type { ConnectActionButtonProps } from './ConnectActionButton'; - diff --git a/src/components/FormGenerator/ActionButtons/CustomActionButton/CustomActionButton.tsx b/src/components/FormGenerator/ActionButtons/CustomActionButton/CustomActionButton.tsx new file mode 100644 index 0000000..51eb11c --- /dev/null +++ b/src/components/FormGenerator/ActionButtons/CustomActionButton/CustomActionButton.tsx @@ -0,0 +1,108 @@ +import React, { useState } from 'react'; +import { useLanguage } from '../../../../providers/language/LanguageContext'; +import styles from '../ActionButton.module.css'; + +export interface CustomActionButtonProps { + row: T; + id: string; // Unique identifier for the action + icon: React.ReactNode; // Icon component to display + onClick: (row: T, hookData?: any) => Promise | 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({ + row, + id, + icon, + onClick, + visible, + disabled, + loading, + title, + className = '', + hookData, + idField: _idField = 'id' // Available for future use, kept for API consistency +}: CustomActionButtonProps) { + 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 ( + + ); +} + +export default CustomActionButton; diff --git a/src/components/FormGenerator/ActionButtons/CustomActionButton/index.ts b/src/components/FormGenerator/ActionButtons/CustomActionButton/index.ts new file mode 100644 index 0000000..c64baaa --- /dev/null +++ b/src/components/FormGenerator/ActionButtons/CustomActionButton/index.ts @@ -0,0 +1,2 @@ +export { CustomActionButton } from './CustomActionButton'; +export type { CustomActionButtonProps } from './CustomActionButton'; diff --git a/src/components/FormGenerator/ActionButtons/DownloadActionButton/DownloadActionButton.tsx b/src/components/FormGenerator/ActionButtons/DownloadActionButton/DownloadActionButton.tsx deleted file mode 100644 index 76a503e..0000000 --- a/src/components/FormGenerator/ActionButtons/DownloadActionButton/DownloadActionButton.tsx +++ /dev/null @@ -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 { - row: T; - onDownload: (row: T) => Promise | 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({ - row, - onDownload, - disabled = false, - loading = false, - className = '', - title, - isDownloading = false, - hookData, - idField = 'id', - nameField, - loadingStateName = 'downloadingFiles', - operationName -}: DownloadActionButtonProps) { - 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 ( - - ); -} - -export default DownloadActionButton; diff --git a/src/components/FormGenerator/ActionButtons/DownloadActionButton/index.ts b/src/components/FormGenerator/ActionButtons/DownloadActionButton/index.ts deleted file mode 100644 index 604ec94..0000000 --- a/src/components/FormGenerator/ActionButtons/DownloadActionButton/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as DownloadActionButton } from './DownloadActionButton'; -export type { DownloadActionButtonProps } from './DownloadActionButton'; diff --git a/src/components/FormGenerator/ActionButtons/PlayActionButton/PlayActionButton.tsx b/src/components/FormGenerator/ActionButtons/PlayActionButton/PlayActionButton.tsx deleted file mode 100644 index b216200..0000000 --- a/src/components/FormGenerator/ActionButtons/PlayActionButton/PlayActionButton.tsx +++ /dev/null @@ -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 { - row: T; - onPlay?: (row: T) => Promise | 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({ - row, - onPlay, - disabled = false, - loading = false, - className = '', - title, - hookData: _hookData, - idField = 'id', - nameField: _nameField = 'name', - contentField = 'content', - navigateTo = 'start/dashboard', - mode = 'prompt' -}: PlayActionButtonProps) { - 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 ( - - ); -} - -export default PlayActionButton; diff --git a/src/components/FormGenerator/ActionButtons/PlayActionButton/index.ts b/src/components/FormGenerator/ActionButtons/PlayActionButton/index.ts deleted file mode 100644 index 6b4a2a9..0000000 --- a/src/components/FormGenerator/ActionButtons/PlayActionButton/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { PlayActionButton } from './PlayActionButton'; -export type { PlayActionButtonProps } from './PlayActionButton'; diff --git a/src/components/FormGenerator/ActionButtons/index.ts b/src/components/FormGenerator/ActionButtons/index.ts index d496506..c7ce88b 100644 --- a/src/components/FormGenerator/ActionButtons/index.ts +++ b/src/components/FormGenerator/ActionButtons/index.ts @@ -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'; diff --git a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.module.css b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.module.css index 34bcb66..367ca7c 100644 --- a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.module.css +++ b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.module.css @@ -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; diff --git a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx index 06de77f..92b14a7 100644 --- a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx +++ b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx @@ -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; onFilterChange: (key: string, value: any) => void; filterFocused: Record; @@ -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) => { - 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 (
@@ -204,6 +119,11 @@ export function FormGeneratorControls({ {t('formgen.search.placeholder')}
+ {activeFiltersCount > 0 && ( + + {activeFiltersCount} {t('formgen.filter.active', 'filter(s)')} + + )} {onRefresh && ( - )} - - ) : field.filterOptions ? ( -
- - {filters[field.key] && ( - - )} -
- ) : field.type === 'date' ? ( -
- 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} - /> - -
- ) : ( -
- 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 : ''}`} - /> - -
- )} - - ))} - - )} ); } diff --git a/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx b/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx index 408c7c6..46b12a4 100644 --- a/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx +++ b/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx @@ -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>({ // 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>({ 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>({ // 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>({ 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); } diff --git a/src/components/FormGenerator/FormGeneratorList/FormGeneratorList.tsx b/src/components/FormGenerator/FormGeneratorList/FormGeneratorList.tsx index f65c5e9..06dec35 100644 --- a/src/components/FormGenerator/FormGeneratorList/FormGeneratorList.tsx +++ b/src/components/FormGenerator/FormGeneratorList/FormGeneratorList.tsx @@ -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>({ case 'delete': return ; case 'download': - return {})} isDownloading={isProcessing} hookData={hookData} operationName={actionButton.operationName} />; + return } + onClick={actionButton.onAction || (() => {})} + disabled={() => disabledResult} + loading={() => isProcessing} + title={actionTitle} + className={actionButton.className} + hookData={hookData} + />; case 'view': return {})} isViewing={isProcessing} hookData={hookData} />; case 'copy': return ; case 'connect': - return ; - case 'play': - return } + onClick={actionButton.onAction || (() => {})} + disabled={() => disabledResult} + loading={() => isLoading} + title={actionTitle} + className={actionButton.className} + hookData={hookData} + />; + case 'play': + return } + onClick={actionButton.onAction || (() => {})} + disabled={() => disabledResult} + loading={() => isLoading} + title={actionTitle} + className={actionButton.className} + hookData={hookData} />; default: return null; diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css index 7079764..95433d3 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css @@ -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; } +} + diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx index 205c1ec..690b612 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx @@ -1,22 +1,22 @@ -import React, { useState, useMemo, useRef, useEffect } from 'react'; +import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react'; import { useLanguage } from '../../../providers/language/LanguageContext'; import styles from './FormGeneratorTable.module.css'; import { EditActionButton, DeleteActionButton, - DownloadActionButton, ViewActionButton, CopyActionButton, - ConnectActionButton, - PlayActionButton + CustomActionButton } from '../ActionButtons'; import { formatUnixTimestamp } from '../../../utils/time'; import { FormGeneratorControls } from '../FormGeneratorControls'; import { CopyableTruncatedValue } from '../../UiComponents/CopyableTruncatedValue'; import { - isDateTimeType + isDateTimeType, + isCheckboxType } from '../../../utils/attributeTypeMapper'; import type { AttributeType } from '../../../utils/attributeTypeMapper'; +import { FaFilter } from 'react-icons/fa'; // Helper function to detect TextMultilingual objects // TextMultilingual has structure: { en: string, ge?: string, fr?: string, it?: string } @@ -99,28 +99,42 @@ export interface FormGeneratorTableProps { selectable?: boolean; isRowSelectable?: (row: T) => boolean; loading?: boolean; + // Inline editing configuration + inlineEditable?: boolean; // Enable inline editing for supported field types (checkbox/boolean) + onInlineUpdate?: (row: T, field: string, newValue: any) => Promise | void; // Called when inline edit occurs + inlineEditingRows?: Set; // Set of row IDs currently being edited (for loading state) + idField?: string; // Field name for unique row identifier (default: 'id') + // Standard action buttons (edit, delete, view, copy) actionButtons?: { - type: 'edit' | 'delete' | 'download' | 'view' | 'copy' | 'connect' | 'play'; + type: 'edit' | 'delete' | 'view' | 'copy'; onAction?: (row: T) => Promise | void; // Optional for delete buttons since they handle their own logic disabled?: (row: T) => boolean | { disabled: boolean; message?: string }; loading?: (row: T) => boolean; title?: string | ((row: T) => string); className?: string; - // For download and view buttons + // For view buttons isProcessing?: (row: T) => boolean; // Field mappings for flexible data access idField?: string; // Field name for the unique identifier nameField?: string; // Field name for display name typeField?: string; // Field name for type/mime type contentField?: string; // Field name for content (used by copy button) - statusField?: string; // Field name for status (used by connect button) - authorityField?: string; // Field name for authority (msft/google) (used by connect button) // Operation and loading state names operationName?: string; // Name of the operation function in hookData - refreshOperationName?: string; // Name of the refresh operation function in hookData (for connect button) loadingStateName?: string; // Name of the loading state in hookData - // Navigation (for play button) - navigateTo?: string; // Route to navigate to when play button is clicked + fetchItemFunctionName?: string; // Name of the function to fetch a single item (for edit button) + }[]; + // Custom action buttons (entity-specific actions like download, connect, play, sendPasswordLink) + customActions?: { + id: string; // Unique identifier for the action + icon: React.ReactNode; // Icon component to display + onClick: (row: T, hookData?: any) => Promise | void; // Handler function + visible?: (row: T, hookData?: any) => boolean; // Show/hide based on row data (default: true) + disabled?: (row: T, hookData?: any) => boolean | { disabled: boolean; message?: string }; + loading?: (row: T, hookData?: any) => boolean; // Loading state based on row data + title?: string | ((row: T) => string); // Tooltip text + className?: string; // Optional custom CSS class + idField?: string; // Field name for unique identifier (default: 'id') }[]; onDelete?: (row: T) => void; onDeleteMultiple?: (rows: T[]) => void; @@ -142,14 +156,19 @@ export function FormGeneratorTable>({ resizable = true, pagination = true, pageSize = 10, - pageSizeOptions = [10, 25, 50, 100], + pageSizeOptions = [10, 25, 50, 100, 500], showPageSizeSelector = true, onRowClick, onRowSelect, selectable = true, // Default to true for selection functionality isRowSelectable, loading = false, + inlineEditable = true, // Enable inline editing by default + onInlineUpdate, + inlineEditingRows, + idField = 'id', actionButtons = [], + customActions = [], onDelete, onDeleteMultiple, onRefresh, @@ -263,12 +282,25 @@ export function FormGeneratorTable>({ const [searchTerm, setSearchTerm] = useState(''); const [searchFocused, setSearchFocused] = useState(false); const [filterFocused, setFilterFocused] = useState>({}); - const [sortConfig, setSortConfig] = useState<{ key: string; direction: 'asc' | 'desc' } | null>(null); + // Multi-column sorting: array of sort configs in order of priority + const [sortConfigs, setSortConfigs] = useState>([]); const [filters, setFilters] = useState>({}); const [columnWidths, setColumnWidths] = useState>({}); const [selectedRows, setSelectedRows] = useState>(new Set()); const [currentPage, setCurrentPage] = useState(1); const [currentPageSize, setCurrentPageSize] = useState(pageSize); + const [openFilterColumn, setOpenFilterColumn] = useState(null); + const filterDropdownRef = useRef(null); + + // Generate a storage key based on column names for localStorage persistence + const storageKey = useMemo(() => { + if (detectedColumns.length === 0) return null; + const columnNames = detectedColumns.map(c => c.key).sort().join(','); + return `table-column-widths-${columnNames.substring(0, 50)}`; // Limit key length + }, [detectedColumns]); + + // Track if we've loaded from localStorage for this storage key + const loadedStorageKeyRef = useRef(null); // Check if backend pagination is supported (hookData has refetch that accepts params) const supportsBackendPagination = hookData?.refetch && typeof hookData.refetch === 'function'; @@ -311,12 +343,12 @@ export function FormGeneratorTable>({ paginationParams.filters = activeFilters; } - // Add sort if provided - if (sortConfig) { - paginationParams.sort = [{ - field: sortConfig.key, - direction: sortConfig.direction - }]; + // Add sort if provided (multi-column sorting) + if (sortConfigs.length > 0) { + paginationParams.sort = sortConfigs.map(sc => ({ + field: sc.key, + direction: sc.direction + })); } // Log search parameters being sent to backend @@ -337,7 +369,7 @@ export function FormGeneratorTable>({ console.error('❌ FormGeneratorTable: Backend refetch failed:', error); }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [debouncedSearchTerm, filters, sortConfig, currentPage, currentPageSize, supportsBackendPagination]); + }, [debouncedSearchTerm, filters, sortConfigs, currentPage, currentPageSize, supportsBackendPagination]); // Refs for action buttons containers to detect clicks outside const actionButtonsRefs = useRef>(new Map()); @@ -348,19 +380,66 @@ export function FormGeneratorTable>({ const startX = useRef(0); const startWidth = useRef(0); - // Initialize column widths - preserve widths even when columns don't change + // Initialize column widths - load from localStorage or set defaults useEffect(() => { - if (detectedColumns.length === 0) return; // Don't clear widths if no columns + if (detectedColumns.length === 0 || !storageKey) return; - const initialWidths: Record = {}; + // Only load from localStorage once per storage key + if (loadedStorageKeyRef.current === storageKey) { + // Already loaded for this key, just add defaults for any new columns + setColumnWidths(prev => { + const newWidths = { ...prev }; + let hasChanges = false; + + detectedColumns.forEach(col => { + if (newWidths[col.key] === undefined) { + newWidths[col.key] = col.width || 150; + hasChanges = true; + } + }); + + return hasChanges ? newWidths : prev; + }); + return; + } + // Load from localStorage for this new storage key + loadedStorageKeyRef.current = storageKey; + + try { + const saved = localStorage.getItem(storageKey); + if (saved) { + const savedWidths = JSON.parse(saved); + // Merge saved widths with defaults for any missing columns + const mergedWidths: Record = {}; + detectedColumns.forEach(col => { + mergedWidths[col.key] = savedWidths[col.key] ?? col.width ?? 150; + }); + setColumnWidths(mergedWidths); + return; + } + } catch { + // Ignore localStorage errors + } + + // No saved widths, set defaults + const defaultWidths: Record = {}; detectedColumns.forEach(col => { - // Set a default width if none specified to ensure all columns have explicit widths - // Preserve existing width if column already exists - initialWidths[col.key] = col.width || columnWidths[col.key] || 150; + defaultWidths[col.key] = col.width || 150; }); - setColumnWidths(prev => ({ ...prev, ...initialWidths })); - }, [detectedColumns]); + setColumnWidths(defaultWidths); + }, [detectedColumns, storageKey]); + + // Save column widths to localStorage when they change (debounced via resize completion) + const saveColumnWidthsToStorage = useRef((widths: Record, key: string | null) => { + if (Object.keys(widths).length > 0 && key) { + try { + localStorage.setItem(key, JSON.stringify(widths)); + } catch { + // Ignore localStorage errors + } + } + }).current; // Data is already filtered, sorted, and paginated by the backend @@ -369,10 +448,6 @@ export function FormGeneratorTable>({ // Get pagination info from backend const totalPages = useMemo(() => { - if (!supportsBackendPagination) { - return 1; // No pagination if backend doesn't support it - } - // If pagination object exists, use totalPages from backend if (hookData?.pagination) { // Debug logging @@ -397,38 +472,57 @@ export function FormGeneratorTable>({ } } - // If we have data and pagination is enabled, assume there might be more pages - // Show pagination controls if we have at least one page of data - if (displayData.length > 0 && displayData.length === currentPageSize) { - // If we got a full page of data, there might be more pages - // Return a minimum of 2 pages to show pagination controls - return 2; - } - + // Default to 1 page (pagination bar will still show) return 1; - }, [supportsBackendPagination, hookData?.pagination, currentPageSize, displayData.length]); + }, [hookData?.pagination, currentPageSize]); - // Handle sorting + // Handle multi-column sorting + // Click cycle: not sorted → ascending → descending → not sorted + // When adding a new column, it becomes the last (lowest priority) sort const handleSort = (key: string) => { if (!sortable) return; - setSortConfig(current => { - if (current?.key === key) { - return current.direction === 'asc' - ? { key, direction: 'desc' } - : null; + setSortConfigs(current => { + const existingIndex = current.findIndex(sc => sc.key === key); + + if (existingIndex === -1) { + // Column not in sort list → add as ascending (lowest priority) + return [...current, { key, direction: 'asc' }]; } - return { key, direction: 'asc' }; + + const existing = current[existingIndex]; + if (existing.direction === 'asc') { + // Ascending → change to descending (keep same position) + const newConfigs = [...current]; + newConfigs[existingIndex] = { key, direction: 'desc' }; + return newConfigs; + } + + // Descending → remove from sort list + return current.filter(sc => sc.key !== key); }); }; + // Get sort info for a column (returns { direction, position } or null) + const getSortInfo = useCallback((key: string): { direction: 'asc' | 'desc'; position: number } | null => { + const index = sortConfigs.findIndex(sc => sc.key === key); + if (index === -1) return null; + return { direction: sortConfigs[index].direction, position: index + 1 }; + }, [sortConfigs]); + // Handle filtering const handleFilter = (key: string, value: any) => { - setFilters(prev => ({ - ...prev, - [key]: value - })); + setFilters(prev => { + const newFilters = { ...prev }; + if (value === undefined || value === '' || value === null) { + delete newFilters[key]; + } else { + newFilters[key] = value; + } + return newFilters; + }); setCurrentPage(1); // Reset to first page when filtering + setOpenFilterColumn(null); // Close filter dropdown }; // Handle filter input focus @@ -439,6 +533,67 @@ export function FormGeneratorTable>({ })); }; + // Clear filter for a column + const clearFilter = useCallback((key: string) => { + setFilters(prev => { + const newFilters = { ...prev }; + delete newFilters[key]; + return newFilters; + }); + setCurrentPage(1); + setOpenFilterColumn(null); + }, []); + + // Count active filters + const activeFiltersCount = useMemo(() => { + return Object.keys(filters).filter(k => filters[k] !== undefined && filters[k] !== '').length; + }, [filters]); + + // Get unique values for a column (for filter dropdown) + const getUniqueValuesForColumn = useCallback((columnKey: string): string[] => { + const values = new Set(); + data.forEach(row => { + const value = row[columnKey]; + if (value !== undefined && value !== null && value !== '') { + // Handle different value types + if (typeof value === 'object' && !Array.isArray(value)) { + // For objects (like TextMultilingual), get the string representation + if (isTextMultilingual(value)) { + const text = value.en || Object.values(value)[0]; + if (text) values.add(String(text)); + } else { + values.add(JSON.stringify(value)); + } + } else if (typeof value === 'boolean') { + values.add(value ? 'true' : 'false'); + } else { + values.add(String(value)); + } + } + }); + return Array.from(values).sort(); + }, [data]); + + // Close filter dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (filterDropdownRef.current && !filterDropdownRef.current.contains(event.target as Node)) { + setOpenFilterColumn(null); + } + }; + + if (openFilterColumn) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [openFilterColumn]); + + // Toggle filter dropdown + const toggleFilterDropdown = useCallback((columnKey: string, event: React.MouseEvent) => { + event.stopPropagation(); // Prevent sort from triggering + setOpenFilterColumn(prev => prev === columnKey ? null : columnKey); + }, []); + // Handle row selection const handleRowSelect = (index: number) => { if (!selectable) return; @@ -515,74 +670,215 @@ export function FormGeneratorTable>({ setCurrentPage(1); // Reset to first page when page size changes }; - // Handle column resizing + // Handle column resizing - use refs to store stable handler references + const handleMouseMoveRef = useRef<((e: MouseEvent) => void) | null>(null); + const handleMouseUpRef = useRef<(() => void) | null>(null); + const handleMouseDown = (e: React.MouseEvent, columnKey: string) => { if (!resizable) return; e.preventDefault(); + e.stopPropagation(); // Prevent sorting when resizing + resizingColumn.current = columnKey; startX.current = e.clientX; startWidth.current = columnWidths[columnKey] || 150; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); + // Create stable handler functions and store in refs + const mouseMoveHandler = (moveEvent: MouseEvent) => { + if (!resizingColumn.current) return; + + const diff = moveEvent.clientX - startX.current; + let newWidth = Math.max(100, startWidth.current + diff); + + // Prevent extending beyond table container + const tableContainer = tableRef.current?.parentElement; + if (tableContainer) { + const containerWidth = tableContainer.clientWidth; + const actionsColumnWidth = actionButtons.length > 0 ? 120 : 0; + const selectColumnWidth = selectable ? 50 : 0; + const fixedWidth = actionsColumnWidth + selectColumnWidth; + + // Maximum allowed width - simple calculation to prevent overflow + const maxAllowedWidth = containerWidth - fixedWidth - 100; // Leave space for other columns + newWidth = Math.min(newWidth, Math.max(100, maxAllowedWidth)); + } + + setColumnWidths(prev => ({ + ...prev, + [resizingColumn.current!]: newWidth + })); + }; + + const mouseUpHandler = () => { + const resizedColumn = resizingColumn.current; + resizingColumn.current = null; + if (handleMouseMoveRef.current) { + document.removeEventListener('mousemove', handleMouseMoveRef.current); + } + if (handleMouseUpRef.current) { + document.removeEventListener('mouseup', handleMouseUpRef.current); + } + handleMouseMoveRef.current = null; + handleMouseUpRef.current = null; + + // Save to localStorage after resize completes + if (resizedColumn) { + setColumnWidths(currentWidths => { + saveColumnWidthsToStorage(currentWidths, storageKey); + return currentWidths; + }); + } + }; + + // Store handlers in refs for cleanup + handleMouseMoveRef.current = mouseMoveHandler; + handleMouseUpRef.current = mouseUpHandler; + + document.addEventListener('mousemove', mouseMoveHandler); + document.addEventListener('mouseup', mouseUpHandler); }; + + // Cleanup on unmount + useEffect(() => { + return () => { + if (handleMouseMoveRef.current) { + document.removeEventListener('mousemove', handleMouseMoveRef.current); + } + if (handleMouseUpRef.current) { + document.removeEventListener('mouseup', handleMouseUpRef.current); + } + }; + }, []); - const handleMouseMove = (e: MouseEvent) => { - if (!resizingColumn.current) return; + // Track which cells are currently being updated (for loading state) + const [updatingCells, setUpdatingCells] = useState>(new Set()); + + // Check if inline editing is allowed for a column (based on RBAC permissions) + const canInlineEdit = useMemo(() => { + // Auto-enable if inlineEditable is explicitly true OR if handleInlineUpdate is available + const hasInlineSupport = inlineEditable || hookData?.handleInlineUpdate || onInlineUpdate; + if (!hasInlineSupport) return false; + // Check RBAC permissions - need update permission + const permissions = hookData?.permissions; + if (!permissions) return false; + // Permission 'n' means no access, anything else (a, m, g) means allowed + return permissions.update !== 'n' && permissions.view; + }, [inlineEditable, hookData?.handleInlineUpdate, hookData?.permissions, onInlineUpdate]); + + // Check if a column supports inline editing (only checkbox/boolean types) + const isInlineEditableColumn = useCallback((column: ColumnConfig): boolean => { + if (!column.type) return false; + return isCheckboxType(column.type); + }, []); + + // Handle inline toggle for boolean fields + const handleInlineToggle = useCallback(async (row: T, column: ColumnConfig, currentValue: boolean) => { + if (!canInlineEdit || !isInlineEditableColumn(column)) return; - const diff = e.clientX - startX.current; - let newWidth = Math.max(100, startWidth.current + diff); + const rowId = row[idField]; + const cellKey = `${rowId}-${column.key}`; - // Prevent extending beyond table container - const tableContainer = tableRef.current?.parentElement; - if (tableContainer) { - const containerWidth = tableContainer.clientWidth; - const actionsColumnWidth = actionButtons.length > 0 ? 120 : 0; // Fixed width actions column - const selectColumnWidth = selectable ? 50 : 0; // Fixed width select column - const fixedWidth = actionsColumnWidth + selectColumnWidth; - - // Calculate total width of all OTHER data columns (excluding the one being resized) - const otherDataColumnsWidth = detectedColumns.reduce((total, col) => { - if (col.key !== resizingColumn.current) { - return total + (columnWidths[col.key] || col.width || 150); - } - return total; - }, 0); - - // Maximum allowed width for this column - const maxAllowedWidth = containerWidth - fixedWidth - otherDataColumnsWidth - 40; // 40px buffer - newWidth = Math.min(newWidth, Math.max(100, maxAllowedWidth)); + // Check if update function is available (either from prop or hookData) + const updateFn = onInlineUpdate || hookData?.handleInlineUpdate; + if (!updateFn) { + // Silent return - inline editing is optional, no warning needed + return; } - setColumnWidths(prev => ({ - ...prev, - [resizingColumn.current!]: newWidth - })); - }; + // Mark cell as updating + setUpdatingCells(prev => new Set(prev).add(cellKey)); + + const newValue = !currentValue; + const hasOptimisticUpdate = !!hookData?.updateOptimistically; + + // If updateOptimistically is available, use it for immediate UI feedback + if (hasOptimisticUpdate) { + hookData.updateOptimistically(rowId, { [column.key]: newValue }); + } + + try { + // Call the update function (generic - no entity-specific logic) + if (onInlineUpdate) { + await onInlineUpdate(row, column.key, newValue); + } else if (hookData?.handleInlineUpdate) { + await hookData.handleInlineUpdate(rowId, { [column.key]: newValue }); + } + + // Only refetch if we DON'T have optimistic update (to get fresh data) + // With optimistic update, local state is already correct + if (!hasOptimisticUpdate && hookData?.refetch) { + await hookData.refetch(); + } + } catch (error) { + console.error('FormGeneratorTable: Inline update failed:', error); + // Revert optimistic update on error + if (hasOptimisticUpdate) { + hookData.updateOptimistically(rowId, { [column.key]: currentValue }); + } + // Refetch to restore consistent state on error + if (hookData?.refetch) { + await hookData.refetch(); + } + } finally { + // Remove cell from updating state + setUpdatingCells(prev => { + const newSet = new Set(prev); + newSet.delete(cellKey); + return newSet; + }); + } + }, [canInlineEdit, isInlineEditableColumn, idField, onInlineUpdate, hookData]); - const handleMouseUp = () => { - resizingColumn.current = null; - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; + // Render inline-editable boolean cell + const renderBooleanCell = useCallback((value: any, column: ColumnConfig, row: T): React.ReactNode => { + const boolValue = Boolean(value); + const rowId = row[idField]; + const cellKey = `${rowId}-${column.key}`; + const isUpdating = updatingCells.has(cellKey) || inlineEditingRows?.has(rowId); + const isEditable = canInlineEdit && isInlineEditableColumn(column); + + // Loading spinner for updating state + if (isUpdating) { + return ( + + + + ); + } + + // Clickable or static based on permissions + if (isEditable) { + return ( + + ); + } + + // Non-editable display + return ( + + {boolValue ? '✓' : '○'} + + ); + }, [idField, updatingCells, inlineEditingRows, canInlineEdit, isInlineEditableColumn, handleInlineToggle, t]); - // Check if a column is an ID field + // Check if a column is an ID field (generic logic only) + // Matches: "id", "ID", "id" followed by uppercase (e.g., "idUser"), ends with "Id" or "ID" (e.g., "userId", "userID") const isIdField = (columnKey: string): boolean => { - const lowerKey = columnKey.toLowerCase(); - // Match exact "id" or fields ending with "Id" or "ID" (camelCase/PascalCase) - // Also match fields like "mandateId", "userId", "workflowId", "fileId", etc. - return /^(id|_id)$/i.test(columnKey) || - /Id$/i.test(columnKey) || - /ID$/i.test(columnKey) || - (lowerKey.includes('id') && ( - lowerKey.includes('mandate') || - lowerKey.includes('user') || - lowerKey.includes('workflow') || - lowerKey.includes('file') || - lowerKey.includes('prompt') || - lowerKey.includes('connection') - )); + return /^(id|ID|_id)$/.test(columnKey) || // Exact match: "id", "ID", "_id" + /^id[A-Z]/.test(columnKey) || // Starts with "id" + uppercase: "idUser", "idMandate" + /Id$/.test(columnKey) || // Ends with "Id": "userId", "mandateId" + /ID$/.test(columnKey); // Ends with "ID": "userID", "mandateID" }; // Check if a column is a hash field @@ -605,6 +901,16 @@ export function FormGeneratorTable>({ return '-'; } + // Handle boolean/checkbox fields with inline editing support + if (column.type && isCheckboxType(column.type)) { + return renderBooleanCell(value, column, row); + } + + // Also detect boolean values even if column type isn't explicitly set + if (typeof value === 'boolean') { + return renderBooleanCell(value, column, row); + } + // Check if this is an ID or hash field that should be truncated and copyable // Do this BEFORE checking for custom formatters to ensure IDs/hashes are always copyable const isId = isIdField(column.key); @@ -804,7 +1110,7 @@ export function FormGeneratorTable>({ return (
- {(searchable || filterable || (selectable && selectedRows.size > 0)) && ( + {(searchable || (selectable && selectedRows.size > 0)) && ( >({ })()} onRefresh={onRefresh} searchable={searchable} - filterable={filterable} selectable={selectable} loading={loading} - onDateFilterChange={(key, value) => handleFilter(key, value)} + activeFiltersCount={activeFiltersCount} /> )} + {/* Pagination - Above Table */} + {pagination && ( +
+ {showPageSizeSelector && ( +
+ + +
+ )} + + {pagination && supportsBackendPagination && ( + <> + + + + {/* Page number buttons - show up to 100 pages before and after current */} +
+ {(() => { + const maxPagesVisible = 100; // Max pages to show on each side + const startPage = Math.max(1, currentPage - maxPagesVisible); + const endPage = Math.min(totalPages, currentPage + maxPagesVisible); + const pages: React.ReactNode[] = []; + + // Show ellipsis at start if we're not showing page 1 + if (startPage > 1) { + pages.push( + ... + ); + } + + // Generate page buttons + for (let i = startPage; i <= endPage; i++) { + pages.push( + + ); + } + + // Show ellipsis at end if we're not showing last page + if (endPage < totalPages) { + pages.push( + ... + ); + } + + return pages; + })()} +
+ + + + + {/* Total items count */} + + ({hookData?.pagination?.totalItems != null + ? hookData.pagination.totalItems.toString() + : (loading ? '...' : displayData.length.toString())} {t('formgen.pagination.items', 'items')}) + + + )} +
+ )} + {/* Table */} -
- {loading ? ( -
+
+ {/* Loading overlay - shown while loading */} + {loading && ( +

{t('common.loading', 'Loading...')}

- ) : displayData.length === 0 ? ( + )} + + {/* Empty state - only shown when not loading AND no data */} + {!loading && displayData.length === 0 ? (

{emptyMessage || t('formgen.empty', 'No data available')}

@@ -889,20 +1305,101 @@ export function FormGeneratorTable>({ style={{ width: columnWidths[column.key] || column.width || 150, minWidth: columnWidths[column.key] || column.width || 150, - maxWidth: columnWidths[column.key] || column.width || 150 + maxWidth: columnWidths[column.key] || column.width || 150, + position: 'relative' }} - onClick={() => column.sortable && handleSort(column.key)} >
- {column.label} - {sortable && column.sortable && ( - - {sortConfig?.key === column.key ? ( - sortConfig.direction === 'asc' ? '↑' : '↓' - ) : '↕'} - + {/* Filter icon */} + {filterable && column.filterable !== false && ( + )} + {/* Sort icon */} + {sortable && column.sortable && (() => { + const sortInfo = getSortInfo(column.key); + return ( + handleSort(column.key)} + title={sortInfo + ? t('formgen.sort.active', `Sort ${sortInfo.position}: ${sortInfo.direction === 'asc' ? 'ascending' : 'descending'}`) + : t('formgen.sort.click', 'Click to sort') + } + > + {sortInfo ? ( + <> + {sortInfo.direction === 'asc' ? '↑' : '↓'} + {sortConfigs.length > 1 && {sortInfo.position}} + + ) : '↕'} + + ); + })()} + {/* Column label */} + column.sortable && handleSort(column.key)} + > + {column.label} +
+ + {/* Filter dropdown */} + {openFilterColumn === column.key && ( +
e.stopPropagation()} + > +
+ {t('formgen.filter.title', 'Filter')}: {column.label} + {filters[column.key] && ( + + )} +
+
+ {/* "All" option to clear filter */} +
clearFilter(column.key)} + > + ({t('formgen.filter.all', 'All')}) +
+ {/* Unique values from data */} + {getUniqueValuesForColumn(column.key).slice(0, 50).map(value => ( +
handleFilter(column.key, value)} + title={value} + > + {value.length > 30 ? value.substring(0, 30) + '...' : value} +
+ ))} + {getUniqueValuesForColumn(column.key).length > 50 && ( +
+ ... {t('formgen.filter.more', 'and {count} more').replace('{count}', String(getUniqueValuesForColumn(column.key).length - 50))} +
+ )} +
+
+ )} + {resizable && (
>({ }} className={styles.actionButtons} > + {/* Standard action buttons (edit, delete, view, copy) */} {actionButtons.map((actionButton, actionIndex) => { const actionTitle = typeof actionButton.title === 'function' ? actionButton.title(row) @@ -969,57 +1467,72 @@ export function FormGeneratorTable>({ const isLoading = actionButton.loading ? actionButton.loading(row) : false; const isProcessing = actionButton.isProcessing ? actionButton.isProcessing(row) : false; - const baseProps = { row, - disabled: disabledResult, // Pass the full disabled result (boolean or object) + disabled: disabledResult, loading: isLoading, className: actionButton.className, title: actionTitle, - // Pass field mappings and operation names idField: actionButton.idField ?? 'id', nameField: actionButton.nameField ?? 'name', typeField: actionButton.typeField ?? 'type', contentField: actionButton.contentField ?? 'content', - statusField: actionButton.statusField ?? 'status', - authorityField: actionButton.authorityField ?? 'authority', operationName: actionButton.operationName, - refreshOperationName: actionButton.refreshOperationName, loadingStateName: actionButton.loadingStateName }; switch (actionButton.type) { case 'edit': return ; case 'delete': - return ; - case 'download': - return {})} isDownloading={isProcessing} hookData={hookData} operationName={actionButton.operationName} />; - case 'view': - return {})} isViewing={isProcessing} hookData={hookData} />; - case 'copy': - return ; - case 'connect': - return ; - case 'play': - return ; + case 'view': + return {})} + isViewing={isProcessing} + hookData={hookData} + />; + case 'copy': + return ; default: return null; } })} + {/* Custom action buttons (entity-specific actions) */} + {customActions.map((customAction) => ( + + ))}
)} @@ -1051,81 +1564,6 @@ export function FormGeneratorTable>({ )}
- - {/* Pagination */} - {pagination && ( -
- {showPageSizeSelector && ( -
- - -
- )} - - {(totalPages > 1 || (supportsBackendPagination && displayData.length >= currentPageSize)) && ( - <> - - - - - {t('formgen.pagination.info') - .replace('{page}', currentPage.toString()) - .replace('{total}', totalPages > 1 ? totalPages.toString() : '?') - .replace('{count}', supportsBackendPagination && hookData?.pagination - ? hookData.pagination.totalItems.toString() - : displayData.length.toString())} - - - - - - )} -
- )}
); } diff --git a/src/components/Sidebar/SidebarStyles/SidebarSubmenu.module.css b/src/components/Sidebar/SidebarStyles/SidebarSubmenu.module.css index 105eed9..4a26c0e 100644 --- a/src/components/Sidebar/SidebarStyles/SidebarSubmenu.module.css +++ b/src/components/Sidebar/SidebarStyles/SidebarSubmenu.module.css @@ -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; } \ No newline at end of file diff --git a/src/components/Sidebar/SidebarSubmenu.tsx b/src/components/Sidebar/SidebarSubmenu.tsx index b8b32d4..67357b9 100644 --- a/src/components/Sidebar/SidebarSubmenu.tsx +++ b/src/components/Sidebar/SidebarSubmenu.tsx @@ -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 = ({ subitem, isActive }) => { + const textRef = useRef(null); + const containerRef = useRef(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>; + + return ( +
  • + +
    + +
    + {SubIcon && } + + {subitem.name} + +
    +
    +
    + +
  • + ); +}; const SidebarSubmenu: React.FC = ({ 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 = ({ item, isOpen, isMinimiz
      {item.submenu.map(subitem => { const SubIcon = subitem.icon as React.ComponentType>; + const isActive = isSubmenuItemActive(subitem.link); return ( -
    • +
    • {SubIcon && ( = ({ item, isOpen, isMinimiz style={{ width: '16px', height: '16px', - color: '#181818', + color: isActive ? 'white' : '#181818', display: 'block' }} /> @@ -77,68 +163,13 @@ const SidebarSubmenu: React.FC = ({ item, isOpen, isMinimiz exit={{ opacity: 0, transition: { duration: 0.25, delay: 0 } }} >
        - {item.submenu.map(subitem => { - const textRef = useRef(null); - const containerRef = useRef(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>; - - return ( -
      • - -
        - -
        - {SubIcon && } - - {subitem.name} - -
        -
        -
        - -
      • - ); - })} + {item.submenu.map(subitem => ( + + ))}
      )} diff --git a/src/components/Sidebar/sidebarLogic.ts b/src/components/Sidebar/sidebarLogic.ts index 0c957ad..5857fe9 100644 --- a/src/components/Sidebar/sidebarLogic.ts +++ b/src/components/Sidebar/sidebarLogic.ts @@ -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 diff --git a/src/components/UiComponents/ConnectedFilesList/ConnectedFilesList.tsx b/src/components/UiComponents/ConnectedFilesList/ConnectedFilesList.tsx index a607363..371f650 100644 --- a/src/components/UiComponents/ConnectedFilesList/ConnectedFilesList.tsx +++ b/src/components/UiComponents/ConnectedFilesList/ConnectedFilesList.tsx @@ -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 {})} - isDownloading={isProcessing} - operationName={actionButton.operationName} + row={file} + id="download" + icon={} + onClick={actionButton.onAction || (() => {})} + disabled={() => disabledResult} + loading={() => isProcessing} + title={actionTitle} + className={actionButton.className} />; case 'view': return ; case 'connect': - return } + onClick={actionButton.onAction || (() => {})} + disabled={() => disabledResult} + loading={() => isLoading} + title={actionTitle} + className={actionButton.className} />; case 'play': - return } + onClick={actionButton.onAction || (() => {})} + disabled={() => disabledResult} + loading={() => isLoading} + title={actionTitle} + className={actionButton.className} />; case 'remove': return = ({ 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 = ({ } // 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 = ({ 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 = ({ 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} diff --git a/src/core/PageManager/SidebarProvider.tsx b/src/core/PageManager/SidebarProvider.tsx index 6cfe639..31aaf52 100644 --- a/src/core/PageManager/SidebarProvider.tsx +++ b/src/core/PageManager/SidebarProvider.tsx @@ -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 = ({ 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 => { @@ -181,34 +184,13 @@ export const SidebarProvider: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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, diff --git a/src/core/PageManager/data/pages/admin/mandates.ts b/src/core/PageManager/data/pages/admin/mandates.ts index ae1151a..0391957 100644 --- a/src/core/PageManager/data/pages/admin/mandates.ts +++ b/src/core/PageManager/data/pages/admin/mandates.ts @@ -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, diff --git a/src/core/PageManager/data/pages/admin/rbac-role.ts b/src/core/PageManager/data/pages/admin/rbac-role.ts index a14ea51..62d5bc4 100644 --- a/src/core/PageManager/data/pages/admin/rbac-role.ts +++ b/src/core/PageManager/data/pages/admin/rbac-role.ts @@ -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, diff --git a/src/core/PageManager/data/pages/admin/rbac-rules.ts b/src/core/PageManager/data/pages/admin/rbac-rules.ts index e19cca7..e8406e0 100644 --- a/src/core/PageManager/data/pages/admin/rbac-rules.ts +++ b/src/core/PageManager/data/pages/admin/rbac-rules.ts @@ -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, diff --git a/src/core/PageManager/data/pages/admin/team-members.ts b/src/core/PageManager/data/pages/admin/team-members.ts index 279f47c..8a44348 100644 --- a/src/core/PageManager/data/pages/admin/team-members.ts +++ b/src/core/PageManager/data/pages/admin/team-members.ts @@ -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, diff --git a/src/core/PageManager/data/pages/files.ts b/src/core/PageManager/data/pages/files.ts index 6236736..ba494d8 100644 --- a/src/core/PageManager/data/pages/files.ts +++ b/src/core/PageManager/data/pages/files.ts @@ -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, diff --git a/src/core/PageManager/data/pages/index.ts b/src/core/PageManager/data/pages/index.ts index 6ae32ef..e4c4043 100644 --- a/src/core/PageManager/data/pages/index.ts +++ b/src/core/PageManager/data/pages/index.ts @@ -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 diff --git a/src/core/PageManager/data/pages/prompts.ts b/src/core/PageManager/data/pages/prompts.ts index 7a05595..ffe1b0b 100644 --- a/src/core/PageManager/data/pages/prompts.ts +++ b/src/core/PageManager/data/pages/prompts.ts @@ -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', diff --git a/src/core/PageManager/data/pages/trustee/access.ts b/src/core/PageManager/data/pages/trustee/access.ts new file mode 100644 index 0000000..c28457a --- /dev/null +++ b/src/core/PageManager/data/pages/trustee/access.ts @@ -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'); + } +}; diff --git a/src/core/PageManager/data/pages/trustee/contracts.ts b/src/core/PageManager/data/pages/trustee/contracts.ts new file mode 100644 index 0000000..ac0c03a --- /dev/null +++ b/src/core/PageManager/data/pages/trustee/contracts.ts @@ -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'); + } +}; diff --git a/src/core/PageManager/data/pages/trustee/documents.ts b/src/core/PageManager/data/pages/trustee/documents.ts new file mode 100644 index 0000000..f12089e --- /dev/null +++ b/src/core/PageManager/data/pages/trustee/documents.ts @@ -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'); + } +}; diff --git a/src/core/PageManager/data/pages/trustee/index.ts b/src/core/PageManager/data/pages/trustee/index.ts new file mode 100644 index 0000000..6632ccc --- /dev/null +++ b/src/core/PageManager/data/pages/trustee/index.ts @@ -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 +]; diff --git a/src/core/PageManager/data/pages/trustee/organisations.ts b/src/core/PageManager/data/pages/trustee/organisations.ts new file mode 100644 index 0000000..d850e5f --- /dev/null +++ b/src/core/PageManager/data/pages/trustee/organisations.ts @@ -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'); + } +}; diff --git a/src/core/PageManager/data/pages/trustee/positions.ts b/src/core/PageManager/data/pages/trustee/positions.ts new file mode 100644 index 0000000..e927f0f --- /dev/null +++ b/src/core/PageManager/data/pages/trustee/positions.ts @@ -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'); + } +}; diff --git a/src/core/PageManager/data/pages/trustee/roles.ts b/src/core/PageManager/data/pages/trustee/roles.ts new file mode 100644 index 0000000..79b02eb --- /dev/null +++ b/src/core/PageManager/data/pages/trustee/roles.ts @@ -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'); + } +}; diff --git a/src/core/PageManager/data/pages/workflows.ts b/src/core/PageManager/data/pages/workflows.ts index 3fbda95..e605ef7 100644 --- a/src/core/PageManager/data/pages/workflows.ts +++ b/src/core/PageManager/data/pages/workflows.ts @@ -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 diff --git a/src/core/PageManager/pageInterface.ts b/src/core/PageManager/pageInterface.ts index d2c8dd0..147f853 100644 --- a/src/core/PageManager/pageInterface.ts +++ b/src/core/PageManager/pageInterface.ts @@ -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; // 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; // 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; diff --git a/src/hooks/useAdminMandates.ts b/src/hooks/useAdminMandates.ts index 3763f9e..010de50 100644 --- a/src/hooks/useAdminMandates.ts +++ b/src/hooks/useAdminMandates.ts @@ -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) => { + 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 }; } diff --git a/src/hooks/useAdminRbacRoles.ts b/src/hooks/useAdminRbacRoles.ts index 0d3b72f..7260f6b 100644 --- a/src/hooks/useAdminRbacRoles.ts +++ b/src/hooks/useAdminRbacRoles.ts @@ -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) => { + 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 }; } diff --git a/src/hooks/useAdminRbacRules.ts b/src/hooks/useAdminRbacRules.ts index d506dbd..12ca79b 100644 --- a/src/hooks/useAdminRbacRules.ts +++ b/src/hooks/useAdminRbacRules.ts @@ -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) => { + 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 }; } diff --git a/src/hooks/useFiles.ts b/src/hooks/useFiles.ts index 6ae5dda..d551804 100644 --- a/src/hooks/useFiles.ts +++ b/src/hooks/useFiles.ts @@ -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, diff --git a/src/hooks/usePermissions.ts b/src/hooks/usePermissions.ts index 58d1eea..256577d 100644 --- a/src/hooks/usePermissions.ts +++ b/src/hooks/usePermissions.ts @@ -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({}); - const cacheRef = useRef({}); const [loading, setLoading] = useState(false); + const cacheRef = useRef({}); + const bulkLoadPromiseRef = useRef | null>(null); + const bulkLoadedContextsRef = useRef>(new Set()); const pendingRequests = useRef>>(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, - maxRetries = 3, - baseDelay = 1000 - ): Promise => { - 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 => { + // 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 => { 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 => { + 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 => { - 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 => { + 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, }; diff --git a/src/hooks/usePrompts.ts b/src/hooks/usePrompts.ts index ec24da6..dbd8bb7 100644 --- a/src/hooks/usePrompts.ts +++ b/src/hooks/usePrompts.ts @@ -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 }; } \ No newline at end of file diff --git a/src/hooks/useTrustee.ts b/src/hooks/useTrustee.ts new file mode 100644 index 0000000..7aff5a1 --- /dev/null +++ b/src/hooks/useTrustee.ts @@ -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 { + entityName: string; + fetchAll: (request: any, params?: PaginationParams) => Promise; + fetchById: (request: any, id: string) => Promise; + create: (request: any, data: Partial) => Promise; + update: (request: any, id: string, data: Partial) => Promise; + deleteItem: (request: any, id: string) => Promise; +} + +function _createTrusteeEntityHook(config: TrusteeEntityConfig) { + return function useTrusteeEntity() { + const [items, setItems] = useState([]); + const [attributes, setAttributes] = useState([]); + const [permissions, setPermissions] = useState(null); + const [pagination, setPagination] = useState<{ + currentPage: number; + pageSize: number; + totalItems: number; + totalPages: number; + } | null>(null); + const { request, isLoading: loading, error } = useApiRequest(); + 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) => { + setItems(prev => + prev.map(item => + item.id === itemId + ? { ...item, ...updateData } + : item + ) + ); + }; + + const fetchById = useCallback(async (itemId: string): Promise => { + 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(config: TrusteeEntityConfig) { + return function useTrusteeEntityOperations() { + const [deletingItems, setDeletingItems] = useState>(new Set()); + const [creatingItem, setCreatingItem] = useState(false); + const { request, isLoading } = useApiRequest(); + const [deleteError, setDeleteError] = useState(null); + const [createError, setCreateError] = useState(null); + const [updateError, setUpdateError] = useState(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) => { + 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) => { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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); diff --git a/src/hooks/useUsers.ts b/src/hooks/useUsers.ts index fd45a56..c174040 100644 --- a/src/hooks/useUsers.ts +++ b/src/hooks/useUsers.ts @@ -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>(new Set()); const [editingUsers, setEditingUsers] = useState>(new Set()); + const [sendingPasswordLink, setSendingPasswordLink] = useState>(new Set()); const [creatingUser, setCreatingUser] = useState(false); const { request, isLoading } = useApiRequest(); const [deleteError, setDeleteError] = useState(null); const [createError, setCreateError] = useState(null); const [updateError, setUpdateError] = useState(null); + const [passwordLinkError, setPasswordLinkError] = useState(null); const handleUserDelete = async (userId: string) => { setDeleteError(null); @@ -702,7 +835,7 @@ export function useUserOperations() { } }; - const handleUserCreate = async (userData: Omit & { password: string }) => { + const handleUserCreate = async (userData: Omit) => { 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) => { + 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 }; } diff --git a/src/hooks/useWorkflows.ts b/src/hooks/useWorkflows.ts index 2983701..df6eaf9 100644 --- a/src/hooks/useWorkflows.ts +++ b/src/hooks/useWorkflows.ts @@ -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 }>) => { diff --git a/src/locales/de.ts b/src/locales/de.ts index 375e7a8..fbc6384 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -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', }; \ No newline at end of file diff --git a/src/locales/en.ts b/src/locales/en.ts index a847a7d..e753ebf 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -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', }; \ No newline at end of file diff --git a/src/locales/fr.ts b/src/locales/fr.ts index d5df351..3eaf437 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -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', }; \ No newline at end of file diff --git a/src/styles/pages.module.css b/src/styles/pages.module.css index 8db87c3..606ff34 100644 --- a/src/styles/pages.module.css +++ b/src/styles/pages.module.css @@ -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 { diff --git a/src/utils/attributeTypeMapper.ts b/src/utils/attributeTypeMapper.ts index b0c02b7..77c3bbe 100644 --- a/src/utils/attributeTypeMapper.ts +++ b/src/utils/attributeTypeMapper.ts @@ -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; }