Fixed UI issues:
Data: roles & rules: fixed id and pydantic handling ui+gateway Table generic: Fixed column width changing with persistent user width not overwritten by default in the session Table generic: fixed active item in navbar working with subpaths Table generic: fixed sort and pagination with modified routes Table generic: enhanced logic to select page directly and bar on top of the table Table generic: Always load data with pagination, fixed page counter (not updated), enhanced pagination to access pages directly Table generic: Filter function enhanced directly as dropdown in column headers, sorting enhanced to have cascading sorting Table generic: Added multilingual to AttributeType and handle it in the form renderer, removed explicit field definitions from team-members.ts and prompts.ts to use dynamic generation from backend attributes strictly Table generic: Inline editting of checkboxes and boolean values Table generig: Only generic logic, no explicit logic (e.g. id's) Table generic: Removed all specific parts like action icons. To e a parameter by calling instance.
This commit is contained in:
parent
7d2808d22e
commit
b2c38e75bf
55 changed files with 4975 additions and 1274 deletions
|
|
@ -16,6 +16,12 @@ export interface UserPermissions {
|
|||
|
||||
export type PermissionContext = 'DATA' | 'UI' | 'RESOURCE';
|
||||
|
||||
// Response type for bulk permissions fetch
|
||||
export interface BulkPermissionsResponse {
|
||||
ui?: Record<string, UserPermissions>;
|
||||
resource?: Record<string, UserPermissions>;
|
||||
}
|
||||
|
||||
// Type for the request function passed to API functions
|
||||
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
||||
|
||||
|
|
@ -38,32 +44,47 @@ export async function fetchPermissions(
|
|||
params.item = item;
|
||||
}
|
||||
|
||||
console.log('📡 fetchPermissions: Requesting permissions:', {
|
||||
context,
|
||||
item,
|
||||
params,
|
||||
url: '/api/rbac/permissions'
|
||||
});
|
||||
|
||||
const data = await request({
|
||||
url: '/api/rbac/permissions',
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
|
||||
console.log('📥 fetchPermissions: Received permissions response:', {
|
||||
context,
|
||||
item,
|
||||
response: data,
|
||||
view: data?.view,
|
||||
read: data?.read,
|
||||
create: data?.create,
|
||||
update: data?.update,
|
||||
delete: data?.delete,
|
||||
type: typeof data,
|
||||
isArray: Array.isArray(data),
|
||||
keys: data ? Object.keys(data) : [],
|
||||
fullResponse: JSON.stringify(data, null, 2)
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all permissions for a given context (UI or RESOURCE)
|
||||
* Endpoint: GET /api/rbac/permissions/all
|
||||
* Query params: context (optional - if not provided, returns both UI and RESOURCE)
|
||||
*
|
||||
* This is optimized for UI initialization to avoid multiple API calls.
|
||||
* Returns a dictionary of item paths to their permissions.
|
||||
*/
|
||||
export async function fetchAllPermissions(
|
||||
request: ApiRequestFunction,
|
||||
context?: 'UI' | 'RESOURCE'
|
||||
): Promise<BulkPermissionsResponse> {
|
||||
const params: Record<string, string> = {};
|
||||
if (context) {
|
||||
params.context = context;
|
||||
}
|
||||
|
||||
console.log('📡 fetchAllPermissions: Fetching all permissions:', {
|
||||
context: context || 'all',
|
||||
url: '/api/rbac/permissions/all'
|
||||
});
|
||||
|
||||
const data = await request({
|
||||
url: '/api/rbac/permissions/all',
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
|
||||
console.log('📥 fetchAllPermissions: Received bulk permissions:', {
|
||||
context: context || 'all',
|
||||
uiItemCount: data?.ui ? Object.keys(data.ui).length : 0,
|
||||
resourceItemCount: data?.resource ? Object.keys(data.resource).length : 0
|
||||
});
|
||||
|
||||
return data;
|
||||
|
|
|
|||
660
src/api/trusteeApi.ts
Normal file
660
src/api/trusteeApi.ts
Normal file
|
|
@ -0,0 +1,660 @@
|
|||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES & INTERFACES
|
||||
// ============================================================================
|
||||
|
||||
export interface TrusteeOrganisation {
|
||||
id: string;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
mandateId?: string;
|
||||
_createdAt?: number;
|
||||
_modifiedAt?: number;
|
||||
_createdBy?: string;
|
||||
_modifiedBy?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface TrusteeRole {
|
||||
id: string;
|
||||
desc: string;
|
||||
mandateId?: string;
|
||||
_createdAt?: number;
|
||||
_modifiedAt?: number;
|
||||
_createdBy?: string;
|
||||
_modifiedBy?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface TrusteeAccess {
|
||||
id: string;
|
||||
organisationId: string;
|
||||
roleId: string;
|
||||
userId: string;
|
||||
contractId?: string | null;
|
||||
mandateId?: string;
|
||||
_createdAt?: number;
|
||||
_modifiedAt?: number;
|
||||
_createdBy?: string;
|
||||
_modifiedBy?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface TrusteeContract {
|
||||
id: string;
|
||||
organisationId: string;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
mandateId?: string;
|
||||
_createdAt?: number;
|
||||
_modifiedAt?: number;
|
||||
_createdBy?: string;
|
||||
_modifiedBy?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface TrusteeDocument {
|
||||
id: string;
|
||||
organisationId: string;
|
||||
contractId: string;
|
||||
documentName: string;
|
||||
documentMimeType: string;
|
||||
documentData?: any; // Binary data, typically not included in list responses
|
||||
mandateId?: string;
|
||||
_createdAt?: number;
|
||||
_modifiedAt?: number;
|
||||
_createdBy?: string;
|
||||
_modifiedBy?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface TrusteePosition {
|
||||
id: string;
|
||||
organisationId: string;
|
||||
contractId: string;
|
||||
valuta?: string;
|
||||
transactionDateTime?: number;
|
||||
company: string;
|
||||
desc: string;
|
||||
tags: string;
|
||||
bookingCurrency: string;
|
||||
bookingAmount: number;
|
||||
originalCurrency: string;
|
||||
originalAmount: number;
|
||||
vatPercentage: number;
|
||||
vatAmount: number;
|
||||
mandateId?: string;
|
||||
_createdAt?: number;
|
||||
_modifiedAt?: number;
|
||||
_createdBy?: string;
|
||||
_modifiedBy?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface TrusteePositionDocument {
|
||||
id: string;
|
||||
organisationId: string;
|
||||
contractId: string;
|
||||
documentId: string;
|
||||
positionId: string;
|
||||
mandateId?: string;
|
||||
_createdAt?: number;
|
||||
_modifiedAt?: number;
|
||||
_createdBy?: string;
|
||||
_modifiedBy?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface PaginationParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||
filters?: Record<string, any>;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
pagination?: {
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
function _buildPaginationParams(params?: PaginationParams): Record<string, any> {
|
||||
const requestParams: any = {};
|
||||
|
||||
if (params) {
|
||||
const paginationObj: any = {};
|
||||
|
||||
if (params.page !== undefined) paginationObj.page = params.page;
|
||||
if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
|
||||
if (params.sort) paginationObj.sort = params.sort;
|
||||
if (params.filters) paginationObj.filters = params.filters;
|
||||
if (params.search) paginationObj.search = params.search;
|
||||
|
||||
if (Object.keys(paginationObj).length > 0) {
|
||||
requestParams.pagination = JSON.stringify(paginationObj);
|
||||
}
|
||||
}
|
||||
|
||||
return requestParams;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ORGANISATION API
|
||||
// ============================================================================
|
||||
|
||||
export async function fetchOrganisations(
|
||||
request: ApiRequestFunction,
|
||||
params?: PaginationParams
|
||||
): Promise<PaginatedResponse<TrusteeOrganisation> | TrusteeOrganisation[]> {
|
||||
return await request({
|
||||
url: '/api/trustee/organisations',
|
||||
method: 'get',
|
||||
params: _buildPaginationParams(params)
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchOrganisationById(
|
||||
request: ApiRequestFunction,
|
||||
orgId: string
|
||||
): Promise<TrusteeOrganisation | null> {
|
||||
try {
|
||||
return await request({
|
||||
url: `/api/trustee/organisations/${orgId}`,
|
||||
method: 'get'
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching organisation by ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createOrganisation(
|
||||
request: ApiRequestFunction,
|
||||
data: Partial<TrusteeOrganisation>
|
||||
): Promise<TrusteeOrganisation> {
|
||||
return await request({
|
||||
url: '/api/trustee/organisations',
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateOrganisation(
|
||||
request: ApiRequestFunction,
|
||||
orgId: string,
|
||||
data: Partial<TrusteeOrganisation>
|
||||
): Promise<TrusteeOrganisation> {
|
||||
return await request({
|
||||
url: `/api/trustee/organisations/${orgId}`,
|
||||
method: 'put',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteOrganisation(
|
||||
request: ApiRequestFunction,
|
||||
orgId: string
|
||||
): Promise<void> {
|
||||
await request({
|
||||
url: `/api/trustee/organisations/${orgId}`,
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ROLE API
|
||||
// ============================================================================
|
||||
|
||||
export async function fetchRoles(
|
||||
request: ApiRequestFunction,
|
||||
params?: PaginationParams
|
||||
): Promise<PaginatedResponse<TrusteeRole> | TrusteeRole[]> {
|
||||
return await request({
|
||||
url: '/api/trustee/roles',
|
||||
method: 'get',
|
||||
params: _buildPaginationParams(params)
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchRoleById(
|
||||
request: ApiRequestFunction,
|
||||
roleId: string
|
||||
): Promise<TrusteeRole | null> {
|
||||
try {
|
||||
return await request({
|
||||
url: `/api/trustee/roles/${roleId}`,
|
||||
method: 'get'
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching role by ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createRole(
|
||||
request: ApiRequestFunction,
|
||||
data: Partial<TrusteeRole>
|
||||
): Promise<TrusteeRole> {
|
||||
return await request({
|
||||
url: '/api/trustee/roles',
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateRole(
|
||||
request: ApiRequestFunction,
|
||||
roleId: string,
|
||||
data: Partial<TrusteeRole>
|
||||
): Promise<TrusteeRole> {
|
||||
return await request({
|
||||
url: `/api/trustee/roles/${roleId}`,
|
||||
method: 'put',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteRole(
|
||||
request: ApiRequestFunction,
|
||||
roleId: string
|
||||
): Promise<void> {
|
||||
await request({
|
||||
url: `/api/trustee/roles/${roleId}`,
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ACCESS API
|
||||
// ============================================================================
|
||||
|
||||
export async function fetchAccess(
|
||||
request: ApiRequestFunction,
|
||||
params?: PaginationParams
|
||||
): Promise<PaginatedResponse<TrusteeAccess> | TrusteeAccess[]> {
|
||||
return await request({
|
||||
url: '/api/trustee/access',
|
||||
method: 'get',
|
||||
params: _buildPaginationParams(params)
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchAccessById(
|
||||
request: ApiRequestFunction,
|
||||
accessId: string
|
||||
): Promise<TrusteeAccess | null> {
|
||||
try {
|
||||
return await request({
|
||||
url: `/api/trustee/access/${accessId}`,
|
||||
method: 'get'
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching access by ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAccessByOrganisation(
|
||||
request: ApiRequestFunction,
|
||||
orgId: string
|
||||
): Promise<TrusteeAccess[]> {
|
||||
return await request({
|
||||
url: `/api/trustee/access/organisation/${orgId}`,
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchAccessByUser(
|
||||
request: ApiRequestFunction,
|
||||
userId: string
|
||||
): Promise<TrusteeAccess[]> {
|
||||
return await request({
|
||||
url: `/api/trustee/access/user/${userId}`,
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
export async function createAccess(
|
||||
request: ApiRequestFunction,
|
||||
data: Partial<TrusteeAccess>
|
||||
): Promise<TrusteeAccess> {
|
||||
return await request({
|
||||
url: '/api/trustee/access',
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateAccess(
|
||||
request: ApiRequestFunction,
|
||||
accessId: string,
|
||||
data: Partial<TrusteeAccess>
|
||||
): Promise<TrusteeAccess> {
|
||||
return await request({
|
||||
url: `/api/trustee/access/${accessId}`,
|
||||
method: 'put',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteAccess(
|
||||
request: ApiRequestFunction,
|
||||
accessId: string
|
||||
): Promise<void> {
|
||||
await request({
|
||||
url: `/api/trustee/access/${accessId}`,
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CONTRACT API
|
||||
// ============================================================================
|
||||
|
||||
export async function fetchContracts(
|
||||
request: ApiRequestFunction,
|
||||
params?: PaginationParams
|
||||
): Promise<PaginatedResponse<TrusteeContract> | TrusteeContract[]> {
|
||||
return await request({
|
||||
url: '/api/trustee/contracts',
|
||||
method: 'get',
|
||||
params: _buildPaginationParams(params)
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchContractById(
|
||||
request: ApiRequestFunction,
|
||||
contractId: string
|
||||
): Promise<TrusteeContract | null> {
|
||||
try {
|
||||
return await request({
|
||||
url: `/api/trustee/contracts/${contractId}`,
|
||||
method: 'get'
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching contract by ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchContractsByOrganisation(
|
||||
request: ApiRequestFunction,
|
||||
orgId: string
|
||||
): Promise<TrusteeContract[]> {
|
||||
return await request({
|
||||
url: `/api/trustee/contracts/organisation/${orgId}`,
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
export async function createContract(
|
||||
request: ApiRequestFunction,
|
||||
data: Partial<TrusteeContract>
|
||||
): Promise<TrusteeContract> {
|
||||
return await request({
|
||||
url: '/api/trustee/contracts',
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateContract(
|
||||
request: ApiRequestFunction,
|
||||
contractId: string,
|
||||
data: Partial<TrusteeContract>
|
||||
): Promise<TrusteeContract> {
|
||||
return await request({
|
||||
url: `/api/trustee/contracts/${contractId}`,
|
||||
method: 'put',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteContract(
|
||||
request: ApiRequestFunction,
|
||||
contractId: string
|
||||
): Promise<void> {
|
||||
await request({
|
||||
url: `/api/trustee/contracts/${contractId}`,
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DOCUMENT API
|
||||
// ============================================================================
|
||||
|
||||
export async function fetchDocuments(
|
||||
request: ApiRequestFunction,
|
||||
params?: PaginationParams
|
||||
): Promise<PaginatedResponse<TrusteeDocument> | TrusteeDocument[]> {
|
||||
return await request({
|
||||
url: '/api/trustee/documents',
|
||||
method: 'get',
|
||||
params: _buildPaginationParams(params)
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchDocumentById(
|
||||
request: ApiRequestFunction,
|
||||
documentId: string
|
||||
): Promise<TrusteeDocument | null> {
|
||||
try {
|
||||
return await request({
|
||||
url: `/api/trustee/documents/${documentId}`,
|
||||
method: 'get'
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching document by ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchDocumentsByContract(
|
||||
request: ApiRequestFunction,
|
||||
contractId: string
|
||||
): Promise<TrusteeDocument[]> {
|
||||
return await request({
|
||||
url: `/api/trustee/documents/contract/${contractId}`,
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
export async function createDocument(
|
||||
request: ApiRequestFunction,
|
||||
data: Partial<TrusteeDocument>
|
||||
): Promise<TrusteeDocument> {
|
||||
return await request({
|
||||
url: '/api/trustee/documents',
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateDocument(
|
||||
request: ApiRequestFunction,
|
||||
documentId: string,
|
||||
data: Partial<TrusteeDocument>
|
||||
): Promise<TrusteeDocument> {
|
||||
return await request({
|
||||
url: `/api/trustee/documents/${documentId}`,
|
||||
method: 'put',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteDocument(
|
||||
request: ApiRequestFunction,
|
||||
documentId: string
|
||||
): Promise<void> {
|
||||
await request({
|
||||
url: `/api/trustee/documents/${documentId}`,
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// POSITION API
|
||||
// ============================================================================
|
||||
|
||||
export async function fetchPositions(
|
||||
request: ApiRequestFunction,
|
||||
params?: PaginationParams
|
||||
): Promise<PaginatedResponse<TrusteePosition> | TrusteePosition[]> {
|
||||
return await request({
|
||||
url: '/api/trustee/positions',
|
||||
method: 'get',
|
||||
params: _buildPaginationParams(params)
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchPositionById(
|
||||
request: ApiRequestFunction,
|
||||
positionId: string
|
||||
): Promise<TrusteePosition | null> {
|
||||
try {
|
||||
return await request({
|
||||
url: `/api/trustee/positions/${positionId}`,
|
||||
method: 'get'
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching position by ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPositionsByContract(
|
||||
request: ApiRequestFunction,
|
||||
contractId: string
|
||||
): Promise<TrusteePosition[]> {
|
||||
return await request({
|
||||
url: `/api/trustee/positions/contract/${contractId}`,
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchPositionsByOrganisation(
|
||||
request: ApiRequestFunction,
|
||||
orgId: string
|
||||
): Promise<TrusteePosition[]> {
|
||||
return await request({
|
||||
url: `/api/trustee/positions/organisation/${orgId}`,
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
export async function createPosition(
|
||||
request: ApiRequestFunction,
|
||||
data: Partial<TrusteePosition>
|
||||
): Promise<TrusteePosition> {
|
||||
return await request({
|
||||
url: '/api/trustee/positions',
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export async function updatePosition(
|
||||
request: ApiRequestFunction,
|
||||
positionId: string,
|
||||
data: Partial<TrusteePosition>
|
||||
): Promise<TrusteePosition> {
|
||||
return await request({
|
||||
url: `/api/trustee/positions/${positionId}`,
|
||||
method: 'put',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export async function deletePosition(
|
||||
request: ApiRequestFunction,
|
||||
positionId: string
|
||||
): Promise<void> {
|
||||
await request({
|
||||
url: `/api/trustee/positions/${positionId}`,
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// POSITION-DOCUMENT API
|
||||
// ============================================================================
|
||||
|
||||
export async function fetchPositionDocuments(
|
||||
request: ApiRequestFunction,
|
||||
params?: PaginationParams
|
||||
): Promise<PaginatedResponse<TrusteePositionDocument> | TrusteePositionDocument[]> {
|
||||
return await request({
|
||||
url: '/api/trustee/position-documents',
|
||||
method: 'get',
|
||||
params: _buildPaginationParams(params)
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchPositionDocumentById(
|
||||
request: ApiRequestFunction,
|
||||
linkId: string
|
||||
): Promise<TrusteePositionDocument | null> {
|
||||
try {
|
||||
return await request({
|
||||
url: `/api/trustee/position-documents/${linkId}`,
|
||||
method: 'get'
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching position-document link by ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchDocumentsForPosition(
|
||||
request: ApiRequestFunction,
|
||||
positionId: string
|
||||
): Promise<TrusteePositionDocument[]> {
|
||||
return await request({
|
||||
url: `/api/trustee/position-documents/position/${positionId}`,
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchPositionsForDocument(
|
||||
request: ApiRequestFunction,
|
||||
documentId: string
|
||||
): Promise<TrusteePositionDocument[]> {
|
||||
return await request({
|
||||
url: `/api/trustee/position-documents/document/${documentId}`,
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
export async function createPositionDocument(
|
||||
request: ApiRequestFunction,
|
||||
data: Partial<TrusteePositionDocument>
|
||||
): Promise<TrusteePositionDocument> {
|
||||
return await request({
|
||||
url: '/api/trustee/position-documents',
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export async function deletePositionDocument(
|
||||
request: ApiRequestFunction,
|
||||
linkId: string
|
||||
): Promise<void> {
|
||||
await request({
|
||||
url: `/api/trustee/position-documents/${linkId}`,
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
|
|
@ -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 }
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,119 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import { IoIosLink } from 'react-icons/io';
|
||||
import { IoIosRefresh } from 'react-icons/io';
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
import styles from '../ActionButton.module.css';
|
||||
|
||||
export interface ConnectActionButtonProps<T = any> {
|
||||
row: T;
|
||||
disabled?: boolean | { disabled: boolean; message?: string };
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
title?: string;
|
||||
connectTitle?: string;
|
||||
refreshTitle?: string;
|
||||
hookData: any; // REQUIRED: Contains all hook data including operations
|
||||
// Field mappings
|
||||
idField?: string; // Field name for the unique identifier
|
||||
statusField?: string; // Field name for the status field
|
||||
operationName?: string; // Name of the connect operation in hookData
|
||||
loadingStateName?: string; // Name of the loading state in hookData
|
||||
}
|
||||
|
||||
export function ConnectActionButton<T = any>({
|
||||
row,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
className = '',
|
||||
title,
|
||||
connectTitle,
|
||||
refreshTitle,
|
||||
hookData,
|
||||
idField = 'id',
|
||||
statusField = 'status',
|
||||
operationName = 'connectWithPopup',
|
||||
loadingStateName = 'isConnecting'
|
||||
}: ConnectActionButtonProps<T>) {
|
||||
const { t } = useLanguage();
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
// Extract disabled state and tooltip message
|
||||
const isDisabled = typeof disabled === 'boolean' ? disabled : disabled?.disabled || false;
|
||||
const disabledMessage = typeof disabled === 'object' ? disabled?.message : undefined;
|
||||
|
||||
// Validate that hookData is provided with required operations
|
||||
if (!hookData) {
|
||||
throw new Error('ConnectActionButton requires hookData to be provided');
|
||||
}
|
||||
|
||||
// Get the connection data from the row
|
||||
const connectionStatus = (row as any)[statusField];
|
||||
const connectionId = (row as any)[idField];
|
||||
const isActive = connectionStatus === 'active';
|
||||
|
||||
// Extract operations from hookData
|
||||
const handleConnect = hookData[operationName];
|
||||
const refetch = hookData.refetch;
|
||||
const loadingState = hookData[loadingStateName];
|
||||
|
||||
// Validate required operations exist
|
||||
if (!handleConnect) {
|
||||
throw new Error(`ConnectActionButton requires hookData.${operationName} to be defined`);
|
||||
}
|
||||
if (!refetch) {
|
||||
throw new Error('ConnectActionButton requires hookData.refetch to be defined');
|
||||
}
|
||||
|
||||
const handleClick = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!isDisabled && !loading && !isProcessing) {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
// Always use the connect operation for both active and inactive connections
|
||||
// The backend will handle refreshing tokens for active connections
|
||||
if (handleConnect) {
|
||||
await handleConnect(connectionId);
|
||||
}
|
||||
|
||||
// Refetch to update the connection status
|
||||
if (refetch) {
|
||||
await refetch();
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Connection operation failed:', error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Determine button title and icon based on connection status
|
||||
const defaultTitle = isActive
|
||||
? (refreshTitle || t('connections.action.refresh', 'Refresh'))
|
||||
: (connectTitle || t('connections.action.connect', 'Connect'));
|
||||
|
||||
const buttonTitle = title || defaultTitle;
|
||||
const buttonIcon = isActive ? <IoIosRefresh /> : <IoIosLink />;
|
||||
|
||||
// Check if this specific connection is being processed
|
||||
const isLoadingState = loadingState === true || isProcessing;
|
||||
|
||||
// Determine the final button title (tooltip)
|
||||
const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={`${styles.actionButton} ${isActive ? styles.refresh : styles.connect} ${isLoadingState ? styles.loading : ''} ${isDisabled ? styles.disabled : ''} ${className}`}
|
||||
title={finalTitle}
|
||||
disabled={isDisabled || loading || isLoadingState}
|
||||
>
|
||||
<span className={styles.actionIcon}>
|
||||
{isLoadingState ? <IoIosRefresh /> : buttonIcon}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConnectActionButton;
|
||||
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export { default as ConnectActionButton } from './ConnectActionButton';
|
||||
export type { ConnectActionButtonProps } from './ConnectActionButton';
|
||||
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
import styles from '../ActionButton.module.css';
|
||||
|
||||
export interface CustomActionButtonProps<T = any> {
|
||||
row: T;
|
||||
id: string; // Unique identifier for the action
|
||||
icon: React.ReactNode; // Icon component to display
|
||||
onClick: (row: T, hookData?: any) => Promise<void> | void; // Handler function
|
||||
visible?: (row: T, hookData?: any) => boolean; // Show/hide based on row data
|
||||
disabled?: (row: T, hookData?: any) => boolean | { disabled: boolean; message?: string };
|
||||
loading?: (row: T, hookData?: any) => boolean;
|
||||
title?: string | ((row: T) => string); // Tooltip text or translation key
|
||||
className?: string; // Optional custom CSS class
|
||||
hookData?: any; // Hook data passed through for context
|
||||
idField?: string; // Field name for unique identifier (default: 'id')
|
||||
}
|
||||
|
||||
export function CustomActionButton<T = any>({
|
||||
row,
|
||||
id,
|
||||
icon,
|
||||
onClick,
|
||||
visible,
|
||||
disabled,
|
||||
loading,
|
||||
title,
|
||||
className = '',
|
||||
hookData,
|
||||
idField: _idField = 'id' // Available for future use, kept for API consistency
|
||||
}: CustomActionButtonProps<T>) {
|
||||
const { t } = useLanguage();
|
||||
const [internalLoading, setInternalLoading] = useState(false);
|
||||
const [showSuccessFeedback, setShowSuccessFeedback] = useState(false);
|
||||
const [showErrorFeedback, setShowErrorFeedback] = useState(false);
|
||||
|
||||
// Check visibility - if not visible, don't render
|
||||
if (visible && !visible(row, hookData)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract disabled state and tooltip message
|
||||
const disabledResult = disabled ? disabled(row, hookData) : false;
|
||||
const isDisabled = typeof disabledResult === 'boolean' ? disabledResult : disabledResult?.disabled || false;
|
||||
const disabledMessage = typeof disabledResult === 'object' ? disabledResult?.message : undefined;
|
||||
|
||||
// Check loading state
|
||||
const isLoadingFromProp = loading ? loading(row, hookData) : false;
|
||||
const isLoading = isLoadingFromProp || internalLoading;
|
||||
|
||||
// Resolve title
|
||||
let buttonTitle = '';
|
||||
if (typeof title === 'function') {
|
||||
buttonTitle = title(row);
|
||||
} else if (typeof title === 'string') {
|
||||
buttonTitle = t(title, title); // Try to translate, fallback to original
|
||||
}
|
||||
|
||||
// Determine the final tooltip
|
||||
const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle;
|
||||
|
||||
const handleClick = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!isDisabled && !isLoading) {
|
||||
setInternalLoading(true);
|
||||
setShowErrorFeedback(false);
|
||||
setShowSuccessFeedback(false);
|
||||
try {
|
||||
await onClick(row, hookData);
|
||||
// Show brief success feedback
|
||||
setShowSuccessFeedback(true);
|
||||
setTimeout(() => setShowSuccessFeedback(false), 2000);
|
||||
} catch (error) {
|
||||
console.error(`CustomActionButton (${id}): Action failed:`, error);
|
||||
setShowErrorFeedback(true);
|
||||
setTimeout(() => setShowErrorFeedback(false), 3000);
|
||||
} finally {
|
||||
setInternalLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Determine icon to show based on state
|
||||
let displayIcon: React.ReactNode = icon;
|
||||
if (isLoading) {
|
||||
displayIcon = '⏳';
|
||||
} else if (showSuccessFeedback) {
|
||||
displayIcon = '✓';
|
||||
} else if (showErrorFeedback) {
|
||||
displayIcon = '✗';
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={`${styles.actionButton} ${styles.custom} ${isLoading ? styles.loading : ''} ${isDisabled ? styles.disabled : ''} ${showSuccessFeedback ? styles.success : ''} ${showErrorFeedback ? styles.error : ''} ${className}`}
|
||||
title={finalTitle}
|
||||
disabled={isDisabled || isLoading}
|
||||
data-action-id={id}
|
||||
>
|
||||
<span className={styles.actionIcon}>
|
||||
{displayIcon}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomActionButton;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { CustomActionButton } from './CustomActionButton';
|
||||
export type { CustomActionButtonProps } from './CustomActionButton';
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import { IoIosDownload } from 'react-icons/io';
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
import styles from '../ActionButton.module.css';
|
||||
|
||||
export interface DownloadActionButtonProps<T = any> {
|
||||
row: T;
|
||||
onDownload: (row: T) => Promise<void> | void;
|
||||
disabled?: boolean | { disabled: boolean; message?: string };
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
title?: string;
|
||||
isDownloading?: boolean;
|
||||
hookData?: any; // Contains all hook data including operations
|
||||
// Field mappings
|
||||
idField?: string; // Field name for the unique identifier
|
||||
nameField?: string; // Field name for file name (with extension)
|
||||
loadingStateName?: string; // Name of the loading state in hookData
|
||||
operationName?: string; // Name of the operation function in hookData
|
||||
}
|
||||
|
||||
export function DownloadActionButton<T = any>({
|
||||
row,
|
||||
onDownload,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
className = '',
|
||||
title,
|
||||
isDownloading = false,
|
||||
hookData,
|
||||
idField = 'id',
|
||||
nameField,
|
||||
loadingStateName = 'downloadingFiles',
|
||||
operationName
|
||||
}: DownloadActionButtonProps<T>) {
|
||||
const { t } = useLanguage();
|
||||
const [internalLoading, setInternalLoading] = useState(false);
|
||||
|
||||
// Extract disabled state and tooltip message
|
||||
const isDisabled = typeof disabled === 'boolean' ? disabled : disabled?.disabled || false;
|
||||
const disabledMessage = typeof disabled === 'object' ? disabled?.message : undefined;
|
||||
|
||||
// Extract file name from row using nameField or fallback to common field names
|
||||
const getFileName = (): string => {
|
||||
const rowAny = row as any;
|
||||
|
||||
// If nameField is explicitly provided, use it
|
||||
if (nameField && rowAny[nameField]) {
|
||||
return rowAny[nameField];
|
||||
}
|
||||
|
||||
// Try common field names in order of preference
|
||||
if (rowAny.fileName) return rowAny.fileName;
|
||||
if (rowAny.file_name) return rowAny.file_name;
|
||||
if (rowAny.name) return rowAny.name;
|
||||
|
||||
// Fallback: try to find any field that might contain the file name
|
||||
const possibleFields = ['fileName', 'file_name', 'name', 'filename'];
|
||||
for (const field of possibleFields) {
|
||||
if (rowAny[field]) {
|
||||
return rowAny[field];
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: use id or a default
|
||||
return rowAny[idField] || 'download';
|
||||
};
|
||||
|
||||
const handleClick = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!isDisabled && !loading && !isDownloading && !internalLoading) {
|
||||
setInternalLoading(true);
|
||||
try {
|
||||
// If operationName is provided and hookData is available, use the hook function
|
||||
if (operationName && hookData && hookData[operationName]) {
|
||||
const fileId = (row as any)[idField];
|
||||
const fileName = getFileName();
|
||||
await hookData[operationName](fileId, fileName);
|
||||
} else if (onDownload) {
|
||||
// Fallback to the provided onDownload function
|
||||
await onDownload(row);
|
||||
} else {
|
||||
console.error('No download function available');
|
||||
}
|
||||
} finally {
|
||||
setInternalLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const buttonTitle = title || t('files.action.download', 'Download');
|
||||
// Use hookData downloading state if available, otherwise use passed isDownloading
|
||||
const loadingState = hookData?.[loadingStateName];
|
||||
const actualIsDownloading = loadingState?.has((row as any)[idField]) || isDownloading;
|
||||
const isLoading = loading || actualIsDownloading || internalLoading;
|
||||
|
||||
// Determine the final button title (tooltip)
|
||||
const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={`${styles.actionButton} ${styles.download} ${isLoading ? styles.loading : ''} ${isDisabled ? styles.disabled : ''} ${className}`}
|
||||
title={finalTitle}
|
||||
disabled={isDisabled || isLoading}
|
||||
>
|
||||
<span className={styles.actionIcon}>
|
||||
{isLoading ? '⏳' : <IoIosDownload />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default DownloadActionButton;
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { default as DownloadActionButton } from './DownloadActionButton';
|
||||
export type { DownloadActionButtonProps } from './DownloadActionButton';
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
import React from 'react';
|
||||
import { IoIosPlay } from 'react-icons/io';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
import { useWorkflowSelection } from '../../../../contexts/WorkflowSelectionContext';
|
||||
import styles from '../ActionButton.module.css';
|
||||
|
||||
export interface PlayActionButtonProps<T = any> {
|
||||
row: T;
|
||||
onPlay?: (row: T) => Promise<void> | void;
|
||||
disabled?: boolean | { disabled: boolean; message?: string };
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
title?: string;
|
||||
hookData?: any; // Contains all hook data including operations
|
||||
// Field mappings
|
||||
idField?: string; // Field name for the unique identifier
|
||||
nameField?: string; // Field name for display name
|
||||
contentField?: string; // Field name for content (e.g., 'content' for prompts, 'prompt' for workflows)
|
||||
// Navigation
|
||||
navigateTo?: string; // Path to navigate to after selection (default: 'start/dashboard')
|
||||
// Behavior
|
||||
mode?: 'workflow' | 'prompt'; // 'workflow' selects workflow, 'prompt' sets input value
|
||||
}
|
||||
|
||||
export function PlayActionButton<T = any>({
|
||||
row,
|
||||
onPlay,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
className = '',
|
||||
title,
|
||||
hookData: _hookData,
|
||||
idField = 'id',
|
||||
nameField: _nameField = 'name',
|
||||
contentField = 'content',
|
||||
navigateTo = 'start/dashboard',
|
||||
mode = 'prompt'
|
||||
}: PlayActionButtonProps<T>) {
|
||||
const { t } = useLanguage();
|
||||
const navigate = useNavigate();
|
||||
const { selectWorkflow } = useWorkflowSelection();
|
||||
|
||||
// Extract disabled state and tooltip message
|
||||
const isDisabled = typeof disabled === 'boolean' ? disabled : disabled?.disabled || false;
|
||||
const disabledMessage = typeof disabled === 'object' ? disabled?.message : undefined;
|
||||
|
||||
const handleClick = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!isDisabled && !loading) {
|
||||
try {
|
||||
// Call the onPlay callback if provided
|
||||
if (onPlay) {
|
||||
await onPlay(row);
|
||||
}
|
||||
|
||||
if (mode === 'workflow') {
|
||||
// Workflow mode: reset workflow state and select workflow
|
||||
const workflowId = (row as any)[idField];
|
||||
if (!workflowId) {
|
||||
console.error('Workflow ID not found in row');
|
||||
return;
|
||||
}
|
||||
|
||||
// Dispatch event to reset workflow state before selecting new one
|
||||
// This ensures the dashboard resets and loads the selected workflow
|
||||
window.dispatchEvent(new CustomEvent('workflowCleared', {
|
||||
detail: { workflowId: null }
|
||||
}));
|
||||
|
||||
// Select the workflow in context (this will trigger sync in dashboard)
|
||||
selectWorkflow(workflowId);
|
||||
|
||||
// Also dispatch workflowSelected event for any other listeners
|
||||
window.dispatchEvent(new CustomEvent('workflowSelected', {
|
||||
detail: { workflowId }
|
||||
}));
|
||||
} else {
|
||||
// Prompt mode: set input value in dashboard
|
||||
const content = (row as any)[contentField];
|
||||
if (content && typeof content === 'string') {
|
||||
// Dispatch event to set dashboard input value
|
||||
window.dispatchEvent(new CustomEvent('dashboardSetInput', {
|
||||
detail: { value: content }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to dashboard (or specified path)
|
||||
navigate(`/${navigateTo}`);
|
||||
} catch (error: any) {
|
||||
console.error('Error in PlayActionButton:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const buttonTitle = title || (mode === 'workflow'
|
||||
? t('workflows.action.play', 'Play')
|
||||
: t('prompts.action.start', 'Start Prompt'));
|
||||
const isLoading = loading;
|
||||
const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={`${styles.actionButton} ${styles.play} ${isLoading ? styles.loading : ''} ${isDisabled ? styles.disabled : ''} ${className}`}
|
||||
title={finalTitle}
|
||||
disabled={isDisabled || isLoading}
|
||||
>
|
||||
<span className={styles.actionIcon}>
|
||||
{isLoading ? '⏳' : <IoIosPlay />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default PlayActionButton;
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { PlayActionButton } from './PlayActionButton';
|
||||
export type { PlayActionButtonProps } from './PlayActionButton';
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import styles from './FormGeneratorControls.module.css';
|
|||
import { Button } from '../../UiComponents/Button';
|
||||
import { IoIosRefresh } from "react-icons/io";
|
||||
import { FaTrash } from "react-icons/fa";
|
||||
import { isCheckboxType } from '../../../utils/attributeTypeMapper';
|
||||
import type { AttributeType } from '../../../utils/attributeTypeMapper';
|
||||
|
||||
// Generic field/column config interface
|
||||
|
|
@ -26,7 +25,7 @@ export interface FormGeneratorControlsProps {
|
|||
searchFocused: boolean;
|
||||
onSearchFocus: (focused: boolean) => void;
|
||||
|
||||
// Filter state
|
||||
// Filter state (kept for compatibility but not used in this component)
|
||||
filters: Record<string, any>;
|
||||
onFilterChange: (key: string, value: any) => void;
|
||||
filterFocused: Record<string, boolean>;
|
||||
|
|
@ -49,113 +48,29 @@ export interface FormGeneratorControlsProps {
|
|||
selectable?: boolean;
|
||||
loading?: boolean;
|
||||
|
||||
// Special date filter handler (for FormGenerator date formatting)
|
||||
onDateFilterChange?: (key: string, value: string) => void;
|
||||
// Active filters count for display
|
||||
activeFiltersCount?: number;
|
||||
}
|
||||
|
||||
export function FormGeneratorControls({
|
||||
fields,
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
searchFocused,
|
||||
onSearchFocus,
|
||||
filters,
|
||||
onFilterChange,
|
||||
filterFocused,
|
||||
onFilterFocus,
|
||||
selectedCount,
|
||||
displayData,
|
||||
onDeleteSingle,
|
||||
onDeleteMultiple,
|
||||
onRefresh,
|
||||
searchable = true,
|
||||
filterable = true,
|
||||
selectable = true,
|
||||
loading = false,
|
||||
onDateFilterChange
|
||||
activeFiltersCount = 0
|
||||
}: FormGeneratorControlsProps) {
|
||||
const { t } = useLanguage();
|
||||
|
||||
// Check if all items are selected
|
||||
const allItemsSelected = selectedCount > 0 && displayData.length > 0 && selectedCount === displayData.length;
|
||||
|
||||
// Filter fields that are filterable
|
||||
const filterableFields = fields.filter(field => {
|
||||
if (field.type === 'readonly') return false;
|
||||
return field.filterable !== false;
|
||||
});
|
||||
|
||||
// Handle date filter with special formatting (for FormGenerator)
|
||||
const handleDateFilterChange = (key: string, value: string) => {
|
||||
if (onDateFilterChange) {
|
||||
onDateFilterChange(key, value);
|
||||
return;
|
||||
}
|
||||
// Default behavior for FormGeneratorList
|
||||
onFilterChange(key, value);
|
||||
};
|
||||
|
||||
// Date filter formatting logic (for FormGenerator)
|
||||
const handleDateFilterInput = (key: string, e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
let value = e.target.value;
|
||||
const currentValue = filters[key] || '';
|
||||
|
||||
// Check if user is deleting (new value is shorter)
|
||||
const isDeleting = value.length < currentValue.length;
|
||||
|
||||
if (isDeleting) {
|
||||
// When deleting, preserve the exact input without auto-formatting
|
||||
handleDateFilterChange(key, value);
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-pad single digits followed by dot (e.g., "4." -> "04.")
|
||||
value = value.replace(/^(\d)\./, '0$1.');
|
||||
value = value.replace(/\.(\d)\./, '.0$1.');
|
||||
|
||||
// Allow typing and format as DD.MM.YYYY
|
||||
const digitsOnly = value.replace(/\D/g, ''); // Remove non-digits
|
||||
|
||||
let formatted = '';
|
||||
if (digitsOnly.length >= 8) {
|
||||
// Full format: DDMMYYYY -> DD.MM.YYYY
|
||||
const day = digitsOnly.slice(0, 2);
|
||||
const month = digitsOnly.slice(2, 4);
|
||||
const year = digitsOnly.slice(4, 8);
|
||||
|
||||
// Validate day (01-31) and month (01-12)
|
||||
if (parseInt(day) > 31 || parseInt(month) > 12 || parseInt(day) === 0 || parseInt(month) === 0) {
|
||||
return; // Don't update if invalid
|
||||
}
|
||||
formatted = `${day}.${month}.${year}`;
|
||||
} else if (digitsOnly.length >= 4) {
|
||||
// Partial format: DDMM -> DD.MM.
|
||||
const day = digitsOnly.slice(0, 2);
|
||||
const month = digitsOnly.slice(2, 4);
|
||||
const remaining = digitsOnly.slice(4);
|
||||
|
||||
// Validate day (01-31) and month (01-12)
|
||||
if (parseInt(day) > 31 || parseInt(month) > 12 || parseInt(day) === 0 || parseInt(month) === 0) {
|
||||
return; // Don't update if invalid
|
||||
}
|
||||
formatted = `${day}.${month}.${remaining}`;
|
||||
} else if (digitsOnly.length >= 2) {
|
||||
// Start format: DD -> DD.
|
||||
const day = digitsOnly.slice(0, 2);
|
||||
const remaining = digitsOnly.slice(2);
|
||||
|
||||
// Validate day (01-31)
|
||||
if (parseInt(day) > 31 || parseInt(day) === 0) {
|
||||
return; // Don't update if invalid
|
||||
}
|
||||
formatted = `${day}.${remaining}`;
|
||||
} else {
|
||||
// Just digits
|
||||
formatted = digitsOnly;
|
||||
}
|
||||
|
||||
handleDateFilterChange(key, formatted);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.controls}>
|
||||
|
|
@ -204,6 +119,11 @@ export function FormGeneratorControls({
|
|||
{t('formgen.search.placeholder')}
|
||||
</label>
|
||||
</div>
|
||||
{activeFiltersCount > 0 && (
|
||||
<span className={styles.activeFiltersCount}>
|
||||
{activeFiltersCount} {t('formgen.filter.active', 'filter(s)')}
|
||||
</span>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
|
|
@ -216,93 +136,6 @@ export function FormGeneratorControls({
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
{filterable && (
|
||||
<div className={styles.filtersContainer}>
|
||||
{filterableFields.map(field => (
|
||||
<div key={field.key} className={styles.filterGroup}>
|
||||
{field.type && isCheckboxType(field.type) ? (
|
||||
<div className={styles.customSelectContainer}>
|
||||
<select
|
||||
value={filters[field.key] || ''}
|
||||
onChange={(e) => onFilterChange(field.key, e.target.value === '' ? undefined : e.target.value === 'true')}
|
||||
className={`${styles.filterSelect} ${filters[field.key] ? styles.hasValue : ''}`}
|
||||
>
|
||||
<option value="" disabled hidden>{field.label}</option>
|
||||
<option value="true">{t('formgen.filter.yes')}</option>
|
||||
<option value="false">{t('formgen.filter.no')}</option>
|
||||
</select>
|
||||
{filters[field.key] && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFilterChange(field.key, '')}
|
||||
className={styles.clearFilterButton}
|
||||
title={t('formgen.filter.clear')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : field.filterOptions ? (
|
||||
<div className={styles.customSelectContainer}>
|
||||
<select
|
||||
value={filters[field.key] || ''}
|
||||
onChange={(e) => onFilterChange(field.key, e.target.value)}
|
||||
className={`${styles.filterSelect} ${filters[field.key] ? styles.hasValue : ''}`}
|
||||
>
|
||||
<option value="" disabled hidden>{field.label}</option>
|
||||
{field.filterOptions.map(option => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
{filters[field.key] && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFilterChange(field.key, '')}
|
||||
className={styles.clearFilterButton}
|
||||
title={t('formgen.filter.clear')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : field.type === 'date' ? (
|
||||
<div className={styles.floatingLabelInput}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder=" "
|
||||
value={filters[field.key] || ''}
|
||||
onChange={(e) => handleDateFilterInput(field.key, e)}
|
||||
onFocus={() => onFilterFocus(field.key, true)}
|
||||
onBlur={() => onFilterFocus(field.key, false)}
|
||||
className={`${styles.filterInput} ${filterFocused[field.key] || filters[field.key] ? styles.focused : ''}`}
|
||||
maxLength={10}
|
||||
/>
|
||||
<label className={filterFocused[field.key] || filters[field.key] ? styles.focusedLabel : styles.label}>
|
||||
{field.label}
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.floatingLabelInput}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder=" "
|
||||
value={filters[field.key] || ''}
|
||||
onChange={(e) => onFilterChange(field.key, e.target.value)}
|
||||
onFocus={() => onFilterFocus(field.key, true)}
|
||||
onBlur={() => onFilterFocus(field.key, false)}
|
||||
className={`${styles.filterInput} ${filterFocused[field.key] || filters[field.key] ? styles.focused : ''}`}
|
||||
/>
|
||||
<label className={filterFocused[field.key] || filters[field.key] ? styles.focusedLabel : styles.label}>
|
||||
{t('formgen.filter.placeholder').replace('{column}', field.label)}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
isTextareaType,
|
||||
isSelectType,
|
||||
isMultiselectType,
|
||||
isMultilingualType,
|
||||
isCheckboxType,
|
||||
isFileType,
|
||||
isNumberType,
|
||||
|
|
@ -34,11 +35,11 @@ const isMultilingualFieldName = (fieldName: string): boolean => {
|
|||
const exactMultilingualFields = ['description'];
|
||||
|
||||
// Fields that end with these patterns (but not roleLabel, etc.)
|
||||
// Note: "name" is NOT multilingual - Mandate.name and other name fields are plain strings
|
||||
const multilingualPatterns = [
|
||||
/^description$/i,
|
||||
/^label$/i, // Only exact "label", not "roleLabel"
|
||||
/^title$/i, // Only exact "title"
|
||||
/^name$/i // Only exact "name", not field names containing "name"
|
||||
/^title$/i // Only exact "title"
|
||||
];
|
||||
|
||||
// Check exact matches first
|
||||
|
|
@ -220,12 +221,16 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
|||
|
||||
// Initialize form data with defaults
|
||||
useEffect(() => {
|
||||
// Helper to check if a field should be treated as multilingual
|
||||
const isMultilingual = (attr: AttributeDefinition) =>
|
||||
isMultilingualType(attr.type as AttributeType) || isMultilingualFieldName(attr.name);
|
||||
|
||||
if (data) {
|
||||
// Ensure TextMultilingual fields are properly initialized
|
||||
const processedData: any = { ...data };
|
||||
const filteredAttrs = getFilteredAttributes();
|
||||
filteredAttrs.forEach(attr => {
|
||||
if (isMultilingualFieldName(attr.name) && processedData[attr.name]) {
|
||||
if (isMultilingual(attr) && processedData[attr.name]) {
|
||||
// If it's already a TextMultilingual object, keep it
|
||||
if (!isTextMultilingual(processedData[attr.name])) {
|
||||
// If it's a string, convert to TextMultilingual
|
||||
|
|
@ -242,7 +247,7 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
|||
filteredAttrs.forEach(attr => {
|
||||
if (attr.default !== undefined) {
|
||||
initialData[attr.name] = attr.default;
|
||||
} else if (isMultilingualFieldName(attr.name)) {
|
||||
} else if (isMultilingual(attr)) {
|
||||
// Initialize TextMultilingual fields with empty object
|
||||
initialData[attr.name] = { en: '' };
|
||||
} else {
|
||||
|
|
@ -380,8 +385,9 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
|||
|
||||
// Check required fields
|
||||
if (attr.required) {
|
||||
// Special handling for TextMultilingual fields
|
||||
if (isMultilingualFieldName(attr.name) && isTextMultilingual(value)) {
|
||||
// Special handling for TextMultilingual fields (by type or field name)
|
||||
const isMultilingual = isMultilingualType(attr.type as AttributeType) || isMultilingualFieldName(attr.name);
|
||||
if (isMultilingual && isTextMultilingual(value)) {
|
||||
if (!value.en || typeof value.en !== 'string' || value.en.trim() === '') {
|
||||
newErrors[attr.name] = t('formgen.form.required', `${attr.label} (English) is required`);
|
||||
return;
|
||||
|
|
@ -631,8 +637,9 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
|||
const hasError = errors[attr.name];
|
||||
const isReadonly = mode === 'display' || attr.readonly || attr.editable === false;
|
||||
|
||||
// Check if this is a multilingual field
|
||||
if (isMultilingualFieldName(attr.name) && (isTextMultilingual(value) || value === undefined || value === null || value === '')) {
|
||||
// Check if this is a multilingual field - either by type or by field name convention
|
||||
if ((isMultilingualType(attr.type as AttributeType) || isMultilingualFieldName(attr.name)) &&
|
||||
(isTextMultilingual(value) || value === undefined || value === null || value === '')) {
|
||||
return renderMultilingualField(attr);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,12 +5,11 @@ import actionButtonStyles from '../ActionButtons/ActionButton.module.css';
|
|||
import {
|
||||
EditActionButton,
|
||||
DeleteActionButton,
|
||||
DownloadActionButton,
|
||||
ViewActionButton,
|
||||
CopyActionButton,
|
||||
ConnectActionButton,
|
||||
PlayActionButton
|
||||
CustomActionButton
|
||||
} from '../ActionButtons';
|
||||
import { FaDownload, FaLink, FaPlay } from 'react-icons/fa';
|
||||
import { formatUnixTimestamp } from '../../../utils/time';
|
||||
import TextField from '../../UiComponents/TextField/TextField';
|
||||
import { FormGeneratorControls } from '../FormGeneratorControls';
|
||||
|
|
@ -884,22 +883,47 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
case 'delete':
|
||||
return <DeleteActionButton key={actionIndex} {...baseProps} containerRef={{ current: actionButtonsRefs.current.get(index) || null }} hookData={hookData} />;
|
||||
case 'download':
|
||||
return <DownloadActionButton key={actionIndex} {...baseProps} onDownload={actionButton.onAction || (() => {})} isDownloading={isProcessing} hookData={hookData} operationName={actionButton.operationName} />;
|
||||
return <CustomActionButton
|
||||
key={actionIndex}
|
||||
row={row}
|
||||
id="download"
|
||||
icon={<FaDownload />}
|
||||
onClick={actionButton.onAction || (() => {})}
|
||||
disabled={() => disabledResult}
|
||||
loading={() => isProcessing}
|
||||
title={actionTitle}
|
||||
className={actionButton.className}
|
||||
hookData={hookData}
|
||||
/>;
|
||||
case 'view':
|
||||
return <ViewActionButton key={actionIndex} {...baseProps} onView={actionButton.onAction || (() => {})} isViewing={isProcessing} hookData={hookData} />;
|
||||
case 'copy':
|
||||
return <CopyActionButton key={actionIndex} {...baseProps} onCopy={actionButton.onAction} isCopying={isProcessing} contentField={actionButton.contentField} />;
|
||||
case 'connect':
|
||||
return <ConnectActionButton key={actionIndex} {...baseProps} hookData={hookData} />;
|
||||
case 'play':
|
||||
return <PlayActionButton
|
||||
return <CustomActionButton
|
||||
key={actionIndex}
|
||||
{...baseProps}
|
||||
onPlay={actionButton.onAction}
|
||||
hookData={hookData}
|
||||
navigateTo={actionButton.navigateTo}
|
||||
contentField={actionButton.contentField}
|
||||
mode={(actionButton as any).mode || 'prompt'}
|
||||
row={row}
|
||||
id="connect"
|
||||
icon={<FaLink />}
|
||||
onClick={actionButton.onAction || (() => {})}
|
||||
disabled={() => disabledResult}
|
||||
loading={() => isLoading}
|
||||
title={actionTitle}
|
||||
className={actionButton.className}
|
||||
hookData={hookData}
|
||||
/>;
|
||||
case 'play':
|
||||
return <CustomActionButton
|
||||
key={actionIndex}
|
||||
row={row}
|
||||
id="play"
|
||||
icon={<FaPlay />}
|
||||
onClick={actionButton.onAction || (() => {})}
|
||||
disabled={() => disabledResult}
|
||||
loading={() => isLoading}
|
||||
title={actionTitle}
|
||||
className={actionButton.className}
|
||||
hookData={hookData}
|
||||
/>;
|
||||
default:
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,10 +1,95 @@
|
|||
import styles from './SidebarStyles/SidebarSubmenu.module.css';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { SidebarSubmenuProps } from './sidebarTypes';
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import { SidebarSubmenuProps, SidebarSubmenuItemData } from './sidebarTypes';
|
||||
|
||||
// Separate component for submenu item to properly use hooks
|
||||
interface SubmenuItemProps {
|
||||
subitem: SidebarSubmenuItemData;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
const SubmenuItem: React.FC<SubmenuItemProps> = ({ subitem, isActive }) => {
|
||||
const textRef = useRef<HTMLSpanElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isOverflowing, setIsOverflowing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkOverflow = () => {
|
||||
if (textRef.current && containerRef.current) {
|
||||
const textWidth = textRef.current.scrollWidth;
|
||||
const containerWidth = containerRef.current.clientWidth;
|
||||
setIsOverflowing(textWidth > containerWidth);
|
||||
}
|
||||
};
|
||||
|
||||
checkOverflow();
|
||||
// Also check on window resize
|
||||
window.addEventListener('resize', checkOverflow);
|
||||
return () => window.removeEventListener('resize', checkOverflow);
|
||||
}, [subitem.name]);
|
||||
|
||||
const SubIcon = subitem.icon as React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||
|
||||
return (
|
||||
<li className={isActive ? styles.active : ''}>
|
||||
<Link
|
||||
to={subitem.link || '#'}
|
||||
title={subitem.name}
|
||||
className={isActive ? styles.activeLink : ''}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={styles.textContainer}
|
||||
>
|
||||
<motion.span
|
||||
ref={textRef}
|
||||
style={{
|
||||
display: 'block',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
initial={{ x: 0 }}
|
||||
animate={{ x: 0 }}
|
||||
{...(isOverflowing && {
|
||||
whileHover: {
|
||||
x: -(textRef.current?.scrollWidth || 0) + (containerRef.current?.clientWidth || 153),
|
||||
transition: {
|
||||
duration: 2,
|
||||
ease: "linear"
|
||||
}
|
||||
}
|
||||
})}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', paddingRight: '10px' }}>
|
||||
{SubIcon && <SubIcon className={styles.submenuIcon} />}
|
||||
<span style={{ marginLeft: SubIcon ? '8px' : '0' }}>
|
||||
{subitem.name}
|
||||
</span>
|
||||
</div>
|
||||
</motion.span>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const SidebarSubmenu: React.FC<SidebarSubmenuProps> = ({ item, isOpen, isMinimized = false }) => {
|
||||
const location = useLocation();
|
||||
|
||||
// Check if a submenu item is active
|
||||
const isSubmenuItemActive = (itemPath?: string) => {
|
||||
if (!itemPath) return false;
|
||||
const currentPath = location.pathname;
|
||||
// Exact match or prefix match at path segment boundary
|
||||
if (currentPath === itemPath) return true;
|
||||
if (currentPath.startsWith(itemPath)) {
|
||||
const nextChar = currentPath[itemPath.length];
|
||||
if (nextChar === '/' || nextChar === undefined) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (!item.submenu) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -42,13 +127,14 @@ const SidebarSubmenu: React.FC<SidebarSubmenuProps> = ({ item, isOpen, isMinimiz
|
|||
<ul className={styles.submenuHorizontalList}>
|
||||
{item.submenu.map(subitem => {
|
||||
const SubIcon = subitem.icon as React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||
const isActive = isSubmenuItemActive(subitem.link);
|
||||
|
||||
return (
|
||||
<li key={subitem.id} className={styles.submenuHorizontalItem}>
|
||||
<li key={subitem.id} className={`${styles.submenuHorizontalItem} ${isActive ? styles.active : ''}`}>
|
||||
<Link
|
||||
to={subitem.link || '#'}
|
||||
title={subitem.name}
|
||||
className={styles.submenuHorizontalLink}
|
||||
className={`${styles.submenuHorizontalLink} ${isActive ? styles.activeLink : ''}`}
|
||||
>
|
||||
{SubIcon && (
|
||||
<SubIcon
|
||||
|
|
@ -56,7 +142,7 @@ const SidebarSubmenu: React.FC<SidebarSubmenuProps> = ({ item, isOpen, isMinimiz
|
|||
style={{
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
color: '#181818',
|
||||
color: isActive ? 'white' : '#181818',
|
||||
display: 'block'
|
||||
}}
|
||||
/>
|
||||
|
|
@ -77,68 +163,13 @@ const SidebarSubmenu: React.FC<SidebarSubmenuProps> = ({ item, isOpen, isMinimiz
|
|||
exit={{ opacity: 0, transition: { duration: 0.25, delay: 0 } }}
|
||||
>
|
||||
<ul className={styles.submenuList}>
|
||||
{item.submenu.map(subitem => {
|
||||
const textRef = useRef<HTMLSpanElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isOverflowing, setIsOverflowing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkOverflow = () => {
|
||||
if (textRef.current && containerRef.current) {
|
||||
const textWidth = textRef.current.scrollWidth;
|
||||
const containerWidth = containerRef.current.clientWidth;
|
||||
setIsOverflowing(textWidth > containerWidth);
|
||||
}
|
||||
};
|
||||
|
||||
checkOverflow();
|
||||
// Also check on window resize
|
||||
window.addEventListener('resize', checkOverflow);
|
||||
return () => window.removeEventListener('resize', checkOverflow);
|
||||
}, [subitem.name]);
|
||||
|
||||
const SubIcon = subitem.icon as React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||
|
||||
return (
|
||||
<li key={subitem.id}>
|
||||
<Link
|
||||
to={subitem.link || '#'}
|
||||
title={subitem.name}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={styles.textContainer}
|
||||
>
|
||||
<motion.span
|
||||
ref={textRef}
|
||||
style={{
|
||||
display: 'block',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
initial={{ x: 0 }}
|
||||
animate={{ x: 0 }}
|
||||
{...(isOverflowing && {
|
||||
whileHover: {
|
||||
x: -(textRef.current?.scrollWidth || 0) + (containerRef.current?.clientWidth || 153),
|
||||
transition: {
|
||||
duration: 2,
|
||||
ease: "linear"
|
||||
}
|
||||
}
|
||||
})}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', paddingRight: '10px' }}>
|
||||
{SubIcon && <SubIcon className={styles.submenuIcon} />}
|
||||
<span style={{ marginLeft: SubIcon ? '8px' : '0' }}>
|
||||
{subitem.name}
|
||||
</span>
|
||||
</div>
|
||||
</motion.span>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{item.submenu.map(subitem => (
|
||||
<SubmenuItem
|
||||
key={subitem.id}
|
||||
subitem={subitem}
|
||||
isActive={isSubmenuItemActive(subitem.link)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -4,11 +4,10 @@ import {
|
|||
DeleteActionButton,
|
||||
RemoveActionButton,
|
||||
EditActionButton,
|
||||
DownloadActionButton,
|
||||
CopyActionButton,
|
||||
ConnectActionButton,
|
||||
PlayActionButton
|
||||
CustomActionButton
|
||||
} from '../../FormGenerator/ActionButtons';
|
||||
import { FaDownload, FaLink, FaPlay } from 'react-icons/fa';
|
||||
import { WorkflowFile } from '../../../hooks/usePlayground';
|
||||
import styles from './ConnectedFilesList.module.css';
|
||||
|
||||
|
|
@ -303,12 +302,16 @@ export function ConnectedFilesList({
|
|||
{...baseProps}
|
||||
/>;
|
||||
case 'download':
|
||||
return <DownloadActionButton
|
||||
return <CustomActionButton
|
||||
key={actionIndex}
|
||||
{...baseProps}
|
||||
onDownload={actionButton.onAction || (() => {})}
|
||||
isDownloading={isProcessing}
|
||||
operationName={actionButton.operationName}
|
||||
row={file}
|
||||
id="download"
|
||||
icon={<FaDownload />}
|
||||
onClick={actionButton.onAction || (() => {})}
|
||||
disabled={() => disabledResult}
|
||||
loading={() => isProcessing}
|
||||
title={actionTitle}
|
||||
className={actionButton.className}
|
||||
/>;
|
||||
case 'view':
|
||||
return <ViewActionButton
|
||||
|
|
@ -326,18 +329,28 @@ export function ConnectedFilesList({
|
|||
contentField={actionButton.contentField}
|
||||
/>;
|
||||
case 'connect':
|
||||
return <ConnectActionButton
|
||||
return <CustomActionButton
|
||||
key={actionIndex}
|
||||
{...baseProps}
|
||||
row={file}
|
||||
id="connect"
|
||||
icon={<FaLink />}
|
||||
onClick={actionButton.onAction || (() => {})}
|
||||
disabled={() => disabledResult}
|
||||
loading={() => isLoading}
|
||||
title={actionTitle}
|
||||
className={actionButton.className}
|
||||
/>;
|
||||
case 'play':
|
||||
return <PlayActionButton
|
||||
return <CustomActionButton
|
||||
key={actionIndex}
|
||||
{...baseProps}
|
||||
onPlay={actionButton.onAction}
|
||||
navigateTo={actionButton.navigateTo}
|
||||
contentField={actionButton.contentField}
|
||||
mode={(actionButton as any).mode || 'prompt'}
|
||||
row={file}
|
||||
id="play"
|
||||
icon={<FaPlay />}
|
||||
onClick={actionButton.onAction || (() => {})}
|
||||
disabled={() => disabledResult}
|
||||
loading={() => isLoading}
|
||||
title={actionTitle}
|
||||
className={actionButton.className}
|
||||
/>;
|
||||
case 'remove':
|
||||
return <RemoveActionButton
|
||||
|
|
|
|||
|
|
@ -924,7 +924,7 @@ const PageRenderer: React.FC<PageRendererProps> = ({
|
|||
|
||||
if (content.tableConfig && currentTableHookData) {
|
||||
|
||||
const { columns: configColumns, actionButtons, emptyMessage, ...tableProps } = content.tableConfig;
|
||||
const { columns: configColumns, actionButtons, customActions, emptyMessage, ...tableProps } = content.tableConfig;
|
||||
|
||||
// Only show loading spinner on initial load (when there's no data yet)
|
||||
// During refetch, keep the existing data visible
|
||||
|
|
@ -972,8 +972,9 @@ const PageRenderer: React.FC<PageRendererProps> = ({
|
|||
}
|
||||
|
||||
// Determine which permission to check based on button type
|
||||
// Only standard action types: edit, delete, view, copy
|
||||
let requiredPermission: 'read' | 'create' | 'update' | 'delete' | null = null;
|
||||
if (action.type === 'view' || action.type === 'play') {
|
||||
if (action.type === 'view') {
|
||||
requiredPermission = 'read';
|
||||
} else if (action.type === 'edit') {
|
||||
requiredPermission = 'update';
|
||||
|
|
@ -1055,9 +1056,7 @@ const PageRenderer: React.FC<PageRendererProps> = ({
|
|||
contentField: action.contentField,
|
||||
operationName: action.operationName,
|
||||
loadingStateName: action.loadingStateName,
|
||||
// Navigation and behavior (for play button)
|
||||
navigateTo: action.navigateTo,
|
||||
mode: action.mode
|
||||
fetchItemFunctionName: action.fetchItemFunctionName
|
||||
};
|
||||
}) || [];
|
||||
|
||||
|
|
@ -1085,6 +1084,21 @@ const PageRenderer: React.FC<PageRendererProps> = ({
|
|||
columns={resolvedColumns}
|
||||
loading={showLoadingSpinner}
|
||||
actionButtons={formGeneratorActions}
|
||||
customActions={customActions?.map(action => {
|
||||
// Resolve LanguageText in title to string
|
||||
let resolvedTitle: string | ((row: any) => string) | undefined = undefined;
|
||||
if (typeof action.title === 'function') {
|
||||
resolvedTitle = action.title;
|
||||
} else if (typeof action.title === 'string') {
|
||||
resolvedTitle = resolveLanguageText(action.title, t);
|
||||
} else if (action.title && typeof action.title === 'object') {
|
||||
resolvedTitle = resolveLanguageText(action.title as any, t);
|
||||
}
|
||||
return {
|
||||
...action,
|
||||
title: resolvedTitle
|
||||
};
|
||||
})}
|
||||
hookData={currentTableHookData}
|
||||
onDelete={currentTableHookData.onDelete}
|
||||
onDeleteMultiple={currentTableHookData.onDeleteMultiple}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@ import { allPageData, SidebarItem } from './data';
|
|||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import { resolveLanguageText } from './pageInterface';
|
||||
import { usePermissions } from '../../hooks/usePermissions';
|
||||
import { getUserDataCache } from '../../utils/userCache';
|
||||
import { FaHome, FaHatWizard } from 'react-icons/fa';
|
||||
import { FaHome, FaHatWizard, FaBriefcase } from 'react-icons/fa';
|
||||
import { RiFolderSettingsFill } from 'react-icons/ri';
|
||||
|
||||
// Configuration for parent groups that don't have a page definition
|
||||
|
|
@ -17,13 +16,17 @@ const parentGroupConfig: Record<string, {
|
|||
icon: FaHome,
|
||||
defaultOrder: 1
|
||||
},
|
||||
'trustee': {
|
||||
icon: FaBriefcase,
|
||||
defaultOrder: 2
|
||||
},
|
||||
'administration': {
|
||||
icon: RiFolderSettingsFill,
|
||||
defaultOrder: 2
|
||||
defaultOrder: 3
|
||||
},
|
||||
'admin': {
|
||||
icon: FaHatWizard,
|
||||
defaultOrder: 3
|
||||
defaultOrder: 4
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -55,7 +58,7 @@ export const SidebarProvider: React.FC<SidebarProviderProps> = ({ children }) =>
|
|||
|
||||
// Get translation function from language context
|
||||
const { t } = useLanguage();
|
||||
const { canView } = usePermissions();
|
||||
const { canView, preloadUiPermissions } = usePermissions();
|
||||
|
||||
// Get sidebar items from page data
|
||||
const getSidebarItems = async (): Promise<SidebarItem[]> => {
|
||||
|
|
@ -181,34 +184,13 @@ export const SidebarProvider: React.FC<SidebarProviderProps> = ({ children }) =>
|
|||
.filter(page => !page.parentPath && !page.hide && page.showInSidebar !== false)
|
||||
.sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||
|
||||
// Log user info for debugging
|
||||
const cachedUser = getUserDataCache();
|
||||
console.log('👤 SidebarProvider: Current user info:', {
|
||||
username: cachedUser?.username,
|
||||
roleLabels: cachedUser?.roleLabels,
|
||||
roleLabelsLength: Array.isArray(cachedUser?.roleLabels) ? cachedUser.roleLabels.length : 0
|
||||
});
|
||||
|
||||
// Process each main page
|
||||
console.log('📋 SidebarProvider: Processing pages, total:', mainPages.length, 'pages to check');
|
||||
const pageAccessResults: Array<{ path: string; name: string; hasAccess: boolean }> = [];
|
||||
// Process each main page (permissions already bulk-loaded)
|
||||
for (const pageData of mainPages) {
|
||||
console.log('🔍 SidebarProvider: Checking access for page:', {
|
||||
path: pageData.path,
|
||||
name: pageData.name,
|
||||
hasSubpages: pageData.hasSubpages
|
||||
});
|
||||
|
||||
// Check RBAC permissions
|
||||
// Check RBAC permissions (from cache - no API call)
|
||||
try {
|
||||
const hasRBACAccess = await canView('UI', pageData.path);
|
||||
console.log('🔍 SidebarProvider: RBAC check result:', {
|
||||
path: pageData.path,
|
||||
hasAccess: hasRBACAccess
|
||||
});
|
||||
|
||||
if (!hasRBACAccess) {
|
||||
console.log('⛔ SidebarProvider: Page hidden due to RBAC:', pageData.path);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -217,16 +199,15 @@ export const SidebarProvider: React.FC<SidebarProviderProps> = ({ children }) =>
|
|||
try {
|
||||
const hasPrivilege = await pageData.privilegeChecker();
|
||||
if (!hasPrivilege) {
|
||||
console.log('⛔ SidebarProvider: Page hidden due to privilegeChecker:', pageData.path);
|
||||
continue;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ SidebarProvider: Error checking privilegeChecker for ${pageData.path}:`, error);
|
||||
console.error(`Error checking privilegeChecker for ${pageData.path}:`, error);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ SidebarProvider: Error checking RBAC access for ${pageData.path}:`, error);
|
||||
console.error(`Error checking RBAC access for ${pageData.path}:`, error);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -349,30 +330,6 @@ export const SidebarProvider: React.FC<SidebarProviderProps> = ({ children }) =>
|
|||
// Sort all items by order
|
||||
const sortedItems = items.sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||
|
||||
// Summary of page access checks
|
||||
const accessiblePages = pageAccessResults.filter(r => r.hasAccess);
|
||||
const deniedPages = pageAccessResults.filter(r => !r.hasAccess);
|
||||
|
||||
console.log('📊 SidebarProvider: Page access summary:', {
|
||||
totalPagesChecked: pageAccessResults.length,
|
||||
accessiblePages: accessiblePages.length,
|
||||
deniedPages: deniedPages.length,
|
||||
accessiblePagePaths: accessiblePages.map(p => p.path),
|
||||
deniedPagePaths: deniedPages.map(p => p.path),
|
||||
deniedPageDetails: deniedPages.map(p => ({ path: p.path, name: p.name }))
|
||||
});
|
||||
|
||||
console.log('📊 SidebarProvider: Final sidebar items built and sorted:', {
|
||||
totalItems: sortedItems.length,
|
||||
sortedPaths: sortedItems.map(item => item.link),
|
||||
items: sortedItems.map(item => ({
|
||||
id: item.id,
|
||||
link: item.link,
|
||||
name: item.name,
|
||||
hasSubmenu: !!item.submenu,
|
||||
submenuCount: item.submenu?.length || 0
|
||||
}))
|
||||
});
|
||||
return sortedItems;
|
||||
};
|
||||
|
||||
|
|
@ -383,6 +340,10 @@ export const SidebarProvider: React.FC<SidebarProviderProps> = ({ children }) =>
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
// Preload all UI permissions in a single API call
|
||||
// This caches all permissions before iterating through pages
|
||||
await preloadUiPermissions();
|
||||
|
||||
const items = await getSidebarItems();
|
||||
console.log('✅ SidebarProvider: Setting sidebar items:', {
|
||||
count: items.length,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
221
src/core/PageManager/data/pages/trustee/access.ts
Normal file
221
src/core/PageManager/data/pages/trustee/access.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import { useCallback } from 'react';
|
||||
import { GenericPageData } from '../../../pageInterface';
|
||||
import { FaKey, FaPlus } from 'react-icons/fa';
|
||||
import { useTrusteeAccess, useTrusteeAccessOperations } from '../../../../../hooks/useTrustee';
|
||||
|
||||
const attributesToColumns = (attributes: any[]) => {
|
||||
return attributes.map(attr => {
|
||||
const isDateField = attr.type === 'date' || attr.type === 'timestamp' ||
|
||||
/(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name);
|
||||
|
||||
return {
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type || 'string',
|
||||
width: attr.width || 200,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: isDateField ? false : (attr.filterable !== false),
|
||||
searchable: attr.searchable !== false,
|
||||
filterOptions: attr.filterOptions
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const createAccessHook = () => {
|
||||
return () => {
|
||||
const {
|
||||
items: accessRecords,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
removeOptimistically,
|
||||
updateOptimistically,
|
||||
attributes,
|
||||
permissions,
|
||||
fetchById,
|
||||
generateEditFieldsFromAttributes,
|
||||
ensureAttributesLoaded
|
||||
} = useTrusteeAccess();
|
||||
const {
|
||||
handleDelete,
|
||||
handleCreate,
|
||||
handleUpdate,
|
||||
deletingItems,
|
||||
creatingItem,
|
||||
deleteError,
|
||||
createError,
|
||||
updateError
|
||||
} = useTrusteeAccessOperations();
|
||||
|
||||
const generatedColumns = attributes && attributes.length > 0
|
||||
? attributesToColumns(attributes)
|
||||
: undefined;
|
||||
|
||||
const wrappedHandleCreate = useCallback(async (formData: any) => {
|
||||
return await handleCreate(formData);
|
||||
}, [handleCreate]);
|
||||
|
||||
const handleDeleteSingle = useCallback(async (item: any) => {
|
||||
const success = await handleDelete(item.id);
|
||||
if (success) {
|
||||
refetch();
|
||||
}
|
||||
}, [handleDelete, refetch]);
|
||||
|
||||
const handleDeleteMultiple = useCallback(async (selectedItems: any[]) => {
|
||||
const ids = selectedItems.map(item => item.id);
|
||||
const results = await Promise.all(ids.map(id => handleDelete(id)));
|
||||
const allSuccessful = results.every(result => result);
|
||||
if (allSuccessful) {
|
||||
refetch();
|
||||
}
|
||||
}, [handleDelete, refetch]);
|
||||
|
||||
return {
|
||||
data: accessRecords,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
removeOptimistically,
|
||||
updateOptimistically,
|
||||
handleDelete,
|
||||
handleDeleteMultiple,
|
||||
handleCreate: wrappedHandleCreate,
|
||||
handleUpdate,
|
||||
onDelete: handleDeleteSingle,
|
||||
onDeleteMultiple: handleDeleteMultiple,
|
||||
deletingItems,
|
||||
creatingItem,
|
||||
deleteError,
|
||||
createError,
|
||||
updateError,
|
||||
attributes,
|
||||
permissions,
|
||||
columns: generatedColumns,
|
||||
fetchById,
|
||||
generateEditFieldsFromAttributes,
|
||||
ensureAttributesLoaded
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export const trusteeAccessPageData: GenericPageData = {
|
||||
id: 'trustee-access',
|
||||
path: 'trustee/access',
|
||||
name: 'trustee.access.title',
|
||||
description: 'trustee.access.description',
|
||||
parentPath: 'trustee',
|
||||
|
||||
icon: FaKey,
|
||||
title: 'trustee.access.title',
|
||||
subtitle: 'trustee.access.subtitle',
|
||||
|
||||
headerButtons: [
|
||||
{
|
||||
id: 'new-access',
|
||||
label: 'trustee.access.new_button',
|
||||
icon: FaPlus,
|
||||
variant: 'primary',
|
||||
formConfig: {
|
||||
fields: [
|
||||
{
|
||||
key: 'organisationId',
|
||||
label: 'trustee.access.field.organisationId',
|
||||
type: 'enum',
|
||||
required: true,
|
||||
optionsReference: 'TrusteeOrganisation'
|
||||
},
|
||||
{
|
||||
key: 'roleId',
|
||||
label: 'trustee.access.field.roleId',
|
||||
type: 'enum',
|
||||
required: true,
|
||||
optionsReference: 'TrusteeRole'
|
||||
},
|
||||
{
|
||||
key: 'userId',
|
||||
label: 'trustee.access.field.userId',
|
||||
type: 'enum',
|
||||
required: true,
|
||||
optionsReference: 'User'
|
||||
},
|
||||
{
|
||||
key: 'contractId',
|
||||
label: 'trustee.access.field.contractId',
|
||||
type: 'enum',
|
||||
required: false,
|
||||
optionsReference: 'TrusteeContract',
|
||||
placeholder: 'trustee.access.field.contractId_placeholder'
|
||||
}
|
||||
],
|
||||
popupTitle: 'trustee.access.modal.create.title',
|
||||
popupSize: 'medium',
|
||||
createOperationName: 'handleCreate',
|
||||
successMessage: 'trustee.access.create.success',
|
||||
errorMessage: 'trustee.access.create.error'
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
content: [
|
||||
{
|
||||
id: 'access-table',
|
||||
type: 'table',
|
||||
tableConfig: {
|
||||
hookFactory: createAccessHook,
|
||||
actionButtons: [
|
||||
{
|
||||
type: 'edit',
|
||||
title: 'trustee.access.action.edit',
|
||||
idField: 'id',
|
||||
nameField: 'id',
|
||||
operationName: 'handleUpdate',
|
||||
loadingStateName: 'updatingItems',
|
||||
fetchItemFunctionName: 'fetchById',
|
||||
disabled: (hookData: any) => {
|
||||
if (!hookData?.permissions) return { disabled: false };
|
||||
const hasUpdate = hookData.permissions.update !== 'n' && hookData.permissions.view;
|
||||
return { disabled: !hasUpdate, message: 'No permission to edit access' };
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'delete',
|
||||
title: 'trustee.access.action.delete',
|
||||
idField: 'id',
|
||||
operationName: 'handleDelete',
|
||||
loadingStateName: 'deletingItems',
|
||||
disabled: (hookData: any) => {
|
||||
if (!hookData?.permissions) return { disabled: false };
|
||||
const hasDelete = hookData.permissions.delete !== 'n' && hookData.permissions.view;
|
||||
return { disabled: !hasDelete, message: 'No permission to delete access' };
|
||||
}
|
||||
}
|
||||
],
|
||||
searchable: true,
|
||||
filterable: true,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
pagination: true,
|
||||
pageSize: 10,
|
||||
className: 'trustee-access-table'
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
persistent: false,
|
||||
preload: false,
|
||||
preserveState: true,
|
||||
moduleEnabled: true,
|
||||
|
||||
onActivate: async () => {
|
||||
if (import.meta.env.DEV) console.log('Trustee Access activated');
|
||||
},
|
||||
onLoad: async () => {
|
||||
if (import.meta.env.DEV) console.log('Trustee Access loaded');
|
||||
},
|
||||
onUnload: async () => {
|
||||
if (import.meta.env.DEV) console.log('Trustee Access unloaded');
|
||||
}
|
||||
};
|
||||
212
src/core/PageManager/data/pages/trustee/contracts.ts
Normal file
212
src/core/PageManager/data/pages/trustee/contracts.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import { useCallback } from 'react';
|
||||
import { GenericPageData } from '../../../pageInterface';
|
||||
import { FaFileContract, FaPlus } from 'react-icons/fa';
|
||||
import { useTrusteeContracts, useTrusteeContractOperations } from '../../../../../hooks/useTrustee';
|
||||
|
||||
const attributesToColumns = (attributes: any[]) => {
|
||||
return attributes.map(attr => {
|
||||
const isDateField = attr.type === 'date' || attr.type === 'timestamp' ||
|
||||
/(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name);
|
||||
|
||||
return {
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type || 'string',
|
||||
width: attr.width || 200,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: isDateField ? false : (attr.filterable !== false),
|
||||
searchable: attr.searchable !== false,
|
||||
filterOptions: attr.filterOptions
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const createContractsHook = () => {
|
||||
return () => {
|
||||
const {
|
||||
items: contracts,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
removeOptimistically,
|
||||
updateOptimistically,
|
||||
attributes,
|
||||
permissions,
|
||||
fetchById,
|
||||
generateEditFieldsFromAttributes,
|
||||
ensureAttributesLoaded
|
||||
} = useTrusteeContracts();
|
||||
const {
|
||||
handleDelete,
|
||||
handleCreate,
|
||||
handleUpdate,
|
||||
deletingItems,
|
||||
creatingItem,
|
||||
deleteError,
|
||||
createError,
|
||||
updateError
|
||||
} = useTrusteeContractOperations();
|
||||
|
||||
const generatedColumns = attributes && attributes.length > 0
|
||||
? attributesToColumns(attributes)
|
||||
: undefined;
|
||||
|
||||
const wrappedHandleCreate = useCallback(async (formData: any) => {
|
||||
return await handleCreate(formData);
|
||||
}, [handleCreate]);
|
||||
|
||||
const handleDeleteSingle = useCallback(async (item: any) => {
|
||||
const success = await handleDelete(item.id);
|
||||
if (success) {
|
||||
refetch();
|
||||
}
|
||||
}, [handleDelete, refetch]);
|
||||
|
||||
const handleDeleteMultiple = useCallback(async (selectedItems: any[]) => {
|
||||
const ids = selectedItems.map(item => item.id);
|
||||
const results = await Promise.all(ids.map(id => handleDelete(id)));
|
||||
const allSuccessful = results.every(result => result);
|
||||
if (allSuccessful) {
|
||||
refetch();
|
||||
}
|
||||
}, [handleDelete, refetch]);
|
||||
|
||||
return {
|
||||
data: contracts,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
removeOptimistically,
|
||||
updateOptimistically,
|
||||
handleDelete,
|
||||
handleDeleteMultiple,
|
||||
handleCreate: wrappedHandleCreate,
|
||||
handleUpdate,
|
||||
onDelete: handleDeleteSingle,
|
||||
onDeleteMultiple: handleDeleteMultiple,
|
||||
deletingItems,
|
||||
creatingItem,
|
||||
deleteError,
|
||||
createError,
|
||||
updateError,
|
||||
attributes,
|
||||
permissions,
|
||||
columns: generatedColumns,
|
||||
fetchById,
|
||||
generateEditFieldsFromAttributes,
|
||||
ensureAttributesLoaded
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export const trusteeContractsPageData: GenericPageData = {
|
||||
id: 'trustee-contracts',
|
||||
path: 'trustee/contracts',
|
||||
name: 'trustee.contracts.title',
|
||||
description: 'trustee.contracts.description',
|
||||
parentPath: 'trustee',
|
||||
|
||||
icon: FaFileContract,
|
||||
title: 'trustee.contracts.title',
|
||||
subtitle: 'trustee.contracts.subtitle',
|
||||
|
||||
headerButtons: [
|
||||
{
|
||||
id: 'new-contract',
|
||||
label: 'trustee.contracts.new_button',
|
||||
icon: FaPlus,
|
||||
variant: 'primary',
|
||||
formConfig: {
|
||||
fields: [
|
||||
{
|
||||
key: 'organisationId',
|
||||
label: 'trustee.contracts.field.organisationId',
|
||||
type: 'enum',
|
||||
required: true,
|
||||
optionsReference: 'TrusteeOrganisation'
|
||||
},
|
||||
{
|
||||
key: 'label',
|
||||
label: 'trustee.contracts.field.label',
|
||||
type: 'string',
|
||||
required: true,
|
||||
placeholder: 'trustee.contracts.field.label_placeholder'
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
label: 'trustee.contracts.field.enabled',
|
||||
type: 'boolean',
|
||||
required: false
|
||||
}
|
||||
],
|
||||
popupTitle: 'trustee.contracts.modal.create.title',
|
||||
popupSize: 'medium',
|
||||
createOperationName: 'handleCreate',
|
||||
successMessage: 'trustee.contracts.create.success',
|
||||
errorMessage: 'trustee.contracts.create.error'
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
content: [
|
||||
{
|
||||
id: 'contracts-table',
|
||||
type: 'table',
|
||||
tableConfig: {
|
||||
hookFactory: createContractsHook,
|
||||
actionButtons: [
|
||||
{
|
||||
type: 'edit',
|
||||
title: 'trustee.contracts.action.edit',
|
||||
idField: 'id',
|
||||
nameField: 'label',
|
||||
operationName: 'handleUpdate',
|
||||
loadingStateName: 'updatingItems',
|
||||
fetchItemFunctionName: 'fetchById',
|
||||
disabled: (hookData: any) => {
|
||||
if (!hookData?.permissions) return { disabled: false };
|
||||
const hasUpdate = hookData.permissions.update !== 'n' && hookData.permissions.view;
|
||||
return { disabled: !hasUpdate, message: 'No permission to edit contracts' };
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'delete',
|
||||
title: 'trustee.contracts.action.delete',
|
||||
idField: 'id',
|
||||
operationName: 'handleDelete',
|
||||
loadingStateName: 'deletingItems',
|
||||
disabled: (hookData: any) => {
|
||||
if (!hookData?.permissions) return { disabled: false };
|
||||
const hasDelete = hookData.permissions.delete !== 'n' && hookData.permissions.view;
|
||||
return { disabled: !hasDelete, message: 'No permission to delete contracts' };
|
||||
}
|
||||
}
|
||||
],
|
||||
searchable: true,
|
||||
filterable: true,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
pagination: true,
|
||||
pageSize: 10,
|
||||
className: 'trustee-contracts-table'
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
persistent: false,
|
||||
preload: false,
|
||||
preserveState: true,
|
||||
moduleEnabled: true,
|
||||
|
||||
onActivate: async () => {
|
||||
if (import.meta.env.DEV) console.log('Trustee Contracts activated');
|
||||
},
|
||||
onLoad: async () => {
|
||||
if (import.meta.env.DEV) console.log('Trustee Contracts loaded');
|
||||
},
|
||||
onUnload: async () => {
|
||||
if (import.meta.env.DEV) console.log('Trustee Contracts unloaded');
|
||||
}
|
||||
};
|
||||
230
src/core/PageManager/data/pages/trustee/documents.ts
Normal file
230
src/core/PageManager/data/pages/trustee/documents.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import { useCallback } from 'react';
|
||||
import { GenericPageData } from '../../../pageInterface';
|
||||
import { FaFile, FaPlus } from 'react-icons/fa';
|
||||
import { useTrusteeDocuments, useTrusteeDocumentOperations } from '../../../../../hooks/useTrustee';
|
||||
|
||||
const attributesToColumns = (attributes: any[]) => {
|
||||
return attributes.map(attr => {
|
||||
const isDateField = attr.type === 'date' || attr.type === 'timestamp' ||
|
||||
/(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name);
|
||||
|
||||
// Hide binary data column
|
||||
if (attr.name === 'documentData') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type || 'string',
|
||||
width: attr.width || 200,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: isDateField ? false : (attr.filterable !== false),
|
||||
searchable: attr.searchable !== false,
|
||||
filterOptions: attr.filterOptions
|
||||
};
|
||||
}).filter(Boolean);
|
||||
};
|
||||
|
||||
const createDocumentsHook = () => {
|
||||
return () => {
|
||||
const {
|
||||
items: documents,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
removeOptimistically,
|
||||
updateOptimistically,
|
||||
attributes,
|
||||
permissions,
|
||||
fetchById,
|
||||
generateEditFieldsFromAttributes,
|
||||
ensureAttributesLoaded
|
||||
} = useTrusteeDocuments();
|
||||
const {
|
||||
handleDelete,
|
||||
handleCreate,
|
||||
handleUpdate,
|
||||
deletingItems,
|
||||
creatingItem,
|
||||
deleteError,
|
||||
createError,
|
||||
updateError
|
||||
} = useTrusteeDocumentOperations();
|
||||
|
||||
const generatedColumns = attributes && attributes.length > 0
|
||||
? attributesToColumns(attributes)
|
||||
: undefined;
|
||||
|
||||
const wrappedHandleCreate = useCallback(async (formData: any) => {
|
||||
return await handleCreate(formData);
|
||||
}, [handleCreate]);
|
||||
|
||||
const handleDeleteSingle = useCallback(async (item: any) => {
|
||||
const success = await handleDelete(item.id);
|
||||
if (success) {
|
||||
refetch();
|
||||
}
|
||||
}, [handleDelete, refetch]);
|
||||
|
||||
const handleDeleteMultiple = useCallback(async (selectedItems: any[]) => {
|
||||
const ids = selectedItems.map(item => item.id);
|
||||
const results = await Promise.all(ids.map(id => handleDelete(id)));
|
||||
const allSuccessful = results.every(result => result);
|
||||
if (allSuccessful) {
|
||||
refetch();
|
||||
}
|
||||
}, [handleDelete, refetch]);
|
||||
|
||||
return {
|
||||
data: documents,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
removeOptimistically,
|
||||
updateOptimistically,
|
||||
handleDelete,
|
||||
handleDeleteMultiple,
|
||||
handleCreate: wrappedHandleCreate,
|
||||
handleUpdate,
|
||||
onDelete: handleDeleteSingle,
|
||||
onDeleteMultiple: handleDeleteMultiple,
|
||||
deletingItems,
|
||||
creatingItem,
|
||||
deleteError,
|
||||
createError,
|
||||
updateError,
|
||||
attributes,
|
||||
permissions,
|
||||
columns: generatedColumns,
|
||||
fetchById,
|
||||
generateEditFieldsFromAttributes,
|
||||
ensureAttributesLoaded
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export const trusteeDocumentsPageData: GenericPageData = {
|
||||
id: 'trustee-documents',
|
||||
path: 'trustee/documents',
|
||||
name: 'trustee.documents.title',
|
||||
description: 'trustee.documents.description',
|
||||
parentPath: 'trustee',
|
||||
|
||||
icon: FaFile,
|
||||
title: 'trustee.documents.title',
|
||||
subtitle: 'trustee.documents.subtitle',
|
||||
|
||||
headerButtons: [
|
||||
{
|
||||
id: 'new-document',
|
||||
label: 'trustee.documents.new_button',
|
||||
icon: FaPlus,
|
||||
variant: 'primary',
|
||||
formConfig: {
|
||||
fields: [
|
||||
{
|
||||
key: 'organisationId',
|
||||
label: 'trustee.documents.field.organisationId',
|
||||
type: 'enum',
|
||||
required: true,
|
||||
optionsReference: 'TrusteeOrganisation'
|
||||
},
|
||||
{
|
||||
key: 'contractId',
|
||||
label: 'trustee.documents.field.contractId',
|
||||
type: 'enum',
|
||||
required: true,
|
||||
optionsReference: 'TrusteeContract'
|
||||
},
|
||||
{
|
||||
key: 'documentName',
|
||||
label: 'trustee.documents.field.documentName',
|
||||
type: 'string',
|
||||
required: true,
|
||||
placeholder: 'trustee.documents.field.documentName_placeholder'
|
||||
},
|
||||
{
|
||||
key: 'documentMimeType',
|
||||
label: 'trustee.documents.field.documentMimeType',
|
||||
type: 'enum',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'application/pdf', label: 'PDF' },
|
||||
{ value: 'image/jpeg', label: 'JPEG' },
|
||||
{ value: 'image/png', label: 'PNG' },
|
||||
{ value: 'application/octet-stream', label: 'Other' }
|
||||
]
|
||||
}
|
||||
],
|
||||
popupTitle: 'trustee.documents.modal.create.title',
|
||||
popupSize: 'medium',
|
||||
createOperationName: 'handleCreate',
|
||||
successMessage: 'trustee.documents.create.success',
|
||||
errorMessage: 'trustee.documents.create.error'
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
content: [
|
||||
{
|
||||
id: 'documents-table',
|
||||
type: 'table',
|
||||
tableConfig: {
|
||||
hookFactory: createDocumentsHook,
|
||||
actionButtons: [
|
||||
{
|
||||
type: 'edit',
|
||||
title: 'trustee.documents.action.edit',
|
||||
idField: 'id',
|
||||
nameField: 'documentName',
|
||||
operationName: 'handleUpdate',
|
||||
loadingStateName: 'updatingItems',
|
||||
fetchItemFunctionName: 'fetchById',
|
||||
disabled: (hookData: any) => {
|
||||
if (!hookData?.permissions) return { disabled: false };
|
||||
const hasUpdate = hookData.permissions.update !== 'n' && hookData.permissions.view;
|
||||
return { disabled: !hasUpdate, message: 'No permission to edit documents' };
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'delete',
|
||||
title: 'trustee.documents.action.delete',
|
||||
idField: 'id',
|
||||
operationName: 'handleDelete',
|
||||
loadingStateName: 'deletingItems',
|
||||
disabled: (hookData: any) => {
|
||||
if (!hookData?.permissions) return { disabled: false };
|
||||
const hasDelete = hookData.permissions.delete !== 'n' && hookData.permissions.view;
|
||||
return { disabled: !hasDelete, message: 'No permission to delete documents' };
|
||||
}
|
||||
}
|
||||
],
|
||||
searchable: true,
|
||||
filterable: true,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
pagination: true,
|
||||
pageSize: 10,
|
||||
className: 'trustee-documents-table'
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
persistent: false,
|
||||
preload: false,
|
||||
preserveState: true,
|
||||
moduleEnabled: true,
|
||||
|
||||
onActivate: async () => {
|
||||
if (import.meta.env.DEV) console.log('Trustee Documents activated');
|
||||
},
|
||||
onLoad: async () => {
|
||||
if (import.meta.env.DEV) console.log('Trustee Documents loaded');
|
||||
},
|
||||
onUnload: async () => {
|
||||
if (import.meta.env.DEV) console.log('Trustee Documents unloaded');
|
||||
}
|
||||
};
|
||||
31
src/core/PageManager/data/pages/trustee/index.ts
Normal file
31
src/core/PageManager/data/pages/trustee/index.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { GenericPageData } from '../../../pageInterface';
|
||||
|
||||
// Import all trustee page configurations
|
||||
import { trusteeOrganisationsPageData } from './organisations';
|
||||
import { trusteeRolesPageData } from './roles';
|
||||
import { trusteeAccessPageData } from './access';
|
||||
import { trusteeContractsPageData } from './contracts';
|
||||
import { trusteeDocumentsPageData } from './documents';
|
||||
import { trusteePositionsPageData } from './positions';
|
||||
|
||||
// Export all trustee pages
|
||||
export {
|
||||
trusteeOrganisationsPageData,
|
||||
trusteeRolesPageData,
|
||||
trusteeAccessPageData,
|
||||
trusteeContractsPageData,
|
||||
trusteeDocumentsPageData,
|
||||
trusteePositionsPageData
|
||||
};
|
||||
|
||||
// Export array of all trustee pages for registration
|
||||
// No explicit container needed - SidebarProvider creates a virtual parent group
|
||||
// based on parentPath: 'trustee' in the child pages
|
||||
export const trusteePages: GenericPageData[] = [
|
||||
trusteeOrganisationsPageData,
|
||||
trusteeRolesPageData,
|
||||
trusteeAccessPageData,
|
||||
trusteeContractsPageData,
|
||||
trusteeDocumentsPageData,
|
||||
trusteePositionsPageData
|
||||
];
|
||||
226
src/core/PageManager/data/pages/trustee/organisations.ts
Normal file
226
src/core/PageManager/data/pages/trustee/organisations.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
import { useCallback } from 'react';
|
||||
import { GenericPageData } from '../../../pageInterface';
|
||||
import { FaBuilding, FaPlus } from 'react-icons/fa';
|
||||
import { useTrusteeOrganisations, useTrusteeOrganisationOperations } from '../../../../../hooks/useTrustee';
|
||||
|
||||
// Helper function to convert attribute definitions to column config
|
||||
const attributesToColumns = (attributes: any[]) => {
|
||||
return attributes.map(attr => {
|
||||
const isDateField = attr.type === 'date' || attr.type === 'timestamp' ||
|
||||
/(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name);
|
||||
|
||||
return {
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type || 'string',
|
||||
width: attr.width || 200,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: isDateField ? false : (attr.filterable !== false),
|
||||
searchable: attr.searchable !== false,
|
||||
filterOptions: attr.filterOptions
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Hook factory function for organisations data
|
||||
const createOrganisationsHook = () => {
|
||||
return () => {
|
||||
const {
|
||||
items: organisations,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
removeOptimistically,
|
||||
updateOptimistically,
|
||||
attributes,
|
||||
permissions,
|
||||
fetchById,
|
||||
generateEditFieldsFromAttributes,
|
||||
ensureAttributesLoaded
|
||||
} = useTrusteeOrganisations();
|
||||
const {
|
||||
handleDelete,
|
||||
handleCreate,
|
||||
handleUpdate,
|
||||
deletingItems,
|
||||
creatingItem,
|
||||
deleteError,
|
||||
createError,
|
||||
updateError
|
||||
} = useTrusteeOrganisationOperations();
|
||||
|
||||
const generatedColumns = attributes && attributes.length > 0
|
||||
? attributesToColumns(attributes)
|
||||
: undefined;
|
||||
|
||||
const wrappedHandleCreate = useCallback(async (formData: any) => {
|
||||
return await handleCreate(formData);
|
||||
}, [handleCreate]);
|
||||
|
||||
const handleDeleteSingle = useCallback(async (item: any) => {
|
||||
const success = await handleDelete(item.id);
|
||||
if (success) {
|
||||
refetch();
|
||||
}
|
||||
}, [handleDelete, refetch]);
|
||||
|
||||
const handleDeleteMultiple = useCallback(async (selectedItems: any[]) => {
|
||||
const ids = selectedItems.map(item => item.id);
|
||||
const results = await Promise.all(ids.map(id => handleDelete(id)));
|
||||
const allSuccessful = results.every(result => result);
|
||||
if (allSuccessful) {
|
||||
refetch();
|
||||
}
|
||||
}, [handleDelete, refetch]);
|
||||
|
||||
return {
|
||||
data: organisations,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
removeOptimistically,
|
||||
updateOptimistically,
|
||||
handleDelete,
|
||||
handleDeleteMultiple,
|
||||
handleCreate: wrappedHandleCreate,
|
||||
handleUpdate,
|
||||
onDelete: handleDeleteSingle,
|
||||
onDeleteMultiple: handleDeleteMultiple,
|
||||
deletingItems,
|
||||
creatingItem,
|
||||
deleteError,
|
||||
createError,
|
||||
updateError,
|
||||
attributes,
|
||||
permissions,
|
||||
columns: generatedColumns,
|
||||
fetchById,
|
||||
generateEditFieldsFromAttributes,
|
||||
ensureAttributesLoaded
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export const trusteeOrganisationsPageData: GenericPageData = {
|
||||
id: 'trustee-organisations',
|
||||
path: 'trustee/organisations',
|
||||
name: 'trustee.organisations.title',
|
||||
description: 'trustee.organisations.description',
|
||||
parentPath: 'trustee',
|
||||
|
||||
icon: FaBuilding,
|
||||
title: 'trustee.organisations.title',
|
||||
subtitle: 'trustee.organisations.subtitle',
|
||||
|
||||
headerButtons: [
|
||||
{
|
||||
id: 'new-organisation',
|
||||
label: 'trustee.organisations.new_button',
|
||||
icon: FaPlus,
|
||||
variant: 'primary',
|
||||
formConfig: {
|
||||
fields: [
|
||||
{
|
||||
key: 'id',
|
||||
label: 'trustee.organisations.field.id',
|
||||
type: 'string',
|
||||
required: true,
|
||||
placeholder: 'trustee.organisations.field.id_placeholder',
|
||||
validator: (value: string) => {
|
||||
if (!value || value.trim() === '') {
|
||||
return 'Organisation ID cannot be empty';
|
||||
}
|
||||
if (value.length < 3 || value.length > 50) {
|
||||
return 'Organisation ID must be 3-50 characters';
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
|
||||
return 'Organisation ID can only contain letters, numbers, hyphens, and underscores';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'label',
|
||||
label: 'trustee.organisations.field.label',
|
||||
type: 'string',
|
||||
required: true,
|
||||
placeholder: 'trustee.organisations.field.label_placeholder'
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
label: 'trustee.organisations.field.enabled',
|
||||
type: 'boolean',
|
||||
required: false
|
||||
}
|
||||
],
|
||||
popupTitle: 'trustee.organisations.modal.create.title',
|
||||
popupSize: 'medium',
|
||||
createOperationName: 'handleCreate',
|
||||
successMessage: 'trustee.organisations.create.success',
|
||||
errorMessage: 'trustee.organisations.create.error'
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
content: [
|
||||
{
|
||||
id: 'organisations-table',
|
||||
type: 'table',
|
||||
tableConfig: {
|
||||
hookFactory: createOrganisationsHook,
|
||||
actionButtons: [
|
||||
{
|
||||
type: 'edit',
|
||||
title: 'trustee.organisations.action.edit',
|
||||
idField: 'id',
|
||||
nameField: 'label',
|
||||
operationName: 'handleUpdate',
|
||||
loadingStateName: 'updatingItems',
|
||||
fetchItemFunctionName: 'fetchById',
|
||||
disabled: (hookData: any) => {
|
||||
if (!hookData?.permissions) return { disabled: false };
|
||||
const hasUpdate = hookData.permissions.update !== 'n' && hookData.permissions.view;
|
||||
return { disabled: !hasUpdate, message: 'No permission to edit organisations' };
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'delete',
|
||||
title: 'trustee.organisations.action.delete',
|
||||
idField: 'id',
|
||||
operationName: 'handleDelete',
|
||||
loadingStateName: 'deletingItems',
|
||||
disabled: (hookData: any) => {
|
||||
if (!hookData?.permissions) return { disabled: false };
|
||||
const hasDelete = hookData.permissions.delete !== 'n' && hookData.permissions.view;
|
||||
return { disabled: !hasDelete, message: 'No permission to delete organisations' };
|
||||
}
|
||||
}
|
||||
],
|
||||
searchable: true,
|
||||
filterable: true,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
pagination: true,
|
||||
pageSize: 10,
|
||||
className: 'trustee-organisations-table'
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
persistent: false,
|
||||
preload: false,
|
||||
preserveState: true,
|
||||
moduleEnabled: true,
|
||||
|
||||
onActivate: async () => {
|
||||
if (import.meta.env.DEV) console.log('Trustee Organisations activated');
|
||||
},
|
||||
onLoad: async () => {
|
||||
if (import.meta.env.DEV) console.log('Trustee Organisations loaded');
|
||||
},
|
||||
onUnload: async () => {
|
||||
if (import.meta.env.DEV) console.log('Trustee Organisations unloaded');
|
||||
}
|
||||
};
|
||||
279
src/core/PageManager/data/pages/trustee/positions.ts
Normal file
279
src/core/PageManager/data/pages/trustee/positions.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import { useCallback } from 'react';
|
||||
import { GenericPageData } from '../../../pageInterface';
|
||||
import { FaReceipt, FaPlus } from 'react-icons/fa';
|
||||
import { useTrusteePositions, useTrusteePositionOperations } from '../../../../../hooks/useTrustee';
|
||||
|
||||
const attributesToColumns = (attributes: any[]) => {
|
||||
return attributes.map(attr => {
|
||||
const isDateField = attr.type === 'date' || attr.type === 'timestamp' ||
|
||||
/(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name);
|
||||
|
||||
return {
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type || 'string',
|
||||
width: attr.width || 200,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: isDateField ? false : (attr.filterable !== false),
|
||||
searchable: attr.searchable !== false,
|
||||
filterOptions: attr.filterOptions
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const createPositionsHook = () => {
|
||||
return () => {
|
||||
const {
|
||||
items: positions,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
removeOptimistically,
|
||||
updateOptimistically,
|
||||
attributes,
|
||||
permissions,
|
||||
fetchById,
|
||||
generateEditFieldsFromAttributes,
|
||||
ensureAttributesLoaded
|
||||
} = useTrusteePositions();
|
||||
const {
|
||||
handleDelete,
|
||||
handleCreate,
|
||||
handleUpdate,
|
||||
deletingItems,
|
||||
creatingItem,
|
||||
deleteError,
|
||||
createError,
|
||||
updateError
|
||||
} = useTrusteePositionOperations();
|
||||
|
||||
const generatedColumns = attributes && attributes.length > 0
|
||||
? attributesToColumns(attributes)
|
||||
: undefined;
|
||||
|
||||
const wrappedHandleCreate = useCallback(async (formData: any) => {
|
||||
// Auto-calculate VAT amount if not provided
|
||||
if (formData.bookingAmount && formData.vatPercentage && !formData.vatAmount) {
|
||||
formData.vatAmount = formData.bookingAmount * formData.vatPercentage / 100;
|
||||
}
|
||||
return await handleCreate(formData);
|
||||
}, [handleCreate]);
|
||||
|
||||
const handleDeleteSingle = useCallback(async (item: any) => {
|
||||
const success = await handleDelete(item.id);
|
||||
if (success) {
|
||||
refetch();
|
||||
}
|
||||
}, [handleDelete, refetch]);
|
||||
|
||||
const handleDeleteMultiple = useCallback(async (selectedItems: any[]) => {
|
||||
const ids = selectedItems.map(item => item.id);
|
||||
const results = await Promise.all(ids.map(id => handleDelete(id)));
|
||||
const allSuccessful = results.every(result => result);
|
||||
if (allSuccessful) {
|
||||
refetch();
|
||||
}
|
||||
}, [handleDelete, refetch]);
|
||||
|
||||
return {
|
||||
data: positions,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
removeOptimistically,
|
||||
updateOptimistically,
|
||||
handleDelete,
|
||||
handleDeleteMultiple,
|
||||
handleCreate: wrappedHandleCreate,
|
||||
handleUpdate,
|
||||
onDelete: handleDeleteSingle,
|
||||
onDeleteMultiple: handleDeleteMultiple,
|
||||
deletingItems,
|
||||
creatingItem,
|
||||
deleteError,
|
||||
createError,
|
||||
updateError,
|
||||
attributes,
|
||||
permissions,
|
||||
columns: generatedColumns,
|
||||
fetchById,
|
||||
generateEditFieldsFromAttributes,
|
||||
ensureAttributesLoaded
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export const trusteePositionsPageData: GenericPageData = {
|
||||
id: 'trustee-positions',
|
||||
path: 'trustee/positions',
|
||||
name: 'trustee.positions.title',
|
||||
description: 'trustee.positions.description',
|
||||
parentPath: 'trustee',
|
||||
|
||||
icon: FaReceipt,
|
||||
title: 'trustee.positions.title',
|
||||
subtitle: 'trustee.positions.subtitle',
|
||||
|
||||
headerButtons: [
|
||||
{
|
||||
id: 'new-position',
|
||||
label: 'trustee.positions.new_button',
|
||||
icon: FaPlus,
|
||||
variant: 'primary',
|
||||
formConfig: {
|
||||
fields: [
|
||||
{
|
||||
key: 'organisationId',
|
||||
label: 'trustee.positions.field.organisationId',
|
||||
type: 'enum',
|
||||
required: true,
|
||||
optionsReference: 'TrusteeOrganisation'
|
||||
},
|
||||
{
|
||||
key: 'contractId',
|
||||
label: 'trustee.positions.field.contractId',
|
||||
type: 'enum',
|
||||
required: true,
|
||||
optionsReference: 'TrusteeContract'
|
||||
},
|
||||
{
|
||||
key: 'valuta',
|
||||
label: 'trustee.positions.field.valuta',
|
||||
type: 'date',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
key: 'company',
|
||||
label: 'trustee.positions.field.company',
|
||||
type: 'string',
|
||||
required: false,
|
||||
placeholder: 'trustee.positions.field.company_placeholder'
|
||||
},
|
||||
{
|
||||
key: 'desc',
|
||||
label: 'trustee.positions.field.desc',
|
||||
type: 'textarea',
|
||||
required: false,
|
||||
minRows: 2,
|
||||
maxRows: 4
|
||||
},
|
||||
{
|
||||
key: 'bookingCurrency',
|
||||
label: 'trustee.positions.field.bookingCurrency',
|
||||
type: 'enum',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'CHF', label: 'CHF' },
|
||||
{ value: 'EUR', label: 'EUR' },
|
||||
{ value: 'USD', label: 'USD' },
|
||||
{ value: 'GBP', label: 'GBP' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'bookingAmount',
|
||||
label: 'trustee.positions.field.bookingAmount',
|
||||
type: 'number',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
key: 'originalCurrency',
|
||||
label: 'trustee.positions.field.originalCurrency',
|
||||
type: 'enum',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'CHF', label: 'CHF' },
|
||||
{ value: 'EUR', label: 'EUR' },
|
||||
{ value: 'USD', label: 'USD' },
|
||||
{ value: 'GBP', label: 'GBP' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'originalAmount',
|
||||
label: 'trustee.positions.field.originalAmount',
|
||||
type: 'number',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
key: 'vatPercentage',
|
||||
label: 'trustee.positions.field.vatPercentage',
|
||||
type: 'number',
|
||||
required: false
|
||||
},
|
||||
{
|
||||
key: 'vatAmount',
|
||||
label: 'trustee.positions.field.vatAmount',
|
||||
type: 'number',
|
||||
required: false
|
||||
}
|
||||
],
|
||||
popupTitle: 'trustee.positions.modal.create.title',
|
||||
popupSize: 'large',
|
||||
createOperationName: 'handleCreate',
|
||||
successMessage: 'trustee.positions.create.success',
|
||||
errorMessage: 'trustee.positions.create.error'
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
content: [
|
||||
{
|
||||
id: 'positions-table',
|
||||
type: 'table',
|
||||
tableConfig: {
|
||||
hookFactory: createPositionsHook,
|
||||
actionButtons: [
|
||||
{
|
||||
type: 'edit',
|
||||
title: 'trustee.positions.action.edit',
|
||||
idField: 'id',
|
||||
nameField: 'desc',
|
||||
operationName: 'handleUpdate',
|
||||
loadingStateName: 'updatingItems',
|
||||
fetchItemFunctionName: 'fetchById',
|
||||
disabled: (hookData: any) => {
|
||||
if (!hookData?.permissions) return { disabled: false };
|
||||
const hasUpdate = hookData.permissions.update !== 'n' && hookData.permissions.view;
|
||||
return { disabled: !hasUpdate, message: 'No permission to edit positions' };
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'delete',
|
||||
title: 'trustee.positions.action.delete',
|
||||
idField: 'id',
|
||||
operationName: 'handleDelete',
|
||||
loadingStateName: 'deletingItems',
|
||||
disabled: (hookData: any) => {
|
||||
if (!hookData?.permissions) return { disabled: false };
|
||||
const hasDelete = hookData.permissions.delete !== 'n' && hookData.permissions.view;
|
||||
return { disabled: !hasDelete, message: 'No permission to delete positions' };
|
||||
}
|
||||
}
|
||||
],
|
||||
searchable: true,
|
||||
filterable: true,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
pagination: true,
|
||||
pageSize: 10,
|
||||
className: 'trustee-positions-table'
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
persistent: false,
|
||||
preload: false,
|
||||
preserveState: true,
|
||||
moduleEnabled: true,
|
||||
|
||||
onActivate: async () => {
|
||||
if (import.meta.env.DEV) console.log('Trustee Positions activated');
|
||||
},
|
||||
onLoad: async () => {
|
||||
if (import.meta.env.DEV) console.log('Trustee Positions loaded');
|
||||
},
|
||||
onUnload: async () => {
|
||||
if (import.meta.env.DEV) console.log('Trustee Positions unloaded');
|
||||
}
|
||||
};
|
||||
208
src/core/PageManager/data/pages/trustee/roles.ts
Normal file
208
src/core/PageManager/data/pages/trustee/roles.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import { useCallback } from 'react';
|
||||
import { GenericPageData } from '../../../pageInterface';
|
||||
import { FaUserTag, FaPlus } from 'react-icons/fa';
|
||||
import { useTrusteeRoles, useTrusteeRoleOperations } from '../../../../../hooks/useTrustee';
|
||||
|
||||
const attributesToColumns = (attributes: any[]) => {
|
||||
return attributes.map(attr => {
|
||||
const isDateField = attr.type === 'date' || attr.type === 'timestamp' ||
|
||||
/(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name);
|
||||
|
||||
return {
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type || 'string',
|
||||
width: attr.width || 200,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: isDateField ? false : (attr.filterable !== false),
|
||||
searchable: attr.searchable !== false,
|
||||
filterOptions: attr.filterOptions
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const createRolesHook = () => {
|
||||
return () => {
|
||||
const {
|
||||
items: roles,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
removeOptimistically,
|
||||
updateOptimistically,
|
||||
attributes,
|
||||
permissions,
|
||||
fetchById,
|
||||
generateEditFieldsFromAttributes,
|
||||
ensureAttributesLoaded
|
||||
} = useTrusteeRoles();
|
||||
const {
|
||||
handleDelete,
|
||||
handleCreate,
|
||||
handleUpdate,
|
||||
deletingItems,
|
||||
creatingItem,
|
||||
deleteError,
|
||||
createError,
|
||||
updateError
|
||||
} = useTrusteeRoleOperations();
|
||||
|
||||
const generatedColumns = attributes && attributes.length > 0
|
||||
? attributesToColumns(attributes)
|
||||
: undefined;
|
||||
|
||||
const wrappedHandleCreate = useCallback(async (formData: any) => {
|
||||
return await handleCreate(formData);
|
||||
}, [handleCreate]);
|
||||
|
||||
const handleDeleteSingle = useCallback(async (item: any) => {
|
||||
const success = await handleDelete(item.id);
|
||||
if (success) {
|
||||
refetch();
|
||||
}
|
||||
}, [handleDelete, refetch]);
|
||||
|
||||
const handleDeleteMultiple = useCallback(async (selectedItems: any[]) => {
|
||||
const ids = selectedItems.map(item => item.id);
|
||||
const results = await Promise.all(ids.map(id => handleDelete(id)));
|
||||
const allSuccessful = results.every(result => result);
|
||||
if (allSuccessful) {
|
||||
refetch();
|
||||
}
|
||||
}, [handleDelete, refetch]);
|
||||
|
||||
return {
|
||||
data: roles,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
removeOptimistically,
|
||||
updateOptimistically,
|
||||
handleDelete,
|
||||
handleDeleteMultiple,
|
||||
handleCreate: wrappedHandleCreate,
|
||||
handleUpdate,
|
||||
onDelete: handleDeleteSingle,
|
||||
onDeleteMultiple: handleDeleteMultiple,
|
||||
deletingItems,
|
||||
creatingItem,
|
||||
deleteError,
|
||||
createError,
|
||||
updateError,
|
||||
attributes,
|
||||
permissions,
|
||||
columns: generatedColumns,
|
||||
fetchById,
|
||||
generateEditFieldsFromAttributes,
|
||||
ensureAttributesLoaded
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export const trusteeRolesPageData: GenericPageData = {
|
||||
id: 'trustee-roles',
|
||||
path: 'trustee/roles',
|
||||
name: 'trustee.roles.title',
|
||||
description: 'trustee.roles.description',
|
||||
parentPath: 'trustee',
|
||||
|
||||
icon: FaUserTag,
|
||||
title: 'trustee.roles.title',
|
||||
subtitle: 'trustee.roles.subtitle',
|
||||
|
||||
headerButtons: [
|
||||
{
|
||||
id: 'new-role',
|
||||
label: 'trustee.roles.new_button',
|
||||
icon: FaPlus,
|
||||
variant: 'primary',
|
||||
formConfig: {
|
||||
fields: [
|
||||
{
|
||||
key: 'id',
|
||||
label: 'trustee.roles.field.id',
|
||||
type: 'string',
|
||||
required: true,
|
||||
placeholder: 'trustee.roles.field.id_placeholder'
|
||||
},
|
||||
{
|
||||
key: 'desc',
|
||||
label: 'trustee.roles.field.desc',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
placeholder: 'trustee.roles.field.desc_placeholder',
|
||||
minRows: 3,
|
||||
maxRows: 6
|
||||
}
|
||||
],
|
||||
popupTitle: 'trustee.roles.modal.create.title',
|
||||
popupSize: 'medium',
|
||||
createOperationName: 'handleCreate',
|
||||
successMessage: 'trustee.roles.create.success',
|
||||
errorMessage: 'trustee.roles.create.error'
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
content: [
|
||||
{
|
||||
id: 'roles-table',
|
||||
type: 'table',
|
||||
tableConfig: {
|
||||
hookFactory: createRolesHook,
|
||||
actionButtons: [
|
||||
{
|
||||
type: 'edit',
|
||||
title: 'trustee.roles.action.edit',
|
||||
idField: 'id',
|
||||
nameField: 'id',
|
||||
operationName: 'handleUpdate',
|
||||
loadingStateName: 'updatingItems',
|
||||
fetchItemFunctionName: 'fetchById',
|
||||
disabled: (hookData: any) => {
|
||||
if (!hookData?.permissions) return { disabled: false };
|
||||
const hasUpdate = hookData.permissions.update !== 'n' && hookData.permissions.view;
|
||||
return { disabled: !hasUpdate, message: 'No permission to edit roles' };
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'delete',
|
||||
title: 'trustee.roles.action.delete',
|
||||
idField: 'id',
|
||||
operationName: 'handleDelete',
|
||||
loadingStateName: 'deletingItems',
|
||||
disabled: (hookData: any) => {
|
||||
if (!hookData?.permissions) return { disabled: false };
|
||||
const hasDelete = hookData.permissions.delete !== 'n' && hookData.permissions.view;
|
||||
return { disabled: !hasDelete, message: 'No permission to delete roles' };
|
||||
}
|
||||
}
|
||||
],
|
||||
searchable: true,
|
||||
filterable: true,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
pagination: true,
|
||||
pageSize: 10,
|
||||
className: 'trustee-roles-table'
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
persistent: false,
|
||||
preload: false,
|
||||
preserveState: true,
|
||||
moduleEnabled: true,
|
||||
|
||||
onActivate: async () => {
|
||||
if (import.meta.env.DEV) console.log('Trustee Roles activated');
|
||||
},
|
||||
onLoad: async () => {
|
||||
if (import.meta.env.DEV) console.log('Trustee Roles loaded');
|
||||
},
|
||||
onUnload: async () => {
|
||||
if (import.meta.env.DEV) console.log('Trustee Roles unloaded');
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -262,9 +262,9 @@ export interface GenericDataHook {
|
|||
[key: string]: any; // Allow additional properties for dynamic data sources
|
||||
}
|
||||
|
||||
// Action button configuration
|
||||
// Standard action button configuration (built-in actions: edit, delete, view, copy)
|
||||
export interface ActionButtonConfig {
|
||||
type: 'view' | 'edit' | 'download' | 'delete' | 'copy' | 'connect' | 'play';
|
||||
type: 'view' | 'edit' | 'delete' | 'copy';
|
||||
onAction?: (row: any) => Promise<void> | void; // Optional for delete buttons since they handle their own logic
|
||||
title?: string | LanguageText;
|
||||
disabled?: (row: any) => boolean | { disabled: boolean; message?: string };
|
||||
|
|
@ -274,24 +274,32 @@ export interface ActionButtonConfig {
|
|||
nameField?: string; // Field name for display name (default: 'name' or 'file_name')
|
||||
typeField?: string; // Field name for type/mime type (default: 'type' or 'mime_type')
|
||||
contentField?: string; // Field name for content (default: 'content')
|
||||
statusField?: string; // Field name for status (default: 'status')
|
||||
authorityField?: string; // Field name for authority (msft/google) (default: 'authority')
|
||||
// Operation and loading state names
|
||||
operationName?: string; // Name of the operation function in hookData
|
||||
disconnectOperationName?: string; // Name of the disconnect operation function in hookData (for connect button)
|
||||
refreshOperationName?: string; // Name of the refresh operation function in hookData (for connect button)
|
||||
loadingStateName?: string; // Name of the loading state in hookData
|
||||
fetchItemFunctionName?: string; // Name of the function in hookData to fetch a single item by ID (for edit button)
|
||||
// Navigation and behavior (for play button)
|
||||
navigateTo?: string; // Path to navigate to after action (default: 'start/dashboard')
|
||||
mode?: 'workflow' | 'prompt'; // Behavior mode for play button: 'workflow' selects workflow, 'prompt' sets input value (default: 'prompt')
|
||||
}
|
||||
|
||||
// Custom action button configuration (for entity-specific actions like download, connect, play, sendPasswordLink)
|
||||
export interface CustomActionConfig {
|
||||
id: string; // Unique identifier for the action
|
||||
icon: React.ReactNode; // Icon component to display
|
||||
onClick: (row: any, hookData?: any) => Promise<void> | void; // Handler function
|
||||
visible?: (row: any, hookData?: any) => boolean; // Show/hide based on row data (default: true)
|
||||
disabled?: (row: any, hookData?: any) => boolean | { disabled: boolean; message?: string }; // Disable based on row data
|
||||
loading?: (row: any, hookData?: any) => boolean; // Loading state based on row data
|
||||
title?: string | LanguageText | ((row: any) => string); // Tooltip text
|
||||
className?: string; // Optional custom CSS class
|
||||
// Field mappings (optional, for convenience)
|
||||
idField?: string; // Field name for the unique identifier (default: 'id')
|
||||
}
|
||||
|
||||
// Table content configuration
|
||||
export interface TableContentConfig {
|
||||
hookFactory: () => () => GenericDataHook; // Hook factory that returns a hook function
|
||||
columns?: any[]; // Column configuration (optional - can be generated dynamically from attributes via hookData.columns)
|
||||
actionButtons?: ActionButtonConfig[]; // Action buttons configuration
|
||||
actionButtons?: ActionButtonConfig[]; // Standard action buttons configuration (edit, delete, view, copy)
|
||||
customActions?: CustomActionConfig[]; // Custom action buttons (download, connect, play, sendPasswordLink, etc.)
|
||||
searchable?: boolean;
|
||||
filterable?: boolean;
|
||||
sortable?: boolean;
|
||||
|
|
|
|||
|
|
@ -235,11 +235,11 @@ export function useMandates() {
|
|||
|
||||
// Email validation
|
||||
if (fieldType === 'email') {
|
||||
validator = (value: string) => {
|
||||
if (required && (!value || value.trim() === '')) {
|
||||
validator = (value: any) => {
|
||||
if (required && (!value || (typeof value === 'string' && value.trim() === ''))) {
|
||||
return 'Email cannot be empty';
|
||||
}
|
||||
if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
||||
if (value && typeof value === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
||||
return 'Invalid email format';
|
||||
}
|
||||
return null;
|
||||
|
|
@ -369,11 +369,11 @@ export function useMandates() {
|
|||
|
||||
// Email validation
|
||||
if (fieldType === 'email') {
|
||||
validator = (value: string) => {
|
||||
if (required && (!value || value.trim() === '')) {
|
||||
validator = (value: any) => {
|
||||
if (required && (!value || (typeof value === 'string' && value.trim() === ''))) {
|
||||
return 'Email cannot be empty';
|
||||
}
|
||||
if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
||||
if (value && typeof value === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
||||
return 'Invalid email format';
|
||||
}
|
||||
return null;
|
||||
|
|
@ -399,8 +399,8 @@ export function useMandates() {
|
|||
}
|
||||
// String validation for required fields
|
||||
else if (fieldType === 'string' && required) {
|
||||
validator = (value: string) => {
|
||||
if (!value || value.trim() === '') {
|
||||
validator = (value: any) => {
|
||||
if (!value || (typeof value === 'string' && value.trim() === '')) {
|
||||
return `${attr.label} is required`;
|
||||
}
|
||||
return null;
|
||||
|
|
@ -548,6 +548,15 @@ export function useMandateOperations() {
|
|||
}
|
||||
};
|
||||
|
||||
// Generic inline update handler for FormGeneratorTable
|
||||
const handleInlineUpdate = async (mandateId: string, changes: Partial<MandateUpdateData>) => {
|
||||
const result = await handleMandateUpdate(mandateId, changes as MandateUpdateData);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to update');
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
return {
|
||||
deletingMandates,
|
||||
editingMandates,
|
||||
|
|
@ -558,6 +567,7 @@ export function useMandateOperations() {
|
|||
handleMandateDelete,
|
||||
handleMandateCreate,
|
||||
handleMandateUpdate,
|
||||
handleInlineUpdate,
|
||||
isLoading
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -276,11 +276,11 @@ export function useRbacRoles() {
|
|||
|
||||
// Email validation
|
||||
if (fieldType === 'email') {
|
||||
validator = (value: string) => {
|
||||
if (required && (!value || value.trim() === '')) {
|
||||
validator = (value: any) => {
|
||||
if (required && (!value || (typeof value === 'string' && value.trim() === ''))) {
|
||||
return 'Email cannot be empty';
|
||||
}
|
||||
if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
||||
if (value && typeof value === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
||||
return 'Invalid email format';
|
||||
}
|
||||
return null;
|
||||
|
|
@ -410,11 +410,11 @@ export function useRbacRoles() {
|
|||
|
||||
// Email validation
|
||||
if (fieldType === 'email') {
|
||||
validator = (value: string) => {
|
||||
if (required && (!value || value.trim() === '')) {
|
||||
validator = (value: any) => {
|
||||
if (required && (!value || (typeof value === 'string' && value.trim() === ''))) {
|
||||
return 'Email cannot be empty';
|
||||
}
|
||||
if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
||||
if (value && typeof value === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
||||
return 'Invalid email format';
|
||||
}
|
||||
return null;
|
||||
|
|
@ -650,6 +650,15 @@ export function useRbacRoleOperations() {
|
|||
}
|
||||
};
|
||||
|
||||
// Generic inline update handler for FormGeneratorTable
|
||||
const handleInlineUpdate = async (roleId: string, changes: Partial<RoleUpdateData>) => {
|
||||
const result = await handleRoleUpdate(roleId, changes as RoleUpdateData);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to update');
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
return {
|
||||
deletingRoles,
|
||||
editingRoles,
|
||||
|
|
@ -660,6 +669,7 @@ export function useRbacRoleOperations() {
|
|||
handleRoleDelete,
|
||||
handleRoleCreate,
|
||||
handleRoleUpdate,
|
||||
handleInlineUpdate,
|
||||
isLoading
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -252,11 +252,11 @@ export function useRbacRules() {
|
|||
|
||||
// Email validation
|
||||
if (fieldType === 'email') {
|
||||
validator = (value: string) => {
|
||||
if (required && (!value || value.trim() === '')) {
|
||||
validator = (value: any) => {
|
||||
if (required && (!value || (typeof value === 'string' && value.trim() === ''))) {
|
||||
return 'Email cannot be empty';
|
||||
}
|
||||
if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
||||
if (value && typeof value === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
||||
return 'Invalid email format';
|
||||
}
|
||||
return null;
|
||||
|
|
@ -386,11 +386,11 @@ export function useRbacRules() {
|
|||
|
||||
// Email validation
|
||||
if (fieldType === 'email') {
|
||||
validator = (value: string) => {
|
||||
if (required && (!value || value.trim() === '')) {
|
||||
validator = (value: any) => {
|
||||
if (required && (!value || (typeof value === 'string' && value.trim() === ''))) {
|
||||
return 'Email cannot be empty';
|
||||
}
|
||||
if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
||||
if (value && typeof value === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
||||
return 'Invalid email format';
|
||||
}
|
||||
return null;
|
||||
|
|
@ -416,8 +416,8 @@ export function useRbacRules() {
|
|||
}
|
||||
// String validation for required fields
|
||||
else if (fieldType === 'string' && required) {
|
||||
validator = (value: string) => {
|
||||
if (!value || value.trim() === '') {
|
||||
validator = (value: any) => {
|
||||
if (!value || (typeof value === 'string' && value.trim() === '')) {
|
||||
return `${attr.label} is required`;
|
||||
}
|
||||
return null;
|
||||
|
|
@ -565,6 +565,15 @@ export function useRbacRuleOperations() {
|
|||
}
|
||||
};
|
||||
|
||||
// Generic inline update handler for FormGeneratorTable
|
||||
const handleInlineUpdate = async (ruleId: string, changes: Partial<RbacRuleUpdateData>) => {
|
||||
const result = await handleRbacRuleUpdate(ruleId, changes as RbacRuleUpdateData);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to update');
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
return {
|
||||
deletingRbacRules,
|
||||
editingRbacRules,
|
||||
|
|
@ -575,6 +584,7 @@ export function useRbacRuleOperations() {
|
|||
handleRbacRuleDelete,
|
||||
handleRbacRuleCreate,
|
||||
handleRbacRuleUpdate,
|
||||
handleInlineUpdate,
|
||||
isLoading
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useApiRequest } from './useApi';
|
||||
import {
|
||||
fetchPermissions as fetchPermissionsApi,
|
||||
fetchAllPermissions as fetchAllPermissionsApi,
|
||||
type PermissionLevel,
|
||||
type UserPermissions,
|
||||
type PermissionContext
|
||||
|
|
@ -18,22 +19,30 @@ interface PermissionCache {
|
|||
// Operation type for permission checks
|
||||
export type PermissionOperation = 'read' | 'create' | 'update' | 'delete';
|
||||
|
||||
// Default permission (no access)
|
||||
const DEFAULT_NO_ACCESS: UserPermissions = {
|
||||
view: false,
|
||||
read: 'n' as PermissionLevel,
|
||||
create: 'n' as PermissionLevel,
|
||||
update: 'n' as PermissionLevel,
|
||||
delete: 'n' as PermissionLevel,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for managing RBAC permissions
|
||||
* Provides centralized permission checking with caching
|
||||
*
|
||||
* Optimized to fetch all UI permissions in a single API call on first use,
|
||||
* then serve subsequent requests from cache.
|
||||
*/
|
||||
export const usePermissions = () => {
|
||||
const [cache, setCache] = useState<PermissionCache>({});
|
||||
const cacheRef = useRef<PermissionCache>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const cacheRef = useRef<PermissionCache>({});
|
||||
const bulkLoadPromiseRef = useRef<Promise<void> | null>(null);
|
||||
const bulkLoadedContextsRef = useRef<Set<string>>(new Set());
|
||||
const pendingRequests = useRef<Map<string, Promise<UserPermissions>>>(new Map());
|
||||
const { request } = useApiRequest();
|
||||
|
||||
// Keep cacheRef in sync with cache state
|
||||
useEffect(() => {
|
||||
cacheRef.current = cache;
|
||||
}, [cache]);
|
||||
|
||||
/**
|
||||
* Generate a cache key for a permission check
|
||||
*/
|
||||
|
|
@ -42,39 +51,67 @@ export const usePermissions = () => {
|
|||
};
|
||||
|
||||
/**
|
||||
* Retry function with exponential backoff for 429 errors
|
||||
* Load all permissions for a context (UI or RESOURCE) in bulk
|
||||
* This is called once per context and caches all permissions
|
||||
*/
|
||||
const retryWithBackoff = async (
|
||||
fn: () => Promise<any>,
|
||||
maxRetries = 3,
|
||||
baseDelay = 1000
|
||||
): Promise<any> => {
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 429 && attempt < maxRetries - 1) {
|
||||
const delay = baseDelay * Math.pow(2, attempt);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const loadBulkPermissions = useCallback(async (context: 'UI' | 'RESOURCE'): Promise<void> => {
|
||||
// Skip if already loaded for this context
|
||||
if (bulkLoadedContextsRef.current.has(context)) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if there's already a pending bulk load
|
||||
if (bulkLoadPromiseRef.current) {
|
||||
await bulkLoadPromiseRef.current;
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the bulk load promise
|
||||
bulkLoadPromiseRef.current = (async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
console.log(`🔐 usePermissions: Bulk loading all ${context} permissions...`);
|
||||
const response = await fetchAllPermissionsApi(request, context);
|
||||
|
||||
// Cache all permissions from the response
|
||||
const contextKey = context.toLowerCase() as 'ui' | 'resource';
|
||||
const permissions = response[contextKey] || {};
|
||||
|
||||
const newCache: PermissionCache = { ...cacheRef.current };
|
||||
let count = 0;
|
||||
|
||||
for (const [item, perm] of Object.entries(permissions)) {
|
||||
const key = getPermissionKey(context, item);
|
||||
newCache[key] = perm;
|
||||
count++;
|
||||
}
|
||||
|
||||
cacheRef.current = newCache;
|
||||
bulkLoadedContextsRef.current.add(context);
|
||||
|
||||
console.log(`✅ usePermissions: Bulk loaded ${count} ${context} permissions`);
|
||||
} catch (error: any) {
|
||||
console.error(`❌ usePermissions: Error bulk loading ${context} permissions:`, error);
|
||||
// Don't mark as loaded on error - allow retry
|
||||
} finally {
|
||||
bulkLoadPromiseRef.current = null;
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
await bulkLoadPromiseRef.current;
|
||||
}, [request]);
|
||||
|
||||
/**
|
||||
* Check permissions for a given context and item
|
||||
* Returns full UserPermissions object
|
||||
* Checks cache first, then fetches from backend if not cached
|
||||
* Fetch individual permission (used for DATA context and fallback)
|
||||
*/
|
||||
const checkPermission = useCallback(async (
|
||||
const fetchIndividualPermission = useCallback(async (
|
||||
context: PermissionContext,
|
||||
item?: string
|
||||
): Promise<UserPermissions> => {
|
||||
const key = getPermissionKey(context, item);
|
||||
|
||||
// Check cache first using ref to avoid stale closures
|
||||
// Check cache first
|
||||
if (cacheRef.current[key]) {
|
||||
return cacheRef.current[key];
|
||||
}
|
||||
|
|
@ -84,76 +121,21 @@ export const usePermissions = () => {
|
|||
return pendingRequests.current.get(key)!;
|
||||
}
|
||||
|
||||
// Create new request
|
||||
// Fetch individual permission
|
||||
const requestPromise = (async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Use retry logic for 429 errors
|
||||
// Note: We wrap the API call in retry logic since useApiRequest doesn't handle 429 retries
|
||||
console.log('🔐 usePermissions: Checking permissions for:', { context, item, cacheKey: key });
|
||||
const permissions = await fetchPermissionsApi(request, context, item);
|
||||
|
||||
// Update cache
|
||||
cacheRef.current = { ...cacheRef.current, [key]: permissions };
|
||||
|
||||
const permissions = await retryWithBackoff(async () => {
|
||||
try {
|
||||
const result = await fetchPermissionsApi(request, context, item);
|
||||
console.log('✅ usePermissions: Received permissions response:', {
|
||||
context,
|
||||
item,
|
||||
permissions: result,
|
||||
view: result?.view,
|
||||
viewType: typeof result?.view,
|
||||
viewValue: result?.view,
|
||||
read: result?.read,
|
||||
create: result?.create,
|
||||
update: result?.update,
|
||||
delete: result?.delete,
|
||||
isArray: Array.isArray(result),
|
||||
keys: result ? Object.keys(result) : [],
|
||||
fullResponse: JSON.stringify(result, null, 2)
|
||||
});
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
console.error('❌ usePermissions: Error fetching permissions:', {
|
||||
context,
|
||||
item,
|
||||
error: error.message,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
fullError: error
|
||||
});
|
||||
// If useApiRequest throws, we need to check if it's a 429
|
||||
// For now, we'll let the retry logic handle it
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
// Update cache after fetching from backend
|
||||
setCache(prev => {
|
||||
const newCache = { ...prev, [key]: permissions };
|
||||
cacheRef.current = newCache;
|
||||
console.log('💾 usePermissions: Cached permissions:', { context, item, permissions });
|
||||
return newCache;
|
||||
});
|
||||
|
||||
return permissions;
|
||||
} catch (error: any) {
|
||||
// Only log non-429 errors to avoid spam
|
||||
if (error.response?.status !== 429) {
|
||||
console.error('Error checking permissions:', error);
|
||||
}
|
||||
console.error('Error checking permissions:', error);
|
||||
|
||||
// Return cached value if available, otherwise default (no access)
|
||||
const cached = cacheRef.current[key];
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
return {
|
||||
view: false,
|
||||
read: 'n' as PermissionLevel,
|
||||
create: 'n' as PermissionLevel,
|
||||
update: 'n' as PermissionLevel,
|
||||
delete: 'n' as PermissionLevel,
|
||||
};
|
||||
return cacheRef.current[key] || DEFAULT_NO_ACCESS;
|
||||
} finally {
|
||||
pendingRequests.current.delete(key);
|
||||
setLoading(false);
|
||||
|
|
@ -166,6 +148,39 @@ export const usePermissions = () => {
|
|||
return requestPromise;
|
||||
}, [request]);
|
||||
|
||||
/**
|
||||
* Check permissions for a given context and item
|
||||
* Returns full UserPermissions object
|
||||
*
|
||||
* For UI/RESOURCE contexts: Uses bulk-loaded cache, falls back to individual fetch
|
||||
* For DATA context: Fetches individually (as items are dynamic)
|
||||
*/
|
||||
const checkPermission = useCallback(async (
|
||||
context: PermissionContext,
|
||||
item?: string
|
||||
): Promise<UserPermissions> => {
|
||||
const key = getPermissionKey(context, item);
|
||||
|
||||
// For UI and RESOURCE contexts, try bulk loading first
|
||||
if (context === 'UI' || context === 'RESOURCE') {
|
||||
// Ensure bulk permissions are loaded
|
||||
await loadBulkPermissions(context);
|
||||
|
||||
// Check cache after bulk load
|
||||
if (cacheRef.current[key]) {
|
||||
return cacheRef.current[key];
|
||||
}
|
||||
|
||||
// If not in bulk cache, fall back to individual fetch
|
||||
// (item may not have explicit rule, but backend will calculate effective permissions)
|
||||
console.log(`⚠️ usePermissions: ${context}:${item} not in bulk cache, fetching individually`);
|
||||
return fetchIndividualPermission(context, item);
|
||||
}
|
||||
|
||||
// For DATA context, fetch individually
|
||||
return fetchIndividualPermission(context, item);
|
||||
}, [loadBulkPermissions, fetchIndividualPermission]);
|
||||
|
||||
/**
|
||||
* Check if user has permission for a specific operation
|
||||
* Returns true if user has any level of permission (not 'n')
|
||||
|
|
@ -197,35 +212,25 @@ export const usePermissions = () => {
|
|||
context: PermissionContext,
|
||||
item: string
|
||||
): Promise<boolean> => {
|
||||
console.log('👁️ canView: Checking view access for:', { context, item });
|
||||
const permissions = await checkPermission(context, item);
|
||||
const hasAccess = permissions.view === true;
|
||||
console.log('👁️ canView: Result:', {
|
||||
context,
|
||||
item,
|
||||
hasAccess,
|
||||
viewPermission: permissions.view,
|
||||
viewPermissionType: typeof permissions.view,
|
||||
viewPermissionValue: permissions.view,
|
||||
allPermissions: {
|
||||
view: permissions.view,
|
||||
read: permissions.read,
|
||||
create: permissions.create,
|
||||
update: permissions.update,
|
||||
delete: permissions.delete
|
||||
},
|
||||
fullPermissionsObject: JSON.stringify(permissions, null, 2)
|
||||
});
|
||||
return hasAccess;
|
||||
return permissions.view === true;
|
||||
}, [checkPermission]);
|
||||
|
||||
/**
|
||||
* Preload all permissions for UI context
|
||||
* Call this early in the app lifecycle to warm the cache
|
||||
*/
|
||||
const preloadUiPermissions = useCallback(async (): Promise<void> => {
|
||||
await loadBulkPermissions('UI');
|
||||
}, [loadBulkPermissions]);
|
||||
|
||||
/**
|
||||
* Clear the permission cache
|
||||
* Useful when user permissions change or after logout
|
||||
*/
|
||||
const clearCache = useCallback(() => {
|
||||
setCache({});
|
||||
cacheRef.current = {};
|
||||
bulkLoadedContextsRef.current.clear();
|
||||
pendingRequests.current.clear();
|
||||
}, []);
|
||||
|
||||
|
|
@ -233,6 +238,7 @@ export const usePermissions = () => {
|
|||
checkPermission,
|
||||
hasPermission,
|
||||
canView,
|
||||
preloadUiPermissions,
|
||||
loading,
|
||||
clearCache,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -235,11 +235,11 @@ export function usePrompts() {
|
|||
// Match create button configuration for prompts
|
||||
if (attr.name === 'name') {
|
||||
required = true;
|
||||
validator = (value: string) => {
|
||||
if (!value || value.trim() === '') {
|
||||
validator = (value: any) => {
|
||||
if (!value || (typeof value === 'string' && value.trim() === '')) {
|
||||
return 'Prompt name cannot be empty';
|
||||
}
|
||||
if (value.length > 100) {
|
||||
if (typeof value === 'string' && value.length > 100) {
|
||||
return 'Prompt name cannot exceed 100 characters';
|
||||
}
|
||||
return null;
|
||||
|
|
@ -248,11 +248,11 @@ export function usePrompts() {
|
|||
required = true;
|
||||
minRows = 6; // Match create button: minRows: 6
|
||||
maxRows = 12; // Match create button: maxRows: 12
|
||||
validator = (value: string) => {
|
||||
if (!value || value.trim() === '') {
|
||||
validator = (value: any) => {
|
||||
if (!value || (typeof value === 'string' && value.trim() === '')) {
|
||||
return 'Prompt content cannot be empty';
|
||||
}
|
||||
if (value.length > 10000) {
|
||||
if (typeof value === 'string' && value.length > 10000) {
|
||||
return 'Prompt content cannot exceed 10,000 characters';
|
||||
}
|
||||
return null;
|
||||
|
|
@ -302,11 +302,11 @@ export function usePrompts() {
|
|||
|
||||
if (attr.name === 'name') {
|
||||
required = true;
|
||||
validator = (value: string) => {
|
||||
if (!value || value.trim() === '') {
|
||||
validator = (value: any) => {
|
||||
if (!value || (typeof value === 'string' && value.trim() === '')) {
|
||||
return 'Prompt name cannot be empty';
|
||||
}
|
||||
if (value.length > 100) {
|
||||
if (typeof value === 'string' && value.length > 100) {
|
||||
return 'Prompt name cannot exceed 100 characters';
|
||||
}
|
||||
return null;
|
||||
|
|
@ -315,11 +315,11 @@ export function usePrompts() {
|
|||
required = true;
|
||||
minRows = 6; // Match create button
|
||||
maxRows = 12; // Match create button
|
||||
validator = (value: string) => {
|
||||
if (!value || value.trim() === '') {
|
||||
validator = (value: any) => {
|
||||
if (!value || (typeof value === 'string' && value.trim() === '')) {
|
||||
return 'Prompt content cannot be empty';
|
||||
}
|
||||
if (value.length > 10000) {
|
||||
if (typeof value === 'string' && value.length > 10000) {
|
||||
return 'Prompt content cannot exceed 10,000 characters';
|
||||
}
|
||||
return null;
|
||||
|
|
@ -342,6 +342,95 @@ export function usePrompts() {
|
|||
return editableFields;
|
||||
}, [attributes]);
|
||||
|
||||
// Generate create fields from attributes dynamically
|
||||
// For prompts, the create form is essentially the same as edit form
|
||||
const generateCreateFieldsFromAttributes = useCallback((): Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly';
|
||||
required?: boolean;
|
||||
validator?: (value: any) => string | null;
|
||||
minRows?: number;
|
||||
maxRows?: number;
|
||||
options?: Array<{ value: string | number; label: string }>;
|
||||
optionsReference?: string;
|
||||
placeholder?: string;
|
||||
}> => {
|
||||
if (!attributes || attributes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const createFields = attributes
|
||||
.filter(attr => {
|
||||
// Filter out non-editable fields and auto-generated fields for create forms
|
||||
if (attr.readonly === true || attr.editable === false) {
|
||||
return false;
|
||||
}
|
||||
// Filter out ID fields and other auto-generated fields
|
||||
const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete'];
|
||||
return !nonEditableFields.includes(attr.name);
|
||||
})
|
||||
.map(attr => {
|
||||
// Map backend attribute type to form field type
|
||||
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string';
|
||||
let minRows: number | undefined = undefined;
|
||||
let maxRows: number | undefined = undefined;
|
||||
|
||||
// Map backend types to form field types
|
||||
// Cast to string to handle all possible backend type values
|
||||
const attrType = attr.type as string;
|
||||
if (attrType === 'checkbox' || attrType === 'boolean') {
|
||||
fieldType = 'boolean';
|
||||
} else if (attrType === 'email') {
|
||||
fieldType = 'email';
|
||||
} else if (attrType === 'timestamp' || attrType === 'date' || attrType === 'time') {
|
||||
fieldType = 'date';
|
||||
} else if (attrType === 'textarea') {
|
||||
fieldType = 'textarea';
|
||||
// Set default rows for textarea fields
|
||||
minRows = 6;
|
||||
maxRows = 12;
|
||||
} else if (attr.name === 'content' || attr.name.toLowerCase().includes('content')) {
|
||||
// Content fields should be textarea
|
||||
fieldType = 'textarea';
|
||||
minRows = 6;
|
||||
maxRows = 12;
|
||||
}
|
||||
|
||||
// Determine if required and build validator
|
||||
const required = attr.required === true;
|
||||
let validator: ((value: any) => string | null) | undefined = undefined;
|
||||
|
||||
// Required string validation
|
||||
if (required && (fieldType === 'string' || fieldType === 'textarea')) {
|
||||
validator = (value: any) => {
|
||||
if (!value || (typeof value === 'string' && value.trim() === '')) {
|
||||
return `${attr.label} is required`;
|
||||
}
|
||||
if (attr.name === 'name' && typeof value === 'string' && value.length > 100) {
|
||||
return 'Prompt name cannot exceed 100 characters';
|
||||
}
|
||||
if (attr.name === 'content' && typeof value === 'string' && value.length > 10000) {
|
||||
return 'Prompt content cannot exceed 10,000 characters';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: fieldType,
|
||||
required,
|
||||
validator,
|
||||
minRows,
|
||||
maxRows
|
||||
};
|
||||
});
|
||||
|
||||
return createFields;
|
||||
}, [attributes]);
|
||||
|
||||
// Ensure attributes are loaded - can be called by EditActionButton
|
||||
const ensureAttributesLoaded = useCallback(async () => {
|
||||
// If attributes are already loaded, return them
|
||||
|
|
@ -377,6 +466,7 @@ export function usePrompts() {
|
|||
pagination,
|
||||
fetchPromptById,
|
||||
generateEditFieldsFromAttributes,
|
||||
generateCreateFieldsFromAttributes,
|
||||
ensureAttributesLoaded // Generic function to ensure attributes are loaded
|
||||
};
|
||||
}
|
||||
|
|
@ -474,6 +564,15 @@ export function usePromptOperations() {
|
|||
}
|
||||
};
|
||||
|
||||
// Generic inline update handler for FormGeneratorTable
|
||||
const handleInlineUpdate = async (promptId: string, changes: Partial<{ name: string; content: string }>) => {
|
||||
const result = await handlePromptUpdate(promptId, changes as { name: string; content: string });
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to update');
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
return {
|
||||
deletingPrompts,
|
||||
creatingPrompt,
|
||||
|
|
@ -483,6 +582,7 @@ export function usePromptOperations() {
|
|||
handlePromptDelete,
|
||||
handlePromptCreate,
|
||||
handlePromptUpdate,
|
||||
handleInlineUpdate,
|
||||
isLoading
|
||||
};
|
||||
}
|
||||
484
src/hooks/useTrustee.ts
Normal file
484
src/hooks/useTrustee.ts
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useApiRequest } from './useApi';
|
||||
import { getUserDataCache } from '../utils/userCache';
|
||||
import api from '../api';
|
||||
import { usePermissions, type UserPermissions } from './usePermissions';
|
||||
import {
|
||||
// Types
|
||||
type TrusteeOrganisation,
|
||||
type TrusteeRole,
|
||||
type TrusteeAccess,
|
||||
type TrusteeContract,
|
||||
type TrusteeDocument,
|
||||
type TrusteePosition,
|
||||
type TrusteePositionDocument,
|
||||
type PaginationParams,
|
||||
// Organisation API
|
||||
fetchOrganisations as fetchOrganisationsApi,
|
||||
fetchOrganisationById as fetchOrganisationByIdApi,
|
||||
createOrganisation as createOrganisationApi,
|
||||
updateOrganisation as updateOrganisationApi,
|
||||
deleteOrganisation as deleteOrganisationApi,
|
||||
// Role API
|
||||
fetchRoles as fetchRolesApi,
|
||||
fetchRoleById as fetchRoleByIdApi,
|
||||
createRole as createRoleApi,
|
||||
updateRole as updateRoleApi,
|
||||
deleteRole as deleteRoleApi,
|
||||
// Access API
|
||||
fetchAccess as fetchAccessApi,
|
||||
fetchAccessById as fetchAccessByIdApi,
|
||||
createAccess as createAccessApi,
|
||||
updateAccess as updateAccessApi,
|
||||
deleteAccess as deleteAccessApi,
|
||||
// Contract API
|
||||
fetchContracts as fetchContractsApi,
|
||||
fetchContractById as fetchContractByIdApi,
|
||||
createContract as createContractApi,
|
||||
updateContract as updateContractApi,
|
||||
deleteContract as deleteContractApi,
|
||||
// Document API
|
||||
fetchDocuments as fetchDocumentsApi,
|
||||
fetchDocumentById as fetchDocumentByIdApi,
|
||||
createDocument as createDocumentApi,
|
||||
updateDocument as updateDocumentApi,
|
||||
deleteDocument as deleteDocumentApi,
|
||||
// Position API
|
||||
fetchPositions as fetchPositionsApi,
|
||||
fetchPositionById as fetchPositionByIdApi,
|
||||
createPosition as createPositionApi,
|
||||
updatePosition as updatePositionApi,
|
||||
deletePosition as deletePositionApi,
|
||||
// Position-Document API
|
||||
fetchPositionDocuments as fetchPositionDocumentsApi,
|
||||
createPositionDocument as createPositionDocumentApi,
|
||||
deletePositionDocument as deletePositionDocumentApi,
|
||||
} from '../api/trusteeApi';
|
||||
|
||||
// Re-export types
|
||||
export type {
|
||||
TrusteeOrganisation,
|
||||
TrusteeRole,
|
||||
TrusteeAccess,
|
||||
TrusteeContract,
|
||||
TrusteeDocument,
|
||||
TrusteePosition,
|
||||
TrusteePositionDocument,
|
||||
PaginationParams
|
||||
};
|
||||
|
||||
export interface AttributeDefinition {
|
||||
name: string;
|
||||
type: 'text' | 'email' | 'date' | 'checkbox' | 'select' | 'multiselect' | 'number' | 'textarea' | 'timestamp' | 'file';
|
||||
label: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
default?: any;
|
||||
options?: any[] | string;
|
||||
readonly?: boolean;
|
||||
editable?: boolean;
|
||||
visible?: boolean;
|
||||
order?: number;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
searchable?: boolean;
|
||||
width?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
filterOptions?: string[];
|
||||
dependsOn?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GENERIC TRUSTEE ENTITY HOOK FACTORY
|
||||
// ============================================================================
|
||||
|
||||
interface TrusteeEntityConfig<T> {
|
||||
entityName: string;
|
||||
fetchAll: (request: any, params?: PaginationParams) => Promise<any>;
|
||||
fetchById: (request: any, id: string) => Promise<T | null>;
|
||||
create: (request: any, data: Partial<T>) => Promise<T>;
|
||||
update: (request: any, id: string, data: Partial<T>) => Promise<T>;
|
||||
deleteItem: (request: any, id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function _createTrusteeEntityHook<T extends { id: string }>(config: TrusteeEntityConfig<T>) {
|
||||
return function useTrusteeEntity() {
|
||||
const [items, setItems] = useState<T[]>([]);
|
||||
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
|
||||
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
|
||||
const [pagination, setPagination] = useState<{
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
} | null>(null);
|
||||
const { request, isLoading: loading, error } = useApiRequest<null, T[]>();
|
||||
const { checkPermission } = usePermissions();
|
||||
|
||||
const fetchAttributes = useCallback(async () => {
|
||||
try {
|
||||
const response = await api.get(`/api/attributes/${config.entityName}`);
|
||||
let attrs: AttributeDefinition[] = [];
|
||||
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
|
||||
attrs = response.data.attributes;
|
||||
} else if (Array.isArray(response.data)) {
|
||||
attrs = response.data;
|
||||
}
|
||||
setAttributes(attrs);
|
||||
return attrs;
|
||||
} catch (error: any) {
|
||||
console.error(`Error fetching ${config.entityName} attributes:`, error);
|
||||
setAttributes([]);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchPermissions = useCallback(async () => {
|
||||
try {
|
||||
const perms = await checkPermission('DATA', config.entityName);
|
||||
setPermissions(perms);
|
||||
return perms;
|
||||
} catch (error: any) {
|
||||
console.error(`Error fetching ${config.entityName} permissions:`, error);
|
||||
const defaultPerms: UserPermissions = {
|
||||
view: false,
|
||||
read: 'n',
|
||||
create: 'n',
|
||||
update: 'n',
|
||||
delete: 'n',
|
||||
};
|
||||
setPermissions(defaultPerms);
|
||||
return defaultPerms;
|
||||
}
|
||||
}, [checkPermission]);
|
||||
|
||||
const fetchItems = useCallback(async (params?: PaginationParams) => {
|
||||
try {
|
||||
const data = await config.fetchAll(request, params);
|
||||
|
||||
if (data && typeof data === 'object' && 'items' in data) {
|
||||
const fetchedItems = Array.isArray(data.items) ? data.items : [];
|
||||
setItems(fetchedItems);
|
||||
if (data.pagination) {
|
||||
setPagination(data.pagination);
|
||||
}
|
||||
} else {
|
||||
const fetchedItems = Array.isArray(data) ? data : [];
|
||||
setItems(fetchedItems);
|
||||
setPagination(null);
|
||||
}
|
||||
} catch (error: any) {
|
||||
setItems([]);
|
||||
setPagination(null);
|
||||
}
|
||||
}, [request]);
|
||||
|
||||
const removeOptimistically = (itemId: string) => {
|
||||
setItems(prev => prev.filter(item => item.id !== itemId));
|
||||
};
|
||||
|
||||
const updateOptimistically = (itemId: string, updateData: Partial<T>) => {
|
||||
setItems(prev =>
|
||||
prev.map(item =>
|
||||
item.id === itemId
|
||||
? { ...item, ...updateData }
|
||||
: item
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const fetchById = useCallback(async (itemId: string): Promise<T | null> => {
|
||||
return await config.fetchById(request, itemId);
|
||||
}, [request]);
|
||||
|
||||
const generateEditFieldsFromAttributes = useCallback(() => {
|
||||
if (!attributes || attributes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return attributes
|
||||
.filter(attr => {
|
||||
if (attr.readonly === true || attr.editable === false) {
|
||||
return false;
|
||||
}
|
||||
const nonEditableFields = ['_createdBy', '_createdAt', '_modifiedBy', '_modifiedAt'];
|
||||
return !nonEditableFields.includes(attr.name);
|
||||
})
|
||||
.map(attr => {
|
||||
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' | 'number' = 'string';
|
||||
let options: Array<{ value: string | number; label: string }> | undefined = undefined;
|
||||
let optionsReference: string | undefined = undefined;
|
||||
|
||||
if (attr.type === 'checkbox') {
|
||||
fieldType = 'boolean';
|
||||
} else if (attr.type === 'email') {
|
||||
fieldType = 'email';
|
||||
} else if (attr.type === 'date') {
|
||||
fieldType = 'date';
|
||||
} else if (attr.type === 'number') {
|
||||
fieldType = 'number';
|
||||
} else if (attr.type === 'select') {
|
||||
fieldType = 'enum';
|
||||
if (Array.isArray(attr.options)) {
|
||||
options = attr.options.map((opt: any) => {
|
||||
const labelValue = typeof opt.label === 'string'
|
||||
? opt.label
|
||||
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
|
||||
return { value: opt.value, label: labelValue };
|
||||
});
|
||||
} else if (typeof attr.options === 'string') {
|
||||
optionsReference = attr.options;
|
||||
}
|
||||
} else if (attr.type === 'multiselect') {
|
||||
fieldType = 'multiselect';
|
||||
if (Array.isArray(attr.options)) {
|
||||
options = attr.options.map((opt: any) => {
|
||||
const labelValue = typeof opt.label === 'string'
|
||||
? opt.label
|
||||
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
|
||||
return { value: opt.value, label: labelValue };
|
||||
});
|
||||
} else if (typeof attr.options === 'string') {
|
||||
optionsReference = attr.options;
|
||||
}
|
||||
} else if (attr.type === 'textarea') {
|
||||
fieldType = 'textarea';
|
||||
} else if (attr.type === 'timestamp') {
|
||||
fieldType = 'readonly';
|
||||
}
|
||||
|
||||
return {
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: fieldType,
|
||||
editable: attr.editable !== false && attr.readonly !== true,
|
||||
required: attr.required === true,
|
||||
options,
|
||||
optionsReference,
|
||||
dependsOn: attr.dependsOn
|
||||
};
|
||||
});
|
||||
}, [attributes]);
|
||||
|
||||
const ensureAttributesLoaded = useCallback(async () => {
|
||||
if (attributes && attributes.length > 0) {
|
||||
return attributes;
|
||||
}
|
||||
return await fetchAttributes();
|
||||
}, [attributes, fetchAttributes]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttributes();
|
||||
fetchPermissions();
|
||||
}, [fetchAttributes, fetchPermissions]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, [fetchItems]);
|
||||
|
||||
return {
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchItems,
|
||||
removeOptimistically,
|
||||
updateOptimistically,
|
||||
attributes,
|
||||
permissions,
|
||||
pagination,
|
||||
fetchById,
|
||||
generateEditFieldsFromAttributes,
|
||||
ensureAttributesLoaded
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function _createTrusteeOperationsHook<T extends { id: string }>(config: TrusteeEntityConfig<T>) {
|
||||
return function useTrusteeEntityOperations() {
|
||||
const [deletingItems, setDeletingItems] = useState<Set<string>>(new Set());
|
||||
const [creatingItem, setCreatingItem] = useState(false);
|
||||
const { request, isLoading } = useApiRequest();
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = async (itemId: string) => {
|
||||
setDeleteError(null);
|
||||
setDeletingItems(prev => new Set(prev).add(itemId));
|
||||
|
||||
try {
|
||||
await config.deleteItem(request, itemId);
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
setDeleteError(error.message);
|
||||
return false;
|
||||
} finally {
|
||||
setDeletingItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(itemId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async (itemData: Partial<T>) => {
|
||||
setCreateError(null);
|
||||
setCreatingItem(true);
|
||||
|
||||
try {
|
||||
const newItem = await config.create(request, itemData);
|
||||
return { success: true, data: newItem };
|
||||
} catch (error: any) {
|
||||
setCreateError(error.message);
|
||||
return { success: false, error: error.message };
|
||||
} finally {
|
||||
setCreatingItem(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (itemId: string, updateData: Partial<T>) => {
|
||||
setUpdateError(null);
|
||||
|
||||
try {
|
||||
const updatedItem = await config.update(request, itemId, updateData);
|
||||
return { success: true, data: updatedItem };
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.message || error.message || 'Failed to update';
|
||||
setUpdateError(errorMessage);
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
statusCode: error.response?.status,
|
||||
isPermissionError: error.response?.status === 403,
|
||||
isValidationError: error.response?.status === 400
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
deletingItems,
|
||||
creatingItem,
|
||||
deleteError,
|
||||
createError,
|
||||
updateError,
|
||||
handleDelete,
|
||||
handleCreate,
|
||||
handleUpdate,
|
||||
isLoading
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ORGANISATION HOOKS
|
||||
// ============================================================================
|
||||
|
||||
const organisationConfig: TrusteeEntityConfig<TrusteeOrganisation> = {
|
||||
entityName: 'TrusteeOrganisation',
|
||||
fetchAll: fetchOrganisationsApi,
|
||||
fetchById: fetchOrganisationByIdApi,
|
||||
create: createOrganisationApi,
|
||||
update: updateOrganisationApi,
|
||||
deleteItem: deleteOrganisationApi
|
||||
};
|
||||
|
||||
export const useTrusteeOrganisations = _createTrusteeEntityHook(organisationConfig);
|
||||
export const useTrusteeOrganisationOperations = _createTrusteeOperationsHook(organisationConfig);
|
||||
|
||||
// ============================================================================
|
||||
// ROLE HOOKS
|
||||
// ============================================================================
|
||||
|
||||
const roleConfig: TrusteeEntityConfig<TrusteeRole> = {
|
||||
entityName: 'TrusteeRole',
|
||||
fetchAll: fetchRolesApi,
|
||||
fetchById: fetchRoleByIdApi,
|
||||
create: createRoleApi,
|
||||
update: updateRoleApi,
|
||||
deleteItem: deleteRoleApi
|
||||
};
|
||||
|
||||
export const useTrusteeRoles = _createTrusteeEntityHook(roleConfig);
|
||||
export const useTrusteeRoleOperations = _createTrusteeOperationsHook(roleConfig);
|
||||
|
||||
// ============================================================================
|
||||
// ACCESS HOOKS
|
||||
// ============================================================================
|
||||
|
||||
const accessConfig: TrusteeEntityConfig<TrusteeAccess> = {
|
||||
entityName: 'TrusteeAccess',
|
||||
fetchAll: fetchAccessApi,
|
||||
fetchById: fetchAccessByIdApi,
|
||||
create: createAccessApi,
|
||||
update: updateAccessApi,
|
||||
deleteItem: deleteAccessApi
|
||||
};
|
||||
|
||||
export const useTrusteeAccess = _createTrusteeEntityHook(accessConfig);
|
||||
export const useTrusteeAccessOperations = _createTrusteeOperationsHook(accessConfig);
|
||||
|
||||
// ============================================================================
|
||||
// CONTRACT HOOKS
|
||||
// ============================================================================
|
||||
|
||||
const contractConfig: TrusteeEntityConfig<TrusteeContract> = {
|
||||
entityName: 'TrusteeContract',
|
||||
fetchAll: fetchContractsApi,
|
||||
fetchById: fetchContractByIdApi,
|
||||
create: createContractApi,
|
||||
update: updateContractApi,
|
||||
deleteItem: deleteContractApi
|
||||
};
|
||||
|
||||
export const useTrusteeContracts = _createTrusteeEntityHook(contractConfig);
|
||||
export const useTrusteeContractOperations = _createTrusteeOperationsHook(contractConfig);
|
||||
|
||||
// ============================================================================
|
||||
// DOCUMENT HOOKS
|
||||
// ============================================================================
|
||||
|
||||
const documentConfig: TrusteeEntityConfig<TrusteeDocument> = {
|
||||
entityName: 'TrusteeDocument',
|
||||
fetchAll: fetchDocumentsApi,
|
||||
fetchById: fetchDocumentByIdApi,
|
||||
create: createDocumentApi,
|
||||
update: updateDocumentApi,
|
||||
deleteItem: deleteDocumentApi
|
||||
};
|
||||
|
||||
export const useTrusteeDocuments = _createTrusteeEntityHook(documentConfig);
|
||||
export const useTrusteeDocumentOperations = _createTrusteeOperationsHook(documentConfig);
|
||||
|
||||
// ============================================================================
|
||||
// POSITION HOOKS
|
||||
// ============================================================================
|
||||
|
||||
const positionConfig: TrusteeEntityConfig<TrusteePosition> = {
|
||||
entityName: 'TrusteePosition',
|
||||
fetchAll: fetchPositionsApi,
|
||||
fetchById: fetchPositionByIdApi,
|
||||
create: createPositionApi,
|
||||
update: updatePositionApi,
|
||||
deleteItem: deletePositionApi
|
||||
};
|
||||
|
||||
export const useTrusteePositions = _createTrusteeEntityHook(positionConfig);
|
||||
export const useTrusteePositionOperations = _createTrusteeOperationsHook(positionConfig);
|
||||
|
||||
// ============================================================================
|
||||
// POSITION-DOCUMENT HOOKS
|
||||
// ============================================================================
|
||||
|
||||
const positionDocumentConfig: TrusteeEntityConfig<TrusteePositionDocument> = {
|
||||
entityName: 'TrusteePositionDocument',
|
||||
fetchAll: fetchPositionDocumentsApi,
|
||||
fetchById: async () => null, // Not typically needed
|
||||
create: createPositionDocumentApi,
|
||||
update: async () => { throw new Error('Update not supported for position-document links'); },
|
||||
deleteItem: deletePositionDocumentApi
|
||||
};
|
||||
|
||||
export const useTrusteePositionDocuments = _createTrusteeEntityHook(positionDocumentConfig);
|
||||
export const useTrusteePositionDocumentOperations = _createTrusteeOperationsHook(positionDocumentConfig);
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
createUser as createUserApi,
|
||||
updateUser as updateUserApi,
|
||||
deleteUser as deleteUserApi,
|
||||
sendPasswordLink as sendPasswordLinkApi,
|
||||
type User,
|
||||
type UserUpdateData,
|
||||
type AttributeDefinition,
|
||||
|
|
@ -579,11 +580,11 @@ export function useOrgUsers() {
|
|||
|
||||
// Email validation
|
||||
if (fieldType === 'email') {
|
||||
validator = (value: string) => {
|
||||
if (required && (!value || value.trim() === '')) {
|
||||
validator = (value: any) => {
|
||||
if (required && (!value || (typeof value === 'string' && value.trim() === ''))) {
|
||||
return 'Email cannot be empty';
|
||||
}
|
||||
if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
||||
if (value && typeof value === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
||||
return 'Invalid email format';
|
||||
}
|
||||
return null;
|
||||
|
|
@ -625,6 +626,135 @@ export function useOrgUsers() {
|
|||
return editableFields;
|
||||
}, [attributes]);
|
||||
|
||||
// Generate create fields from attributes dynamically
|
||||
// For users, we add a password field that's not in the backend attributes (since passwords are hashed)
|
||||
const generateCreateFieldsFromAttributes = useCallback((): Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly';
|
||||
required?: boolean;
|
||||
validator?: (value: any) => string | null;
|
||||
minRows?: number;
|
||||
maxRows?: number;
|
||||
options?: Array<{ value: string | number; label: string }>;
|
||||
optionsReference?: string;
|
||||
placeholder?: string;
|
||||
}> => {
|
||||
if (!attributes || attributes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const createFields = attributes
|
||||
.filter(attr => {
|
||||
// Filter out non-editable fields and auto-generated fields for create forms
|
||||
if (attr.readonly === true || attr.editable === false) {
|
||||
return false;
|
||||
}
|
||||
// Filter out ID fields and other auto-generated fields
|
||||
const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete', 'authenticationAuthority'];
|
||||
return !nonEditableFields.includes(attr.name);
|
||||
})
|
||||
.map(attr => {
|
||||
// Map backend attribute type to form field type
|
||||
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string';
|
||||
let options: Array<{ value: string | number; label: string }> | undefined = undefined;
|
||||
let optionsReference: string | undefined = undefined;
|
||||
|
||||
// Map backend types to form field types
|
||||
// Cast to string to handle all possible backend type values
|
||||
const attrType = attr.type as string;
|
||||
if (attrType === 'checkbox' || attrType === 'boolean') {
|
||||
fieldType = 'boolean';
|
||||
} else if (attrType === 'email') {
|
||||
fieldType = 'email';
|
||||
} else if (attrType === 'timestamp' || attrType === 'date' || attrType === 'time') {
|
||||
fieldType = 'date';
|
||||
} else if (attrType === 'select' || attrType === 'enum') {
|
||||
fieldType = 'enum';
|
||||
if (Array.isArray(attr.options)) {
|
||||
options = attr.options.map(opt => {
|
||||
const labelValue = typeof opt.label === 'string'
|
||||
? opt.label
|
||||
: opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value);
|
||||
return {
|
||||
value: opt.value,
|
||||
label: labelValue
|
||||
};
|
||||
});
|
||||
} else if (typeof attr.options === 'string') {
|
||||
optionsReference = attr.options;
|
||||
}
|
||||
} else if (attrType === 'multiselect') {
|
||||
fieldType = 'multiselect';
|
||||
if (Array.isArray(attr.options)) {
|
||||
options = attr.options.map(opt => {
|
||||
const labelValue = typeof opt.label === 'string'
|
||||
? opt.label
|
||||
: opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value);
|
||||
return {
|
||||
value: opt.value,
|
||||
label: labelValue
|
||||
};
|
||||
});
|
||||
} else if (typeof attr.options === 'string') {
|
||||
optionsReference = attr.options;
|
||||
}
|
||||
} else if (attrType === 'textarea') {
|
||||
fieldType = 'textarea';
|
||||
}
|
||||
|
||||
// Determine if required and build validator
|
||||
const required = attr.required === true;
|
||||
let validator: ((value: any) => string | null) | undefined = undefined;
|
||||
|
||||
// Email validation
|
||||
if (attr.type === 'email') {
|
||||
validator = (value: any) => {
|
||||
if (required && (!value || (typeof value === 'string' && value.trim() === ''))) {
|
||||
return 'Email cannot be empty';
|
||||
}
|
||||
if (value && typeof value === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
||||
return 'Invalid email format';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
// Required string validation
|
||||
else if (required && fieldType === 'string') {
|
||||
validator = (value: any) => {
|
||||
if (!value || (typeof value === 'string' && value.trim() === '')) {
|
||||
return `${attr.label} is required`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
// Multiselect validation
|
||||
else if (fieldType === 'multiselect' && required) {
|
||||
validator = (value: any[]) => {
|
||||
if (!value || !Array.isArray(value) || value.length === 0) {
|
||||
return `${attr.label} is required`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: fieldType,
|
||||
required,
|
||||
validator,
|
||||
options,
|
||||
optionsReference
|
||||
};
|
||||
});
|
||||
|
||||
// Note: Password field removed - users are created without password
|
||||
// Admin can send password setup link after user creation using handleSendPasswordLink
|
||||
|
||||
return createFields;
|
||||
}, [attributes]);
|
||||
|
||||
// Ensure attributes are loaded - can be called by EditActionButton
|
||||
const ensureAttributesLoaded = useCallback(async () => {
|
||||
// Don't fetch attributes if user is not authenticated (prevents 401 errors)
|
||||
|
|
@ -667,6 +797,7 @@ export function useOrgUsers() {
|
|||
pagination,
|
||||
fetchUserById,
|
||||
generateEditFieldsFromAttributes,
|
||||
generateCreateFieldsFromAttributes,
|
||||
ensureAttributesLoaded
|
||||
};
|
||||
}
|
||||
|
|
@ -675,11 +806,13 @@ export function useOrgUsers() {
|
|||
export function useUserOperations() {
|
||||
const [deletingUsers, setDeletingUsers] = useState<Set<string>>(new Set());
|
||||
const [editingUsers, setEditingUsers] = useState<Set<string>>(new Set());
|
||||
const [sendingPasswordLink, setSendingPasswordLink] = useState<Set<string>>(new Set());
|
||||
const [creatingUser, setCreatingUser] = useState(false);
|
||||
const { request, isLoading } = useApiRequest();
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||
const [passwordLinkError, setPasswordLinkError] = useState<string | null>(null);
|
||||
|
||||
const handleUserDelete = async (userId: string) => {
|
||||
setDeleteError(null);
|
||||
|
|
@ -702,7 +835,7 @@ export function useUserOperations() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleUserCreate = async (userData: Omit<User, 'id' | 'mandateId'> & { password: string }) => {
|
||||
const handleUserCreate = async (userData: Omit<User, 'id' | 'mandateId'>) => {
|
||||
setCreateError(null);
|
||||
setCreatingUser(true);
|
||||
|
||||
|
|
@ -726,6 +859,31 @@ export function useUserOperations() {
|
|||
}
|
||||
};
|
||||
|
||||
// Send password setup link to a user (admin function)
|
||||
const handleSendPasswordLink = async (userId: string) => {
|
||||
setPasswordLinkError(null);
|
||||
setSendingPasswordLink(prev => new Set(prev).add(userId));
|
||||
|
||||
try {
|
||||
// Get frontend URL from current window location
|
||||
const frontendUrl = `${window.location.protocol}//${window.location.host}`;
|
||||
|
||||
const result = await sendPasswordLinkApi(request, userId, frontendUrl);
|
||||
|
||||
return { success: true, message: result.message, email: result.email };
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.detail || error.message || 'Failed to send password link';
|
||||
setPasswordLinkError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setSendingPasswordLink(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(userId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserUpdate = async (userId: string, updateData: UserUpdateData, _originalData?: any) => {
|
||||
setUpdateError(null);
|
||||
setEditingUsers(prev => new Set(prev).add(userId));
|
||||
|
|
@ -764,16 +922,29 @@ export function useUserOperations() {
|
|||
}
|
||||
};
|
||||
|
||||
// Generic inline update handler for FormGeneratorTable
|
||||
const handleInlineUpdate = async (userId: string, changes: Partial<UserUpdateData>) => {
|
||||
const result = await handleUserUpdate(userId, changes as UserUpdateData);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to update');
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
return {
|
||||
deletingUsers,
|
||||
editingUsers,
|
||||
sendingPasswordLink,
|
||||
creatingUser,
|
||||
deleteError,
|
||||
createError,
|
||||
updateError,
|
||||
passwordLinkError,
|
||||
handleUserDelete,
|
||||
handleUserCreate,
|
||||
handleUserUpdate,
|
||||
handleInlineUpdate,
|
||||
handleSendPasswordLink,
|
||||
isLoading
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }>) => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
@ -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',
|
||||
};
|
||||
|
|
@ -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',
|
||||
};
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue