Cleanup: remove frontend code that does not belong in wiki repo
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
23878279ee
commit
db4db516aa
588 changed files with 0 additions and 199861 deletions
|
|
@ -1,144 +0,0 @@
|
|||
# Implement RBAC Roles Page
|
||||
|
||||
## Overview
|
||||
Implement the RBAC roles admin page following the exact pattern used in `mandates.ts`. This includes creating the API file, custom hook for state management, updating the page configuration with CreateButton header button, and adding translations in all three languages (German, English, French).
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
### 1. Create API File: `frontend_nyla/src/api/roleApi.ts`
|
||||
- Follow the pattern from `mandateApi.ts`
|
||||
- Implement all required endpoints:
|
||||
- `fetchRoles()` - GET /api/rbac/roles (with pagination support)
|
||||
- `fetchRoleById()` - GET /api/rbac/roles/{roleId}
|
||||
- `fetchRoleOptions()` - GET /api/rbac/roles/options
|
||||
- `createRole()` - POST /api/rbac/roles
|
||||
- `updateRole()` - PUT /api/rbac/roles/{roleId}
|
||||
- `deleteRole()` - DELETE /api/rbac/roles/{roleId}
|
||||
- Include TypeScript types: `Role`, `RoleUpdateData`, `PaginationParams`, `PaginatedResponse`
|
||||
|
||||
### 2. Create Hook: `frontend_nyla/src/hooks/useAdminRbacRoles.ts`
|
||||
- Follow the exact pattern from `useAdminMandates.ts`
|
||||
- Create two hooks:
|
||||
- `useRbacRoles()` - Main hook for data fetching and state management
|
||||
- Fetch roles with pagination support
|
||||
- Fetch attributes from `/api/attributes/Role` using `fetchAttributes(request, 'Role')`
|
||||
- Fetch permissions using `checkPermission('DATA', 'Role')`
|
||||
- Implement `generateEditFieldsFromAttributes()` using `attributeTypeMapper` utilities
|
||||
- Implement `generateCreateFieldsFromAttributes()` using `attributeTypeMapper` utilities
|
||||
- Implement `ensureAttributesLoaded()` for EditActionButton
|
||||
- Implement optimistic updates (`removeOptimistically`, `updateOptimistically`)
|
||||
- Return pagination info, attributes, permissions, and all required functions
|
||||
- `useRbacRoleOperations()` - Operations hook for CRUD
|
||||
- `handleRoleDelete()` - Delete with loading state tracking
|
||||
- `handleRoleCreate()` - Create with error handling
|
||||
- `handleRoleUpdate()` - Update with error handling
|
||||
- Track loading states in Sets (deletingRoles, editingRoles, creatingRole)
|
||||
- Return error states (deleteError, createError, updateError)
|
||||
|
||||
### 3. Update Page Configuration: `frontend_nyla/src/core/PageManager/data/pages/admin/rbac-role.ts`
|
||||
- Follow the exact structure from `mandates.ts`
|
||||
- Import `FaPlus` from `react-icons/fa` for the create button icon
|
||||
- Create `createRbacRolesHook()` factory function that:
|
||||
- Uses `useRbacRoles()` and `useRbacRoleOperations()`
|
||||
- Converts attributes to columns using `attributesToColumns()` helper
|
||||
- Implements `handleDeleteSingle` and `handleDeleteMultiple` callbacks
|
||||
- Returns all required data for FormGeneratorTable
|
||||
- Update `rbacRolePageData`:
|
||||
- Add header button with `FaPlus` icon for creating roles (following mandates.ts pattern):
|
||||
```typescript
|
||||
headerButtons: [
|
||||
{
|
||||
id: 'add-role',
|
||||
label: 'admin.rbac-role.new_button',
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
icon: FaPlus,
|
||||
formConfig: {
|
||||
fields: [], // Empty array - fields will be generated dynamically from attributes
|
||||
popupTitle: 'admin.rbac-role.modal.create.title',
|
||||
popupSize: 'medium',
|
||||
createOperationName: 'handleRoleCreate',
|
||||
successMessage: 'admin.rbac-role.create.success',
|
||||
errorMessage: 'admin.rbac-role.create.error'
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
- Add table content section with:
|
||||
- `hookFactory: createRbacRolesHook`
|
||||
- Action buttons: edit and delete (following mandates pattern)
|
||||
- Configure edit button with `fetchItemFunctionName: 'fetchRoleById'`
|
||||
- Configure delete button with proper operation names
|
||||
- Add permission-based disabled logic
|
||||
- Keep existing privilege checker (sysadmin only)
|
||||
|
||||
### 4. Update Translations: All three locale files
|
||||
- **German (`frontend_nyla/src/locales/de.ts`)**: Add missing translations after line 756:
|
||||
- `'admin.rbac-role.new_button': 'Rolle hinzufügen'`
|
||||
- `'admin.rbac-role.action.edit': 'Bearbeiten'`
|
||||
- `'admin.rbac-role.action.delete': 'Löschen'`
|
||||
- `'admin.rbac-role.modal.create.title': 'Neue Rolle erstellen'`
|
||||
- `'admin.rbac-role.create.success': 'Rolle erfolgreich erstellt'`
|
||||
- `'admin.rbac-role.create.error': 'Fehler beim Erstellen der Rolle'`
|
||||
|
||||
- **English (`frontend_nyla/src/locales/en.ts`)**: Add missing translations after line 756:
|
||||
- `'admin.rbac-role.new_button': 'Add Role'`
|
||||
- `'admin.rbac-role.action.edit': 'Edit'`
|
||||
- `'admin.rbac-role.action.delete': 'Delete'`
|
||||
- `'admin.rbac-role.modal.create.title': 'Create New Role'`
|
||||
- `'admin.rbac-role.create.success': 'Role created successfully'`
|
||||
- `'admin.rbac-role.create.error': 'Error creating role'`
|
||||
|
||||
- **French (`frontend_nyla/src/locales/fr.ts`)**: Add missing translations after line 756:
|
||||
- `'admin.rbac-role.new_button': 'Ajouter un rôle'`
|
||||
- `'admin.rbac-role.action.edit': 'Modifier'`
|
||||
- `'admin.rbac-role.action.delete': 'Supprimer'`
|
||||
- `'admin.rbac-role.modal.create.title': 'Créer un nouveau rôle'`
|
||||
- `'admin.rbac-role.create.success': 'Rôle créé avec succès'`
|
||||
- `'admin.rbac-role.create.error': 'Erreur lors de la création du rôle'`
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### API File Structure
|
||||
- Use `ApiRequestFunction` type from `useApi`
|
||||
- Support pagination parameters (page, pageSize, sort, filters, search)
|
||||
- Handle both paginated and non-paginated responses
|
||||
- Use `/api/rbac/roles` as base URL
|
||||
- Use `/api/attributes/Role` for attributes endpoint
|
||||
|
||||
### Hook Pattern
|
||||
- Use `useApiRequest` hook for API calls
|
||||
- Use `usePermissions` hook for permission checking
|
||||
- Use `getUserDataCache()` to check authentication before fetching
|
||||
- Implement attribute type mapping using utilities from `attributeTypeMapper.ts`:
|
||||
- `isCheckboxType()`, `isSelectType()`, `isMultiselectType()`, `isDateTimeType()`, `isTextareaType()`
|
||||
- Filter out non-editable fields (id, readonly fields, etc.)
|
||||
- Handle options arrays and option references
|
||||
|
||||
### Page Configuration Pattern
|
||||
- Use `attributesToColumns()` helper to convert attributes to column config
|
||||
- Disable filtering for date/timestamp fields using `isDateTimeType()`
|
||||
- Configure action buttons with proper field mappings and operation names
|
||||
- Use permission-based disabled logic for buttons
|
||||
- Set `entityType: 'Role'` for EditActionButton
|
||||
- Add header button using CreateButton component pattern (via formConfig in headerButtons)
|
||||
|
||||
## Key Dependencies
|
||||
- `useApiRequest` from `hooks/useApi`
|
||||
- `usePermissions` from `hooks/usePermissions`
|
||||
- `fetchAttributes` from `api/attributesApi`
|
||||
- `attributeTypeMapper` utilities from `utils/attributeTypeMapper`
|
||||
- `FormGeneratorTable` component
|
||||
- `EditActionButton` and `DeleteActionButton` components
|
||||
- `CreateButton` component (rendered via PageRenderer from headerButtons formConfig)
|
||||
- `FaPlus` icon from `react-icons/fa`
|
||||
|
||||
## Testing Considerations
|
||||
- Verify all API endpoints are called correctly
|
||||
- Ensure attributes are fetched from `/api/attributes/Role`
|
||||
- Verify permission checks work correctly
|
||||
- Test create, edit, delete operations
|
||||
- Verify optimistic updates work
|
||||
- Check that date/timestamp fields are not filterable
|
||||
- Verify CreateButton appears in header and opens create modal
|
||||
- Verify translations work in all three languages
|
||||
34
.gitignore
vendored
34
.gitignore
vendored
|
|
@ -1,34 +0,0 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
.cursorignore
|
||||
|
||||
# Keep environment files in config/ (naming: env-<workflow>.env)
|
||||
!config/env-*.env
|
||||
214
README copy.md
214
README copy.md
|
|
@ -1,214 +0,0 @@
|
|||
# PowerOn Nyla Frontend
|
||||
|
||||
## 🏗️ Project Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
%% Environment Files
|
||||
ENV_DEV["env-poweron-nyla-dev.env<br/>Development"]
|
||||
ENV_PROD["env-poweron-nyla-prod.env<br/>Production"]
|
||||
ENV_INT["env-poweron-nyla-int.env<br/>Integration"]
|
||||
|
||||
%% Configuration System
|
||||
CONFIG_TS["config.ts<br/>TypeScript Config<br/>(React Frontend)"]
|
||||
CONFIG_JS["serverConfig.js<br/>JavaScript Config<br/>(Node.js Servers)"]
|
||||
|
||||
%% Source Code
|
||||
SRC["src/<br/>React Components"]
|
||||
PUBLIC["public/<br/>Static Assets"]
|
||||
HTML["index.html<br/>Main Template"]
|
||||
|
||||
%% Build Process
|
||||
VITE["vite.config.ts<br/>Build Configuration"]
|
||||
PACKAGE["package.json<br/>Dependencies & Scripts"]
|
||||
TSCONFIG["tsconfig.json<br/>TypeScript Config"]
|
||||
|
||||
%% Build Output
|
||||
DIST["dist/<br/>Built Application"]
|
||||
|
||||
%% Servers
|
||||
SERVER_DEV["server.js<br/>Dev Server"]
|
||||
SERVER_DEPLOY["deploy-server.js<br/>Deploy Server"]
|
||||
SERVER_LEGACY["server.js<br/>Legacy Server"]
|
||||
|
||||
%% Deployment
|
||||
GITHUB[".github/workflows/<br/>GitHub Actions"]
|
||||
AZURE["Azure Static Web Apps"]
|
||||
STATIC_CONFIG["staticwebapp.config.json<br/>Azure Config"]
|
||||
|
||||
%% Environment Flow
|
||||
ENV_DEV --> CONFIG_TS
|
||||
ENV_PROD --> CONFIG_TS
|
||||
ENV_INT --> CONFIG_TS
|
||||
|
||||
ENV_DEV --> CONFIG_JS
|
||||
ENV_PROD --> CONFIG_JS
|
||||
ENV_INT --> CONFIG_JS
|
||||
|
||||
%% Configuration Usage
|
||||
CONFIG_TS --> SRC
|
||||
CONFIG_JS --> SERVER_DEV
|
||||
CONFIG_JS --> SERVER_DEPLOY
|
||||
CONFIG_JS --> SERVER_LEGACY
|
||||
|
||||
%% Build Process
|
||||
SRC --> VITE
|
||||
PUBLIC --> VITE
|
||||
HTML --> VITE
|
||||
PACKAGE --> VITE
|
||||
TSCONFIG --> VITE
|
||||
CONFIG_TS --> VITE
|
||||
|
||||
VITE --> DIST
|
||||
|
||||
%% Server Usage
|
||||
DIST --> SERVER_DEV
|
||||
DIST --> SERVER_DEPLOY
|
||||
DIST --> SERVER_LEGACY
|
||||
|
||||
%% Deployment Flow
|
||||
GITHUB --> ENV_PROD
|
||||
GITHUB --> ENV_INT
|
||||
GITHUB --> DIST
|
||||
GITHUB --> SERVER_DEPLOY
|
||||
GITHUB --> AZURE
|
||||
|
||||
STATIC_CONFIG --> AZURE
|
||||
|
||||
%% Styling
|
||||
classDef envFile fill:black,stroke:#01579b,stroke-width:2px
|
||||
classDef configFile fill:black,stroke:#4a148c,stroke-width:2px
|
||||
classDef sourceFile fill:black,stroke:#1b5e20,stroke-width:2px
|
||||
classDef buildFile fill:black,stroke:#e65100,stroke-width:2px
|
||||
classDef serverFile fill:black,stroke:#880e4f,stroke-width:2px
|
||||
classDef deployFile fill:black,stroke:#33691e,stroke-width:2px
|
||||
|
||||
class ENV_DEV,ENV_PROD,ENV_INT envFile
|
||||
class CONFIG_TS,CONFIG_JS configFile
|
||||
class SRC,PUBLIC,HTML sourceFile
|
||||
class VITE,PACKAGE,TSCONFIG,DIST buildFile
|
||||
class SERVER_DEV,SERVER_DEPLOY,SERVER_LEGACY serverFile
|
||||
class GITHUB,AZURE,STATIC_CONFIG deployFile
|
||||
```
|
||||
|
||||
### Flow Overview:
|
||||
1. **Environment files** → **Configuration system** → **Source code**
|
||||
2. **Source code** → **Build process** → **Built application**
|
||||
3. **Built application** → **Servers** → **Deployment**
|
||||
4. **GitHub Actions** → **Azure Static Web Apps**
|
||||
|
||||
|
||||
## 🔧 Configuration Setup
|
||||
|
||||
The app uses a **dual configuration system** to handle environment variables across different contexts:
|
||||
|
||||
### Configuration Files
|
||||
- **`config/config.ts`** - TypeScript config for React frontend
|
||||
- **Why:** Provides type-safe access to environment variables in the browser
|
||||
- **How:** Uses `import.meta.env` (Vite's environment API) to access `VITE_*` variables
|
||||
- **Used by:** All React components, hooks, and frontend logic
|
||||
|
||||
- **`config/serverConfig.js`** - JavaScript config for Node.js servers
|
||||
- **Why:** Node.js can't access `import.meta.env`, needs `process.env`
|
||||
- **How:** Uses `dotenv` to load `.env` files and `process.env` to access variables
|
||||
- **Used by:** Express servers and build scripts
|
||||
|
||||
### Environment Files
|
||||
|
||||
Naming convention: `env-<workflow-name>.env` — matches the GitHub Actions workflow that uses it.
|
||||
|
||||
- **`config/env-poweron-nyla-dev.env`** — Local development (localhost gateway)
|
||||
- **`config/env-poweron-nyla-int.env`** — Integration (used by `poweron_nyla_int` workflow)
|
||||
- **`config/env-poweron-nyla-prod.env`** — Production (used by `poweron_nyla_main` workflow)
|
||||
|
||||
Each env is copied to root `.env` at build time (by CI or manually for local dev).
|
||||
|
||||
### Usage
|
||||
```bash
|
||||
# Local development — copy env then start Vite
|
||||
cp config/env-poweron-nyla-dev.env .env
|
||||
npm run dev
|
||||
|
||||
# Production build (CI copies env-poweron-nyla-prod.env → .env)
|
||||
npm run build:prod
|
||||
|
||||
# Integration build (CI copies env-poweron-nyla-int.env → .env)
|
||||
npm run build:int
|
||||
```
|
||||
|
||||
## 🖥️ Server Files
|
||||
|
||||
- **`scripts/server.js`** - Express server for local development
|
||||
- **Why:** Serves the built React app locally for testing
|
||||
- **How:** Serves static files from `dist/` directory on port 3000
|
||||
- **Used by:** Developers running `npm run dev` or manual testing
|
||||
|
||||
- **`scripts/deploy-server.js`** - Express server for deployment
|
||||
- **Why:** Production server that serves the built app in cloud environments
|
||||
- **How:** Serves static files and handles routing for SPA (Single Page Application)
|
||||
- **Used by:** Azure Static Web Apps and other deployment platforms
|
||||
|
||||
- **`server.js`** - Legacy server (kept for compatibility)
|
||||
- **Why:** Backup server file in case deployment scripts need it
|
||||
- **How:** Basic Express server setup
|
||||
- **Used by:** Fallback option for deployments
|
||||
|
||||
Both servers use `config/serverConfig.js` for environment variables and logging.
|
||||
|
||||
## 📁 Root Directory Files
|
||||
|
||||
### Core Application Files
|
||||
- **`package.json`** - Node.js project configuration
|
||||
- **Why:** Defines dependencies, scripts, and project metadata
|
||||
- **How:** Used by npm/yarn to install packages and run scripts
|
||||
- **Contains:** Dependencies, build scripts, project info
|
||||
|
||||
- **`vite.config.ts`** - Vite build tool configuration
|
||||
- **Why:** Configures how Vite builds and serves the React app
|
||||
- **How:** Defines plugins, build options, and environment variable handling
|
||||
- **Contains:** React plugin, HTML plugin, environment loading, build settings
|
||||
|
||||
- **`tsconfig.json`** - TypeScript compiler configuration
|
||||
- **Why:** Defines TypeScript compilation rules and target settings
|
||||
- **How:** Used by TypeScript compiler and IDE for type checking
|
||||
- **Contains:** Compiler options, file includes/excludes, module resolution
|
||||
|
||||
- **`index.html`** - Main HTML template
|
||||
- **Why:** Entry point for the React application
|
||||
- **How:** Vite injects the built JavaScript and CSS into this template
|
||||
- **Contains:** HTML structure, dynamic title injection, script tags
|
||||
|
||||
### Build and Deployment
|
||||
|
||||
- **`staticwebapp.config.json`** - Azure Static Web Apps configuration
|
||||
- **Why:** Configures routing and MIME types for Azure deployment
|
||||
- **How:** Used by Azure Static Web Apps service
|
||||
- **Contains:** Navigation fallback rules, MIME type mappings
|
||||
|
||||
### Source Code and Assets
|
||||
- **`src/`** - Source code directory
|
||||
- **Why:** Contains all React components, hooks, and application logic
|
||||
- **How:** Vite processes these files during build
|
||||
- **Contains:** Components, pages, hooks, contexts, locales, assets
|
||||
|
||||
- **`public/`** - Static assets
|
||||
- **Why:** Files that should be copied directly to build output
|
||||
- **How:** Vite copies these files to `dist/` during build
|
||||
- **Contains:** Images, logos, favicons, other static files
|
||||
|
||||
### Configuration and Scripts
|
||||
- **`config/`** - Configuration files directory
|
||||
- **Why:** Centralizes all configuration and environment files
|
||||
- **How:** Contains environment files and config modules
|
||||
- **Contains:** Environment files, config.ts, serverConfig.js
|
||||
|
||||
- **`scripts/`** - Server scripts directory
|
||||
- **Why:** Separates server-related files from main application code
|
||||
- **How:** Contains Express server implementations
|
||||
- **Contains:** server.js, deploy-server.js
|
||||
|
||||
### CI/CD
|
||||
- **`.github/workflows/`** - GitHub Actions workflows
|
||||
- **Why:** Automates deployment to different environments
|
||||
- **How:** Triggers on git pushes and deploys to Azure
|
||||
- **Contains:** YAML files for dev, int, and prod deployments
|
||||
1
Untitled
1
Untitled
|
|
@ -1 +0,0 @@
|
|||
s
|
||||
178
config/config.ts
178
config/config.ts
|
|
@ -1,178 +0,0 @@
|
|||
/**
|
||||
* Simple Configuration Service
|
||||
* Centralized access to environment variables with fallbacks
|
||||
*/
|
||||
|
||||
// API Configuration
|
||||
export const getApiBaseUrl = (): string => {
|
||||
return import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
|
||||
};
|
||||
|
||||
export const getApiTimeout = (): number => {
|
||||
return parseInt(import.meta.env.VITE_API_TIMEOUT || '10000');
|
||||
};
|
||||
|
||||
// App Configuration
|
||||
export const getAppName = (): string => {
|
||||
return import.meta.env.VITE_APP_NAME || 'PowerOn';
|
||||
};
|
||||
|
||||
export const getAppVersion = (): string => {
|
||||
return import.meta.env.VITE_APP_VERSION || '0.0.0';
|
||||
};
|
||||
|
||||
export const getAppEnvironment = (): string => {
|
||||
return import.meta.env.VITE_APP_ENVIRONMENT || 'dev';
|
||||
};
|
||||
|
||||
// Environment Detection
|
||||
export const isDevelopment = (): boolean => {
|
||||
return import.meta.env.MODE === 'development' || getAppEnvironment() === 'dev';
|
||||
};
|
||||
|
||||
export const isProduction = (): boolean => {
|
||||
return import.meta.env.MODE === 'production' || getAppEnvironment() === 'prod';
|
||||
};
|
||||
|
||||
export const isIntegration = (): boolean => {
|
||||
return getAppEnvironment() === 'int';
|
||||
};
|
||||
|
||||
// Debug Configuration
|
||||
export const isDebugMode = (): boolean => {
|
||||
return import.meta.env.VITE_DEBUG === 'true';
|
||||
};
|
||||
|
||||
export const getLogLevel = (): string => {
|
||||
return import.meta.env.VITE_LOG_LEVEL || 'info';
|
||||
};
|
||||
|
||||
export const isConsoleLogsEnabled = (): boolean => {
|
||||
return import.meta.env.VITE_ENABLE_CONSOLE_LOGS === 'true';
|
||||
};
|
||||
|
||||
// Microsoft Authentication
|
||||
export const getMicrosoftClientId = (): string | undefined => {
|
||||
return import.meta.env.VITE_MICROSOFT_CLIENT_ID;
|
||||
};
|
||||
|
||||
export const getMicrosoftTenantId = (): string | undefined => {
|
||||
return import.meta.env.VITE_MICROSOFT_TENANT_ID;
|
||||
};
|
||||
|
||||
export const getEntraClientSecret = (): string | undefined => {
|
||||
return import.meta.env.VITE_ENTRA_CLIENT_SECRET;
|
||||
};
|
||||
|
||||
export const getEntraAuthority = (): string | undefined => {
|
||||
return import.meta.env.VITE_ENTRA_AUTHORITY;
|
||||
};
|
||||
|
||||
export const getEntraRedirectPath = (): string | undefined => {
|
||||
return import.meta.env.VITE_ENTRA_REDIRECT_PATH;
|
||||
};
|
||||
|
||||
export const getEntraRedirectUri = (): string | undefined => {
|
||||
return import.meta.env.VITE_ENTRA_REDIRECT_URI;
|
||||
};
|
||||
|
||||
// Feature Flags (if needed in the future)
|
||||
export const isFeatureEnabled = (feature: string): boolean => {
|
||||
const envKey = `VITE_ENABLE_${feature.toUpperCase()}`;
|
||||
return import.meta.env[envKey] === 'true';
|
||||
};
|
||||
|
||||
// Analytics and Monitoring
|
||||
export const isAnalyticsEnabled = (): boolean => {
|
||||
return import.meta.env.VITE_ENABLE_ANALYTICS === 'true';
|
||||
};
|
||||
|
||||
export const isErrorReportingEnabled = (): boolean => {
|
||||
return import.meta.env.VITE_ENABLE_ERROR_REPORTING === 'true';
|
||||
};
|
||||
|
||||
export const isPerformanceMonitoringEnabled = (): boolean => {
|
||||
return import.meta.env.VITE_ENABLE_PERFORMANCE_MONITORING === 'true';
|
||||
};
|
||||
|
||||
// Development Server (for dev environment)
|
||||
export const getDevServerPort = (): number => {
|
||||
return parseInt(import.meta.env.VITE_DEV_SERVER_PORT || '5176');
|
||||
};
|
||||
|
||||
export const getDevServerHost = (): string => {
|
||||
return import.meta.env.VITE_DEV_SERVER_HOST || 'localhost';
|
||||
};
|
||||
|
||||
export const isDevServerHttps = (): boolean => {
|
||||
return import.meta.env.VITE_DEV_SERVER_HTTPS === 'true';
|
||||
};
|
||||
|
||||
// Security Configuration
|
||||
export const isHttpsEnabled = (): boolean => {
|
||||
return import.meta.env.VITE_ENABLE_HTTPS === 'true';
|
||||
};
|
||||
|
||||
export const isCspEnabled = (): boolean => {
|
||||
return import.meta.env.VITE_ENABLE_CSP === 'true';
|
||||
};
|
||||
|
||||
// Test Configuration
|
||||
export const isMockDataEnabled = (): boolean => {
|
||||
return import.meta.env.VITE_ENABLE_MOCK_DATA === 'true';
|
||||
};
|
||||
|
||||
export const isTestMode = (): boolean => {
|
||||
return import.meta.env.VITE_ENABLE_TEST_MODE === 'true';
|
||||
};
|
||||
|
||||
// Convenience object for easy destructuring
|
||||
export const config = {
|
||||
// API
|
||||
getApiBaseUrl,
|
||||
getApiTimeout,
|
||||
|
||||
// App
|
||||
getAppName,
|
||||
getAppVersion,
|
||||
getAppEnvironment,
|
||||
|
||||
// Environment
|
||||
isDevelopment,
|
||||
isProduction,
|
||||
isIntegration,
|
||||
|
||||
// Debug
|
||||
isDebugMode,
|
||||
getLogLevel,
|
||||
isConsoleLogsEnabled,
|
||||
|
||||
// Microsoft Auth
|
||||
getMicrosoftClientId,
|
||||
getMicrosoftTenantId,
|
||||
getEntraClientSecret,
|
||||
getEntraAuthority,
|
||||
getEntraRedirectPath,
|
||||
getEntraRedirectUri,
|
||||
|
||||
// Features
|
||||
isFeatureEnabled,
|
||||
|
||||
// Analytics
|
||||
isAnalyticsEnabled,
|
||||
isErrorReportingEnabled,
|
||||
isPerformanceMonitoringEnabled,
|
||||
|
||||
// Dev Server
|
||||
getDevServerPort,
|
||||
getDevServerHost,
|
||||
isDevServerHttps,
|
||||
|
||||
// Security
|
||||
isHttpsEnabled,
|
||||
isCspEnabled,
|
||||
|
||||
// Test
|
||||
isMockDataEnabled,
|
||||
isTestMode,
|
||||
};
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
# Environment: poweron-nyla-dev (local development)
|
||||
# Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName)
|
||||
# Auth and secrets live on the gateway — never in frontend env.
|
||||
|
||||
VITE_API_BASE_URL="http://localhost:8000/"
|
||||
VITE_APP_NAME=PowerOn Nyla dev
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
# Environment: poweron-nyla-int (integration)
|
||||
# Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName)
|
||||
# Auth and secrets live on the gateway — never in frontend env.
|
||||
|
||||
VITE_API_BASE_URL=https://gateway-int.poweron.swiss
|
||||
VITE_APP_NAME=Poweron Nyla int
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
# Environment: poweron-nyla-prod (production)
|
||||
# Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName)
|
||||
# Auth and secrets live on the gateway — never in frontend env.
|
||||
|
||||
VITE_API_BASE_URL=https://gateway-prod.poweron.swiss
|
||||
VITE_APP_NAME=PowerOn Nyla
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
// Export simple configuration service
|
||||
export * from './config';
|
||||
|
||||
// Re-export commonly used functions
|
||||
export {
|
||||
getApiBaseUrl,
|
||||
getAppName,
|
||||
isDevelopment,
|
||||
isProduction,
|
||||
isDebugMode,
|
||||
config
|
||||
} from './config';
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
/**
|
||||
* Server Configuration Service
|
||||
* Node.js compatible configuration for server files
|
||||
*/
|
||||
|
||||
// API Configuration
|
||||
export const getApiBaseUrl = () => {
|
||||
return process.env.VITE_API_BASE_URL || 'http://localhost:8000';
|
||||
};
|
||||
|
||||
export const getApiTimeout = () => {
|
||||
return parseInt(process.env.VITE_API_TIMEOUT || '10000');
|
||||
};
|
||||
|
||||
// App Configuration
|
||||
export const getAppName = () => {
|
||||
return process.env.VITE_APP_NAME || 'PowerOn';
|
||||
};
|
||||
|
||||
export const getAppVersion = () => {
|
||||
return process.env.VITE_APP_VERSION || '0.0.0';
|
||||
};
|
||||
|
||||
export const getAppEnvironment = () => {
|
||||
return process.env.VITE_APP_ENVIRONMENT || 'dev';
|
||||
};
|
||||
|
||||
// Environment Detection
|
||||
export const isDevelopment = () => {
|
||||
return process.env.NODE_ENV === 'development' || getAppEnvironment() === 'dev';
|
||||
};
|
||||
|
||||
export const isProduction = () => {
|
||||
return process.env.NODE_ENV === 'production' || getAppEnvironment() === 'prod';
|
||||
};
|
||||
|
||||
export const isIntegration = () => {
|
||||
return getAppEnvironment() === 'int';
|
||||
};
|
||||
|
||||
// Debug Configuration
|
||||
export const isDebugMode = () => {
|
||||
return process.env.VITE_DEBUG === 'true';
|
||||
};
|
||||
|
||||
export const getLogLevel = () => {
|
||||
return process.env.VITE_LOG_LEVEL || 'info';
|
||||
};
|
||||
|
||||
export const isConsoleLogsEnabled = () => {
|
||||
return process.env.VITE_ENABLE_CONSOLE_LOGS === 'true';
|
||||
};
|
||||
|
||||
// Microsoft Authentication
|
||||
export const getMicrosoftClientId = () => {
|
||||
return process.env.VITE_MICROSOFT_CLIENT_ID;
|
||||
};
|
||||
|
||||
export const getMicrosoftTenantId = () => {
|
||||
return process.env.VITE_MICROSOFT_TENANT_ID;
|
||||
};
|
||||
|
||||
export const getEntraClientSecret = () => {
|
||||
return process.env.VITE_ENTRA_CLIENT_SECRET;
|
||||
};
|
||||
|
||||
export const getEntraAuthority = () => {
|
||||
return process.env.VITE_ENTRA_AUTHORITY;
|
||||
};
|
||||
|
||||
export const getEntraRedirectPath = () => {
|
||||
return process.env.VITE_ENTRA_REDIRECT_PATH;
|
||||
};
|
||||
|
||||
export const getEntraRedirectUri = () => {
|
||||
return process.env.VITE_ENTRA_REDIRECT_URI;
|
||||
};
|
||||
|
||||
// Feature Flags
|
||||
export const isFeatureEnabled = (feature) => {
|
||||
const envKey = `VITE_ENABLE_${feature.toUpperCase()}`;
|
||||
return process.env[envKey] === 'true';
|
||||
};
|
||||
|
||||
// Analytics and Monitoring
|
||||
export const isAnalyticsEnabled = () => {
|
||||
return process.env.VITE_ENABLE_ANALYTICS === 'true';
|
||||
};
|
||||
|
||||
export const isErrorReportingEnabled = () => {
|
||||
return process.env.VITE_ENABLE_ERROR_REPORTING === 'true';
|
||||
};
|
||||
|
||||
export const isPerformanceMonitoringEnabled = () => {
|
||||
return process.env.VITE_ENABLE_PERFORMANCE_MONITORING === 'true';
|
||||
};
|
||||
|
||||
// Development Server
|
||||
export const getDevServerPort = () => {
|
||||
return parseInt(process.env.VITE_DEV_SERVER_PORT || '5176');
|
||||
};
|
||||
|
||||
export const getDevServerHost = () => {
|
||||
return process.env.VITE_DEV_SERVER_HOST || 'localhost';
|
||||
};
|
||||
|
||||
export const isDevServerHttps = () => {
|
||||
return process.env.VITE_DEV_SERVER_HTTPS === 'true';
|
||||
};
|
||||
|
||||
// Security Configuration
|
||||
export const isHttpsEnabled = () => {
|
||||
return process.env.VITE_ENABLE_HTTPS === 'true';
|
||||
};
|
||||
|
||||
export const isCspEnabled = () => {
|
||||
return process.env.VITE_ENABLE_CSP === 'true';
|
||||
};
|
||||
|
||||
// Test Configuration
|
||||
export const isMockDataEnabled = () => {
|
||||
return process.env.VITE_ENABLE_MOCK_DATA === 'true';
|
||||
};
|
||||
|
||||
export const isTestMode = () => {
|
||||
return process.env.VITE_ENABLE_TEST_MODE === 'true';
|
||||
};
|
||||
|
||||
// Server-specific configuration
|
||||
export const getServerPort = () => {
|
||||
return process.env.PORT || 3000;
|
||||
};
|
||||
|
||||
export const getServerHost = () => {
|
||||
return process.env.HOST || '0.0.0.0';
|
||||
};
|
||||
|
||||
// Convenience object for easy destructuring
|
||||
export const config = {
|
||||
// API
|
||||
getApiBaseUrl,
|
||||
getApiTimeout,
|
||||
|
||||
// App
|
||||
getAppName,
|
||||
getAppVersion,
|
||||
getAppEnvironment,
|
||||
|
||||
// Environment
|
||||
isDevelopment,
|
||||
isProduction,
|
||||
isIntegration,
|
||||
|
||||
// Debug
|
||||
isDebugMode,
|
||||
getLogLevel,
|
||||
isConsoleLogsEnabled,
|
||||
|
||||
// Microsoft Auth
|
||||
getMicrosoftClientId,
|
||||
getMicrosoftTenantId,
|
||||
getEntraClientSecret,
|
||||
getEntraAuthority,
|
||||
getEntraRedirectPath,
|
||||
getEntraRedirectUri,
|
||||
|
||||
// Features
|
||||
isFeatureEnabled,
|
||||
|
||||
// Analytics
|
||||
isAnalyticsEnabled,
|
||||
isErrorReportingEnabled,
|
||||
isPerformanceMonitoringEnabled,
|
||||
|
||||
// Dev Server
|
||||
getDevServerPort,
|
||||
getDevServerHost,
|
||||
isDevServerHttps,
|
||||
|
||||
// Security
|
||||
isHttpsEnabled,
|
||||
isCspEnabled,
|
||||
|
||||
// Test
|
||||
isMockDataEnabled,
|
||||
isTestMode,
|
||||
|
||||
// Server
|
||||
getServerPort,
|
||||
getServerHost,
|
||||
};
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
/**
|
||||
* Universal Configuration - Works in both Node.js and Browser
|
||||
*/
|
||||
|
||||
// Helper to get environment variable (works in both Node.js and browser)
|
||||
const getEnvVar = (key, defaultValue = undefined) => {
|
||||
// Node.js environment
|
||||
if (typeof process !== 'undefined' && process.env) {
|
||||
return process.env[key] || defaultValue;
|
||||
}
|
||||
|
||||
// Browser environment (Vite)
|
||||
if (typeof import !== 'undefined' && import.meta && import.meta.env) {
|
||||
return import.meta.env[key] || defaultValue;
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
// API Configuration
|
||||
export const getApiBaseUrl = () => {
|
||||
return getEnvVar('VITE_API_BASE_URL', 'http://localhost:8000');
|
||||
};
|
||||
|
||||
export const getApiTimeout = () => {
|
||||
return parseInt(getEnvVar('VITE_API_TIMEOUT', '10000'));
|
||||
};
|
||||
|
||||
// App Configuration
|
||||
export const getAppName = () => {
|
||||
return getEnvVar('VITE_APP_NAME', 'PowerOn');
|
||||
};
|
||||
|
||||
export const getAppVersion = () => {
|
||||
return getEnvVar('VITE_APP_VERSION', '0.0.0');
|
||||
};
|
||||
|
||||
export const getAppEnvironment = () => {
|
||||
return getEnvVar('VITE_APP_ENVIRONMENT', 'dev');
|
||||
};
|
||||
|
||||
// Environment Detection
|
||||
export const isDevelopment = () => {
|
||||
const mode = getEnvVar('NODE_ENV') || getEnvVar('MODE');
|
||||
const appEnv = getAppEnvironment();
|
||||
return mode === 'development' || appEnv === 'dev';
|
||||
};
|
||||
|
||||
export const isProduction = () => {
|
||||
const mode = getEnvVar('NODE_ENV') || getEnvVar('MODE');
|
||||
const appEnv = getAppEnvironment();
|
||||
return mode === 'production' || appEnv === 'prod';
|
||||
};
|
||||
|
||||
export const isIntegration = () => {
|
||||
return getAppEnvironment() === 'int';
|
||||
};
|
||||
|
||||
// Debug Configuration
|
||||
export const isDebugMode = () => {
|
||||
return getEnvVar('VITE_DEBUG') === 'true';
|
||||
};
|
||||
|
||||
export const getLogLevel = () => {
|
||||
return getEnvVar('VITE_LOG_LEVEL', 'info');
|
||||
};
|
||||
|
||||
// Microsoft Authentication
|
||||
export const getMicrosoftClientId = () => {
|
||||
return getEnvVar('VITE_MICROSOFT_CLIENT_ID');
|
||||
};
|
||||
|
||||
export const getMicrosoftTenantId = () => {
|
||||
return getEnvVar('VITE_MICROSOFT_TENANT_ID');
|
||||
};
|
||||
|
||||
export const getEntraAuthority = () => {
|
||||
return getEnvVar('VITE_ENTRA_AUTHORITY');
|
||||
};
|
||||
|
||||
export const getEntraRedirectUri = () => {
|
||||
return getEnvVar('VITE_ENTRA_REDIRECT_URI');
|
||||
};
|
||||
|
||||
// Convenience object
|
||||
export const config = {
|
||||
getApiBaseUrl,
|
||||
getApiTimeout,
|
||||
getAppName,
|
||||
getAppVersion,
|
||||
getAppEnvironment,
|
||||
isDevelopment,
|
||||
isProduction,
|
||||
isIntegration,
|
||||
isDebugMode,
|
||||
getLogLevel,
|
||||
getMicrosoftClientId,
|
||||
getMicrosoftTenantId,
|
||||
getEntraAuthority,
|
||||
getEntraRedirectUri,
|
||||
};
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
# Frontend (Backend-Driven) Rendering am Beispiel Trustee
|
||||
|
||||
## Kurzfassung der Architektur
|
||||
|
||||
Das Frontend rendert Trustee-Inhalte auf **zwei Wegen**:
|
||||
|
||||
1. **Path-basiert (PageManager)** – z.B. Pfad `trustee/organisations` → PageManager lädt PageData → PageRenderer nutzt `tableConfig.hookFactory()` → Trustee-Hook (z.B. `useTrusteeOrganisations`) → Backend-APIs → FormGeneratorTable mit Spalten/Formularen aus Backend-Attributen.
|
||||
|
||||
2. **Feature-Instanz-Route** – URL `mandates/:mandateId/trustee/:instanceId/documents` → FeatureLayout → FeatureViewPage (view=documents) → TrusteeDocumentsView → gleiche Hooks und gleiche Backend-APIs → FormGeneratorTable/FormGeneratorForm.
|
||||
|
||||
Backend-seitig liefern die Trustee-Routes (Prefix `/api/trustee`) Attribute, CRUD und Options; die Pydantic-Modelle bestimmen die Felddefinitionen.
|
||||
|
||||
---
|
||||
|
||||
## Mermaid-Diagramm: Backend-Driven Rendering (Trustee)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Router["Router (App.tsx)"]
|
||||
RouteHome["/ → Home"]
|
||||
RouteFeature["/mandates/:mandateId/:featureCode/:instanceId/*"]
|
||||
end
|
||||
|
||||
subgraph PathBased["Path-basierter Pfad (z.B. trustee/organisations)"]
|
||||
Home[Home]
|
||||
SidebarProvider[SidebarProvider]
|
||||
PageManager[PageManager]
|
||||
Location[useLocation → path]
|
||||
GetPageData[getPageDataByPath(path)]
|
||||
PageData[trusteeOrganisationsPageData etc.]
|
||||
PageRenderer[PageRenderer]
|
||||
HookFactory[tableConfig.hookFactory]
|
||||
CreateHook[createOrganisationsHook]
|
||||
end
|
||||
|
||||
subgraph FeatureRoute["Feature-Instanz-Pfad"]
|
||||
FeatureLayout[FeatureLayout]
|
||||
Outlet[Outlet]
|
||||
FeatureViewPage[FeatureViewPage view=documents|positions|...]
|
||||
ViewRegistry[VIEW_COMPONENTS.trustee]
|
||||
TrusteeView[TrusteeDocumentsView / TrusteePositionsView / ...]
|
||||
end
|
||||
|
||||
subgraph Hooks["Trustee Hooks (useTrustee.ts)"]
|
||||
UseTrusteeOrgs[useTrusteeOrganisations]
|
||||
UseTrusteeDocs[useTrusteeDocuments]
|
||||
UseTrusteePositions[useTrusteePositions]
|
||||
UseInstanceId[useInstanceId aus URL]
|
||||
end
|
||||
|
||||
subgraph BackendCalls["Backend-API-Aufrufe"]
|
||||
AttrAPI["GET /api/trustee/instanceId/attributes/EntityType"]
|
||||
ListAPI["GET /api/trustee/instanceId/organisations|documents|positions|..."]
|
||||
CrudAPI["POST|PUT|DELETE /api/trustee/instanceId/..."]
|
||||
OptionsAPI["GET /api/trustee/instanceId/organisations/options etc."]
|
||||
end
|
||||
|
||||
subgraph Backend["Gateway (Backend)"]
|
||||
RouteTrustee[routeFeatureTrustee]
|
||||
AttrEndpoint["get_entity_attributes → getModelAttributeDefinitions"]
|
||||
Interface[interfaceFeatureTrustee]
|
||||
DB[(PostgreSQL poweron_trustee)]
|
||||
end
|
||||
|
||||
subgraph UI["Generische UI-Komponenten"]
|
||||
FormGeneratorTable[FormGeneratorTable]
|
||||
FormGeneratorForm[FormGeneratorForm]
|
||||
end
|
||||
|
||||
RouteHome --> Home
|
||||
Home --> SidebarProvider
|
||||
Home --> PageManager
|
||||
PageManager --> Location
|
||||
Location --> GetPageData
|
||||
GetPageData --> PageData
|
||||
PageData --> PageRenderer
|
||||
PageRenderer --> HookFactory
|
||||
HookFactory --> CreateHook
|
||||
CreateHook --> UseTrusteeOrgs
|
||||
|
||||
RouteFeature --> FeatureLayout
|
||||
FeatureLayout --> Outlet
|
||||
Outlet --> FeatureViewPage
|
||||
FeatureViewPage --> ViewRegistry
|
||||
ViewRegistry --> TrusteeView
|
||||
TrusteeView --> UseTrusteeDocs
|
||||
TrusteeView --> UseTrusteePositions
|
||||
|
||||
UseTrusteeOrgs --> UseInstanceId
|
||||
UseTrusteeDocs --> UseInstanceId
|
||||
UseTrusteePositions --> UseInstanceId
|
||||
UseInstanceId --> AttrAPI
|
||||
UseInstanceId --> ListAPI
|
||||
UseInstanceId --> CrudAPI
|
||||
UseInstanceId --> OptionsAPI
|
||||
|
||||
AttrAPI --> RouteTrustee
|
||||
ListAPI --> RouteTrustee
|
||||
CrudAPI --> RouteTrustee
|
||||
OptionsAPI --> RouteTrustee
|
||||
RouteTrustee --> AttrEndpoint
|
||||
RouteTrustee --> Interface
|
||||
Interface --> DB
|
||||
AttrEndpoint --> AttrAPI
|
||||
|
||||
UseTrusteeOrgs --> FormGeneratorTable
|
||||
UseTrusteeDocs --> FormGeneratorTable
|
||||
UseTrusteePositions --> FormGeneratorTable
|
||||
FormGeneratorTable --> FormGeneratorForm
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wichtige Datenflüsse (Backend-Driven)
|
||||
|
||||
| Was | Wo definiert / geladen | Verwendung im Frontend |
|
||||
|-----|------------------------|-------------------------|
|
||||
| **Spalten (Table)** | Backend: `GET .../attributes/{EntityType}` → `getModelAttributeDefinitions(PydanticModel)` | Hook setzt `attributes` → `attributesToColumns()` bzw. `hookData.columns` → FormGeneratorTable |
|
||||
| **Formularfelder (Create/Edit)** | Entweder `formConfig.fields` in PageData (statisch) oder aus Hook: `generateCreateFieldsFromAttributes` / `generateEditFieldsFromAttributes` (Backend-Attribute) | PageRenderer / CreateButton → FormGeneratorForm(attributes=...) |
|
||||
| **Dropdown-Optionen** | Backend: `GET .../organisations/options`, `.../contracts/options` etc. | `optionsReference: 'TrusteeOrganisation'` → useTrusteeOptions lädt Options → FormGeneratorForm |
|
||||
| **CRUD** | Backend: POST/PUT/DELETE unter `/api/trustee/{instanceId}/{entity}` | Hook-Operationen (handleCreate, handleUpdate, handleDelete) → FormGeneratorTable Actions / Modals |
|
||||
|
||||
---
|
||||
|
||||
## Relevante Dateien
|
||||
|
||||
- **Frontend:** `src/core/PageManager/PageManager.tsx`, `src/core/PageManager/PageRenderer.tsx`, `src/core/PageManager/pageInterface.ts`, `src/core/PageManager/data/pages/trustee/organisations.ts`, `src/pages/FeatureView.tsx`, `src/pages/views/trustee/TrusteeDocumentsView.tsx`, `src/hooks/useTrustee.ts`, `src/api/trusteeApi.ts`.
|
||||
- **Backend (Gateway):** `modules/features/trustee/routeFeatureTrustee.py` (Attributes, CRUD, Options), `modules/features/trustee/interfaceFeatureTrustee.py`, `modules/features/trustee/datamodelFeatureTrustee.py`.
|
||||
|
||||
---
|
||||
|
||||
## Prüfung: Chatbot gleiche Logik wie Trustee?
|
||||
|
||||
Der Chatbot wird im Frontend an **zwei Stellen** angebunden. Nur eine nutzt dieselbe backend-driven Logik wie Trustee.
|
||||
|
||||
| Kriterium | Trustee (path-basiert) | Chatbot Route `/chatbot` | Chatbot Path `start/chatbot` |
|
||||
|-----------|------------------------|---------------------------|-------------------------------|
|
||||
| PageData (GenericPageData) | Ja | Nein | Ja (chatbotPageData) |
|
||||
| PageRenderer | Ja | Nein | Ja |
|
||||
| hookFactory in Config | Ja (tableConfig.hookFactory) | Nein | Ja (inputFormConfig.hookFactory) |
|
||||
| Hook ruft Backend-API auf | Ja | Nein (Platzhalter) | Ja (chatbotApi) |
|
||||
| Generische UI | FormGeneratorTable/Form | Eigene UI (ChatbotPage) | Messages, InputForm, ChatHistory |
|
||||
|
||||
- **Route `/chatbot`** → rendert `ChatbotPage` (pages/migrate): eigenständige Komponente, kein PageManager/PageRenderer, simulierte Antwort (TODO: echte API). **Nicht** gleiche Logik wie Trustee.
|
||||
- **Path `start/chatbot`** (PageManager/Sidebar) → `chatbotPageData` mit `createChatbotHook` → PageRenderer → useChatbot → echte APIs (`/api/chatbot/start/stream` etc.). **Gleiche** Logik wie Trustee (PageData + hookFactory + PageRenderer + Hook + Backend-API).
|
||||
|
|
@ -1,575 +0,0 @@
|
|||
# Dashboard Log Polling and Rendering Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
This documentation explains the complete flow of how dashboard messages (logs with `operationId`) are polled, processed, sorted, and rendered in the workflow dashboard. The system uses a hierarchical tree structure to display operations and their progress, with real-time updates through polling.
|
||||
|
||||
## Architecture Flow
|
||||
|
||||
The system follows this flow:
|
||||
|
||||
1. **Polling Controller** (`workflowPollingController.js`) - Manages polling intervals and scheduling
|
||||
2. **Data Layer** (`workflowData.js`) - Fetches data from API and routes logs to appropriate handlers
|
||||
3. **Dashboard Processor** (`workflowUiRendererDashboard.js`) - Processes logs with `operationId` and builds hierarchical tree
|
||||
4. **Dashboard Renderer** (`workflowUiRendererDashboard.js`) - Renders the hierarchical tree structure
|
||||
|
||||
## Key Files
|
||||
|
||||
- `workflowPollingController.js` - Centralized polling controller
|
||||
- `workflowData.js` - API communication and data routing
|
||||
- `workflowUiRendererDashboard.js` - Dashboard log processing and rendering
|
||||
- `workflowCoordination.js` - State management coordination
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Polling Mechanism
|
||||
|
||||
**File**: `frontend_agents/public/js/modules/workflowPollingController.js`
|
||||
|
||||
The polling controller uses a recursive `setTimeout` approach to create an infinite polling chain. This ensures continuous updates while preventing race conditions and rate limiting issues.
|
||||
|
||||
#### Configuration
|
||||
|
||||
- **Base interval**: 5 seconds (`baseInterval = 5000`)
|
||||
- **Maximum interval**: 10 seconds (`maxInterval = 10000`)
|
||||
- **Exponential backoff multiplier**: 1.5
|
||||
- **Concurrency prevention**: Uses `isPollInProgress` flag to prevent multiple simultaneous polls
|
||||
|
||||
#### Key Methods
|
||||
|
||||
**`startPolling(workflowId)`**
|
||||
- Starts polling for a specific workflow
|
||||
- Stops any existing polling before starting new one
|
||||
- Sets `activeWorkflowId` and `isPolling` flag
|
||||
- Executes immediate first poll (no delay)
|
||||
- Validates workflow ID before starting
|
||||
|
||||
**`doPolling()`**
|
||||
- Executes one poll cycle asynchronously
|
||||
- Prevents concurrent execution using `isPollInProgress` flag
|
||||
- Calls `pollWorkflowData()` from `workflowData.js`
|
||||
- Handles errors and implements exponential backoff on failures
|
||||
- Self-schedules next poll using recursive `setTimeout`
|
||||
- Validates workflow is still valid before scheduling next poll
|
||||
|
||||
**`stopPolling()`**
|
||||
- Stops all polling operations immediately
|
||||
- Clears all scheduled timeouts
|
||||
- Resets all state flags (`isPolling`, `isPollInProgress`, `activeWorkflowId`)
|
||||
- Resets failure count
|
||||
|
||||
**`pausePolling()` / `resumePolling()`**
|
||||
- Temporarily pauses polling (e.g., during user interactions)
|
||||
- Resumes polling after pause
|
||||
|
||||
#### Polling Flow
|
||||
|
||||
```javascript
|
||||
startPolling(workflowId)
|
||||
↓
|
||||
doPolling() [immediate first poll]
|
||||
↓
|
||||
pollWorkflowData(workflowId) [async API call]
|
||||
↓
|
||||
setTimeout(() => doPolling(), interval) [schedule next poll]
|
||||
↓
|
||||
[recursive loop continues until stopped]
|
||||
```
|
||||
|
||||
#### Error Handling
|
||||
|
||||
- **Rate limiting (429 errors)**: Increases backoff more aggressively, stops polling after 5 consecutive rate limit errors
|
||||
- **Network errors**: Logged but don't immediately stop polling (allows retry)
|
||||
- **Workflow validation**: Checks if workflow is still valid before each poll cycle
|
||||
- **Poll failures**: Exponential backoff increases interval up to `maxInterval`
|
||||
|
||||
### 2. Data Fetching
|
||||
|
||||
**File**: `frontend_agents/public/js/modules/workflowData.js`
|
||||
|
||||
The `pollWorkflowData()` function orchestrates the data fetching process.
|
||||
|
||||
#### API Calls
|
||||
|
||||
The function makes two parallel API calls:
|
||||
|
||||
1. **`api.getWorkflow(workflowId)`** - Fetches workflow status and metadata
|
||||
2. **`api.getWorkflowChatData(workflowId, afterTimestamp)`** - Fetches unified chat data (messages, logs, stats)
|
||||
|
||||
#### Incremental Polling
|
||||
|
||||
- **First poll**: `afterTimestamp = null` → Fetches ALL historical data
|
||||
- **Subsequent polls**: `afterTimestamp = workflowState.lastRenderedTimestamp` → Fetches only new items since last render
|
||||
- **Timestamp tracking**: Uses `createdAt` timestamp from each item to track what's been rendered
|
||||
|
||||
#### Data Processing
|
||||
|
||||
The `processUnifiedChatData()` function processes items in chronological order:
|
||||
|
||||
1. Routes each item based on `type` field:
|
||||
- `'message'` → `processUnifiedMessage()`
|
||||
- `'log'` → `processUnifiedLog()`
|
||||
- `'stat'` → `processUnifiedStat()`
|
||||
|
||||
2. Updates `lastRenderedTimestamp` after processing each item (ensures accurate incremental polling)
|
||||
|
||||
3. Processes items sequentially to maintain chronological order
|
||||
|
||||
#### Workflow Status Updates
|
||||
|
||||
- Monitors workflow status changes
|
||||
- Updates UI buttons and controls when status changes
|
||||
- Handles special case: Ignores 'completed' status if workflow is in Round 2+ (prevents premature stopping)
|
||||
|
||||
#### Polling Continuation Logic
|
||||
|
||||
Polling continues based on workflow status:
|
||||
- **'running'**: Continues polling
|
||||
- **'completed'**: Continues polling temporarily to get final messages, then stops
|
||||
- **'failed' / 'stopped'**: Stops polling immediately
|
||||
- **Other statuses**: Stops polling
|
||||
|
||||
### 3. Log Routing
|
||||
|
||||
**File**: `frontend_agents/public/js/modules/workflowData.js` - `processUnifiedLog()`
|
||||
|
||||
Logs are routed to different rendering areas based on the presence of `operationId`:
|
||||
|
||||
#### Routing Logic
|
||||
|
||||
```javascript
|
||||
if (log.operationId) {
|
||||
// Logs WITH operationId → Dashboard
|
||||
processDashboardLogs([frontendLog]);
|
||||
} else {
|
||||
// Logs WITHOUT operationId → Unified Content Area
|
||||
WorkflowCoordination.addLogEntry(frontendLog.message, frontendLog.type, frontendLog);
|
||||
}
|
||||
```
|
||||
|
||||
#### Log Format Conversion
|
||||
|
||||
Backend `ChatLog` format is converted to frontend format:
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: log.id,
|
||||
message: log.message,
|
||||
type: log.type || 'info',
|
||||
timestamp: log.timestamp,
|
||||
status: log.status || 'running',
|
||||
progress: log.progress !== undefined && log.progress !== null ? log.progress : undefined,
|
||||
performance: log.performance,
|
||||
operationId: log.operationId || null,
|
||||
parentId: log.parentId || null
|
||||
}
|
||||
```
|
||||
|
||||
#### Key Points
|
||||
|
||||
- **All logs are processed**: No duplicates are skipped (logs may contain progress updates)
|
||||
- **Progress tracking**: Logs with `operationId` typically contain progress information
|
||||
- **Hierarchical structure**: `parentId` field enables parent-child relationships between operations
|
||||
|
||||
### 4. Dashboard Log Processing
|
||||
|
||||
**File**: `frontend_agents/public/js/modules/workflowUiRendererDashboard.js` - `processDashboardLogs()`
|
||||
|
||||
This function processes logs with `operationId` and builds the hierarchical tree structure.
|
||||
|
||||
#### Processing Steps
|
||||
|
||||
1. **Group by operationId**
|
||||
- Creates or updates operation groups in `dashboardLogTree.operations` Map
|
||||
- Each operation stores logs in a Map keyed by `logId` (ensures uniqueness)
|
||||
|
||||
2. **Update operation metadata**
|
||||
- Updates `parentId` if not set yet (from first log entry)
|
||||
- Updates `latestProgress` when log contains progress value
|
||||
- Updates `latestStatus` when log contains status value
|
||||
|
||||
3. **Generate unique log IDs**
|
||||
- Uses provided `log.id` if available
|
||||
- Otherwise generates: `log_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`
|
||||
- Ensures all progress updates are stored, even with same progress value
|
||||
|
||||
4. **Build root operations list**
|
||||
- Filters operations without `parentId`
|
||||
- Stores in `dashboardLogTree.rootOperations` array
|
||||
|
||||
5. **Trigger rendering**
|
||||
- Calls `renderDashboard()` after processing all logs
|
||||
|
||||
#### Data Structure
|
||||
|
||||
```javascript
|
||||
dashboardLogTree = {
|
||||
operations: Map<operationId, {
|
||||
logs: Map<logId, log>, // All logs for this operation
|
||||
parentId: string | null, // Parent operation ID (if nested)
|
||||
expanded: boolean, // UI expanded/collapsed state
|
||||
latestProgress: number | null, // Most recent progress value
|
||||
latestStatus: string | null // Most recent status value
|
||||
}>,
|
||||
rootOperations: string[], // Operation IDs without parent
|
||||
logExpandedStates: Map<logId, boolean>, // Individual log expanded states
|
||||
currentRound: number | null // Current workflow round
|
||||
}
|
||||
```
|
||||
|
||||
#### Important Behaviors
|
||||
|
||||
- **All logs stored**: Every log with same `operationId` is stored (represents progress updates)
|
||||
- **Latest values tracked**: `latestProgress` and `latestStatus` always reflect most recent state
|
||||
- **Parent-child relationships**: Operations can nest via `parentId` field
|
||||
|
||||
### 5. Sorting
|
||||
|
||||
**File**: `frontend_agents/public/js/modules/workflowUiRendererDashboard.js`
|
||||
|
||||
Multiple sorting mechanisms ensure consistent display order:
|
||||
|
||||
#### Operation-Level Log Sorting
|
||||
|
||||
**Location**: `renderOperationNode()` function, lines 169-173
|
||||
|
||||
Logs within an operation are sorted by timestamp in ascending order:
|
||||
|
||||
```javascript
|
||||
const logsArray = Array.from(operation.logs.values()).sort((a, b) => {
|
||||
const tsA = a.timestamp || 0;
|
||||
const tsB = b.timestamp || 0;
|
||||
return tsA - tsB; // Ascending order (oldest first)
|
||||
});
|
||||
```
|
||||
|
||||
**Purpose**: Ensures logs are displayed in chronological order within each operation.
|
||||
|
||||
#### Child Operations Sorting
|
||||
|
||||
**Location**: `getChildOperations()` function, line 453
|
||||
|
||||
Child operations are sorted alphabetically by `operationId`:
|
||||
|
||||
```javascript
|
||||
return Array.from(dashboardLogTree.operations.entries())
|
||||
.filter(([opId, op]) => op.parentId === parentId)
|
||||
.map(([opId]) => opId)
|
||||
.sort(); // Alphabetical sort for consistent ordering
|
||||
```
|
||||
|
||||
**Purpose**: Provides consistent, predictable ordering of sibling operations.
|
||||
|
||||
#### Timeline Sorting (Unified Content)
|
||||
|
||||
**Location**: `workflowUiRenderer.js` - `renderUnifiedContent()` function
|
||||
|
||||
Logs without `operationId` are combined with messages and sorted by timestamp:
|
||||
|
||||
```javascript
|
||||
timeline.sort((a, b) => a.timestamp - b.timestamp);
|
||||
```
|
||||
|
||||
**Purpose**: Creates a unified chronological timeline of all non-dashboard content.
|
||||
|
||||
#### Sorting Summary
|
||||
|
||||
| Context | Sort Key | Order | Purpose |
|
||||
|---------|----------|-------|---------|
|
||||
| Logs within operation | `timestamp` | Ascending | Chronological display |
|
||||
| Child operations | `operationId` | Alphabetical | Consistent ordering |
|
||||
| Unified timeline | `timestamp` | Ascending | Chronological timeline |
|
||||
|
||||
### 6. Rendering
|
||||
|
||||
**File**: `frontend_agents/public/js/modules/workflowUiRendererDashboard.js` - `renderDashboard()`
|
||||
|
||||
The rendering system creates a hierarchical tree structure with collapsible nodes and progress indicators.
|
||||
|
||||
#### Hierarchical Structure
|
||||
|
||||
- **Root operations**: Operations without `parentId` are rendered first
|
||||
- **Child operations**: Operations with `parentId` matching a parent's `operationId` are nested
|
||||
- **Single line per operation**: Each operation shows ONE line that updates with latest status/progress
|
||||
- **All logs represented**: All logs with same `operationId` are represented by this single updating line
|
||||
|
||||
#### Rendering Process
|
||||
|
||||
**Step 1: `renderDashboard()`**
|
||||
- Builds HTML from `dashboardLogTree` structure
|
||||
- Handles empty state (no operations)
|
||||
- Sets up event handlers for collapse/expand functionality
|
||||
|
||||
**Step 2: `renderOperationNode(operationId, depth)`** (Recursive)
|
||||
- Renders a single operation node
|
||||
- Calculates indentation based on depth (8px per level)
|
||||
- Determines if operation has child operations
|
||||
- Gets latest log entry for operation name and type
|
||||
- Calculates progress percentage (forces 100% when status is 'completed')
|
||||
- Builds HTML for:
|
||||
- Expand/collapse button (if has children)
|
||||
- Operation icon (based on log type)
|
||||
- Operation name (from latest log message)
|
||||
- Status and progress percentage
|
||||
- Progress bar (if progress available)
|
||||
- Recursively renders child operations if expanded
|
||||
|
||||
#### Visual Elements
|
||||
|
||||
**Operation Header**
|
||||
- Expand/collapse button (chevron icon)
|
||||
- Operation icon (info/success/error/warning)
|
||||
- Operation name (from latest log message)
|
||||
- Status badge (running/completed/failed/etc.)
|
||||
- Progress percentage (if available)
|
||||
|
||||
**Progress Bar**
|
||||
- Visual progress indicator
|
||||
- Width based on progress percentage (0-100%)
|
||||
- "completed" class when progress >= 100%
|
||||
- Hidden if no progress value
|
||||
|
||||
**Indentation**
|
||||
- Root level (depth 0): No indentation
|
||||
- Child levels: Indented via parent container padding (8px per level)
|
||||
- Creates visual hierarchy
|
||||
|
||||
#### State Management
|
||||
|
||||
**Expanded/Collapsed State**
|
||||
- Stored in `operation.expanded` boolean
|
||||
- Toggled via `toggleOperationExpanded(operationId)`
|
||||
- Persists during re-renders
|
||||
- Controls visibility of child operations container
|
||||
|
||||
**Event Handlers**
|
||||
- `setupCollapseExpandHandlers()`: Sets up click handlers for expand buttons
|
||||
- `setupLogCollapseExpandHandlers()`: Sets up handlers for log entry expansion
|
||||
- Click handlers toggle expanded state and re-render dashboard
|
||||
|
||||
#### Rendering Flow
|
||||
|
||||
```
|
||||
renderDashboard()
|
||||
↓
|
||||
[For each root operation]
|
||||
renderOperationNode(operationId, 0)
|
||||
↓
|
||||
[Build operation header HTML]
|
||||
↓
|
||||
[If has children and expanded]
|
||||
[For each child operation]
|
||||
renderOperationNode(childOperationId, depth)
|
||||
↓
|
||||
[Recursive rendering continues...]
|
||||
↓
|
||||
[Set innerHTML of dashboard container]
|
||||
↓
|
||||
[Setup event handlers]
|
||||
```
|
||||
|
||||
#### Key Rendering Features
|
||||
|
||||
1. **Progress Updates**: Operation line updates in-place as new logs arrive
|
||||
2. **Status Changes**: Status badge updates when operation status changes
|
||||
3. **Collapsible Tree**: Users can expand/collapse operation groups
|
||||
4. **Visual Hierarchy**: Indentation shows parent-child relationships
|
||||
5. **Latest State**: Always shows most recent log message, progress, and status
|
||||
|
||||
## Data Structures
|
||||
|
||||
### Dashboard Log Tree
|
||||
|
||||
```javascript
|
||||
{
|
||||
operations: Map<operationId, {
|
||||
logs: Map<logId, log>, // All logs for this operation
|
||||
parentId: string | null, // Parent operation ID
|
||||
expanded: boolean, // UI expanded state
|
||||
latestProgress: number | null, // Most recent progress (0-1)
|
||||
latestStatus: string | null // Most recent status
|
||||
}>,
|
||||
rootOperations: string[], // Operation IDs without parent
|
||||
logExpandedStates: Map<logId, boolean>, // Individual log expanded states
|
||||
currentRound: number | null // Current workflow round
|
||||
}
|
||||
```
|
||||
|
||||
### Log Entry Format
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: string, // Unique log ID
|
||||
message: string, // Log message text
|
||||
type: 'info' | 'success' | 'error' | 'warning',
|
||||
timestamp: number, // Unix timestamp (seconds)
|
||||
status: string, // Operation status
|
||||
progress: number | null, // Progress value (0-1) or null
|
||||
operationId: string | null, // Operation ID (null = unified content)
|
||||
parentId: string | null // Parent operation ID (for nesting)
|
||||
}
|
||||
```
|
||||
|
||||
### Unified Chat Data Item
|
||||
|
||||
```javascript
|
||||
{
|
||||
type: 'message' | 'log' | 'stat', // Item type
|
||||
item: { /* message/log/stat data */ },
|
||||
createdAt: number // Timestamp for sorting
|
||||
}
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. Incremental Polling
|
||||
|
||||
- Uses `lastRenderedTimestamp` to fetch only new items
|
||||
- First poll loads all historical data (`afterTimestamp = null`)
|
||||
- Subsequent polls fetch incrementally (`afterTimestamp = lastRenderedTimestamp`)
|
||||
- Reduces API load and improves performance
|
||||
|
||||
### 2. Hierarchical Display
|
||||
|
||||
- Operations can have parent-child relationships via `parentId`
|
||||
- Visual indentation shows hierarchy
|
||||
- Collapsible tree structure for better UX
|
||||
- Supports unlimited nesting depth
|
||||
|
||||
### 3. Progress Tracking
|
||||
|
||||
- Shows progress bars for operations with progress values
|
||||
- Updates in real-time as new logs arrive
|
||||
- Forces 100% progress when status is 'completed'
|
||||
- Displays status badges (running/completed/failed/etc.)
|
||||
|
||||
### 4. Collapsible Tree
|
||||
|
||||
- Users can expand/collapse operation groups
|
||||
- Expand/collapse state persists during re-renders
|
||||
- Click handlers on operation headers and expand buttons
|
||||
- Smooth visual transitions
|
||||
|
||||
### 5. Round Detection
|
||||
|
||||
- Tracks current workflow round in `dashboardLogTree.currentRound`
|
||||
- Clears dashboard when round changes (via `updateProgressFromMessage()`)
|
||||
- Prevents mixing data from different workflow rounds
|
||||
|
||||
### 6. Duplicate Prevention
|
||||
|
||||
- Uses Map with `logId` keys to prevent duplicate entries
|
||||
- Same log ID updates in place rather than creating duplicates
|
||||
- Ensures unique log entries even with same progress value
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Rate Limiting (429 Errors)
|
||||
|
||||
- Detected in `pollWorkflowData()` and `doPolling()`
|
||||
- Triggers exponential backoff with increased multiplier
|
||||
- Stops polling after 5 consecutive rate limit errors
|
||||
- Prevents API abuse
|
||||
|
||||
### Network Errors
|
||||
|
||||
- Logged but don't immediately stop polling
|
||||
- Allows retry on transient network issues
|
||||
- Controller handles backoff automatically
|
||||
- Polling continues for recoverable errors
|
||||
|
||||
### Rendering Errors
|
||||
|
||||
- Don't stop polling (UI issue, not data issue)
|
||||
- Logged for debugging
|
||||
- Polling continues to get workflow status updates
|
||||
- UI can recover on next successful render
|
||||
|
||||
### Workflow Validation
|
||||
|
||||
- `isWorkflowValid()` checks before each poll cycle
|
||||
- Validates workflow state exists and matches active workflow
|
||||
- Checks if polling is still enabled (`pollActive` flag)
|
||||
- Stops polling if workflow is invalid
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Polling Intervals
|
||||
|
||||
- Base interval: 5 seconds (balanced between responsiveness and server load)
|
||||
- Maximum interval: 10 seconds (prevents excessive backoff)
|
||||
- Exponential backoff: Prevents overwhelming server during errors
|
||||
|
||||
### Data Processing
|
||||
|
||||
- Processes items sequentially to maintain chronological order
|
||||
- Uses Maps for O(1) lookups when grouping operations
|
||||
- Incremental polling reduces data transfer
|
||||
- Timestamp-based filtering at API level
|
||||
|
||||
### Rendering Optimization
|
||||
|
||||
- Full re-render on each update (simplifies state management)
|
||||
- Event handlers re-attached after each render
|
||||
- HTML generation is efficient (string concatenation)
|
||||
- Minimal DOM manipulation (innerHTML replacement)
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Starting Polling
|
||||
|
||||
```javascript
|
||||
import pollingController from './workflowPollingController.js';
|
||||
|
||||
// Start polling for a workflow
|
||||
pollingController.startPolling('workflow-123');
|
||||
```
|
||||
|
||||
### Stopping Polling
|
||||
|
||||
```javascript
|
||||
// Stop polling
|
||||
pollingController.stopPolling();
|
||||
```
|
||||
|
||||
### Processing Dashboard Logs
|
||||
|
||||
```javascript
|
||||
import { processDashboardLogs } from './workflowUiRendererDashboard.js';
|
||||
|
||||
// Process logs with operationId
|
||||
const logs = [
|
||||
{
|
||||
id: 'log-1',
|
||||
message: 'Processing file...',
|
||||
type: 'info',
|
||||
timestamp: 1234567890,
|
||||
status: 'running',
|
||||
progress: 0.5,
|
||||
operationId: 'op-123',
|
||||
parentId: null
|
||||
}
|
||||
];
|
||||
|
||||
processDashboardLogs(logs);
|
||||
```
|
||||
|
||||
### Clearing Dashboard
|
||||
|
||||
```javascript
|
||||
import { clearDashboard } from './workflowUiRendererDashboard.js';
|
||||
|
||||
// Clear dashboard (e.g., on workflow reset)
|
||||
clearDashboard(true); // true = reset round tracking
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- `FRONTEND_ARCHITECTURE.md` - Overall frontend architecture
|
||||
- `workflowCoordination.js` - State management coordination
|
||||
- `workflowUiRenderer.js` - Unified content rendering
|
||||
|
||||
## Conclusion
|
||||
|
||||
The dashboard log polling and rendering system provides a robust, hierarchical display of workflow operations with real-time updates. The system efficiently handles incremental polling, sorts data chronologically, and renders a collapsible tree structure that scales to complex workflows with multiple nested operations.
|
||||
|
||||
|
|
@ -1,227 +0,0 @@
|
|||
# Monetarisierung der Features — interaktives Klärungs-Playbook
|
||||
|
||||
Dieses Dokument ist für **Live-Workshops** gedacht (Product, Sales, Tech). Du kannst es in Cursor/VS Code oder GitHub öffnen: **Checkboxen** (`- [ ]`) und **leere Tabellenzellen** werden direkt im Editor abgehakt bzw. ausgefüllt.
|
||||
|
||||
---
|
||||
|
||||
## 1. Ausgangslage aus `frontend_nyla` (gemeinsames Bild)
|
||||
|
||||
Die App denkt in **Mandanten → Features → Instanzen → Rechte (Views)**. Nutzer haben keinen direkten „Mandanten-Login“, sondern **Zugriff auf Feature-Instanzen** (`featureStore`).
|
||||
|
||||
| Baustein | Bedeutung für Pricing |
|
||||
|----------|------------------------|
|
||||
| **Feature-Code** (z. B. `chatbot`, `workspace`) | logische Produktlinie / Modul |
|
||||
| **Instanz** | oft Kunde, Abteilung, Bot, “Organisation” — **horizontale Skalierung** (mehr Instanzen = mehr Wert oder mehr Kosten) |
|
||||
| **Views / Berechtigungen** | micro-Segmente im Produkt (**Add-ons**, Rollenpakete, „Light vs Pro“) |
|
||||
| **Feature Store** (`Store.tsx`) | Self-Service-Aktivierung (z. B. `automation`, `teamsbot`) — Kandidaten für **Freemium / Trial / Upsell** |
|
||||
| **Billing im Frontend** (`billingApi.ts`) | Modelle `PREPAY_MANDATE`, `PREPAY_USER`, `UNLIMITED`; Transaktionen mit u. a. `featureCode`, `featureInstanceId`, Provider/Modell |
|
||||
|
||||
**Leitidee:** Preis = *was* (Feature/View) × *wie viel* (Instanz, Nutzer, Volumen) × *wie abgerechnet* (Pauschale, Credits, Nachzahlung).
|
||||
|
||||
---
|
||||
|
||||
## 2. Schnell-Check: Welches Geschäftsmodell passt grob?
|
||||
|
||||
Arbeite die Fragen der Reihe nach durch und hake ab.
|
||||
|
||||
- [ ] **Primärer Käufer:** zahlt der **Mandant** (Firma), der **Endnutzer**, oder ein **Partner**?
|
||||
- [ ] **Value Metric:** was korreliert am ehesten mit Kundennutzen? (z. B. Mandanten, Instanzen, aktive Nutzer, gespeicherte Dokumente, API-Calls, AI-Tokens/CHF-Verbrauch)
|
||||
- [ ] **Margen-Risiko:** wo sind **variable Kosten** (LLM, Storage, Third-Party) — müssen die **durchgereicht** oder **gepuffert** werden?
|
||||
- [ ] **Go-to-Market:** brauchst du **Selbstbedienung** (Store) oder nur **Sales / Onboarding** (Admin legt Instanzen an)?
|
||||
- [ ] **Fairness:** soll ein Power-User **pro Nutzer** limitiert sein oder **Kontingent pro Mandant**?
|
||||
|
||||
### Entscheidungsbaum (Diskussionsvorlage)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Nutzerwert skaliert primär mit Volumen?] -->|Ja| B[Usage / Credits / Nachzahlung]
|
||||
A -->|Nein| C[Festpreis / Seat / Instanz]
|
||||
B --> D[Pro Feature oder globaler Credit-Pool?]
|
||||
C --> E[Wer zählt als Seat: Login oder aktive Nutzung?]
|
||||
D --> F[Sichtbar im Billing-Dashboard pro FeatureCode?]
|
||||
E --> G[Wie viele Instanzen sind inkludiert?]
|
||||
```
|
||||
|
||||
Trage Ergebnis hier ein (ein Satz):
|
||||
|
||||
> **Entscheidung Grobmodell:** …
|
||||
|
||||
---
|
||||
|
||||
## 3. Billing-Modelle (Anknüpfung an `billingApi.ts`)
|
||||
|
||||
Ordnet euer **Angebot** den technisch vorhandenen **BillingModel**-Werten zu (Namen aus dem Frontend):
|
||||
|
||||
| `BillingModel` | Typische Kundenstory | Wann sinnvoll? |
|
||||
|----------------|---------------------|----------------|
|
||||
| `PREPAY_MANDATE` | „Wir laden ein Konto auf, alle ziehen daraus.“ | Gemeinsamer Pool, ein Rechnungsempfänger |
|
||||
| `PREPAY_USER` | „Jeder Nutzer hat ein Kontingent.“ | faire Verteilung bei heterogenem Nutzungsverhalten |
|
||||
| `UNLIMITED` | „Flat rate / Enterprise-Vertrag.“ | Paketpreis deckt erwarteten Mix |
|
||||
|
||||
**Workshop-Aufgabe**
|
||||
|
||||
- [ ] Standard für **SMB**:
|
||||
- [ ] Standard für **Enterprise**:
|
||||
- [ ] Ausnahme / Piloten:
|
||||
|
||||
---
|
||||
|
||||
## 4. Feature-Inventar (aus `FEATURE_REGISTRY`)
|
||||
|
||||
Die folgende Tabelle ist die **Checkliste pro Modul**. Pro Zeile: **was** verkaufen wir, **woran** messen wir, **was** ist Inhalt eines Pakets?
|
||||
|
||||
| `featureCode` | Produktname (DE) | Hauptnutzen (1 Satz) | Typische „Einheit“ für Preis (*Seat / Instanz / Request / GB / CHF-Verbrauch*) | Im **Feature Store** relevant? | Variable Kosten? (ja/nein/klein) | Notizen |
|
||||
|---------------|------------------|----------------------|----------------------------------------------------------------------------------|-------------------------------|-----------------------------------|---------|
|
||||
| `trustee` | Treuhand | | | ☐ ja ☐ nein | | |
|
||||
| `realestate` | Immobilien | | | ☐ ja ☐ nein | | |
|
||||
| `chatbot` | Chatbot | | | ☐ ja ☐ nein | | |
|
||||
| `chatworkflow` | Workflow | | | ☐ ja ☐ nein | | |
|
||||
| `automation` | Automatisierung | | | ☐ ja ☐ nein | | |
|
||||
| `teamsbot` | Teams Bot | | | ☐ ja ☐ nein | | |
|
||||
| `neutralization` | Neutralisierung | | | ☐ ja ☐ nein | | |
|
||||
| `commcoach` | Kommunikations-Coach | | | ☐ ja ☐ nein | | |
|
||||
| `workspace` | AI Workspace | | | ☐ ja ☐ nein | | |
|
||||
|
||||
> **Hinweis Store:** In `Store.tsx` sind derzeit u. a. `automation` und `teamsbot` als Self-Service beschrieben — weitere Features können folgen, sobald Backend/Policy das erlaubt.
|
||||
|
||||
---
|
||||
|
||||
## 5. Views als Upsell-Stufen (fein granulare Monetarisierung)
|
||||
|
||||
Viele **Views** sind Kandidaten für „Basic / Pro“ oder Add-ons (technisch: Rechte pro View).
|
||||
|
||||
**Vorgehen pro Feature:** Liste die Views und markiere: **Base** (muss rein), **Upsell**, **Admin-only** (meist kein direkter Upsell).
|
||||
|
||||
### `trustee`
|
||||
|
||||
- [ ] `dashboard` — Base / Upsell / Admin-only
|
||||
- [ ] `positions` — …
|
||||
- [ ] `documents` — …
|
||||
- [ ] `position-documents` — …
|
||||
- [ ] `expense-import` — …
|
||||
- [ ] `scan-upload` — …
|
||||
- [ ] `instance-roles` (adminOnly) — …
|
||||
- [ ] `settings` — …
|
||||
|
||||
### `chatbot`
|
||||
|
||||
- [ ] `conversations` — …
|
||||
- [ ] `settings` — …
|
||||
|
||||
### `automation`
|
||||
|
||||
- [ ] `definitions` — …
|
||||
- [ ] `templates` — …
|
||||
- [ ] `logs` — …
|
||||
|
||||
### `teamsbot`
|
||||
|
||||
- [ ] `dashboard` — …
|
||||
- [ ] `sessions` — …
|
||||
- [ ] `settings` — …
|
||||
|
||||
### `neutralization`
|
||||
|
||||
- [ ] `playground` / `dashboard` — …
|
||||
- [ ] `config` — …
|
||||
- [ ] `attributes` — …
|
||||
|
||||
### `commcoach`
|
||||
|
||||
- [ ] `dashboard` — …
|
||||
- [ ] `coaching` — …
|
||||
- [ ] `dossier` — …
|
||||
- [ ] `settings` — …
|
||||
|
||||
### `workspace`
|
||||
|
||||
- [ ] `dashboard` — …
|
||||
- [ ] `editor` — …
|
||||
- [ ] `settings` — …
|
||||
|
||||
### `realestate`
|
||||
|
||||
- [ ] `dashboard` — …
|
||||
- [ ] `instance-roles` (adminOnly) — …
|
||||
|
||||
### `chatworkflow`
|
||||
|
||||
- [ ] `dashboard` — …
|
||||
- [ ] `runs` — …
|
||||
- [ ] `files` — …
|
||||
|
||||
**Paket-Entscheid (freies Feld):**
|
||||
|
||||
| Paketname | Enthaltene `featureCode`s | Enthaltene Views / Ausnahmen | Limits (Instanzen, Nutzer, Speicher, Credits) |
|
||||
|-----------|---------------------------|------------------------------|-----------------------------------------------|
|
||||
| Starter | | | |
|
||||
| Business | | | |
|
||||
| Enterprise | | | |
|
||||
|
||||
---
|
||||
|
||||
## 6. Nutzungsmessung vs. Produktversprechen
|
||||
|
||||
Transaktionen im Billing können unter anderem **`featureCode`**, **`featureInstanceId`**, **`aicoreProvider`**, **`aicoreModel`** tragen — gut für **trans-parente** oder **Feature-spezifische** Auswertung.
|
||||
|
||||
**Abgleich (interaktiv):**
|
||||
|
||||
- [ ] Welche Features sollen im Kunden-UI **getrennt** sichtbar sein (`costByFeature` im Usage-Report)?
|
||||
- [ ] Welche Kosten werden **in einen Topf** gelegt (einfacheres Pricing, weniger Erklärungsbedarf)?
|
||||
- [ ] Gibt es **überproportionale** Kostenfaktoren (z. B. Web-Recherche beim Chatbot), die ein **Aufsatz** oder **Limit** brauchen?
|
||||
|
||||
**Policy-Sätze (ausfüllen):**
|
||||
|
||||
1. Wenn Guthaben **`warningThreshold`** unterschreitet, dann: …
|
||||
2. Wenn **`blockOnZeroBalance`** aktiv ist, dann welche Features blocken wir zuerst: …
|
||||
3. **Trial:** welche Features sind zeitlich / volumenmäßig limitiert: …
|
||||
|
||||
---
|
||||
|
||||
## 7. Angebots-Builder (1 Seite für Sales)
|
||||
|
||||
_Kopiere diesen Block pro Lead._
|
||||
|
||||
- **Kunde / Segment:** …
|
||||
- **Mandanten-Setup:** # Instanzen geplant pro Feature: …
|
||||
- **Nutzer / Rollen:** …
|
||||
- **Inkludierte Module (`featureCode`):** …
|
||||
- **Add-ons (Views):** …
|
||||
- **BillingModel:** `PREPAY_MANDATE` / `PREPAY_USER` / `UNLIMITED`
|
||||
- **Kontingente:** CHF/Monat, Tokens, API-Calls, Speicher: …
|
||||
- **Preisgestaltung:** Listenpreis, Rabatt %, Laufzeit: …
|
||||
- **Risiken / Sonderkosten (LLM, Integrationen):** …
|
||||
|
||||
**Einwand-Notizen:**
|
||||
|
||||
| Einwand | Antwort / Kompromiss |
|
||||
|---------|----------------------|
|
||||
| „Wir wollen unbegrenzt.“ | |
|
||||
| „Wir haben viele Abteilungen (Instanzen).“ | |
|
||||
| „Wir brauchen nur eine View.“ | |
|
||||
|
||||
---
|
||||
|
||||
## 8. Definition of Done (Pricing ist „fertig“ besprochen)
|
||||
|
||||
- [ ] Jedes `featureCode` hat **Paket** + **Messgröße** + **Ausnahmen**
|
||||
- [ ] Jede revenue-relevante View ist **Base/Upsell** zugeordnet
|
||||
- [ ] Store-Features haben **Activation Policy** (wer darf, was passiert nach Trial)
|
||||
- [ ] Billing-Model pro Segment ist festgelegt inkl. **Warn-/Block-Verhalten**
|
||||
- [ ] Sales-Template (Abschnitt 7) ist befüllt für **Pilotkunde 1**
|
||||
|
||||
---
|
||||
|
||||
## 9. Referenz im Repo (für Technik-Abgleich)
|
||||
|
||||
| Thema | Datei |
|
||||
|-------|--------|
|
||||
| Feature-Definitionen (Codes, Views) | `src/types/mandate.ts` (`FEATURE_REGISTRY`) |
|
||||
| Mandant → Instanzen → Rechte | `src/stores/featureStore.tsx` |
|
||||
| Self-Service Store | `src/pages/Store.tsx`, `src/api/storeApi.ts` |
|
||||
| Billing-Typen & Reports | `src/api/billingApi.ts` |
|
||||
| UI-Komponenten / Routing-Helfer | `src/config/pageRegistry.tsx` |
|
||||
|
||||
---
|
||||
|
||||
*Version: 2026-03-20 — ausgerichtet auf den Stand von `frontend_nyla`; bei neuen Features die Tabellen in Abschnitt 4–5 ergänzen.*
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
# Monetarisierung — Kurzfassung (Präsentation)
|
||||
|
||||
*Technische Einordnung: Plattform `frontend_nyla` — Mandat → Feature → **Instanz** → **Views** (Rechte). Billing-Typen aus `billingApi.ts`.*
|
||||
|
||||
---
|
||||
|
||||
## Folie 1 · Kernthese
|
||||
|
||||
**Wir verkaufen Module (`featureCode`) und deren Ausprägung: wie viele Instanzen, welche Views, wie viel Verbrauch.**
|
||||
|
||||
Preislogik: **Was** × **Wie viel** × **Abrechnungsmodell** (Pauschale, Credits, Nachzahlung, Flat).
|
||||
|
||||
---
|
||||
|
||||
## Folie 2 · Drei Hebel für das Angebot
|
||||
|
||||
| Hebel | Bedeutung |
|
||||
|--------|-----------|
|
||||
| **Modul** | z. B. `chatbot`, `workspace`, `trustee`, `automation`, … |
|
||||
| **Instanz** | Skalierung pro Mandat (Bots, Organisationen, Teams, …) |
|
||||
| **View / Rolle** | „Light vs Pro“, Add-ons (fein granular über Berechtigungen) |
|
||||
|
||||
**Self-Service:** Feature Store (`automation`, `teamsbot`, …) → Trial, Freemium, Upsell.
|
||||
|
||||
---
|
||||
|
||||
## Folie 3 · Abrechnung (Systemkonzept)
|
||||
|
||||
| Modell | Kunde versteht es so |
|
||||
|--------|----------------------|
|
||||
| **PREPAY_MANDATE** | Ein Guthaben-Topf für die ganze Organisation |
|
||||
| **PREPAY_USER** | Kontingent pro Nutzer |
|
||||
| **UNLIMITED** | Paket / Enterprise-Flat |
|
||||
|
||||
Transparenz: Verbrauch lässt sich nach **Feature**, **Instanz**, **Provider/Modell** auswerten (technische Basis im Billing).
|
||||
|
||||
---
|
||||
|
||||
## Folie 4 · Produktportfolio (Überblick)
|
||||
|
||||
| Modul | Fokus |
|
||||
|--------|--------|
|
||||
| Treuhand (`trustee`) | Dokumente, Positionen, Import/Scan, Buchhaltung |
|
||||
| Immobilien (`realestate`) | Karte / Mandantenfähigkeit |
|
||||
| Chatbot (`chatbot`) | Konversationen, Konfiguration |
|
||||
| Workflow (`chatworkflow`) | Überblicke, Runs, Dateien |
|
||||
| Automatisierung (`automation`) | Definitionen, Vorlagen, Logs |
|
||||
| Teams Bot (`teamsbot`) | Dashboard, Sessions, Settings |
|
||||
| Neutralisierung (`neutralization`) | Playground, Config, Attribute |
|
||||
| CommCoach (`commcoach`) | Dashboard, Coaching, Dossier |
|
||||
| AI Workspace (`workspace`) | Dashboard, Editor, Settings |
|
||||
|
||||
*Details & Workshop-Checklisten: siehe `MONETARISIERUNG_FEATURES_INTERAKTIV.md`.*
|
||||
|
||||
---
|
||||
|
||||
## Folie 5 · Entscheidungen (noch zu füllen)
|
||||
|
||||
1. **Segment:** SMB-Paket vs. Enterprise — Standard-Billingmodell?
|
||||
2. **Messgröße:** Seats, Instanzen, CHF-Verbrauch, hybride Limits?
|
||||
3. **Transparenz:** getrennte Kosten pro Feature oder ein Gesamtpool?
|
||||
4. **Store:** welche Module dürfen selbst aktiviert werden — mit welchem Trial?
|
||||
|
||||
---
|
||||
|
||||
## Folie 6 · Nächster Schritt
|
||||
|
||||
Paketraster **Starter / Business / Enterprise** mit festen Inklusiv-Features + klaren **Überlaufregeln** (Warnung, Sperre, Nachkauf).
|
||||
|
|
@ -1,246 +0,0 @@
|
|||
# PR-Bericht: frontend_nyla (feat/saas-multi-tenant-mandates)
|
||||
|
||||
**Ziel-Branch:** `int`
|
||||
**Feature-Branch:** `feat/saas-multi-tenant-mandates`
|
||||
**Stand:** Januar 2026
|
||||
|
||||
---
|
||||
|
||||
## Übersicht
|
||||
|
||||
| Metrik | Wert |
|
||||
|--------|------|
|
||||
| **Anzahl Commits** | 73 |
|
||||
| **Geänderte Dateien** | 455 |
|
||||
| **Zeilen hinzugefügt** | ~78'604 |
|
||||
| **Zeilen gelöscht** | ~15'070 |
|
||||
|
||||
---
|
||||
|
||||
## Wichtigste inhaltliche Änderungen
|
||||
|
||||
### 1. SaaS Multi-Mandate & Mandatsverwaltung
|
||||
- Mandaten-Navigation und Mandatenwechsel im UI
|
||||
- Mandats-Einladungen und Benachrichtigungssystem
|
||||
- Mandats-Rollen und -Berechtigungen (RBAC)
|
||||
- Zugriff auf Seiten und Features mandatenbasiert
|
||||
- UID-/ID-Mapping und Referenzen für Multi-Mandate angepasst
|
||||
|
||||
### 2. Admin-Bereich
|
||||
- Admin-Seiten: Mandate, RBAC-Rollen, RBAC-Regeln, Team-Mitglieder
|
||||
- Feature-Zugriff, Benutzer-Zugriff, Mandats-Rollen-Berechtigungen
|
||||
- Einladungsverwaltung, Benutzer-Mandate, Benutzerübersicht
|
||||
- RBAC-Export/Import
|
||||
|
||||
### 3. Trustee (Treuhand)-Feature
|
||||
- Trustee-Views: Dashboard, Positionen, Dokumente, Verträge, Rollen, Organisationen, Access
|
||||
- Expense-Import-View, Positions-Dokumente
|
||||
- Trustee-API und Hooks (`useTrustee`, `useTrusteeOptions`)
|
||||
|
||||
### 4. Workflows & Automation
|
||||
- Workflow-Playground mit zentralem State, Log-Polling, Lifecycle-Hooks
|
||||
- Automations-Seite und Workflows-Seite
|
||||
- Workflow-Statistiken, verbesserte Log-Darstellung
|
||||
- Chatbot-Integration (Messages, Dateien, Lifecycle)
|
||||
|
||||
### 5. PEK (Projekte & Stammdaten)
|
||||
- Isoliert in Folder
|
||||
|
||||
### 6. Authentifizierung & Nutzerverwaltung
|
||||
- Magic-Link-Login
|
||||
- Passwort-Reset (Request & Reset-Seiten)
|
||||
- Registrierung und Login überarbeitet
|
||||
- Erweiterter Auth-Hook und CSRF-Utilities
|
||||
|
||||
### 7. GDPR & Rechtliches
|
||||
- GDPR-konforme Seite/Flows
|
||||
- PowerOn Home, Datenschutz, AGB als statische HTML-Seiten
|
||||
|
||||
### 8. UI/UX & Architektur
|
||||
- Neues Page-Management (`core/PageManager`) mit datengetriebenen Seiten
|
||||
- FormGenerator: Aufteilung in Form, List, Table, Controls, Action-Buttons
|
||||
- Server-seitige Filter/Sort für generische Tabellen, Scroll-Lock für Header
|
||||
- Sidebar überarbeitet, Mandaten-Navigation, Tree-Navigation, UserSection
|
||||
- Notification-Bell, Access-Rules-Editor
|
||||
- Layouts: `MainLayout`, `FeatureLayout`; Seiten: `FeatureView`, `Dashboard`, `Settings`
|
||||
- Diverse UI-Komponenten (Log, Messages, MapView, Tabs, Toast, ViewForm, WorkflowStatus, etc.)
|
||||
|
||||
### 9. Konfiguration & Deployment
|
||||
- Konfiguration in `config/` (env, serverConfig, universalConfig)
|
||||
- Scripts nach `scripts/` (server, deploy-server)
|
||||
- GitHub-Workflows angepasst, Node-Version 20
|
||||
|
||||
### 10. Weitere Features & Fixes
|
||||
- Speech-Integration (Prototyp), Speech-Seiten & -Transkripte
|
||||
- Real-Estate/Privilege-Checker isoliert in Folder
|
||||
- Althaus-Seite, PEK-Tabs umbenannt (Projects, Data Management)
|
||||
- Einstellungen-Seite, Basedata-Seiten (Connections, Files, Prompts)
|
||||
- Privilege-Caching und -Checker konsolidiert
|
||||
- Diverse Build- und TypeScript-Fixes, Hotfixes
|
||||
|
||||
---
|
||||
|
||||
## Geänderte Dateien (nach Bereichen)
|
||||
|
||||
### Konfiguration & Projekt
|
||||
- `.cursorignore`, `.gitignore`, `.github/workflows/*.yml`
|
||||
- `README.md`, `index.html`, `package.json`, `package-lock.json`
|
||||
- `vite.config.ts`
|
||||
- `config/*` (neu), `.env.*` → `config/.env.*`
|
||||
- `scripts/server.js`, `scripts/deploy-server.js`
|
||||
- `public/`: Favicon, Logos, `poweron-home.html`, `poweron-privacy.html`, `poweron-terms.html`
|
||||
|
||||
### API-Schicht
|
||||
- `src/api.ts` (erweitert)
|
||||
- Neu: `src/api/attributesApi.ts`, `authApi.ts`, `automationApi.ts`, `chatbotApi.ts`, `connectionApi.ts`, `featuresApi.ts`, `fileApi.ts`, `mandateApi.ts`, `permissionApi.ts`, `promptApi.ts`, `rbacRulesApi.ts`, `roleApi.ts`, `trusteeApi.ts`, `userApi.ts`, `workflowApi.ts`
|
||||
|
||||
### Auth & Provider
|
||||
- `src/providers/auth/AuthProvider.tsx`, `ProtectedRoute.tsx`, `authConfig.ts`
|
||||
- `src/providers/language/LanguageContext.tsx`
|
||||
- `src/auth/ProtectedRoute.tsx` (entfernt/ersetzt)
|
||||
|
||||
### Core: PageManager
|
||||
- `src/core/PageManager/PageManager.tsx`, `PageRenderer.tsx`, `SidebarProvider.tsx`, `pageInterface.ts`
|
||||
- `src/core/PageManager/data/pages/*`: admin (mandates, rbac-role, rbac-rules, team-members), automations, chatbot, connections, dashboard, files, pek, pek-tables, prompts, settings, speech, speech-transcripts, trustee (access, contracts, documents, organisations, positions, roles), workflows
|
||||
- `src/config/pageRegistry.tsx`
|
||||
|
||||
### Komponenten (Auswahl)
|
||||
- **AccessRules:** AccessLevelSelect, AccessRulesEditor, AccessRulesTable + Styles
|
||||
- **FormGenerator:** FormGeneratorControls, FormGeneratorForm, FormGeneratorList, FormGeneratorTable, ActionButtons (Copy, Custom, Delete, Download, Edit, Remove, View)
|
||||
- **Navigation:** MandateNavigation, TreeNavigation, UserSection + Styles
|
||||
- **NotificationBell**, **ContentPreview** (inkl. Renderer)
|
||||
- **Sidebar:** Sidebar, SidebarItem, SidebarSubmenu, SidebarUser, Styles, Logic, Types
|
||||
- **Dashboard/DashboardChat:** teils entfernt/umgebaut (Playground-basiert)
|
||||
- **Connections, Dateien, Mitglieder, PageManager, Popup, Prompts:** entfernt oder nach core/Pages migriert
|
||||
- **Speech:** SpeechConfirmation, SpeechInfo, SpeechSettings, SpeechSignUp
|
||||
- **TestSharepoint:** Tabelle, Logic, Interfaces
|
||||
- **Workflows:** WorkflowsTable, workflowsLogic, workflowsTypes
|
||||
- **UiComponents:** AutoScroll, Button, ConnectedFilesList, CopyableTruncatedValue, DragDropOverlay, DropdownSelect, InfoMessageOverlay, LocationInput, Log/LogMessage, MapView, Messages/ChatMessages, ParcelInfoPanel, Popup, Tabs, TextField, Toast, ViewForm, VoiceLanguageSelect, WorkflowStatus
|
||||
- **settings:** settingsUser
|
||||
|
||||
### Hooks
|
||||
- Neu/erweitert: `useAccessRules`, `useAdminMandates`, `useAdminRbacRoles`, `useAdminRbacRules`, `useAuthentication`, `useAutomations`, `useChatbot`, `useConnections`, `useCurrentInstance`, `useFeatureAccess`, `useFiles`, `useInstancePermissions`, `useInvitations`, `useMandateRoles`, `useMandates`, `useNavigation`, `useNotifications`, `usePek`, `usePekTables`, `usePermissions`, `usePlayground`, `usePrompts`, `useRbacExportImport`, `useResizablePanels`, `useRoles`, `useSettings`, `useTrustee`, `useTrusteeOptions`, `useUserMandates`, `useUsers`, `useWorkflows`
|
||||
- Playground: `useDashboardInputForm`, `useDashboardLogTree`, `useWorkflowLifecycle`, `useWorkflowPolling`, `useWorkflows`, `playgroundUtils`
|
||||
- Entfernt: `useSharePointTest`
|
||||
|
||||
### Seiten
|
||||
- **Neu:** `Dashboard.tsx`, `FeatureView.tsx`, `GDPR.tsx`, `InvitePage.tsx`, `PasswordResetRequest.tsx`, `Reset.tsx`, `Settings.tsx`
|
||||
- **Login, Register:** überarbeitet
|
||||
- **Home:** Connections, Dashboard, Dateien, Einstellungen, Prompts, TeamBereich, TestSharepoint, Workflows entfernt/ausgelagert; `Home.tsx` angepasst
|
||||
- **admin/:** AdminFeatureAccessPage, AdminFeatureInstanceUsersPage, AdminFeatureRolesPage, AdminInvitationsPage, AdminMandateRolePermissionsPage, AdminMandateRolesPage, AdminMandatesPage, AdminUserAccessOverviewPage, AdminUserMandatesPage, AdminUsersPage
|
||||
- **basedata/:** ConnectionsPage, FilesPage, PromptsPage
|
||||
- **migrate/:** ChatbotPage, PekPage, SpeechPage
|
||||
- **views/trustee/:** TrusteeDashboardView, TrusteeDocumentsView, TrusteeExpenseImportView, TrusteeInstanceRolesView, TrusteePositionDocumentsView, TrusteePositionsView
|
||||
- **workflows/:** AutomationsPage, PlaygroundPage, WorkflowsPage
|
||||
|
||||
### Contexts, Stores, Utils, Locales
|
||||
- **Contexts:** FileContext, PekContext, PekTablesContext, ToastContext, WorkflowSelectionContext
|
||||
- **Stores:** featureStore
|
||||
- **Utils:** attributeTypeMapper, csrfUtils, privilegeCheckers, time, userCache
|
||||
- **Types:** mandate.ts
|
||||
- **Locales:** de.ts, en.ts, fr.ts (erweitert)
|
||||
|
||||
### Styles & Assets
|
||||
- `src/styles/`: buttons.css, pages.module.css, themes (dark, light), assets/bg.jpg
|
||||
- `src/assets/styles/light.css` entfernt
|
||||
- `src/index.css`, `src/main.tsx`
|
||||
|
||||
### Dokumentation
|
||||
- `docs/DASHBOARD_LOG_POLLING_DOCUMENTATION.md` (neu)
|
||||
- `documentation/sidebar.md` (entfernt)
|
||||
- `.cursor/plans/implement_rbac_roles_page_*.plan.md` (Cursor-Plan)
|
||||
|
||||
---
|
||||
|
||||
## Commit-Historie (chronologisch)
|
||||
|
||||
| Datum | Commit | Nachricht |
|
||||
|-------|--------|-----------|
|
||||
| 2025-09-10 | 0bd6091 | updated cofig logic |
|
||||
| 2025-09-15 | 9fc33c7 | feat: added speech integration prototype |
|
||||
| 2025-09-16 | 9e7c3b2 | implemented feedback |
|
||||
| 2025-09-18 | 41aa0fd | minor bugfixing |
|
||||
| 2025-10-01 | 05f51c4 | working on action button |
|
||||
| 2025-10-08 | b238ab8 | fixed action buttons |
|
||||
| 2025-10-12 | 6988984 | finished files page |
|
||||
| 2025-10-12 | 9519fed | pushing to int |
|
||||
| 2025-10-12 | 8a0e5f8 | fix: ready for build |
|
||||
| 2025-12-01 | 101b306 | added PEK pages |
|
||||
| 2025-12-01 | aa34508 | updated the table view |
|
||||
| 2025-12-15 | aaf64b8 | resumed backend integration, RBAC focus |
|
||||
| 2025-12-15 | f8d5c0a | fix: geolinien outline |
|
||||
| 2025-12-15 | 78889bf | fix: collapsed sidebar |
|
||||
| 2025-12-22 | bfbe3f8 | PEK updates |
|
||||
| 2025-12-30 | cf76e89 | pek update |
|
||||
| 2025-12-30 | 94e8681 | fix: centralized workflow state management on dashboard page |
|
||||
| 2025-12-30 | 14273c2 | Merge PR #2 feat/real-estate |
|
||||
| 2025-12-30 | d3c950d | fix: consolidated privilegechecker and usepermissions hook |
|
||||
| 2026-01-02 | 641930b | updated log rendering |
|
||||
| 2026-01-02 | 401c088 | fix: fixed styling of log messages |
|
||||
| 2026-01-02 | ae6a634 | feature: show workflow stats |
|
||||
| 2026-01-05 | c76e7ef | feat: completely build up althaus page |
|
||||
| 2026-01-05 | 079d398 | fix: build errors removed |
|
||||
| 2026-01-05 | 6315c9a | fix: constant reload |
|
||||
| 2026-01-05 | 6c90a00 | fix: another build error |
|
||||
| 2026-01-05 | 05508cc | fix: privilege caching led to no pages showing |
|
||||
| 2026-01-05 | 826eead | fix: added more rolelabel logging |
|
||||
| 2026-01-05 | 48754d6 | fix: typescript build errors |
|
||||
| 2026-01-05 | fc55a25 | fix: added more rolelabel logging |
|
||||
| 2026-01-05 | 5f22c7b | fix: added more rolelabel logging |
|
||||
| 2026-01-05 | eb280db | feat: completely build up althaus page |
|
||||
| 2026-01-05 | c80ad96 | Rename PEK tabs: Projects and Data Management |
|
||||
| 2026-01-05 | dd79895 | Merge int |
|
||||
| 2026-01-05 | b0826a3 | feat: weiter chatbot implementiert |
|
||||
| 2026-01-05 | 23508ea | fix: merge conflicts |
|
||||
| 2026-01-05 | 350cc7b | fix: geolinien outline |
|
||||
| 2026-01-05 | 407a3c4 | PEK updates |
|
||||
| 2026-01-05 | c5a82dd | feat: multiselect parcels and create projects |
|
||||
| 2026-01-09 | 836b803 | fix: fixed and finished chatbot integration |
|
||||
| 2026-01-09 | 3df83f0 | fix: button fix |
|
||||
| 2026-01-12 | 7d794ef | Merge feat/chatbot into int |
|
||||
| 2026-01-12 | 5808bd4 | fixed merge conflicts |
|
||||
| 2026-01-12 | 239fd32 | fix: readded deleted code, fixed build |
|
||||
| 2026-01-12 | be3844f | feat: privilege checker into real estate pages |
|
||||
| 2026-01-12 | eaf69f4 | feat/fix: added admin pages, fixed sidebar width |
|
||||
| 2026-01-12 | 64d14af | feat: finished admin pages |
|
||||
| 2026-01-12 | acdcf2c | fix: moved team-members file to correct place |
|
||||
| 2026-01-12 | 06ffce8 | fix: fixed build |
|
||||
| 2026-01-13 | 9fc0c9b | user magic link implemented |
|
||||
| 2026-01-13 | 7d2808d | hotfix |
|
||||
| 2026-01-13 | b2c38e7 | Fixed UI issues |
|
||||
| 2026-01-13 | 71666d2 | hotfixes |
|
||||
| 2026-01-13 | de98b86 | hotfixes |
|
||||
| 2026-01-13 | c5d60c4 | node-version 20 |
|
||||
| 2026-01-13 | 2b96ab7 | fixed trustee access |
|
||||
| 2026-01-14 | 54ba020 | fix: fixed formgenerator layout and design |
|
||||
| 2026-01-17 | 8033ca9 | prepared multimandate |
|
||||
| 2026-01-20 | 70c84dd | revised ui components |
|
||||
| 2026-01-21 | 7f07a55 | saas mandates core done |
|
||||
| 2026-01-21 | 537b624 | fixed uid mapping to id |
|
||||
| 2026-01-21 | d387322 | serverside filter and sort for form generic |
|
||||
| 2026-01-21 | 34d4646 | generic form table scroll lock for headers |
|
||||
| 2026-01-21 | f99b6b7 | saas multi mandate tested |
|
||||
| 2026-01-22 | b207c0c | dyn options in api |
|
||||
| 2026-01-23 | dc4b475 | refactored pages ui access with saas mandates |
|
||||
| 2026-01-24 | cc8770d | reference fixes |
|
||||
| 2026-01-24 | 41e02b5 | fixed automation and trustee |
|
||||
| 2026-01-24 | 5952074 | access rules editor enhanced |
|
||||
| 2026-01-24 | 6a406d8 | fixes |
|
||||
| 2026-01-25 | bf4ddc6 | rbac rules tested and fixed |
|
||||
| 2026-01-25 | 2b220fe | gpdr compliancy implemented |
|
||||
| 2026-01-26 | 28af4cb | mandate invitation and notification system |
|
||||
| 2026-01-26 | f41e6d0 | fixed ai call end to end with saas multimandate |
|
||||
|
||||
---
|
||||
|
||||
## Hinweise für das PR-Review
|
||||
|
||||
1. **Breite Änderung:** Viele Dateien und neue Strukturen; Fokus auf PageManager, Admin, Trustee und Workflow/Playground empfohlen.
|
||||
2. **Auth & Mandate:** Magic Link, Reset-Flow und Mandatenwechsel sollten manuell geprüft werden.
|
||||
3. **RBAC:** Admin RBAC-Seiten und Access Rules Editor sind kritisch für Berechtigungen.
|
||||
4. **Build & Lint:** `npm run build` und Linter im CI prüfen.
|
||||
5. **Doku:** `docs/DASHBOARD_LOG_POLLING_DOCUMENTATION.md` für Playground/Logging konsultieren.
|
||||
|
||||
---
|
||||
|
||||
*Bericht erstellt aus der Git-Historie `main..HEAD` im Repository `frontend_nyla` (Branch `feat/saas-multi-tenant-mandates`).*
|
||||
|
|
@ -1,182 +0,0 @@
|
|||
# i18n — Verbleibende statische Texte
|
||||
|
||||
> Stand: 2026-04-08
|
||||
> Diese Stellen verwenden noch hardcoded Strings in Object-Literalen, Arrays oder Hook-Defaults.
|
||||
> Sie können nicht einfach mit `t()` gewrapped werden, da sie ausserhalb des React-Render-Kontexts definiert sind.
|
||||
> **Lösung:** Array/Object in die Komponente verschieben oder eine Factory-Funktion `(t) => [...]` nutzen.
|
||||
|
||||
---
|
||||
|
||||
## 1. Hook-Defaults (`useConfirm`, `usePrompt`)
|
||||
|
||||
Diese Defaults propagieren in die gesamte App. Ein Fix hier wirkt global.
|
||||
|
||||
| Datei | Zeile | Property | Text |
|
||||
|-------|-------|----------|------|
|
||||
| `hooks/useConfirm.tsx` | 26 | `title` | `'Bestätigung'` |
|
||||
| `hooks/useConfirm.tsx` | 27 | `confirmLabel` | `'Bestätigen'` |
|
||||
| `hooks/useConfirm.tsx` | 28 | `cancelLabel` | `'Abbrechen'` |
|
||||
| `hooks/usePrompt.tsx` | 29 | `title` | `'Eingabe'` |
|
||||
| `hooks/usePrompt.tsx` | 30 | `confirmLabel` | `'OK'` |
|
||||
| `hooks/usePrompt.tsx` | 31 | `cancelLabel` | `'Abbrechen'` |
|
||||
|
||||
---
|
||||
|
||||
## 2. Monatsnamen
|
||||
|
||||
| Datei | Zeilen | Kontext |
|
||||
|-------|--------|---------|
|
||||
| `pages/billing/BillingDashboard.tsx` | 182–193 | Monats-Select: `'Januar'` bis `'Dezember'` (12 Einträge) |
|
||||
| `components/FormGenerator/FormGeneratorReport/FormGeneratorReport.tsx` | 536–541 | Monats-Select: `'Januar'` bis `'Dezember'` (12 Einträge) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Tab-Labels (statische Arrays ausserhalb Komponente)
|
||||
|
||||
| Datei | Zeilen | Labels |
|
||||
|-------|--------|--------|
|
||||
| `pages/Settings.tsx` | 23–27 | `'Profil'`, `'Darstellung'`, `'Stimme & Sprache'`, `'Neutralisierung (lokal)'`, `'Datenschutz'` |
|
||||
| `pages/views/workspace/WorkspaceSettingsPage.tsx` | 16–17 | `'Generelle Einstellungen'`, `'Neutralisierung (Workspace)'` |
|
||||
| `pages/views/neutralization/NeutralizationView.tsx` | 744–745 | `'Configuration'`, `'Playground'` |
|
||||
|
||||
---
|
||||
|
||||
## 4. Spalten-Definitionen (Column-Arrays)
|
||||
|
||||
| Datei | Zeilen | Labels |
|
||||
|-------|--------|--------|
|
||||
| `pages/admin/AdminLanguagesPage.tsx` | 29–32 | `'Code'`, `'Bezeichnung'`, `'Status'`, `'Einträge'` |
|
||||
| `pages/admin/AdminSubscriptionsPage.tsx` | 13–23 | `'Mandant'`, `'Plan'`, `'Status'`, `'Wiederkehrend'`, `'User'`, `'Instanzen'`, `'Revenue/Mt (CHF)'`, `'Gestartet'`, `'Periodenende'`, `'Preis/User'`, `'Preis/Instanz'` |
|
||||
| `pages/admin/AdminUserMandatesPage.tsx` | 104–144 | `'Benutzername'`, `'E-Mail'`, `'Vollständiger Name'`, `'Rollen'`, `'Aktiv'` |
|
||||
| `pages/admin/AdminMandateRolesPage.tsx` | 106–124 | `'Bezeichnung'`, `'Beschreibung'`, `'Geltungsbereich'` |
|
||||
| `pages/admin/AdminInvitationsPage.tsx` | 90–155 | `'Benutzername'`, `'E-Mail'`, `'Rollen'`, `'Gültig bis'`, `'Verwendet'`, `'Erstellt'` |
|
||||
| `pages/admin/AdminFeatureRolesPage.tsx` | 138–155 | `'Rollen-Label'`, `'Beschreibung'`, `'Feature'` |
|
||||
| `pages/admin/AdminFeatureInstanceUsersPage.tsx` | 205–245 | `'Benutzername'`, `'E-Mail'`, `'Vollständiger Name'`, `'Rollen'`, `'Aktiv'` |
|
||||
| `pages/admin/AdminFeatureAccessPage.tsx` | 91–104 | `'Name'`, `'Feature'`, `'Aktiv'` |
|
||||
| `pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx` | 184–235 | `'Workflow'`, `'Aktiv'`, `'Läuft'`, `'Steht bei'`, `'Erstellt'`, `'Zuletzt gestartet'`, `'Läufe'` |
|
||||
| `pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx` | 174–202 | `'Vorlage'`, `'Scope'`, `'Freigegeben'`, `'Erstellt von'`, `'Erstellt'` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Formular-Feld-Definitionen (AttributeDefinition-Arrays)
|
||||
|
||||
| Datei | Zeilen | Labels |
|
||||
|-------|--------|--------|
|
||||
| `pages/Settings.tsx` | 56–58 | `'Vollstaendiger Name'`, `'E-Mail-Adresse'`, `'Sprache'` + descriptions + placeholders |
|
||||
| `pages/admin/wizards/FeatureInstanceWizard.tsx` | 75–78 | `'Mandant'`, `'Feature'`, `'Bezeichnung'`, `'Aktiv'` |
|
||||
| `pages/admin/InstanceDetailModal.tsx` | 186–279 | `'Benutzer'`, `'Rollen'`, `'Aktiv'`, `'Einstellungen'`, `'Bezeichnung'`, `'Aktiviert'` |
|
||||
| `pages/admin/AdminMandateRolesPage.tsx` | 165–171 | `'Geltungsbereich'`, `'Nur dieser Mandant'`, `'Template (wird bei neuen Mandanten kopiert)'` |
|
||||
| `pages/admin/AdminMandateRolePermissionsPage.tsx` | 219–221 | `'Mandanten-Rollen'`, `'Alle (inkl. Templates)'`, `'Nur Templates'` |
|
||||
| `pages/admin/AdminFeatureRolesPage.tsx` | 173–205 | `'Rollen-Label'`, `'Beschreibung'` + descriptions |
|
||||
| `pages/admin/AdminFeatureInstanceUsersPage.tsx` | 272–299 | `'Benutzer'`, `'Rollen'`, `'Aktiv'` |
|
||||
| `pages/admin/AdminInvitationsPage.tsx` | 181 | `'Gültigkeitsdauer (Stunden)'` |
|
||||
| `pages/admin/AdminFeatureAccessPage.tsx` | 629–636 | `'Bezeichnung'`, `'Aktiviert'` |
|
||||
|
||||
---
|
||||
|
||||
## 6. Status-/Option-Maps (Object-Literale)
|
||||
|
||||
| Datei | Zeilen | Kontext |
|
||||
|-------|--------|---------|
|
||||
| `pages/billing/SubscriptionTab.tsx` | 48–53 | Status-Map: `'Zahlung ausstehend'`, `'Geplant'`, `'Aktiv'`, `'Testphase'`, `'Abgelaufen'` |
|
||||
| `components/FlowEditor/editor/CanvasHeader.tsx` | 40–42 | Status-Map: `'Entwurf'`, `'Veröffentlicht'`, `'Archiviert'` |
|
||||
| `components/FlowEditor/editor/WorkflowConfigurationModal.tsx` | 17–20 | Trigger-Typen: `'Manueller Trigger'`, `'Formular'`, `'Zeitplan'`, `'Immer aktiv'` |
|
||||
| `components/FlowEditor/nodes/start/ScheduleStartNodeConfig.tsx` | 22–49 | Schedule-Optionen: `'Täglich'`, `'Werktage'`, `'Bestimmte Tage'`, `'Intervall'`, `'Sekunden'`, `'Minuten'`, `'Stunden'`, `'Tage'`, `'Jahre'` |
|
||||
| `components/RbacExportImport/RbacExportImport.tsx` | 49–62 | Import-Modi: `'Zusammenführen'`, `'Nur hinzufügen'`, `'Ersetzen'` + descriptions |
|
||||
| `components/AccessRules/AccessRulesEditor.tsx` | 633–636 | Tab-Labels: `'Daten'`, `'Ressourcen'` |
|
||||
| `hooks/useAccessRules.tsx` | 23–26 | Scope-Labels: `'Keine'`, `'Eigene'`, `'Gruppe'`, `'Alle'` |
|
||||
|
||||
---
|
||||
|
||||
## 7. Action-Button `title:`-Props (in Object-Literalen)
|
||||
|
||||
| Datei | Zeilen | Titles |
|
||||
|-------|--------|--------|
|
||||
| `pages/admin/AdminUsersPage.tsx` | 200–212 | `'Bearbeiten'`, `'Löschen'`, `'Passwort-Link senden'` |
|
||||
| `pages/admin/AdminUserMandatesPage.tsx` | 352–356 | `'Rollen bearbeiten'`, `'Aus Mandant entfernen'` |
|
||||
| `pages/admin/AdminMandatesPage.tsx` | 127–234 | `'Mandant deaktivieren'`, `'Deaktivieren'`, `'Hard Delete (irreversibel)'`, `'Endgültig löschen'`, `'Bearbeiten'`, `'Deaktivieren (Soft-Delete)'` |
|
||||
| `pages/admin/AdminMandateRolesPage.tsx` | 430–435 | `'Rolle bearbeiten'`, `'Rolle löschen'` |
|
||||
| `pages/admin/AdminInvitationsPage.tsx` | 354–362 | `'Einladung widerrufen'`, `'Einladungs-Link anzeigen'` |
|
||||
| `pages/admin/AdminFeatureRolesPage.tsx` | 372–384 | `'Rolle bearbeiten'`, `'Rolle löschen'`, `'Berechtigungen verwalten'` |
|
||||
| `pages/admin/AdminFeatureInstanceUsersPage.tsx` | 535–539 | `'Rollen bearbeiten'`, `'Aus Instanz entfernen'` |
|
||||
| `pages/admin/AdminFeatureAccessPage.tsx` | 457–471 | `'Instanz löschen'`, `'Instanz bearbeiten'`, `'Rollen synchronisieren'` |
|
||||
| `pages/admin/PermissionMatrix.tsx` | 39 | `'Benutzer entfernen'` |
|
||||
| `pages/basedata/ConnectionsPage.tsx` | 324–347 | `'Bearbeiten'`, `'Löschen'`, `'Verbinden'`, `'Token erneuern'` |
|
||||
| `pages/basedata/FilesPage.tsx` | 232–458 | `'Neuer Ordner'`, `'Bearbeiten'`, `'Löschen'`, `'Herunterladen'`, `'Vorschau'` |
|
||||
| `pages/basedata/PromptsPage.tsx` | 215–225 | `'Duplizieren'`, `'Bearbeiten'`, `'Löschen'` |
|
||||
| `pages/billing/AdminSubscriptionsPage.tsx` | 67 | `'Sofort kündigen'` |
|
||||
| `pages/views/trustee/TrusteePositionsView.tsx` | 455–467 | `'Bearbeiten'`, `'Löschen'`, `'In Buchhaltung synchronisieren'` |
|
||||
| `pages/views/trustee/TrusteePositionDocumentsView.tsx` | 192–198 | `'Verknüpfung bearbeiten'`, `'Verknüpfung entfernen'` |
|
||||
| `pages/views/trustee/TrusteeDocumentsView.tsx` | 225–238 | `'Bearbeiten'`, `'Löschen'`, `'Herunterladen'` |
|
||||
| `pages/views/realestate/RealEstateProjectsView.tsx` | 167–168 | `'Bearbeiten'`, `'Löschen'` |
|
||||
| `pages/views/realestate/RealEstateParcelsView.tsx` | 186–194 | `'Bearbeiten'`, `'Löschen'` |
|
||||
| `pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx` | 136–336 | `'Workflow umbenennen'`, `'Bearbeiten'`, `'Löschen'`, `'Umbenennen'`, `'Aktivieren'`, `'Deaktivieren'`, `'Ausführen'` |
|
||||
| `pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx` | 149–289 | `'Vorlage umbenennen'`, `'Im Editor öffnen'`, `'Löschen'`, `'Umbenennen'`, `'Als Workflow kopieren'`, `'Scope ändern'` |
|
||||
| `pages/views/chatbot/ChatbotConversationsView.tsx` | 88 | `'Konversation löschen'` |
|
||||
| `components/FlowEditor/editor/Automation2FlowEditor.tsx` | 239 | `'Workflow speichern'` |
|
||||
| `components/FolderTree/FolderTree.tsx` | 384, 768 | `'Neuer Ordner'` (prompt title) |
|
||||
|
||||
---
|
||||
|
||||
## 8. Onboarding-Texte (Object-Literale)
|
||||
|
||||
| Datei | Zeilen | Labels |
|
||||
|-------|--------|--------|
|
||||
| `components/OnboardingAssistant.tsx` | 99–149 | `'Mandant einrichten'`, `'Erstes Feature aktivieren'`, `'Erste Datenquelle einbinden'`, `'Ersten AI-Chat starten'` |
|
||||
|
||||
---
|
||||
|
||||
## 9. ClickUp Node Config (Feld-Optionen)
|
||||
|
||||
| Datei | Zeilen | Labels |
|
||||
|-------|--------|--------|
|
||||
| `components/FlowEditor/nodes/configs/ClickUpNodeConfig.tsx` | 786–794 | `'Titel (name)'`, `'Beschreibung'`, `'Status'`, `'Priorität (1–4)'`, `'Fälligkeit (Datum oder ms)'`, `'Zeitschätzung (Stunden)'`, `'Zeitschätzung (ms)'`, `'Zugewiesene'`, `'Benutzerdefiniertes Feld'` |
|
||||
|
||||
---
|
||||
|
||||
## 10. Sonstige Einzel-Stellen
|
||||
|
||||
| Datei | Zeile | Property | Text |
|
||||
|-------|-------|----------|------|
|
||||
| `pages/admin/ChatbotConfigSection.tsx` | 58 | `label` | `'Althaus Preprocessor'` |
|
||||
| `pages/views/trustee/TrusteePositionsView.tsx` | 167 | `label` | `'Belege'` |
|
||||
| `pages/views/trustee/TrusteePositionsView.tsx` | 232 | `label` | `'Sync-Status'` |
|
||||
| `pages/views/trustee/TrusteePositionsView.tsx` | 445 | `label` | `'Buchhaltung synchronisieren'` |
|
||||
| `pages/views/trustee/TrusteeExpenseImportView.tsx` | 361 | `label` | `'Daily at 22:00'` |
|
||||
| `pages/basedata/PromptsPage.tsx` | 81 | `label` | `'Created By'` |
|
||||
| `pages/basedata/FilesPage.tsx` | 152 | `label` | `'Created By'` |
|
||||
| `components/FlowEditor/nodes/start/FormStartNodeConfig.tsx` | 22 | `label` | `'Feld 1'` |
|
||||
| `components/FlowEditor/nodes/start/FormStartNodeConfig.tsx` | 116 | `label` | `'Neues Feld'` |
|
||||
|
||||
---
|
||||
|
||||
## 11. Sprach-/Locale-Listen (Eigenname-Labels — evtl. NICHT übersetzen)
|
||||
|
||||
> Diese Listen enthalten Sprachnamen in der jeweiligen Sprache (Endonym). Sie werden typischerweise **nicht** übersetzt, da der User die Sprache in ihrer Originalbezeichnung erkennen soll.
|
||||
|
||||
| Datei | Zeilen | Kontext |
|
||||
|-------|--------|---------|
|
||||
| `pages/Settings.tsx` | 50–52, 563–565 | Fallback-Sprachoptionen: `'Deutsch'`, `'English'`, `'Français'` |
|
||||
| `pages/views/workspace/WorkspaceInput.tsx` | 16–27 | STT-Sprachliste (12 Sprachen) |
|
||||
| `pages/admin/AdminLanguagesPage.tsx` | 38–65 | Alle verfügbaren Sprach-Codes mit Endonymen |
|
||||
| `components/UiComponents/VoiceLanguageSelect/VoiceLanguageSelect.tsx` | 23–32 | Voice-Sprachliste mit Endonymen |
|
||||
| `components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx` | 672–675 | Fallback-Sprachoptionen |
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
| Kategorie | Anzahl Stellen | Dateien |
|
||||
|-----------|---------------|---------|
|
||||
| Hook-Defaults | 6 | 2 |
|
||||
| Monatsnamen | 24 | 2 |
|
||||
| Tab-Labels | 9 | 3 |
|
||||
| Spalten-Definitionen | ~55 | 10 |
|
||||
| Formular-Felder | ~30 | 9 |
|
||||
| Status-/Option-Maps | ~30 | 7 |
|
||||
| Action-Button titles | ~65 | 23 |
|
||||
| Onboarding-Texte | 4 | 1 |
|
||||
| ClickUp-Felder | 9 | 1 |
|
||||
| Sonstige | 9 | 5 |
|
||||
| Sprach-Listen (evtl. nicht übersetzen) | ~60 | 5 |
|
||||
| **Total (ohne Sprach-Listen)** | **~241** | **~45 Dateien** |
|
||||
6
env.d.ts
vendored
6
env.d.ts
vendored
|
|
@ -1,6 +0,0 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string
|
||||
readonly VITE_APP_NAME?: string
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'no-restricted-imports': [
|
||||
'warn',
|
||||
{
|
||||
patterns: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
17
index.html
17
index.html
|
|
@ -1,17 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title><%- VITE_APP_NAME %></title>
|
||||
<!-- Google Fonts - DM Sans -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
9200
package-lock.json
generated
9200
package-lock.json
generated
File diff suppressed because it is too large
Load diff
72
package.json
72
package.json
|
|
@ -1,72 +0,0 @@
|
|||
{
|
||||
"name": "frontend_nyla_new",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5176 --mode dev",
|
||||
"dev:prod": "vite --port 5176 --mode prod",
|
||||
"dev:int": "vite --port 5176 --mode int",
|
||||
"build": "tsc -b && vite build",
|
||||
"build:prod": "tsc -b && vite build --mode prod",
|
||||
"build:int": "tsc -b && vite build --mode int",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@xstate/react": "^5.0.0",
|
||||
"axios": "^1.8.3",
|
||||
"docx-preview": "^0.3.7",
|
||||
"dotenv": "^16.0.3",
|
||||
"express": "^4.18.2",
|
||||
"framer-motion": "^12.7.3",
|
||||
"fs": "^0.0.1-security",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"mammoth": "^1.12.0",
|
||||
"motion": "^12.7.3",
|
||||
"pg": "^8.8.0",
|
||||
"proj4": "^2.20.2",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-markdown": "^9.1.0",
|
||||
"react-router-dom": "^7.7.1",
|
||||
"recharts": "^3.7.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"xlsx": "^0.18.5",
|
||||
"xstate": "^5.20.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.30.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/proj4": "^2.5.6",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"@vitest/coverage-v8": "^2.1.9",
|
||||
"eslint": "^9.30.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.35.1",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 62 KiB |
|
|
@ -1,195 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="google-site-verification" content="HF3EVKLJvD7jp5xiS-r7in7Jo01_okijtWzDSnu_YhQ" />
|
||||
<title>PowerOn AI Platform - Home</title>
|
||||
<link rel="icon" type="image/x-icon" href="./favicon.ico">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #f8fafc;
|
||||
color: #1e293b;
|
||||
line-height: 1.6;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
background-color: #ffffff;
|
||||
min-height: 100vh;
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: #3b82f6;
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.header p {
|
||||
color: #64748b;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.content-section {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.content-section h2 {
|
||||
color: #1e293b;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
border-left: 4px solid #3b82f6;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.content-section p {
|
||||
margin-bottom: 1rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.features {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background-color: #f1f5f9;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border-left: 4px solid #3b82f6;
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
color: #1e293b;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
color: #64748b;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.navigation {
|
||||
text-align: center;
|
||||
margin-top: 3rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: inline-block;
|
||||
margin: 0 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 0.25rem;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>PowerOn AI Platform</h1>
|
||||
<p>Intelligent Workflow Automation & Multi-Agent Collaboration</p>
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<h2>What is PowerOn?</h2>
|
||||
<p>
|
||||
PowerOn is an advanced AI-powered platform that revolutionizes how businesses manage workflows,
|
||||
collaborate with AI agents, and automate complex processes. Our platform combines cutting-edge
|
||||
artificial intelligence with intuitive workflow design tools to help organizations work smarter,
|
||||
not harder.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<h2>Core Capabilities</h2>
|
||||
<div class="features">
|
||||
<div class="feature-card">
|
||||
<h3>AI Agent Management</h3>
|
||||
<p>Create, configure, and manage multiple AI agents for different business tasks and workflows.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3>Workflow Automation</h3>
|
||||
<p>Design and execute complex business processes with drag-and-drop workflow builder.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3>Document Processing</h3>
|
||||
<p>Intelligent document extraction, analysis, and generation powered by AI.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3>Multi-Platform Integration</h3>
|
||||
<p>Seamlessly connect with Microsoft 365, SharePoint, Outlook, and web services.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<h2>Who Benefits from PowerOn?</h2>
|
||||
<p>
|
||||
PowerOn is designed for businesses of all sizes that want to leverage AI to streamline operations,
|
||||
improve productivity, and reduce manual workload. Whether you're managing customer relationships,
|
||||
processing documents, or coordinating team workflows, PowerOn provides the tools you need to succeed
|
||||
in the AI-powered future.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<h2>Key Benefits</h2>
|
||||
<ul style="color: #475569; margin-left: 2rem;">
|
||||
<li>Reduce manual work by up to 80% through intelligent automation</li>
|
||||
<li>Improve accuracy and consistency in business processes</li>
|
||||
<li>Enable 24/7 operation with AI agents that never sleep</li>
|
||||
<li>Scale operations without proportional increase in human resources</li>
|
||||
<li>Gain insights from AI-powered analytics and reporting</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="navigation">
|
||||
<a href="poweron-privacy.html" class="nav-link">Privacy Policy</a>
|
||||
<a href="poweron-terms.html" class="nav-link">Terms of Service</a>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p class="legal-meta" style="margin-bottom: 0.75rem; color: #64748b; font-size: 0.85rem;">Company & legal details · May 2026</p>
|
||||
<p><strong>PowerOn AG</strong> · Birmensdorferstrasse 94 · CH-8003 Zürich · Switzerland</p>
|
||||
<p><a href="mailto:p.motsch@poweron.swiss">p.motsch@poweron.swiss</a></p>
|
||||
<p style="margin-top: 1rem;">© 2026 PowerOn. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,295 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PowerOn AI Platform - Privacy Policy</title>
|
||||
<link rel="icon" type="image/x-icon" href="./favicon.ico">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #f8fafc;
|
||||
color: #1e293b;
|
||||
line-height: 1.6;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
background-color: #ffffff;
|
||||
min-height: 100vh;
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: #3b82f6;
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.header p {
|
||||
color: #64748b;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.content-section {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.content-section h2 {
|
||||
color: #1e293b;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
border-left: 4px solid #3b82f6;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.content-section h3 {
|
||||
color: #1e293b;
|
||||
font-size: 1.2rem;
|
||||
margin: 1.5rem 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.content-section p {
|
||||
margin-bottom: 1rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.content-section ul {
|
||||
margin-left: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.content-section li {
|
||||
color: #475569;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.highlight-box {
|
||||
background-color: #f1f5f9;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.highlight-box h4 {
|
||||
color: #1e293b;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.navigation {
|
||||
text-align: center;
|
||||
margin-top: 3rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: inline-block;
|
||||
margin: 0 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 0.25rem;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.last-updated {
|
||||
background-color: #fef3c7;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Privacy Policy</h1>
|
||||
<p>PowerOn AI Platform - Data Protection & Privacy</p>
|
||||
</div>
|
||||
|
||||
<div class="last-updated">
|
||||
<strong>Last Updated:</strong> May 2026
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<h2>Introduction</h2>
|
||||
<p>
|
||||
PowerOn AI Platform ("we," "our," or "us") is committed to protecting your privacy and ensuring
|
||||
the security of your personal information. This Privacy Policy explains how we collect, use,
|
||||
disclose, and safeguard your information when you use our AI-powered workflow automation platform.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<h2>Information We Collect</h2>
|
||||
|
||||
<h3>Personal Information</h3>
|
||||
<p>We may collect the following types of personal information:</p>
|
||||
<ul>
|
||||
<li>Name and contact information (email address, phone number)</li>
|
||||
<li>Company and job title information</li>
|
||||
<li>Authentication credentials and account settings</li>
|
||||
<li>Payment and billing information</li>
|
||||
</ul>
|
||||
|
||||
<h3>Usage Information</h3>
|
||||
<p>We automatically collect information about how you use our platform:</p>
|
||||
<ul>
|
||||
<li>Workflow creation and execution data</li>
|
||||
<li>AI agent interactions and configurations</li>
|
||||
<li>Document processing activities</li>
|
||||
<li>Platform access logs and performance metrics</li>
|
||||
</ul>
|
||||
|
||||
<h3>Technical Information</h3>
|
||||
<p>We collect technical information to ensure platform functionality:</p>
|
||||
<ul>
|
||||
<li>Device and browser information</li>
|
||||
<li>IP address and location data</li>
|
||||
<li>Cookies and similar tracking technologies</li>
|
||||
<li>System performance and error logs</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<h2>How We Use Your Information</h2>
|
||||
<p>We use the collected information for the following purposes:</p>
|
||||
<ul>
|
||||
<li>Provide and maintain our AI platform services</li>
|
||||
<li>Process and execute your workflow automations</li>
|
||||
<li>Improve platform performance and user experience</li>
|
||||
<li>Send important service updates and notifications</li>
|
||||
<li>Provide customer support and technical assistance</li>
|
||||
<li>Ensure platform security and prevent fraud</li>
|
||||
<li>Comply with legal obligations and regulations</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<h2>Data Sharing and Disclosure</h2>
|
||||
<p>We do not sell, trade, or rent your personal information to third parties. We may share your information only in the following circumstances:</p>
|
||||
|
||||
<div class="highlight-box">
|
||||
<h4>Service Providers</h4>
|
||||
<p>We work with trusted third-party service providers who assist us in operating our platform, such as cloud hosting services, payment processors, and AI model providers. These providers are contractually obligated to protect your information.</p>
|
||||
</div>
|
||||
|
||||
<div class="highlight-box">
|
||||
<h4>Legal Requirements</h4>
|
||||
<p>We may disclose your information if required by law, court order, or government regulation, or to protect our rights, property, or safety.</p>
|
||||
</div>
|
||||
|
||||
<div class="highlight-box">
|
||||
<h4>Business Transfers</h4>
|
||||
<p>In the event of a merger, acquisition, or sale of assets, your information may be transferred as part of the business transaction.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<h2>Data Security</h2>
|
||||
<p>We implement comprehensive security measures to protect your information:</p>
|
||||
<ul>
|
||||
<li>Encryption of data in transit and at rest</li>
|
||||
<li>Regular security audits and vulnerability assessments</li>
|
||||
<li>Access controls and authentication mechanisms</li>
|
||||
<li>Secure data centers and infrastructure</li>
|
||||
<li>Employee training on data protection practices</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<h2>Your Rights and Choices</h2>
|
||||
<p>You have the following rights regarding your personal information:</p>
|
||||
<ul>
|
||||
<li><strong>Access:</strong> Request a copy of your personal information</li>
|
||||
<li><strong>Correction:</strong> Update or correct inaccurate information</li>
|
||||
<li><strong>Deletion:</strong> Request deletion of your personal information</li>
|
||||
<li><strong>Portability:</strong> Receive your data in a portable format</li>
|
||||
<li><strong>Opt-out:</strong> Unsubscribe from marketing communications</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<h2>Data Retention</h2>
|
||||
<p>We retain your personal information only as long as necessary to:</p>
|
||||
<ul>
|
||||
<li>Provide our services to you</li>
|
||||
<li>Comply with legal obligations</li>
|
||||
<li>Resolve disputes and enforce agreements</li>
|
||||
<li>Improve our platform and services</li>
|
||||
</ul>
|
||||
<p>When we no longer need your information, we securely delete or anonymize it.</p>
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<h2>International Data Transfers</h2>
|
||||
<p>Your information may be transferred to and processed in countries other than your own. We ensure that such transfers comply with applicable data protection laws and implement appropriate safeguards to protect your information.</p>
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<h2>Children's Privacy</h2>
|
||||
<p>Our platform is not intended for use by children under the age of 13. We do not knowingly collect personal information from children under 13. If you believe we have collected such information, please contact us immediately.</p>
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<h2>Changes to This Policy</h2>
|
||||
<p>We may update this Privacy Policy from time to time. We will notify you of any material changes by posting the new policy on our platform and updating the "Last Updated" date. Your continued use of our platform after such changes constitutes acceptance of the updated policy.</p>
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<h2>Contact Us</h2>
|
||||
<p>If you have any questions about this Privacy Policy or our data practices, please contact us:</p>
|
||||
<div class="highlight-box">
|
||||
<p><strong>Email:</strong> <a href="mailto:p.motsch@poweron.swiss">p.motsch@poweron.swiss</a></p>
|
||||
<p><strong>Address:</strong><br>
|
||||
PowerOn AG<br>
|
||||
Birmensdorferstrasse 94<br>
|
||||
CH-8003 Zürich<br>
|
||||
Switzerland
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="navigation">
|
||||
<a href="poweron-home.html" class="nav-link">Home</a>
|
||||
<a href="poweron-terms.html" class="nav-link">Terms of Service</a>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>© 2026 PowerOn. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,338 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PowerOn AI Platform - Terms of Service</title>
|
||||
<link rel="icon" type="image/x-icon" href="./favicon.ico">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #f8fafc;
|
||||
color: #1e293b;
|
||||
line-height: 1.6;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
background-color: #ffffff;
|
||||
min-height: 100vh;
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: #3b82f6;
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.header p {
|
||||
color: #64748b;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.content-section {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.content-section h2 {
|
||||
color: #1e293b;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
border-left: 4px solid #3b82f6;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.content-section h3 {
|
||||
color: #1e293b;
|
||||
font-size: 1.2rem;
|
||||
margin: 1.5rem 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.content-section p {
|
||||
margin-bottom: 1rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.content-section ul {
|
||||
margin-left: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.content-section li {
|
||||
color: #475569;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.highlight-box {
|
||||
background-color: #f1f5f9;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.highlight-box h4 {
|
||||
color: #1e293b;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.warning-box {
|
||||
background-color: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.warning-box h4 {
|
||||
color: #dc2626;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.navigation {
|
||||
text-align: center;
|
||||
margin-top: 3rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: inline-block;
|
||||
margin: 0 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 0.25rem;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.last-updated {
|
||||
background-color: #fef3c7;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Terms of Service</h1>
|
||||
<p>PowerOn AI Platform - Service Agreement & User Terms</p>
|
||||
</div>
|
||||
|
||||
<div class="last-updated">
|
||||
<strong>Last Updated:</strong> May 2026
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<h2>Acceptance of Terms</h2>
|
||||
<p>
|
||||
By accessing or using the PowerOn AI Platform ("Platform"), you agree to be bound by these Terms of Service
|
||||
("Terms"). If you do not agree to these Terms, you must not use our Platform. These Terms constitute a
|
||||
legally binding agreement between you and PowerOn AI Platform ("we," "our," or "us").
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<h2>Description of Service</h2>
|
||||
<p>
|
||||
PowerOn AI Platform is an AI-powered workflow automation and multi-agent collaboration platform that enables
|
||||
users to create, manage, and execute automated business processes using artificial intelligence agents.
|
||||
</p>
|
||||
<p>Our Platform includes the following services:</p>
|
||||
<ul>
|
||||
<li>AI agent creation and management</li>
|
||||
<li>Workflow design and automation tools</li>
|
||||
<li>Document processing and analysis capabilities</li>
|
||||
<li>Integration with third-party services and platforms</li>
|
||||
<li>Analytics and reporting features</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<h2>User Accounts and Registration</h2>
|
||||
|
||||
<h3>Account Creation</h3>
|
||||
<p>To use our Platform, you must create an account by providing accurate, current, and complete information. You are responsible for maintaining the confidentiality of your account credentials.</p>
|
||||
|
||||
<h3>Account Security</h3>
|
||||
<p>You are responsible for all activities that occur under your account. You must immediately notify us of any unauthorized use of your account or any other security breach.</p>
|
||||
|
||||
<h3>Account Termination</h3>
|
||||
<p>We reserve the right to terminate or suspend your account at any time for violation of these Terms or for any other reason at our sole discretion.</p>
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<h2>Acceptable Use Policy</h2>
|
||||
<p>You agree to use our Platform only for lawful purposes and in accordance with these Terms. You agree not to:</p>
|
||||
|
||||
<div class="warning-box">
|
||||
<h4>Prohibited Activities</h4>
|
||||
<ul>
|
||||
<li>Use the Platform for any illegal or unauthorized purpose</li>
|
||||
<li>Violate any applicable laws or regulations</li>
|
||||
<li>Infringe upon the intellectual property rights of others</li>
|
||||
<li>Attempt to gain unauthorized access to our systems</li>
|
||||
<li>Interfere with or disrupt the Platform's operation</li>
|
||||
<li>Use the Platform to transmit harmful or malicious code</li>
|
||||
<li>Harass, abuse, or harm other users</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<h2>User Content and Data</h2>
|
||||
|
||||
<h3>Content Ownership</h3>
|
||||
<p>You retain ownership of any content, data, or information you upload, create, or process through our Platform ("User Content").</p>
|
||||
|
||||
<h3>Content License</h3>
|
||||
<p>By using our Platform, you grant us a limited, non-exclusive license to use your User Content solely for the purpose of providing our services to you.</p>
|
||||
|
||||
<h3>Content Responsibility</h3>
|
||||
<p>You are solely responsible for the accuracy, legality, and appropriateness of your User Content. We do not review or monitor User Content and are not responsible for its content.</p>
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<h2>Service Availability and Limitations</h2>
|
||||
|
||||
<div class="highlight-box">
|
||||
<h4>Service Availability</h4>
|
||||
<p>We strive to maintain high service availability but do not guarantee uninterrupted access to our Platform. We may perform maintenance, updates, or modifications that may temporarily affect service availability.</p>
|
||||
</div>
|
||||
|
||||
<div class="highlight-box">
|
||||
<h4>Service Limitations</h4>
|
||||
<p>Our Platform is subject to reasonable usage limits and technical constraints. We reserve the right to implement usage limits, rate limiting, or other restrictions to ensure fair usage and system stability.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<h2>Intellectual Property Rights</h2>
|
||||
<p>
|
||||
The Platform, including its software, design, content, and functionality, is owned by PowerOn AI Platform
|
||||
and is protected by intellectual property laws. You may not copy, modify, distribute, or create derivative
|
||||
works based on our Platform without our express written consent.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<h2>Third-Party Services and Integrations</h2>
|
||||
<p>
|
||||
Our Platform may integrate with third-party services, applications, or platforms. We are not responsible
|
||||
for the availability, accuracy, or content of these third-party services. Your use of third-party services
|
||||
is subject to their respective terms of service and privacy policies.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<h2>Payment Terms</h2>
|
||||
|
||||
<h3>Pricing and Billing</h3>
|
||||
<p>Service pricing is available on our Platform and may be subject to change. We will provide reasonable notice of any price changes.</p>
|
||||
|
||||
<h3>Payment Obligations</h3>
|
||||
<p>You agree to pay all fees associated with your use of our Platform. Failure to pay may result in service suspension or termination.</p>
|
||||
|
||||
<h3>Refunds</h3>
|
||||
<p>Refund policies are determined by your subscription plan and are subject to our discretion and applicable laws.</p>
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<h2>Disclaimers and Limitations of Liability</h2>
|
||||
|
||||
<div class="warning-box">
|
||||
<h4>Service Disclaimers</h4>
|
||||
<p>Our Platform is provided "as is" and "as available" without warranties of any kind. We disclaim all warranties, express or implied, including but not limited to warranties of merchantability, fitness for a particular purpose, and non-infringement.</p>
|
||||
</div>
|
||||
|
||||
<div class="warning-box">
|
||||
<h4>Limitation of Liability</h4>
|
||||
<p>In no event shall PowerOn AI Platform be liable for any indirect, incidental, special, consequential, or punitive damages, including but not limited to loss of profits, data, or use, arising out of or relating to your use of our Platform.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<h2>Indemnification</h2>
|
||||
<p>
|
||||
You agree to indemnify, defend, and hold harmless PowerOn AI Platform and its officers, directors,
|
||||
employees, and agents from and against any claims, damages, losses, liabilities, costs, and expenses
|
||||
arising out of or relating to your use of our Platform or violation of these Terms.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<h2>Governing Law and Dispute Resolution</h2>
|
||||
<p>
|
||||
These Terms are governed by and construed in accordance with the laws of the jurisdiction where
|
||||
PowerOn AI Platform is incorporated. Any disputes arising from these Terms or your use of our Platform
|
||||
shall be resolved through binding arbitration in accordance with applicable arbitration rules.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<h2>Changes to Terms</h2>
|
||||
<p>
|
||||
We reserve the right to modify these Terms at any time. We will notify you of any material changes
|
||||
by posting the updated Terms on our Platform. Your continued use of our Platform after such changes
|
||||
constitutes acceptance of the updated Terms.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<h2>Contact Information</h2>
|
||||
<p>If you have any questions about these Terms of Service, please contact us:</p>
|
||||
<div class="highlight-box">
|
||||
<p><strong>Email:</strong> <a href="mailto:p.motsch@poweron.swiss">p.motsch@poweron.swiss</a></p>
|
||||
<p><strong>Address:</strong><br>
|
||||
PowerOn AG<br>
|
||||
Birmensdorferstrasse 94<br>
|
||||
CH-8003 Zürich<br>
|
||||
Switzerland
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="navigation">
|
||||
<a href="poweron-home.html" class="nav-link">Home</a>
|
||||
<a href="poweron-privacy.html" class="nav-link">Privacy Policy</a>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>© 2026 PowerOn. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
|
|
@ -1,30 +0,0 @@
|
|||
import express from 'express';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { getAppName, getAppVersion, getAppEnvironment, isDebugMode } from '../config/serverConfig.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
|
||||
// Serve static files from current directory
|
||||
app.use(express.static(path.join(__dirname)));
|
||||
|
||||
// Handle React Router - send all requests to index.html
|
||||
app.get('/*', function(req, res) {
|
||||
res.sendFile(path.join(__dirname, 'index.html'));
|
||||
});
|
||||
|
||||
// Use Azure's PORT environment variable or fallback to 8080
|
||||
const port = process.env.PORT || 8080;
|
||||
|
||||
// Listen on all interfaces (important for Azure)
|
||||
app.listen(port, '0.0.0.0', () => {
|
||||
console.log(`🚀 ${getAppName()} Deploy Server running on port ${port}`);
|
||||
console.log(`📦 Version: ${getAppVersion()}`);
|
||||
console.log(`🌍 Environment: ${getAppEnvironment()}`);
|
||||
console.log(`🐛 Debug Mode: ${isDebugMode() ? 'Enabled' : 'Disabled'}`);
|
||||
console.log(`📁 Serving from: ${__dirname}`);
|
||||
console.log(`🔧 Node Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,17 +0,0 @@
|
|||
import re
|
||||
from pathlib import Path
|
||||
|
||||
text = Path(__file__).resolve().parent.parent / "tsc-out.txt"
|
||||
content = text.read_text(encoding="utf-8")
|
||||
files = sorted(
|
||||
{
|
||||
m.group(1)
|
||||
for line in content.splitlines()
|
||||
if "Cannot find name 't'" in line
|
||||
for m in [re.match(r"^(src/[^\(:]+\.tsx)", line)]
|
||||
if m
|
||||
}
|
||||
)
|
||||
for f in files:
|
||||
print(f)
|
||||
print("TOTAL", len(files), file=__import__("sys").stderr)
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import express from 'express';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { getAppName, getAppVersion, getAppEnvironment, isDebugMode } from '../config/serverConfig.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
|
||||
// Serve static files from the dist directory
|
||||
app.use(express.static(path.join(__dirname, 'dist')));
|
||||
|
||||
// Handle React Router - send all requests to index.html
|
||||
app.get('/*', function(req, res) {
|
||||
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
|
||||
});
|
||||
|
||||
// Use Azure's PORT environment variable or fallback to 3000
|
||||
const port = process.env.PORT || 3000;
|
||||
|
||||
// Listen on all interfaces (important for Azure)
|
||||
app.listen(port, '0.0.0.0', () => {
|
||||
console.log(`🚀 ${getAppName()} Server running on port ${port}`);
|
||||
console.log(`📦 Version: ${getAppVersion()}`);
|
||||
console.log(`🌍 Environment: ${getAppEnvironment()}`);
|
||||
console.log(`🐛 Debug Mode: ${isDebugMode() ? 'Enabled' : 'Disabled'}`);
|
||||
console.log(`📁 Serving from: ${path.join(__dirname, 'dist')}`);
|
||||
});
|
||||
251
src/App.tsx
251
src/App.tsx
|
|
@ -1,251 +0,0 @@
|
|||
/**
|
||||
* App.tsx
|
||||
*
|
||||
* Haupt-App-Komponente mit Multi-Tenant Router-Setup.
|
||||
*
|
||||
* URL-Struktur:
|
||||
* - / → Dashboard/Übersicht
|
||||
* - /settings → Benutzer-Einstellungen
|
||||
* - /gdpr → GDPR / Datenschutz
|
||||
* - /mandates/:mandateId/:featureCode/:instanceId/* → Feature-Instanz-Routen
|
||||
* - /admin/* → System-Administration (nur SysAdmin)
|
||||
*/
|
||||
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
// Import global CSS reset first
|
||||
import './index.css';
|
||||
|
||||
// Auth Pages (Public)
|
||||
import Login from './pages/Login';
|
||||
import Register from './pages/Register';
|
||||
import PasswordResetRequest from './pages/PasswordResetRequest';
|
||||
import Reset from './pages/Reset';
|
||||
import { InvitePage } from './pages/InvitePage';
|
||||
|
||||
// Providers
|
||||
import { ProtectedRoute } from './providers/auth/ProtectedRoute';
|
||||
import { LanguageProvider } from './providers/language/LanguageContext';
|
||||
import { ToastProvider } from './contexts/ToastContext';
|
||||
import { WorkflowSelectionProvider } from './contexts/WorkflowSelectionContext';
|
||||
import { FileProvider } from './contexts/FileContext';
|
||||
import { VoiceCatalogProvider } from './contexts/VoiceCatalogContext';
|
||||
import { MainLayout } from './layouts/MainLayout';
|
||||
import { FeatureLayout } from './layouts/FeatureLayout';
|
||||
import { DashboardPage } from './pages/Dashboard';
|
||||
import { SettingsPage } from './pages/Settings';
|
||||
import { GDPRPage } from './pages/GDPR';
|
||||
import StorePage from './pages/Store';
|
||||
import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage';
|
||||
import { FeatureViewPage } from './pages/FeatureView';
|
||||
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage, AdminDatabaseHealthPage, SttBenchmarkPage } from './pages/admin';
|
||||
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
|
||||
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
||||
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
|
||||
import { AutomationsDashboardPage } from './pages/AutomationsDashboardPage';
|
||||
import { RagInventoryPage } from './pages/RagInventoryPage';
|
||||
import { ComplianceAuditPage } from './pages/ComplianceAuditPage';
|
||||
function App() {
|
||||
// Load saved theme preference and set app name on app mount
|
||||
useEffect(() => {
|
||||
// Set app name globally using configuration
|
||||
import('../config/config').then(({ getAppName }) => {
|
||||
const appName = getAppName();
|
||||
document.title = appName;
|
||||
});
|
||||
|
||||
// Load saved theme preference
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const prefersDark = savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
|
||||
if (prefersDark) {
|
||||
document.documentElement.classList.add('dark-theme');
|
||||
document.documentElement.classList.remove('light-theme');
|
||||
} else {
|
||||
document.documentElement.classList.add('light-theme');
|
||||
document.documentElement.classList.remove('dark-theme');
|
||||
}
|
||||
document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LanguageProvider>
|
||||
<ToastProvider>
|
||||
<VoiceCatalogProvider>
|
||||
<WorkflowSelectionProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
{/* ================================================== */}
|
||||
{/* PUBLIC AUTH ROUTES - NO AUTHENTICATION REQUIRED */}
|
||||
{/* ================================================== */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/password-reset-request" element={<PasswordResetRequest />} />
|
||||
<Route path="/reset" element={<Reset />} />
|
||||
<Route path="/invite/:token" element={<InvitePage />} />
|
||||
|
||||
{/* ================================================== */}
|
||||
{/* PROTECTED ROUTES - REQUIRE AUTHENTICATION */}
|
||||
{/* ================================================== */}
|
||||
<Route path="/" element={
|
||||
<ProtectedRoute>
|
||||
<FileProvider>
|
||||
<MainLayout />
|
||||
</FileProvider>
|
||||
</ProtectedRoute>
|
||||
}>
|
||||
{/* Dashboard (Root) */}
|
||||
<Route index element={<DashboardPage />} />
|
||||
|
||||
{/* System-Seiten (ohne Instanz-Kontext) */}
|
||||
<Route path="store" element={<StorePage />} />
|
||||
<Route path="integrations" element={<IntegrationsOverviewPage />} />
|
||||
<Route path="compliance-audit" element={<ComplianceAuditPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="gdpr" element={<GDPRPage />} />
|
||||
|
||||
{/* ============================================== */}
|
||||
{/* BASISDATEN ROUTES (global) */}
|
||||
{/* ============================================== */}
|
||||
<Route path="basedata">
|
||||
<Route path="prompts" element={<PromptsPage />} />
|
||||
<Route path="files" element={<FilesPage />} />
|
||||
<Route path="connections" element={<ConnectionsPage />} />
|
||||
</Route>
|
||||
|
||||
{/* ============================================== */}
|
||||
{/* BILLING ROUTES */}
|
||||
{/* ============================================== */}
|
||||
<Route path="billing">
|
||||
<Route index element={<Navigate to="/billing/transactions" replace />} />
|
||||
<Route path="transactions" element={<BillingDataView />} />
|
||||
<Route path="admin" element={<BillingAdmin />} />
|
||||
</Route>
|
||||
|
||||
{/* ============================================== */}
|
||||
{/* AUTOMATIONS DASHBOARD */}
|
||||
{/* ============================================== */}
|
||||
<Route path="automations" element={<AutomationsDashboardPage />} />
|
||||
|
||||
{/* ============================================== */}
|
||||
{/* RAG INVENTORY */}
|
||||
{/* ============================================== */}
|
||||
<Route path="rag-inventory" element={<RagInventoryPage />} />
|
||||
|
||||
{/* Legacy top-level routes – redirect to dashboard (migrated to feature-instance routes) */}
|
||||
<Route path="chatbot" element={<Navigate to="/" replace />} />
|
||||
<Route path="pek" element={<Navigate to="/" replace />} />
|
||||
<Route path="speech" element={<Navigate to="/" replace />} />
|
||||
|
||||
{/* ============================================== */}
|
||||
{/* FEATURE-INSTANZ ROUTES */}
|
||||
{/* /mandates/:mandateId/:featureCode/:instanceId */}
|
||||
{/* ============================================== */}
|
||||
<Route
|
||||
path="mandates/:mandateId/:featureCode/:instanceId"
|
||||
element={<FeatureLayout />}
|
||||
>
|
||||
{/* Feature Views - dynamisch basierend auf featureCode */}
|
||||
<Route index element={<FeatureViewPage view="dashboard" />} />
|
||||
<Route path="dashboard" element={<FeatureViewPage view="dashboard" />} />
|
||||
<Route path="organisations" element={<FeatureViewPage view="organisations" />} />
|
||||
<Route path="contracts" element={<FeatureViewPage view="contracts" />} />
|
||||
<Route path="data-tables" element={<FeatureViewPage view="data-tables" />} />
|
||||
<Route path="roles" element={<FeatureViewPage view="roles" />} />
|
||||
<Route path="access" element={<FeatureViewPage view="access" />} />
|
||||
<Route path="runs" element={<FeatureViewPage view="runs" />} />
|
||||
<Route path="files" element={<FeatureViewPage view="files" />} />
|
||||
<Route path="conversations" element={<FeatureViewPage view="conversations" />} />
|
||||
<Route path="upload" element={<FeatureViewPage view="upload" />} />
|
||||
<Route path="chat" element={<FeatureViewPage view="chat" />} />
|
||||
<Route path="threads" element={<FeatureViewPage view="threads" />} />
|
||||
<Route path="position-documents" element={<FeatureViewPage view="position-documents" />} />
|
||||
<Route path="import-process" element={<FeatureViewPage view="import-process" />} />
|
||||
<Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} />
|
||||
<Route path="analyse" element={<FeatureViewPage view="analyse" />} />
|
||||
<Route path="abschluss" element={<FeatureViewPage view="abschluss" />} />
|
||||
|
||||
{/* Automation Feature Views */}
|
||||
<Route path="definitions" element={<FeatureViewPage view="definitions" />} />
|
||||
<Route path="templates" element={<FeatureViewPage view="templates" />} />
|
||||
<Route path="logs" element={<FeatureViewPage view="logs" />} />
|
||||
|
||||
{/* Workspace + Automation2 Editor */}
|
||||
<Route path="editor" element={<FeatureViewPage view="editor" />} />
|
||||
|
||||
{/* Automation2: legacy workflows URL → editor */}
|
||||
<Route path="workflows" element={<Navigate to="../editor" replace />} />
|
||||
<Route path="workflows-tasks" element={<FeatureViewPage view="workflows-tasks" />} />
|
||||
|
||||
{/* Teams Bot Feature Views */}
|
||||
<Route path="sessions" element={<FeatureViewPage view="sessions" />} />
|
||||
<Route path="settings" element={<FeatureViewPage view="settings" />} />
|
||||
|
||||
{/* Shared: assistant + modules routes (ComCoach + TeamsBot) */}
|
||||
<Route path="assistant" element={<FeatureViewPage view="assistant" />} />
|
||||
<Route path="modules" element={<FeatureViewPage view="modules" />} />
|
||||
|
||||
{/* Neutralization Feature Views */}
|
||||
<Route path="playground" element={<FeatureViewPage view="playground" />} />
|
||||
|
||||
{/* CommCoach Feature Views */}
|
||||
<Route path="session" element={<FeatureViewPage view="session" />} />
|
||||
|
||||
{/* Redmine Feature Views */}
|
||||
<Route path="stats" element={<FeatureViewPage view="stats" />} />
|
||||
<Route path="browser" element={<FeatureViewPage view="browser" />} />
|
||||
|
||||
{/* Catch-all für unbekannte Sub-Pfade */}
|
||||
<Route path="*" element={<FeatureViewPage view="not-found" />} />
|
||||
</Route>
|
||||
|
||||
{/* ============================================== */}
|
||||
{/* ADMIN ROUTES (nur SysAdmin) */}
|
||||
{/* ============================================== */}
|
||||
<Route path="admin">
|
||||
<Route index element={<Navigate to="/admin/access" replace />} />
|
||||
<Route path="mandates" element={<AdminMandatesPage />} />
|
||||
<Route path="users" element={<AdminUsersPage />} />
|
||||
<Route path="user-mandates" element={<AdminUserMandatesPage />} />
|
||||
<Route path="access" element={<AccessManagementHub />} />
|
||||
<Route path="feature-instances" element={<AdminFeatureAccessPage />} />
|
||||
<Route path="feature-roles" element={<AdminFeatureRolesPage />} />
|
||||
<Route path="feature-users" element={<AdminFeatureInstanceUsersPage />} />
|
||||
<Route path="invitations" element={<AdminInvitationsPage />} />
|
||||
<Route path="mandate-roles" element={<AdminMandateRolesPage />} />
|
||||
<Route path="mandate-role-permissions" element={<AdminMandateRolePermissionsPage />} />
|
||||
<Route path="user-access-overview" element={<AdminUserAccessOverviewPage />} />
|
||||
<Route path="billing">
|
||||
<Route index element={<Navigate to="/billing/admin" replace />} />
|
||||
<Route path="mandates" element={<BillingMandateView />} />
|
||||
</Route>
|
||||
<Route path="subscriptions" element={<AdminSubscriptionsPage />} />
|
||||
<Route path="logs" element={<AdminLogsPage />} />
|
||||
<Route path="languages" element={null} />
|
||||
<Route path="database-health" element={<AdminDatabaseHealthPage />} />
|
||||
<Route path="demo-config" element={<AdminDemoConfigPage />} />
|
||||
<Route path="stt-benchmark" element={<SttBenchmarkPage />} />
|
||||
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
|
||||
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* ================================================== */}
|
||||
{/* CATCH-ALL - Redirect to Dashboard */}
|
||||
{/* ================================================== */}
|
||||
<Route path="*" element={
|
||||
<ProtectedRoute>
|
||||
<MainLayout />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</WorkflowSelectionProvider>
|
||||
</VoiceCatalogProvider>
|
||||
</ToastProvider>
|
||||
</LanguageProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
193
src/api.ts
193
src/api.ts
|
|
@ -1,193 +0,0 @@
|
|||
// api.ts
|
||||
import axios from 'axios';
|
||||
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from './utils/csrfUtils';
|
||||
import { clearUserDataCache, getUserDataCache } from './utils/userCache';
|
||||
|
||||
// Utility function to resolve hostname to IP address
|
||||
const resolveHostnameToIP = async (hostname: string): Promise<string | null> => {
|
||||
try {
|
||||
// For localhost, return as is
|
||||
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
||||
return hostname;
|
||||
}
|
||||
|
||||
// For production domains, we can't directly resolve IP due to CORS
|
||||
// But we can show the hostname which is more useful anyway
|
||||
return hostname;
|
||||
} catch (error) {
|
||||
console.warn('Could not resolve hostname to IP:', error);
|
||||
return hostname;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract mandate/instance context from current URL.
|
||||
* URL pattern: /mandates/:mandateId/:featureCode/:instanceId/...
|
||||
*
|
||||
* Only feature pages under /mandates/... provide context via URL.
|
||||
* Admin pages (e.g., /admin/users) do NOT send mandate context --
|
||||
* admin endpoints aggregate across all user mandates server-side.
|
||||
*/
|
||||
const getContextFromUrl = (): { mandateId?: string; instanceId?: string } => {
|
||||
const pathname = window.location.pathname;
|
||||
const match = pathname.match(/^\/mandates\/([^/]+)\/([^/]+)\/([^/]+)/);
|
||||
|
||||
if (match) {
|
||||
return {
|
||||
mandateId: match[1],
|
||||
instanceId: match[3]
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
import { getApiBaseUrl } from '../config/config';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: getApiBaseUrl(),
|
||||
withCredentials: true,
|
||||
// FastAPI expects repeat-style array query params (``?ids=1&ids=2``).
|
||||
// Axios v1.x default would render ``?ids[]=1&ids[]=2``, which FastAPI
|
||||
// silently drops -- e.g. ``trackerIds`` filters on the Redmine stats
|
||||
// endpoint never reach the route. Setting ``indexes: null`` switches
|
||||
// the URLSearchParams visitor to repeat format. Applies globally so
|
||||
// every endpoint with array query params gets it for free.
|
||||
paramsSerializer: { indexes: null },
|
||||
});
|
||||
|
||||
// Add a request interceptor to add the auth token, context headers, and log backend IP
|
||||
api.interceptors.request.use(
|
||||
async (config) => {
|
||||
// Log backend information
|
||||
const backendUrl = config.baseURL || getApiBaseUrl();
|
||||
console.log(`🌐 Communicating with backend: ${backendUrl}`);
|
||||
|
||||
// Try to resolve and log the IP address
|
||||
if (backendUrl) {
|
||||
try {
|
||||
const url = new URL(backendUrl);
|
||||
const hostname = url.hostname;
|
||||
const resolvedIP = await resolveHostnameToIP(hostname);
|
||||
|
||||
console.log(`📍 Backend hostname: ${hostname}`);
|
||||
console.log(`🔗 Full backend URL: ${backendUrl}`);
|
||||
console.log(`🌍 Resolved address: ${resolvedIP}`);
|
||||
|
||||
// Log environment info
|
||||
console.log(`🏗️ Environment: ${import.meta.env.MODE}`);
|
||||
console.log(`⚙️ API Base URL: ${getApiBaseUrl()}`);
|
||||
} catch (error) {
|
||||
console.warn('Could not parse backend URL:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for auth token in localStorage and add to headers
|
||||
const authToken = localStorage.getItem('authToken');
|
||||
if (authToken && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${authToken}`;
|
||||
console.log('🔑 Using Bearer token for authentication');
|
||||
} else {
|
||||
// Fallback: httpOnly cookies
|
||||
console.log('🍪 Using httpOnly cookies for authentication (automatic)');
|
||||
}
|
||||
|
||||
// Send app language to backend so i18n labels match the UI
|
||||
const userData = getUserDataCache();
|
||||
const appLanguage = userData?.language || navigator.language.split('-')[0] || 'de';
|
||||
if (config.headers) {
|
||||
config.headers['Accept-Language'] = appLanguage;
|
||||
}
|
||||
|
||||
// Send browser IANA timezone (e.g. "Europe/Zurich") so the gateway can
|
||||
// resolve "now" for AI agents and user-visible time strings without
|
||||
// hardcoding a server-side default. Mirrors the Accept-Language pattern.
|
||||
if (config.headers) {
|
||||
try {
|
||||
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
if (browserTimezone) {
|
||||
config.headers['X-User-Timezone'] = browserTimezone;
|
||||
}
|
||||
} catch {
|
||||
// Older browsers without Intl.DateTimeFormat: backend falls back to UTC
|
||||
}
|
||||
}
|
||||
|
||||
// Add multi-tenant context headers from URL (if not already set)
|
||||
// This ensures Feature-Instance roles are loaded for permission checks
|
||||
const context = getContextFromUrl();
|
||||
if (config.headers) {
|
||||
if (context.mandateId && !config.headers['X-Mandate-Id']) {
|
||||
config.headers['X-Mandate-Id'] = context.mandateId;
|
||||
}
|
||||
if (context.instanceId && !config.headers['X-Instance-Id']) {
|
||||
config.headers['X-Instance-Id'] = context.instanceId;
|
||||
}
|
||||
}
|
||||
|
||||
// Add CSRF token to all requests (including GET requests for certain endpoints)
|
||||
// Some endpoints like /api/realestate/* require CSRF tokens even for GET requests
|
||||
const method = config.method?.toLowerCase();
|
||||
const url = config.url || '';
|
||||
const requiresCSRF =
|
||||
['post', 'put', 'patch', 'delete'].includes(method || '') ||
|
||||
url.includes('/api/realestate/');
|
||||
|
||||
if (requiresCSRF) {
|
||||
// Ensure CSRF token exists, generate one if missing
|
||||
if (!getCSRFToken()) {
|
||||
generateAndStoreCSRFToken();
|
||||
}
|
||||
addCSRFTokenToHeaders(config.headers as Record<string, string>);
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Add a response interceptor to handle token expiration
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Don't redirect to login if the request was to a login endpoint
|
||||
const isLoginEndpoint = error.config?.url?.includes('/login') ||
|
||||
error.config?.url?.includes('/api/local/login') ||
|
||||
error.config?.url?.includes('/api/msft/auth/login') ||
|
||||
error.config?.url?.includes('/api/google/auth/login');
|
||||
|
||||
// Don't redirect if we're on a public auth page (prevents redirect loops and allows public pages to work)
|
||||
const pathname = window.location.pathname;
|
||||
const isOnPublicAuthPage = pathname === '/login' ||
|
||||
pathname.startsWith('/login') ||
|
||||
pathname === '/register' ||
|
||||
pathname.startsWith('/register') ||
|
||||
pathname === '/reset' ||
|
||||
pathname.startsWith('/reset') ||
|
||||
pathname === '/password-reset-request' ||
|
||||
pathname.startsWith('/password-reset-request') ||
|
||||
pathname.startsWith('/invite');
|
||||
|
||||
if (!isLoginEndpoint && !isOnPublicAuthPage) {
|
||||
// Clear local auth data (httpOnly cookies are cleared by backend)
|
||||
sessionStorage.removeItem('auth_authority');
|
||||
clearUserDataCache();
|
||||
// Redirect to login
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
|
||||
// Handle rate limiting (429) - don't throw, just log and return error
|
||||
if (error.response?.status === 429) {
|
||||
console.warn('Rate limit exceeded (429). Please wait before making more requests.');
|
||||
// Don't cause cascading errors by throwing here
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
import type { AttributeType } from '../utils/attributeTypeMapper';
|
||||
|
||||
export type { AttributeType };
|
||||
|
||||
// ============================================================================
|
||||
// TYPES & INTERFACES
|
||||
// ============================================================================
|
||||
|
||||
export interface AttributeDefinition {
|
||||
name: string;
|
||||
label: string;
|
||||
type: AttributeType;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
searchable?: boolean;
|
||||
width?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
filterOptions?: string[];
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
default?: any;
|
||||
options?: Array<{ value: string | number; label: string }> | string;
|
||||
validation?: any;
|
||||
ui?: any;
|
||||
readonly?: boolean;
|
||||
editable?: boolean;
|
||||
visible?: boolean;
|
||||
order?: number;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
// Type for the request function passed to API functions
|
||||
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
||||
|
||||
// ============================================================================
|
||||
// API REQUEST FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generic function to fetch attributes for any entity type
|
||||
* Endpoint: GET /api/attributes/{entityType}
|
||||
*/
|
||||
export async function fetchAttributes(
|
||||
request: ApiRequestFunction,
|
||||
entityType: string
|
||||
): Promise<AttributeDefinition[]> {
|
||||
const data = await request({
|
||||
url: `/api/attributes/${entityType}`,
|
||||
method: 'get'
|
||||
}) as any;
|
||||
|
||||
// Extract attributes from response - check if response.data.attributes exists, otherwise check if response.data is an array
|
||||
let attrs: AttributeDefinition[] = [];
|
||||
if (data?.attributes && Array.isArray(data.attributes)) {
|
||||
attrs = data.attributes;
|
||||
} else if (Array.isArray(data)) {
|
||||
attrs = data;
|
||||
} else if (data && typeof data === 'object') {
|
||||
// Try to find any array property in the response
|
||||
const keys = Object.keys(data);
|
||||
for (const key of keys) {
|
||||
if (Array.isArray(data[key])) {
|
||||
attrs = data[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return attrs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch connection attributes from backend
|
||||
* Endpoint: GET /api/attributes/UserConnection
|
||||
*/
|
||||
export async function fetchConnectionAttributes(request: ApiRequestFunction): Promise<AttributeDefinition[]> {
|
||||
return fetchAttributes(request, 'UserConnection');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch file attributes from backend
|
||||
* Endpoint: GET /api/attributes/FileItem
|
||||
*/
|
||||
export async function fetchFileAttributes(request: ApiRequestFunction): Promise<AttributeDefinition[]> {
|
||||
const data = await request({
|
||||
url: '/api/attributes/FileItem',
|
||||
method: 'get'
|
||||
}) as AttributeDefinition[] | { attributes: AttributeDefinition[] };
|
||||
|
||||
// Handle different response formats
|
||||
if (Array.isArray(data)) {
|
||||
return data;
|
||||
}
|
||||
if (data && typeof data === 'object' && 'attributes' in data && Array.isArray(data.attributes)) {
|
||||
return data.attributes;
|
||||
}
|
||||
|
||||
// Try to find any array property in the response
|
||||
if (data && typeof data === 'object') {
|
||||
const keys = Object.keys(data);
|
||||
for (const key of keys) {
|
||||
if (Array.isArray((data as any)[key])) {
|
||||
return (data as any)[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch prompt attributes from backend
|
||||
* Endpoint: GET /api/attributes/Prompt
|
||||
*/
|
||||
export async function fetchPromptAttributes(request: ApiRequestFunction): Promise<AttributeDefinition[]> {
|
||||
return fetchAttributes(request, 'Prompt');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user attributes from backend
|
||||
* Endpoint: GET /api/attributes/User
|
||||
*/
|
||||
export async function fetchUserAttributes(request: ApiRequestFunction): Promise<AttributeDefinition[]> {
|
||||
return fetchAttributes(request, 'User');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch workflow attributes from backend
|
||||
* Endpoint: GET /api/attributes/ChatWorkflow
|
||||
*/
|
||||
export async function fetchWorkflowAttributes(request: ApiRequestFunction): Promise<AttributeDefinition[]> {
|
||||
return fetchAttributes(request, 'ChatWorkflow');
|
||||
}
|
||||
|
|
@ -1,318 +0,0 @@
|
|||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
import api from '../api';
|
||||
import { addCSRFTokenToHeaders } from '../utils/csrfUtils';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES & INTERFACES
|
||||
// ============================================================================
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
type: 'local_auth_success';
|
||||
accessToken?: string;
|
||||
tokenType?: string;
|
||||
authenticationAuthority?: string;
|
||||
label?: any;
|
||||
fieldLabels?: any;
|
||||
}
|
||||
|
||||
export interface RegisterData {
|
||||
username: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
language?: string;
|
||||
enabled?: boolean;
|
||||
privilege?: string;
|
||||
registrationType?: 'personal' | 'company';
|
||||
companyName?: string;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
userData: {
|
||||
username: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
language: string;
|
||||
enabled: boolean;
|
||||
privilege: string;
|
||||
authenticationAuthority: string;
|
||||
};
|
||||
frontendUrl: string;
|
||||
registrationType?: string;
|
||||
companyName?: string;
|
||||
}
|
||||
|
||||
export interface PasswordResetRequestResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface PasswordResetResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RegisterResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
user?: {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
language: string;
|
||||
enabled: boolean;
|
||||
privilege: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MsalRegisterData {
|
||||
username: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export interface UsernameAvailabilityRequest {
|
||||
username: string;
|
||||
authenticationAuthority?: string;
|
||||
}
|
||||
|
||||
export interface UsernameAvailabilityResponse {
|
||||
username: string;
|
||||
authenticationAuthority: string;
|
||||
available: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// User-Typ wird aus userApi.ts importiert
|
||||
// Hier nur für Rückwärtskompatibilität
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
language: string;
|
||||
enabled: boolean;
|
||||
roleLabels?: string[];
|
||||
authenticationAuthority: string;
|
||||
isSysAdmin?: boolean;
|
||||
isPlatformAdmin?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// Type for the request function passed to API functions
|
||||
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
||||
|
||||
// ============================================================================
|
||||
// API REQUEST FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Login with username and password
|
||||
* Endpoint: POST /api/local/login
|
||||
*/
|
||||
export async function loginApi(loginData: LoginRequest): Promise<LoginResponse> {
|
||||
// Create the form data in the exact format FastAPI OAuth2 expects
|
||||
const params = new URLSearchParams();
|
||||
params.append('username', loginData.username);
|
||||
params.append('password', loginData.password);
|
||||
params.append('grant_type', 'password');
|
||||
params.append('scope', '');
|
||||
params.append('client_id', '');
|
||||
params.append('client_secret', '');
|
||||
|
||||
// Prepare headers with CSRF token if available
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
};
|
||||
|
||||
// Add CSRF token if available (for new security implementation)
|
||||
addCSRFTokenToHeaders(headers);
|
||||
|
||||
// Use the existing api instance with custom headers for this request
|
||||
const response = await api.post<LoginResponse>('/api/local/login', params, {
|
||||
headers
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch current user data
|
||||
* Endpoint: GET /api/local/me | /api/msft/me | /api/google/me
|
||||
*/
|
||||
export async function fetchCurrentUserApi(authAuthority?: string): Promise<AuthUser> {
|
||||
let endpoint = '/api/local/me';
|
||||
|
||||
if (authAuthority === 'msft') {
|
||||
endpoint = '/api/msft/me';
|
||||
} else if (authAuthority === 'google') {
|
||||
endpoint = '/api/google/me';
|
||||
}
|
||||
|
||||
const response = await api.get<AuthUser>(endpoint);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new user (magic link based - no password required)
|
||||
* Endpoint: POST /api/local/register
|
||||
*
|
||||
* After registration, user receives an email with a magic link to set their password.
|
||||
*/
|
||||
export async function registerApi(registerData: RegisterData): Promise<RegisterResponse> {
|
||||
// Prepare data to match backend expectations (no password - magic link flow)
|
||||
const dataToSend: RegisterRequest = {
|
||||
userData: {
|
||||
username: registerData.username,
|
||||
email: registerData.email,
|
||||
fullName: registerData.fullName,
|
||||
language: registerData.language || 'de',
|
||||
enabled: registerData.enabled !== undefined ? registerData.enabled : true,
|
||||
privilege: registerData.privilege || 'user',
|
||||
authenticationAuthority: 'local'
|
||||
},
|
||||
frontendUrl: window.location.origin,
|
||||
registrationType: registerData.registrationType,
|
||||
companyName: registerData.companyName,
|
||||
};
|
||||
|
||||
// Prepare headers with CSRF token if available
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
// Add CSRF token if available (for new security implementation)
|
||||
addCSRFTokenToHeaders(headers);
|
||||
|
||||
const response = await api.post<RegisterResponse>('/api/local/register', dataToSend, {
|
||||
headers
|
||||
});
|
||||
|
||||
const userData: any = response.data;
|
||||
return {
|
||||
success: true,
|
||||
message: 'Registration successful - check email for password setup link',
|
||||
user: userData && typeof userData === 'object' && 'id' in userData ? {
|
||||
id: String(userData.id || ''),
|
||||
username: String(userData.username || ''),
|
||||
email: String(userData.email || ''),
|
||||
fullName: String(userData.fullName || ''),
|
||||
language: String(userData.language || 'de'),
|
||||
enabled: Boolean(userData.enabled !== false),
|
||||
privilege: String(userData.privilege || 'user')
|
||||
} : undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Request password reset by username
|
||||
* Endpoint: POST /api/local/password-reset-request
|
||||
*
|
||||
* Sends a reset email to the user's registered email address.
|
||||
*/
|
||||
export async function requestPasswordResetApi(username: string): Promise<PasswordResetRequestResponse> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
addCSRFTokenToHeaders(headers);
|
||||
|
||||
const response = await api.post<PasswordResetRequestResponse>(
|
||||
'/api/local/password-reset-request',
|
||||
{
|
||||
username,
|
||||
frontendUrl: window.location.origin
|
||||
},
|
||||
{ headers }
|
||||
);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset password using token from magic link
|
||||
* Endpoint: POST /api/local/password-reset
|
||||
*/
|
||||
export async function resetPasswordApi(token: string, password: string): Promise<PasswordResetResponse> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
addCSRFTokenToHeaders(headers);
|
||||
|
||||
const response = await api.post<PasswordResetResponse>(
|
||||
'/api/local/password-reset',
|
||||
{ token, password },
|
||||
{ headers }
|
||||
);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register with Microsoft account
|
||||
* Endpoint: POST /api/msft/register
|
||||
*/
|
||||
export async function registerWithMsalApi(
|
||||
request: ApiRequestFunction,
|
||||
userData: MsalRegisterData
|
||||
): Promise<RegisterResponse> {
|
||||
const response = await request({
|
||||
url: '/api/msft/register',
|
||||
method: 'post',
|
||||
data: userData,
|
||||
additionalConfig: {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const responseData: any = response;
|
||||
return {
|
||||
success: true,
|
||||
message: 'Registration successful',
|
||||
user: responseData && typeof responseData === 'object' && 'id' in responseData ? {
|
||||
id: String(responseData.id || ''),
|
||||
username: String(responseData.username || ''),
|
||||
email: String(responseData.email || ''),
|
||||
fullName: String(responseData.fullName || ''),
|
||||
language: String(responseData.language || 'en'),
|
||||
enabled: Boolean((responseData as any).enabled !== false),
|
||||
privilege: String((responseData as any).privilege || 'user')
|
||||
} : undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check username availability
|
||||
* Endpoint: GET /api/local/available
|
||||
*/
|
||||
export async function checkUsernameAvailabilityApi(
|
||||
username: string,
|
||||
authenticationAuthority: string = 'local'
|
||||
): Promise<UsernameAvailabilityResponse> {
|
||||
const response = await api.get<UsernameAvailabilityResponse>('/api/local/available', {
|
||||
params: {
|
||||
username,
|
||||
authenticationAuthority
|
||||
}
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout current user
|
||||
* Endpoint: POST /api/local/logout
|
||||
*/
|
||||
export async function logoutApi(): Promise<void> {
|
||||
await api.post('/api/local/logout');
|
||||
}
|
||||
|
||||
|
|
@ -1,454 +0,0 @@
|
|||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES & INTERFACES
|
||||
// ============================================================================
|
||||
|
||||
export type TransactionType = 'CREDIT' | 'DEBIT' | 'ADJUSTMENT';
|
||||
export type ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM' | 'STORAGE' | 'SUBSCRIPTION';
|
||||
|
||||
export interface BillingBalance {
|
||||
mandateId: string;
|
||||
mandateName: string;
|
||||
balance: number;
|
||||
currency: string;
|
||||
warningThreshold: number;
|
||||
isWarning: boolean;
|
||||
}
|
||||
|
||||
export interface BillingTransaction {
|
||||
id: string;
|
||||
accountId: string;
|
||||
transactionType: TransactionType;
|
||||
amount: number;
|
||||
description: string;
|
||||
referenceType?: ReferenceType;
|
||||
workflowId?: string;
|
||||
featureInstanceId?: string;
|
||||
featureCode?: string;
|
||||
aicoreProvider?: string;
|
||||
aicoreModel?: string;
|
||||
createdByUserId?: string;
|
||||
sysCreatedAt?: string;
|
||||
mandateId?: string;
|
||||
mandateName?: string;
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
}
|
||||
|
||||
/** Pagination request for GET /api/billing/transactions with `pagination` JSON (table + grouping). */
|
||||
export interface BillingTransactionsPaginationParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||
filters?: Record<string, any>;
|
||||
search?: string;
|
||||
viewKey?: string;
|
||||
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
|
||||
}
|
||||
|
||||
export interface BillingTransactionsPaginatedResponse {
|
||||
items: BillingTransaction[];
|
||||
pagination?: {
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
};
|
||||
groupLayout?: import('./connectionApi').GroupLayout;
|
||||
appliedView?: { viewKey?: string; displayName?: string };
|
||||
}
|
||||
|
||||
export interface BillingSettings {
|
||||
id: string;
|
||||
mandateId: string;
|
||||
warningThresholdPercent: number;
|
||||
notifyOnWarning: boolean;
|
||||
notifyEmails: string[];
|
||||
autoRechargeEnabled?: boolean;
|
||||
rechargeAmountCHF?: number;
|
||||
rechargeMaxPerMonth?: number;
|
||||
}
|
||||
|
||||
export interface BillingSettingsUpdate {
|
||||
warningThresholdPercent?: number;
|
||||
notifyOnWarning?: boolean;
|
||||
notifyEmails?: string[];
|
||||
autoRechargeEnabled?: boolean;
|
||||
rechargeAmountCHF?: number;
|
||||
rechargeMaxPerMonth?: number;
|
||||
}
|
||||
|
||||
export type BillingBucketSize = 'day' | 'month' | 'year';
|
||||
|
||||
export interface UsageReport {
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
bucketSize: BillingBucketSize;
|
||||
totalCost: number;
|
||||
transactionCount: number;
|
||||
costByProvider: Record<string, number>;
|
||||
costByModel: Record<string, number>;
|
||||
costByFeature: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface StatisticsRangeRequest {
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
bucketSize: BillingBucketSize;
|
||||
}
|
||||
|
||||
export interface AccountSummary {
|
||||
id: string;
|
||||
mandateId: string;
|
||||
userId?: string;
|
||||
balance: number;
|
||||
warningThreshold: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface CreditAddRequest {
|
||||
userId?: string;
|
||||
amount: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CheckoutCreateRequest {
|
||||
userId?: string;
|
||||
amount: number;
|
||||
returnUrl: string;
|
||||
}
|
||||
|
||||
export interface CheckoutCreateResponse {
|
||||
redirectUrl: string;
|
||||
}
|
||||
|
||||
// Type for the request function passed to API functions
|
||||
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
||||
|
||||
// ============================================================================
|
||||
// USER API FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch billing balances for all mandates the user belongs to
|
||||
* Endpoint: GET /api/billing/balance
|
||||
*/
|
||||
export async function fetchBalances(
|
||||
request: ApiRequestFunction
|
||||
): Promise<BillingBalance[]> {
|
||||
return await request({
|
||||
url: '/api/billing/balance',
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch billing balance for a specific mandate
|
||||
* Endpoint: GET /api/billing/balance/{mandateId}
|
||||
*/
|
||||
export async function fetchBalanceForMandate(
|
||||
request: ApiRequestFunction,
|
||||
mandateId: string
|
||||
): Promise<BillingBalance> {
|
||||
return await request({
|
||||
url: `/api/billing/balance/${mandateId}`,
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch transaction history (table UI: pagination, filters, sort, saved views, grouping).
|
||||
* Endpoint: GET /api/billing/transactions?pagination=...
|
||||
*/
|
||||
export async function fetchTransactionsPaginated(
|
||||
request: ApiRequestFunction,
|
||||
params?: BillingTransactionsPaginationParams
|
||||
): Promise<BillingTransactionsPaginatedResponse> {
|
||||
const paginationObj: Record<string, unknown> = {};
|
||||
if (params?.page !== undefined) paginationObj.page = params.page;
|
||||
if (params?.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
|
||||
if (params?.sort?.length) paginationObj.sort = params.sort;
|
||||
if (params?.filters && Object.keys(params.filters).length > 0) paginationObj.filters = params.filters;
|
||||
if (params?.search) paginationObj.search = params.search;
|
||||
if (params?.viewKey) paginationObj.viewKey = params.viewKey;
|
||||
if (params?.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
|
||||
|
||||
return await request({
|
||||
url: '/api/billing/transactions',
|
||||
method: 'get',
|
||||
params: { pagination: JSON.stringify(paginationObj) },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch transaction history (legacy array window)
|
||||
* Endpoint: GET /api/billing/transactions
|
||||
*/
|
||||
export async function fetchTransactions(
|
||||
request: ApiRequestFunction,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<BillingTransaction[]> {
|
||||
return await request({
|
||||
url: '/api/billing/transactions',
|
||||
method: 'get',
|
||||
params: { limit, offset }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch usage statistics for an explicit date range.
|
||||
* Endpoint: GET /api/billing/statistics
|
||||
*/
|
||||
export async function fetchStatistics(
|
||||
request: ApiRequestFunction,
|
||||
range: StatisticsRangeRequest
|
||||
): Promise<UsageReport> {
|
||||
return await request({
|
||||
url: '/api/billing/statistics',
|
||||
method: 'get',
|
||||
params: {
|
||||
dateFrom: range.dateFrom,
|
||||
dateTo: range.dateTo,
|
||||
bucketSize: range.bucketSize,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch allowed AICore providers
|
||||
* Endpoint: GET /api/billing/providers
|
||||
*/
|
||||
export async function fetchAllowedProviders(
|
||||
request: ApiRequestFunction
|
||||
): Promise<string[]> {
|
||||
return await request({
|
||||
url: '/api/billing/providers',
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ADMIN API FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch billing settings for a mandate (Admin)
|
||||
* Endpoint: GET /api/billing/admin/settings/{mandateId}
|
||||
*/
|
||||
export async function fetchSettingsAdmin(
|
||||
request: ApiRequestFunction,
|
||||
mandateId: string
|
||||
): Promise<BillingSettings> {
|
||||
return await request({
|
||||
url: `/api/billing/admin/settings/${mandateId}`,
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update billing settings (Admin)
|
||||
* Endpoint: POST /api/billing/admin/settings/{mandateId}
|
||||
*/
|
||||
export async function updateSettingsAdmin(
|
||||
request: ApiRequestFunction,
|
||||
mandateId: string,
|
||||
settings: BillingSettingsUpdate
|
||||
): Promise<BillingSettings> {
|
||||
return await request({
|
||||
url: `/api/billing/admin/settings/${mandateId}`,
|
||||
method: 'post',
|
||||
data: settings
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add credit to an account (Admin)
|
||||
* Endpoint: POST /api/billing/admin/credit/{mandateId}
|
||||
*/
|
||||
export async function addCreditAdmin(
|
||||
request: ApiRequestFunction,
|
||||
mandateId: string,
|
||||
creditRequest: CreditAddRequest
|
||||
): Promise<BillingTransaction> {
|
||||
return await request({
|
||||
url: `/api/billing/admin/credit/${mandateId}`,
|
||||
method: 'post',
|
||||
data: creditRequest
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the server-side allow-list of CHF top-up amounts
|
||||
* Endpoint: GET /api/billing/checkout/amounts
|
||||
*/
|
||||
export async function fetchCheckoutAmounts(
|
||||
request: ApiRequestFunction
|
||||
): Promise<number[]> {
|
||||
return await request({
|
||||
url: '/api/billing/checkout/amounts',
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Stripe Checkout Session for credit top-up
|
||||
* Endpoint: POST /api/billing/checkout/create/{mandateId}
|
||||
*/
|
||||
export async function createCheckoutSession(
|
||||
request: ApiRequestFunction,
|
||||
mandateId: string,
|
||||
checkoutRequest: CheckoutCreateRequest
|
||||
): Promise<CheckoutCreateResponse> {
|
||||
return await request({
|
||||
url: `/api/billing/checkout/create/${mandateId}`,
|
||||
method: 'post',
|
||||
data: checkoutRequest
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all accounts for a mandate (Admin)
|
||||
* Endpoint: GET /api/billing/admin/accounts/{mandateId}
|
||||
*/
|
||||
export async function fetchAccountsAdmin(
|
||||
request: ApiRequestFunction,
|
||||
mandateId: string
|
||||
): Promise<AccountSummary[]> {
|
||||
return await request({
|
||||
url: `/api/billing/admin/accounts/${mandateId}`,
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all transactions for a mandate (Admin)
|
||||
* Endpoint: GET /api/billing/admin/transactions/{mandateId}
|
||||
*/
|
||||
export async function fetchTransactionsAdmin(
|
||||
request: ApiRequestFunction,
|
||||
mandateId: string,
|
||||
limit: number = 100
|
||||
): Promise<BillingTransaction[]> {
|
||||
return await request({
|
||||
url: `/api/billing/admin/transactions/${mandateId}`,
|
||||
method: 'get',
|
||||
params: { limit }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* User summary for billing admin
|
||||
*/
|
||||
export interface MandateUserSummary {
|
||||
id: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all users for a mandate (Admin)
|
||||
* Endpoint: GET /api/billing/admin/users/{mandateId}
|
||||
*/
|
||||
export async function fetchUsersForMandateAdmin(
|
||||
request: ApiRequestFunction,
|
||||
mandateId: string
|
||||
): Promise<MandateUserSummary[]> {
|
||||
return await request({
|
||||
url: `/api/billing/admin/users/${mandateId}`,
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MANDATE VIEW TYPES & API FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
export interface MandateBalance {
|
||||
mandateId: string;
|
||||
mandateName: string;
|
||||
totalBalance: number;
|
||||
userCount: number;
|
||||
warningThresholdPercent: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch mandate-level balances (SysAdmin only)
|
||||
* Endpoint: GET /api/billing/view/mandates/balances
|
||||
*/
|
||||
export async function fetchMandateViewBalances(
|
||||
request: ApiRequestFunction
|
||||
): Promise<MandateBalance[]> {
|
||||
return await request({
|
||||
url: '/api/billing/view/mandates/balances',
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch mandate-level transactions (SysAdmin only)
|
||||
* Endpoint: GET /api/billing/view/mandates/transactions
|
||||
*/
|
||||
export async function fetchMandateViewTransactions(
|
||||
request: ApiRequestFunction,
|
||||
limit: number = 100
|
||||
): Promise<BillingTransaction[]> {
|
||||
return await request({
|
||||
url: '/api/billing/view/mandates/transactions',
|
||||
method: 'get',
|
||||
params: { limit }
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// USER VIEW TYPES & API FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
export interface UserBalance {
|
||||
accountId: string;
|
||||
mandateId: string;
|
||||
mandateName: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
balance: number;
|
||||
warningThreshold: number;
|
||||
isWarning: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface UserTransaction extends BillingTransaction {
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user-level balances (RBAC-based)
|
||||
* Endpoint: GET /api/billing/view/users/balances
|
||||
*/
|
||||
export async function fetchUserViewBalances(
|
||||
request: ApiRequestFunction
|
||||
): Promise<UserBalance[]> {
|
||||
return await request({
|
||||
url: '/api/billing/view/users/balances',
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user-level transactions (RBAC-based)
|
||||
* Endpoint: GET /api/billing/view/users/transactions
|
||||
*/
|
||||
export async function fetchUserViewTransactions(
|
||||
request: ApiRequestFunction,
|
||||
limit: number = 100
|
||||
): Promise<UserTransaction[]> {
|
||||
return await request({
|
||||
url: '/api/billing/view/users/transactions',
|
||||
method: 'get',
|
||||
params: { limit }
|
||||
});
|
||||
}
|
||||
|
|
@ -1,329 +0,0 @@
|
|||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
import api from '../api';
|
||||
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from '../utils/csrfUtils';
|
||||
import { Message } from '../components/UiComponents/Messages/MessagesTypes';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES & INTERFACES
|
||||
// ============================================================================
|
||||
|
||||
export interface UserInputRequest {
|
||||
input: string;
|
||||
workflowId?: string;
|
||||
files?: Array<{ id: string; name: string }>;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ChatbotWorkflow {
|
||||
id: string;
|
||||
mandateId?: string; // Optional - not in ChatbotConversation
|
||||
featureInstanceId?: string; // From ChatbotConversation
|
||||
status: string;
|
||||
name?: string;
|
||||
currentRound?: number;
|
||||
currentTask?: number;
|
||||
currentAction?: number;
|
||||
startedAt?: number;
|
||||
lastActivity?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface StartChatbotRequest {
|
||||
prompt: string;
|
||||
listFileId?: string[];
|
||||
userLanguage?: string;
|
||||
workflowId?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface StartChatbotResponse extends ChatbotWorkflow {
|
||||
// Workflow object returned from start endpoint
|
||||
}
|
||||
|
||||
export interface ChatDataItem {
|
||||
type: 'message' | 'log' | 'stat' | 'document' | 'stopped' | 'status' | 'chunk';
|
||||
createdAt?: number;
|
||||
item?: Message | any;
|
||||
label?: string; // For status events
|
||||
content?: string; // For chunk events (token-by-token streaming)
|
||||
}
|
||||
|
||||
// Type for the request function passed to API functions
|
||||
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
||||
|
||||
// Type for SSE event handler
|
||||
export type SSEEventHandler = (item: ChatDataItem) => void;
|
||||
|
||||
// ============================================================================
|
||||
// API REQUEST FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Start a new chatbot workflow or continue an existing one with SSE streaming
|
||||
* Endpoint: POST /api/chatbot/{instanceId}/start/stream
|
||||
*
|
||||
* @param instanceId - Feature Instance ID
|
||||
* @param requestBody - Request body with prompt and optional workflowId
|
||||
* @param onEvent - Callback function called for each SSE event
|
||||
* @param onError - Optional error callback
|
||||
* @param onComplete - Optional completion callback
|
||||
* @returns Promise that resolves when stream completes
|
||||
*/
|
||||
export async function startChatbotStreamApi(
|
||||
instanceId: string,
|
||||
requestBody: StartChatbotRequest,
|
||||
onEvent: SSEEventHandler,
|
||||
onError?: (error: Error) => void,
|
||||
onComplete?: () => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Prepare request body
|
||||
console.log('[startChatbotStreamApi] instanceId:', instanceId);
|
||||
console.log('[startChatbotStreamApi] requestBody received:', JSON.stringify(requestBody, null, 2));
|
||||
|
||||
const body: any = {
|
||||
prompt: requestBody.prompt,
|
||||
...(requestBody.listFileId && requestBody.listFileId.length > 0 && { listFileId: requestBody.listFileId }),
|
||||
...(requestBody.userLanguage && { userLanguage: requestBody.userLanguage }),
|
||||
...(requestBody.metadata && { metadata: requestBody.metadata })
|
||||
};
|
||||
|
||||
console.log('[startChatbotStreamApi] body being sent:', JSON.stringify(body, null, 2));
|
||||
|
||||
// Add workflowId to query params if provided
|
||||
const url = requestBody.workflowId
|
||||
? `/api/chatbot/${instanceId}/start/stream?workflowId=${encodeURIComponent(requestBody.workflowId)}`
|
||||
: `/api/chatbot/${instanceId}/start/stream`;
|
||||
|
||||
// Get base URL from api instance
|
||||
const baseURL = api.defaults.baseURL || '';
|
||||
const fullURL = baseURL + url;
|
||||
|
||||
// Prepare headers with authentication and CSRF token
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
// Add auth token if available
|
||||
const authToken = localStorage.getItem('authToken');
|
||||
if (authToken) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
// Add CSRF token for POST requests
|
||||
if (!getCSRFToken()) {
|
||||
generateAndStoreCSRFToken();
|
||||
}
|
||||
addCSRFTokenToHeaders(headers);
|
||||
|
||||
// Use fetch for SSE streaming (POST with body)
|
||||
const response = await fetch(fullURL, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
credentials: 'include' // Include cookies for authentication
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('Response body is null');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Decode chunk and add to buffer
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Process complete SSE messages
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const jsonStr = line.slice(6); // Remove 'data: ' prefix
|
||||
if (jsonStr.trim()) {
|
||||
const item: ChatDataItem = JSON.parse(jsonStr);
|
||||
console.log('[SSE] Received event:', item.type, item);
|
||||
onEvent(item);
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn('Failed to parse SSE event:', line, parseError);
|
||||
}
|
||||
} else if (line.startsWith(':')) {
|
||||
// Comment/keepalive line, ignore
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process any remaining buffer content
|
||||
if (buffer.trim()) {
|
||||
const lines = buffer.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const jsonStr = line.slice(6);
|
||||
if (jsonStr.trim()) {
|
||||
const item: ChatDataItem = JSON.parse(jsonStr);
|
||||
onEvent(item);
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn('Failed to parse SSE event:', line, parseError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error in startChatbotStreamApi:', error);
|
||||
if (onError) {
|
||||
onError(error instanceof Error ? error : new Error(String(error)));
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a running chatbot workflow
|
||||
* Endpoint: POST /api/chatbot/{instanceId}/stop/{workflowId}
|
||||
*/
|
||||
export async function stopChatbotApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
workflowId: string
|
||||
): Promise<ChatbotWorkflow> {
|
||||
console.log('[stopChatbotApi] Calling stop endpoint:', `/api/chatbot/${instanceId}/stop/${workflowId}`, { instanceId, workflowId });
|
||||
const data = await request({
|
||||
url: `/api/chatbot/${instanceId}/stop/${workflowId}`,
|
||||
method: 'post'
|
||||
});
|
||||
|
||||
console.log('[stopChatbotApi] Stop response:', data);
|
||||
return data as ChatbotWorkflow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chatbot threads/workflows
|
||||
* Endpoint: GET /api/chatbot/{instanceId}/threads
|
||||
*/
|
||||
export async function getChatbotThreadsApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
pagination?: { page?: number; pageSize?: number }
|
||||
): Promise<{ items: ChatbotWorkflow[]; metadata: any }> {
|
||||
const paginationParam = pagination ? JSON.stringify(pagination) : undefined;
|
||||
const requestParams = paginationParam
|
||||
? { pagination: paginationParam }
|
||||
: undefined;
|
||||
|
||||
console.log(`[getChatbotThreadsApi] instanceId: ${instanceId}, params:`, requestParams);
|
||||
|
||||
const data = await request({
|
||||
url: `/api/chatbot/${instanceId}/threads`,
|
||||
method: 'get',
|
||||
params: requestParams
|
||||
}) as any;
|
||||
|
||||
console.log(`[getChatbotThreadsApi] Full response:`, JSON.stringify(data, null, 2));
|
||||
console.log(`[getChatbotThreadsApi] Response structure:`, {
|
||||
hasItems: !!data.items,
|
||||
itemsLength: Array.isArray(data.items) ? data.items.length : 'not an array',
|
||||
hasMetadata: !!data.metadata,
|
||||
metadataKeys: data.metadata ? Object.keys(data.metadata) : []
|
||||
});
|
||||
|
||||
return {
|
||||
items: Array.isArray(data.items) ? data.items : [],
|
||||
metadata: data.pagination ?? data.metadata ?? {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific chatbot thread/workflow with its chat data
|
||||
* Endpoint: GET /api/chatbot/{instanceId}/threads?workflowId={id}
|
||||
*
|
||||
* Backend returns: { workflow: ChatbotWorkflow, chatData: { items: ChatDataItem[] } }
|
||||
*
|
||||
* @param request - API request function
|
||||
* @param instanceId - Feature Instance ID
|
||||
* @param workflowId - ID of the workflow to fetch
|
||||
* @returns Object containing workflow details and chatData with items array
|
||||
*/
|
||||
export async function getChatbotThreadApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
workflowId: string
|
||||
): Promise<{ workflow: ChatbotWorkflow; chatData: { items: ChatDataItem[] } }> {
|
||||
console.log(`[getChatbotThreadApi] instanceId: ${instanceId}, workflowId: ${workflowId}`);
|
||||
|
||||
const data = await request({
|
||||
url: `/api/chatbot/${instanceId}/threads`,
|
||||
method: 'get',
|
||||
params: { workflowId }
|
||||
}) as { workflow: ChatbotWorkflow; chatData: { items: ChatDataItem[] } };
|
||||
|
||||
console.log(`[getChatbotThreadApi] Full response for workflowId ${workflowId}:`, JSON.stringify(data, null, 2));
|
||||
console.log(`[getChatbotThreadApi] Response structure:`, {
|
||||
hasWorkflow: !!data.workflow,
|
||||
workflowKeys: data.workflow ? Object.keys(data.workflow) : [],
|
||||
hasChatData: !!data.chatData,
|
||||
hasItems: !!data.chatData?.items,
|
||||
chatDataKeys: data.chatData ? Object.keys(data.chatData) : [],
|
||||
itemsLength: Array.isArray(data.chatData?.items) ? data.chatData.items.length : 'not an array',
|
||||
chatDataTypes: Array.isArray(data.chatData?.items) ? data.chatData.items.map((item: ChatDataItem) => item?.type).filter(Boolean) : []
|
||||
});
|
||||
|
||||
return {
|
||||
workflow: data.workflow,
|
||||
chatData: data.chatData || { items: [] }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a chatbot workflow
|
||||
* Endpoint: DELETE /api/chatbot/{instanceId}/{workflowId}
|
||||
*
|
||||
* @param request - API request function
|
||||
* @param instanceId - Feature Instance ID
|
||||
* @param workflowId - ID of the workflow to delete
|
||||
* @returns Success status
|
||||
*/
|
||||
export async function deleteChatbotWorkflowApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
workflowId: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await request({
|
||||
url: `/api/chatbot/${instanceId}/${workflowId}`,
|
||||
method: 'delete'
|
||||
});
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting chatbot workflow:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,619 +0,0 @@
|
|||
import api from '../api';
|
||||
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from '../utils/csrfUtils';
|
||||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface CoachingContext {
|
||||
id: string;
|
||||
userId: string;
|
||||
mandateId: string;
|
||||
instanceId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
status: string;
|
||||
goals?: string;
|
||||
insights?: string;
|
||||
sessionCount: number;
|
||||
taskCount: number;
|
||||
lastSessionAt?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CoachingSession {
|
||||
id: string;
|
||||
contextId: string;
|
||||
userId: string;
|
||||
status: string;
|
||||
personaId?: string;
|
||||
summary?: string;
|
||||
durationSeconds: number;
|
||||
messageCount: number;
|
||||
competenceScore?: number;
|
||||
emailSent: boolean;
|
||||
startedAt?: string;
|
||||
endedAt?: string;
|
||||
}
|
||||
|
||||
export interface CoachingPersona {
|
||||
id: string;
|
||||
userId: string;
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
gender?: string;
|
||||
category: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface CoachingBadge {
|
||||
id: string;
|
||||
userId: string;
|
||||
badgeKey: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
awardedAt?: string;
|
||||
}
|
||||
|
||||
export interface CoachingMessage {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
contextId: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
contentType: string;
|
||||
audioRef?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface CoachingTask {
|
||||
id: string;
|
||||
contextId: string;
|
||||
sessionId?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
dueDate?: string;
|
||||
completedAt?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface CoachingScore {
|
||||
id: string;
|
||||
contextId: string;
|
||||
sessionId: string;
|
||||
dimension: string;
|
||||
score: number;
|
||||
trend: string;
|
||||
evidence?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface CoachingUserProfile {
|
||||
id: string;
|
||||
userId: string;
|
||||
dailyReminderTime?: string;
|
||||
dailyReminderEnabled: boolean;
|
||||
emailSummaryEnabled: boolean;
|
||||
streakDays: number;
|
||||
longestStreak: number;
|
||||
totalSessions: number;
|
||||
totalMinutes: number;
|
||||
lastSessionAt?: string;
|
||||
}
|
||||
|
||||
export interface DashboardData {
|
||||
totalModules: number;
|
||||
activeModules: number;
|
||||
totalSessions: number;
|
||||
totalMinutes: number;
|
||||
streakDays: number;
|
||||
longestStreak: number;
|
||||
averageScore?: number;
|
||||
recentScores: CoachingScore[];
|
||||
openTasks: number;
|
||||
completedTasks: number;
|
||||
goalProgress?: number;
|
||||
badges?: CoachingBadge[];
|
||||
level?: { number: number; label: string; totalSessions: number };
|
||||
modules: Array<{ id: string; title: string; moduleType: string; sessionCount: number; lastSessionAt?: string; goalProgress?: number }>;
|
||||
/** @deprecated Use totalModules/activeModules/modules instead */
|
||||
totalContexts?: number;
|
||||
activeContexts?: number;
|
||||
contexts?: Array<{ id: string; title: string; category: string; sessionCount: number; lastSessionAt?: string; goalProgress?: number }>;
|
||||
}
|
||||
|
||||
export interface SSEEvent {
|
||||
type: string;
|
||||
data?: any;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
||||
|
||||
export function getApiRequest(): ApiRequestFunction {
|
||||
return async (options: ApiRequestOptions<any>) => {
|
||||
const response = await api(options);
|
||||
return response.data;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Module API (TrainingModule — replaces Context API)
|
||||
// ============================================================================
|
||||
|
||||
export async function listModulesApi(request: ApiRequestFunction, instanceId: string): Promise<any[]> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules`, method: 'get' });
|
||||
return data.modules || [];
|
||||
}
|
||||
|
||||
export async function createModuleApi(request: ApiRequestFunction, instanceId: string, body: {
|
||||
title: string; moduleType?: string; goals?: string; personaId?: string; kpiTargets?: string;
|
||||
}): Promise<any> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules`, method: 'post', data: body });
|
||||
return data.module;
|
||||
}
|
||||
|
||||
export async function getModuleDetailApi(request: ApiRequestFunction, instanceId: string, moduleId: string): Promise<any> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}`, method: 'get' });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateModuleApi(request: ApiRequestFunction, instanceId: string, moduleId: string, body: any): Promise<any> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}`, method: 'put', data: body });
|
||||
return data.module;
|
||||
}
|
||||
|
||||
export async function deleteModuleApi(request: ApiRequestFunction, instanceId: string, moduleId: string): Promise<void> {
|
||||
await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}`, method: 'delete' });
|
||||
}
|
||||
|
||||
export async function listSessionsApi(request: ApiRequestFunction, instanceId: string, moduleId: string): Promise<any[]> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}/sessions`, method: 'get' });
|
||||
return data.sessions || [];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Context / Module API (uses /modules/ endpoints)
|
||||
// ============================================================================
|
||||
|
||||
export async function getContextsApi(request: ApiRequestFunction, instanceId: string): Promise<CoachingContext[]> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules`, method: 'get' });
|
||||
return data.modules || [];
|
||||
}
|
||||
|
||||
export async function createContextApi(request: ApiRequestFunction, instanceId: string, body: {
|
||||
title: string; description?: string; category?: string; goals?: string[];
|
||||
}): Promise<CoachingContext> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules`, method: 'post', data: body });
|
||||
return data.module;
|
||||
}
|
||||
|
||||
export async function getContextDetailApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<{
|
||||
context: CoachingContext; tasks: CoachingTask[]; scores: CoachingScore[]; sessions: CoachingSession[];
|
||||
}> {
|
||||
const data = await request({
|
||||
url: `/api/commcoach/${instanceId}/modules/${contextId}`,
|
||||
method: 'get',
|
||||
params: { _t: Date.now() },
|
||||
});
|
||||
const ctx = data?.module ?? data;
|
||||
return {
|
||||
context: ctx,
|
||||
tasks: data?.tasks ?? [],
|
||||
scores: data?.scores ?? [],
|
||||
sessions: data?.sessions ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateContextApi(request: ApiRequestFunction, instanceId: string, contextId: string, body: any): Promise<CoachingContext> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}`, method: 'put', data: body });
|
||||
return data.module;
|
||||
}
|
||||
|
||||
export async function deleteContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<void> {
|
||||
await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}`, method: 'delete' });
|
||||
}
|
||||
|
||||
export async function archiveContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingContext> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/archive`, method: 'post' });
|
||||
return data.module;
|
||||
}
|
||||
|
||||
export async function activateContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingContext> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/activate`, method: 'post' });
|
||||
return data.module;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session API
|
||||
// ============================================================================
|
||||
|
||||
export async function startSessionApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<{
|
||||
session: CoachingSession; messages: CoachingMessage[]; resumed: boolean;
|
||||
}> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/sessions/start`, method: 'post' });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function startSessionStreamApi(
|
||||
instanceId: string,
|
||||
contextId: string,
|
||||
onEvent: (event: SSEEvent) => void,
|
||||
onError?: (error: Error) => void,
|
||||
onComplete?: () => void,
|
||||
personaId?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const baseURL = api.defaults.baseURL || '';
|
||||
const personaParam = personaId ? `?personaId=${encodeURIComponent(personaId)}` : '';
|
||||
const url = `${baseURL}/api/commcoach/${instanceId}/modules/${contextId}/sessions/start${personaParam}`;
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const authToken = localStorage.getItem('authToken');
|
||||
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
|
||||
if (!getCSRFToken()) generateAndStoreCSRFToken();
|
||||
addCSRFTokenToHeaders(headers);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
if (!response.body) throw new Error('Response body is null');
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const jsonStr = line.slice(6);
|
||||
if (jsonStr.trim()) {
|
||||
let event: SSEEvent;
|
||||
try { event = JSON.parse(jsonStr); } catch { continue; }
|
||||
onEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
onComplete?.();
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (onError) onError(error instanceof Error ? error : new Error(String(error)));
|
||||
else throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSessionApi(request: ApiRequestFunction, instanceId: string, sessionId: string): Promise<{
|
||||
session: CoachingSession; messages: CoachingMessage[];
|
||||
}> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/sessions/${sessionId}`, method: 'get' });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function completeSessionApi(request: ApiRequestFunction, instanceId: string, sessionId: string): Promise<CoachingSession> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/sessions/${sessionId}/complete`, method: 'post' });
|
||||
return data.session;
|
||||
}
|
||||
|
||||
export async function cancelSessionApi(request: ApiRequestFunction, instanceId: string, sessionId: string): Promise<void> {
|
||||
await request({ url: `/api/commcoach/${instanceId}/sessions/${sessionId}/cancel`, method: 'post' });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Streaming Chat API
|
||||
// ============================================================================
|
||||
|
||||
export interface SendMessageOptions {
|
||||
fileIds?: string[];
|
||||
dataSourceIds?: string[];
|
||||
featureDataSourceIds?: string[];
|
||||
allowedProviders?: string[];
|
||||
}
|
||||
|
||||
export async function sendMessageStreamApi(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
content: string,
|
||||
onEvent: (event: SSEEvent) => void,
|
||||
onError?: (error: Error) => void,
|
||||
onComplete?: () => void,
|
||||
signal?: AbortSignal,
|
||||
options?: SendMessageOptions,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const baseURL = api.defaults.baseURL || '';
|
||||
const url = `${baseURL}/api/commcoach/${instanceId}/sessions/${sessionId}/message/stream`;
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const authToken = localStorage.getItem('authToken');
|
||||
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
|
||||
if (!getCSRFToken()) generateAndStoreCSRFToken();
|
||||
addCSRFTokenToHeaders(headers);
|
||||
|
||||
const body: Record<string, unknown> = { content };
|
||||
if (options?.fileIds?.length) body.fileIds = options.fileIds;
|
||||
if (options?.dataSourceIds?.length) body.dataSourceIds = options.dataSourceIds;
|
||||
if (options?.featureDataSourceIds?.length) body.featureDataSourceIds = options.featureDataSourceIds;
|
||||
if (options?.allowedProviders?.length) body.allowedProviders = options.allowedProviders;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
credentials: 'include',
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
if (!response.body) throw new Error('Response body is null');
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const jsonStr = line.slice(6);
|
||||
if (jsonStr.trim()) {
|
||||
let event: SSEEvent;
|
||||
try { event = JSON.parse(jsonStr); } catch { continue; }
|
||||
onEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
onComplete?.();
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (onError) onError(error instanceof Error ? error : new Error(String(error)));
|
||||
else throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendAudioStreamApi(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
audioBlob: Blob,
|
||||
onEvent: (event: SSEEvent) => void,
|
||||
onError?: (error: Error) => void,
|
||||
onComplete?: () => void,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const baseURL = api.defaults.baseURL || '';
|
||||
const url = `${baseURL}/api/commcoach/${instanceId}/sessions/${sessionId}/audio/stream`;
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/octet-stream' };
|
||||
const authToken = localStorage.getItem('authToken');
|
||||
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
|
||||
const pathMatch = window.location.pathname.match(/^\/mandates\/([^/]+)\/([^/]+)\/([^/]+)/);
|
||||
if (pathMatch) {
|
||||
headers['X-Mandate-Id'] = pathMatch[1];
|
||||
headers['X-Instance-Id'] = pathMatch[3];
|
||||
}
|
||||
if (!getCSRFToken()) generateAndStoreCSRFToken();
|
||||
addCSRFTokenToHeaders(headers);
|
||||
|
||||
const audioBuffer = await audioBlob.arrayBuffer();
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: audioBuffer,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
if (!response.body) throw new Error('Response body is null');
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const jsonStr = line.slice(6);
|
||||
if (jsonStr.trim()) {
|
||||
let event: SSEEvent;
|
||||
try { event = JSON.parse(jsonStr); } catch { continue; }
|
||||
onEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
onComplete?.();
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (onError) onError(error instanceof Error ? error : new Error(String(error)));
|
||||
else throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Task API
|
||||
// ============================================================================
|
||||
|
||||
export async function getTasksApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingTask[]> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/tasks`, method: 'get' });
|
||||
return data.tasks || [];
|
||||
}
|
||||
|
||||
export async function createTaskApi(request: ApiRequestFunction, instanceId: string, contextId: string, body: {
|
||||
title: string; description?: string; priority?: string; dueDate?: string;
|
||||
}): Promise<CoachingTask> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/tasks`, method: 'post', data: body });
|
||||
return data.task;
|
||||
}
|
||||
|
||||
export async function updateTaskApi(request: ApiRequestFunction, instanceId: string, taskId: string, body: any): Promise<CoachingTask> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/tasks/${taskId}`, method: 'put', data: body });
|
||||
return data.task;
|
||||
}
|
||||
|
||||
export async function updateTaskStatusApi(request: ApiRequestFunction, instanceId: string, taskId: string, status: string): Promise<CoachingTask> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/tasks/${taskId}/status`, method: 'put', data: { status } });
|
||||
return data.task;
|
||||
}
|
||||
|
||||
export async function deleteTaskApi(request: ApiRequestFunction, instanceId: string, taskId: string): Promise<void> {
|
||||
await request({ url: `/api/commcoach/${instanceId}/tasks/${taskId}`, method: 'delete' });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Dashboard API
|
||||
// ============================================================================
|
||||
|
||||
export async function getDashboardApi(request: ApiRequestFunction, instanceId: string): Promise<DashboardData> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/dashboard`, method: 'get' });
|
||||
return data.dashboard;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Profile API
|
||||
// ============================================================================
|
||||
|
||||
export async function getProfileApi(request: ApiRequestFunction, instanceId: string): Promise<CoachingUserProfile> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/profile`, method: 'get' });
|
||||
return data.profile;
|
||||
}
|
||||
|
||||
export async function updateProfileApi(request: ApiRequestFunction, instanceId: string, body: any): Promise<CoachingUserProfile> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/profile`, method: 'put', data: body });
|
||||
return data.profile;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Persona API (Iteration 2)
|
||||
// ============================================================================
|
||||
|
||||
export async function getPersonasApi(request: ApiRequestFunction, instanceId: string): Promise<CoachingPersona[]> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/personas`, method: 'get' });
|
||||
return data.items || data.personas || [];
|
||||
}
|
||||
|
||||
export async function fetchPersonasPaginated(request: ApiRequestFunction, instanceId: string, params?: any): Promise<any> {
|
||||
const queryParams: Record<string, string> = {};
|
||||
if (params) queryParams.pagination = JSON.stringify(params);
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/personas`, method: 'get', params: queryParams });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createPersonaApi(request: ApiRequestFunction, instanceId: string, body: {
|
||||
label: string; description: string; gender?: string; systemPromptOverride?: string;
|
||||
}): Promise<CoachingPersona> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/personas`, method: 'post', data: body });
|
||||
return data.persona;
|
||||
}
|
||||
|
||||
export async function updatePersonaApi(request: ApiRequestFunction, instanceId: string, personaId: string, body: {
|
||||
label?: string; description?: string; gender?: string; systemPromptOverride?: string; isActive?: boolean;
|
||||
}): Promise<CoachingPersona> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/personas/${personaId}`, method: 'put', data: body });
|
||||
return data.persona;
|
||||
}
|
||||
|
||||
export async function deletePersonaApi(request: ApiRequestFunction, instanceId: string, personaId: string): Promise<void> {
|
||||
await request({ url: `/api/commcoach/${instanceId}/personas/${personaId}`, method: 'delete' });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Module-Persona Mapping API
|
||||
// ============================================================================
|
||||
|
||||
export async function getModulePersonasApi(request: ApiRequestFunction, instanceId: string, moduleId: string): Promise<string[]> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}/personas`, method: 'get' });
|
||||
return data.personaIds || [];
|
||||
}
|
||||
|
||||
export async function setModulePersonasApi(request: ApiRequestFunction, instanceId: string, moduleId: string, personaIds: string[]): Promise<string[]> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}/personas`, method: 'put', data: { personaIds } });
|
||||
return data.personaIds || [];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Badge API (Iteration 2)
|
||||
// ============================================================================
|
||||
|
||||
export async function getBadgesApi(request: ApiRequestFunction, instanceId: string): Promise<CoachingBadge[]> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/badges`, method: 'get' });
|
||||
return data.badges || [];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Export API (Iteration 2)
|
||||
// ============================================================================
|
||||
|
||||
export function getDossierExportUrl(instanceId: string, contextId: string, format: string = 'md'): string {
|
||||
const baseURL = api.defaults.baseURL || '';
|
||||
return `${baseURL}/api/commcoach/${instanceId}/modules/${contextId}/export?format=${format}`;
|
||||
}
|
||||
|
||||
export function getSessionExportUrl(instanceId: string, sessionId: string, format: string = 'md'): string {
|
||||
const baseURL = api.defaults.baseURL || '';
|
||||
return `${baseURL}/api/commcoach/${instanceId}/sessions/${sessionId}/export?format=${format}`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Score History API (Iteration 2)
|
||||
// ============================================================================
|
||||
|
||||
export async function getScoreHistoryApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<Record<string, Array<{
|
||||
score: number; trend: string; evidence?: string; createdAt?: string; sessionId?: string;
|
||||
}>>> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/scores/history`, method: 'get' });
|
||||
return data.history || {};
|
||||
}
|
||||
|
|
@ -1,510 +0,0 @@
|
|||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES & INTERFACES
|
||||
// ============================================================================
|
||||
|
||||
export interface KnowledgePreferences {
|
||||
schemaVersion?: number;
|
||||
mailContentDepth?: 'metadata' | 'snippet' | 'full';
|
||||
mailIndexAttachments?: boolean;
|
||||
filesIndexBinaries?: boolean;
|
||||
clickupScope?: 'titles' | 'title_description' | 'with_comments';
|
||||
clickupIndexAttachments?: boolean;
|
||||
maxAgeDays?: number;
|
||||
}
|
||||
|
||||
export interface Connection {
|
||||
id: string;
|
||||
userId: string;
|
||||
authority: 'local' | 'google' | 'msft' | 'clickup' | 'infomaniak';
|
||||
externalId: string;
|
||||
externalUsername: string;
|
||||
externalEmail?: string;
|
||||
status: 'active' | 'expired' | 'revoked' | 'pending';
|
||||
connectedAt: number; // Backend uses float for UTC timestamp in seconds
|
||||
lastChecked: number; // Backend uses float for UTC timestamp in seconds
|
||||
expiresAt?: number; // Backend uses Optional[float] for UTC timestamp in seconds
|
||||
knowledgeIngestionEnabled?: boolean;
|
||||
knowledgePreferences?: KnowledgePreferences | null;
|
||||
[key: string]: any; // Allow additional properties
|
||||
}
|
||||
|
||||
export interface AttributeDefinition {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'string' | 'number' | 'date' | 'boolean' | 'enum';
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
searchable?: boolean;
|
||||
width?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
filterOptions?: string[];
|
||||
}
|
||||
|
||||
export interface PaginationParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||
filters?: Record<string, any>;
|
||||
search?: string;
|
||||
/** Key of a saved view to apply (server loads groupByLevels, filters, sort from DB). */
|
||||
viewKey?: string;
|
||||
/** Explicit grouping levels; when sent (incl. []), overrides the view for this request. */
|
||||
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
|
||||
}
|
||||
|
||||
export interface GroupBand {
|
||||
path: string[];
|
||||
label: string;
|
||||
startRowIndex: number;
|
||||
rowCount: number;
|
||||
}
|
||||
|
||||
export interface GroupLayout {
|
||||
levels: string[];
|
||||
bands: GroupBand[];
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
pagination?: {
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
};
|
||||
groupLayout?: GroupLayout;
|
||||
appliedView?: { viewKey?: string; displayName?: string };
|
||||
}
|
||||
|
||||
export interface CreateConnectionData {
|
||||
id?: string;
|
||||
userId?: string;
|
||||
authority?: 'msft' | 'google' | 'clickup' | 'infomaniak';
|
||||
type?: 'msft' | 'google' | 'clickup' | 'infomaniak'; // Backend maps type → authority
|
||||
externalId?: string;
|
||||
externalUsername?: string;
|
||||
externalEmail?: string;
|
||||
status?: 'active' | 'expired' | 'revoked' | 'pending';
|
||||
knowledgeIngestionEnabled?: boolean;
|
||||
knowledgePreferences?: KnowledgePreferences | null;
|
||||
connectedAt?: number;
|
||||
lastChecked?: number;
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
export interface ConnectResponse {
|
||||
authUrl: string;
|
||||
}
|
||||
|
||||
// Type for the request function passed to API functions
|
||||
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
||||
|
||||
// ============================================================================
|
||||
// API REQUEST FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch connection attributes from backend
|
||||
* Endpoint: GET /api/attributes/UserConnection
|
||||
*/
|
||||
export async function fetchConnectionAttributes(_request: ApiRequestFunction): Promise<AttributeDefinition[]> {
|
||||
// Note: This uses api.get directly due to response format handling
|
||||
// For now, we'll use api.get directly in the hook as well
|
||||
throw new Error('fetchConnectionAttributes should use api instance directly for response format handling');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch list of connections with optional pagination
|
||||
* Endpoint: GET /api/connections/
|
||||
*/
|
||||
export async function fetchConnections(
|
||||
request: ApiRequestFunction,
|
||||
params?: PaginationParams
|
||||
): Promise<PaginatedResponse<Connection> | Connection[]> {
|
||||
const requestParams: any = {};
|
||||
|
||||
// Build pagination object if provided
|
||||
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 (params.viewKey) paginationObj.viewKey = params.viewKey;
|
||||
if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
|
||||
|
||||
if (Object.keys(paginationObj).length > 0) {
|
||||
requestParams.pagination = JSON.stringify(paginationObj);
|
||||
}
|
||||
}
|
||||
|
||||
const data = await request({
|
||||
url: '/api/connections/',
|
||||
method: 'get',
|
||||
params: requestParams
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new connection
|
||||
* Endpoint: POST /api/connections/
|
||||
*/
|
||||
export async function createConnection(
|
||||
request: ApiRequestFunction,
|
||||
connectionData: CreateConnectionData
|
||||
): Promise<Connection> {
|
||||
return await request({
|
||||
url: '/api/connections/',
|
||||
method: 'post',
|
||||
data: connectionData
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a service (initiate OAuth)
|
||||
* Endpoint: POST /api/connections/{connectionId}/connect
|
||||
*
|
||||
* @param reauth If true, forces the OAuth provider to re-show the consent screen.
|
||||
* Required when newly added scopes (e.g. Calendar/Contacts after a
|
||||
* feature rollout) need to be granted on top of the existing token.
|
||||
*/
|
||||
export async function connectService(
|
||||
request: ApiRequestFunction,
|
||||
connectionId: string,
|
||||
reauth: boolean = false
|
||||
): Promise<ConnectResponse> {
|
||||
return await request({
|
||||
url: `/api/connections/${connectionId}/connect`,
|
||||
method: 'post',
|
||||
data: reauth ? { reauth: true } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from a service
|
||||
* Endpoint: POST /api/connections/{connectionId}/disconnect
|
||||
*/
|
||||
export async function disconnectService(
|
||||
request: ApiRequestFunction,
|
||||
connectionId: string
|
||||
): Promise<{ message: string }> {
|
||||
return await request({
|
||||
url: `/api/connections/${connectionId}/disconnect`,
|
||||
method: 'post'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a connection
|
||||
* Endpoint: DELETE /api/connections/{connectionId}
|
||||
*/
|
||||
export async function deleteConnection(
|
||||
request: ApiRequestFunction,
|
||||
connectionId: string
|
||||
): Promise<{ message: string }> {
|
||||
return await request({
|
||||
url: `/api/connections/${connectionId}`,
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a connection
|
||||
* Endpoint: PUT /api/connections/{connectionId}
|
||||
*/
|
||||
export async function updateConnection(
|
||||
request: ApiRequestFunction,
|
||||
connectionId: string,
|
||||
updateData: Partial<Connection>
|
||||
): Promise<Connection> {
|
||||
return await request({
|
||||
url: `/api/connections/${connectionId}`,
|
||||
method: 'put',
|
||||
data: updateData
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Microsoft token
|
||||
* Endpoint: POST /api/msft/refresh
|
||||
*/
|
||||
export async function refreshMicrosoftToken(
|
||||
request: ApiRequestFunction,
|
||||
connectionId: string
|
||||
): Promise<Connection> {
|
||||
return await request({
|
||||
url: '/api/msft/refresh',
|
||||
method: 'post',
|
||||
data: { connectionId }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Google token
|
||||
* Endpoint: POST /api/google/refresh
|
||||
*/
|
||||
export async function refreshGoogleToken(
|
||||
request: ApiRequestFunction,
|
||||
connectionId: string
|
||||
): Promise<Connection> {
|
||||
return await request({
|
||||
url: '/api/google/refresh',
|
||||
method: 'post',
|
||||
data: { connectionId }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit an Infomaniak Personal Access Token (kdrive + mail) for an existing
|
||||
* UserConnection. The backend validates the token via /1/profile and stores it
|
||||
* as the connection's data-access bearer token.
|
||||
* Endpoint: POST /api/infomaniak/connections/{connectionId}/token
|
||||
*/
|
||||
export async function submitInfomaniakToken(
|
||||
request: ApiRequestFunction,
|
||||
connectionId: string,
|
||||
token: string
|
||||
): Promise<{
|
||||
id: string;
|
||||
status: string;
|
||||
type: string;
|
||||
externalUsername: string;
|
||||
externalEmail?: string | null;
|
||||
lastChecked: number;
|
||||
}> {
|
||||
return await request({
|
||||
url: `/api/infomaniak/connections/${connectionId}/token`,
|
||||
method: 'post',
|
||||
data: { token }
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RAG KNOWLEDGE CONSENT & CONTROL
|
||||
// ============================================================================
|
||||
|
||||
export async function patchKnowledgeConsent(
|
||||
request: ApiRequestFunction,
|
||||
connectionId: string,
|
||||
enabled: boolean
|
||||
): Promise<{ connectionId: string; knowledgeIngestionEnabled: boolean; purged?: any; cancelledJobs?: number; bootstrapEnqueued?: boolean }> {
|
||||
return await request({
|
||||
url: `/api/connections/${connectionId}/knowledge-consent`,
|
||||
method: 'patch',
|
||||
data: { enabled }
|
||||
});
|
||||
}
|
||||
|
||||
export async function patchKnowledgePreferences(
|
||||
request: ApiRequestFunction,
|
||||
connectionId: string,
|
||||
preferences: KnowledgePreferences
|
||||
): Promise<{ connectionId: string; knowledgePreferences: KnowledgePreferences; updated: boolean }> {
|
||||
return await request({
|
||||
url: `/api/connections/${connectionId}/knowledge-preferences`,
|
||||
method: 'patch',
|
||||
data: { preferences }
|
||||
});
|
||||
}
|
||||
|
||||
export async function postKnowledgeStop(
|
||||
request: ApiRequestFunction,
|
||||
connectionId: string
|
||||
): Promise<{ connectionId: string; cancelled: number }> {
|
||||
return await request({
|
||||
url: `/api/connections/${connectionId}/knowledge-stop`,
|
||||
method: 'post'
|
||||
});
|
||||
}
|
||||
|
||||
export interface RagLimits {
|
||||
maxItems?: number;
|
||||
maxBytes?: number;
|
||||
maxFileSize?: number;
|
||||
maxDepth?: number;
|
||||
// ClickUp variant
|
||||
maxTasks?: number;
|
||||
maxWorkspaces?: number;
|
||||
maxListsPerWorkspace?: number;
|
||||
}
|
||||
|
||||
export interface DataSourceSettings {
|
||||
ragLimits?: RagLimits;
|
||||
}
|
||||
|
||||
export interface CostEstimate {
|
||||
estimatedTokens: number;
|
||||
estimatedChf: number;
|
||||
basis: {
|
||||
kind: string;
|
||||
limits: Record<string, number>;
|
||||
assumptions: Record<string, any>;
|
||||
notes: string;
|
||||
};
|
||||
sourceId?: string;
|
||||
}
|
||||
|
||||
export async function patchDataSourceSettings(
|
||||
request: ApiRequestFunction,
|
||||
dataSourceId: string,
|
||||
settings: DataSourceSettings
|
||||
): Promise<{ sourceId: string; settings: DataSourceSettings; updated: boolean }> {
|
||||
return await request({
|
||||
url: `/api/datasources/${dataSourceId}/settings`,
|
||||
method: 'patch',
|
||||
data: { settings }
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDataSourceCostEstimate(
|
||||
request: ApiRequestFunction,
|
||||
dataSourceId: string
|
||||
): Promise<CostEstimate> {
|
||||
return await request({
|
||||
url: `/api/datasources/${dataSourceId}/cost-estimate`,
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
export interface PatchFlagResponse {
|
||||
sourceId: string;
|
||||
resetDescendantIds: string[];
|
||||
updatedAncestors: { id: string; [key: string]: any }[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export async function patchDataSourceRagIndex(
|
||||
request: ApiRequestFunction,
|
||||
dataSourceId: string,
|
||||
ragIndexEnabled: boolean | null
|
||||
): Promise<PatchFlagResponse> {
|
||||
return await request({
|
||||
url: `/api/datasources/${dataSourceId}/rag-index`,
|
||||
method: 'patch',
|
||||
data: { ragIndexEnabled }
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RAG INVENTORY
|
||||
// ============================================================================
|
||||
|
||||
export interface RagDataSourceDto {
|
||||
id: string;
|
||||
label: string;
|
||||
path: string;
|
||||
sourceType: string;
|
||||
/** Three-state inherit semantics on backend; UI reads as effective boolean from RAG inventory aggregator. */
|
||||
ragIndexEnabled: boolean | null;
|
||||
neutralize: boolean | null;
|
||||
lastIndexed: number | null;
|
||||
/** Distinct files indexed for this DataSource (one row per source document). */
|
||||
fileCount: number;
|
||||
/** Embedding-sized text fragments (one per ContentChunk row, ~400 tokens each). */
|
||||
chunkCount: number;
|
||||
}
|
||||
|
||||
export interface RagConnectionDto {
|
||||
id: string;
|
||||
authority: string;
|
||||
externalEmail: string;
|
||||
knowledgeIngestionEnabled: boolean;
|
||||
preferences: KnowledgePreferences;
|
||||
dataSources: RagDataSourceDto[];
|
||||
totalFiles: number;
|
||||
totalChunks: number;
|
||||
runningJobs: {
|
||||
jobId: string;
|
||||
progress: number;
|
||||
/** Already translated server-side. */
|
||||
progressMessage: string;
|
||||
}[];
|
||||
lastError?: { jobId: string; errorMessage: string; finishedAt: number | null } | null;
|
||||
lastSuccess?: {
|
||||
jobId: string;
|
||||
finishedAt: number | null;
|
||||
indexed: number;
|
||||
skippedDuplicate: number;
|
||||
skippedPolicy: number;
|
||||
failed: number;
|
||||
durationMs: number;
|
||||
/** Name of the first budget that bit (e.g. "maxBytes", "maxItems", "maxTasks"); null if walk completed naturally. */
|
||||
stoppedAtLimit?: string | null;
|
||||
/** Effective limits used by the walker, for showing the value next to the limit name. */
|
||||
limits?: Record<string, number>;
|
||||
bytesProcessed?: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface RagFeatureDataSourceDto {
|
||||
id: string;
|
||||
label: string;
|
||||
tableName: string;
|
||||
featureCode: string;
|
||||
ragIndexEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface RagFeatureInstanceDto {
|
||||
featureInstanceId: string;
|
||||
featureCode: string;
|
||||
label: string;
|
||||
mandateId: string;
|
||||
fileCount: number;
|
||||
chunkCount: number;
|
||||
statusCounts: Record<string, number>;
|
||||
dataSources: RagFeatureDataSourceDto[];
|
||||
ragEnabled: boolean;
|
||||
runningJobs?: {
|
||||
jobId: string;
|
||||
progress: number;
|
||||
progressMessage: string;
|
||||
}[];
|
||||
lastError?: { jobId: string; errorMessage: string; finishedAt: number | null } | null;
|
||||
lastSuccess?: {
|
||||
jobId: string;
|
||||
finishedAt: number | null;
|
||||
indexed: number;
|
||||
skippedDuplicate: number;
|
||||
failed: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface RagInventoryDto {
|
||||
connections: RagConnectionDto[];
|
||||
featureInstances?: RagFeatureInstanceDto[];
|
||||
totals: { files: number; chunks: number; bytes?: number };
|
||||
}
|
||||
|
||||
export interface RagActiveJobDto {
|
||||
jobId: string;
|
||||
connectionId: string;
|
||||
connectionLabel?: string;
|
||||
jobType: string;
|
||||
progress: number | null;
|
||||
/** Already translated server-side. */
|
||||
progressMessage: string;
|
||||
}
|
||||
|
||||
export async function getRagInventoryMe(request: ApiRequestFunction): Promise<RagInventoryDto> {
|
||||
return await request({ url: '/api/rag/inventory/me', method: 'get' });
|
||||
}
|
||||
|
||||
export async function getRagInventoryMandate(request: ApiRequestFunction): Promise<RagInventoryDto> {
|
||||
return await request({ url: '/api/rag/inventory/mandate', method: 'get' });
|
||||
}
|
||||
|
||||
export async function getRagInventoryPlatform(request: ApiRequestFunction): Promise<any> {
|
||||
return await request({ url: '/api/rag/inventory/platform', method: 'get' });
|
||||
}
|
||||
|
||||
export async function getRagActiveJobs(request: ApiRequestFunction): Promise<RagActiveJobDto[]> {
|
||||
return await request({ url: '/api/rag/inventory/jobs', method: 'get' });
|
||||
}
|
||||
|
||||
|
|
@ -1,277 +0,0 @@
|
|||
/**
|
||||
* Features API
|
||||
*
|
||||
* API-Schicht für das Multi-Tenant Feature-System.
|
||||
* Hauptendpoint: GET /features/my - Lädt alle Mandate + Features + Instanzen + Permissions
|
||||
*/
|
||||
|
||||
import api from '../api';
|
||||
import type {
|
||||
FeaturesMyResponse,
|
||||
Mandate,
|
||||
MandateFeature,
|
||||
FeatureInstance,
|
||||
InstancePermissions,
|
||||
AccessLevel,
|
||||
} from '../types/mandate';
|
||||
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
|
||||
|
||||
// =============================================================================
|
||||
// MOCK DATA (Temporär bis Backend bereit)
|
||||
// =============================================================================
|
||||
|
||||
const MOCK_PERMISSIONS: InstancePermissions = {
|
||||
tables: {
|
||||
TrusteeOrganisation: { view: true, read: 'g', create: 'g', update: 'g', delete: 'n' },
|
||||
TrusteeContract: { view: true, read: 'g', create: 'g', update: 'm', delete: 'n' },
|
||||
TrusteeDocument: { view: true, read: 'g', create: 'g', update: 'm', delete: 'm' },
|
||||
TrusteePosition: { view: true, read: 'g', create: 'g', update: 'm', delete: 'n' },
|
||||
},
|
||||
views: {
|
||||
'trustee-dashboard': true,
|
||||
'trustee-organisations': true,
|
||||
'trustee-contracts': true,
|
||||
'trustee-documents': true,
|
||||
'trustee-positions': true,
|
||||
'trustee-roles': true,
|
||||
'trustee-access': true,
|
||||
},
|
||||
};
|
||||
|
||||
const MOCK_CUSTOMER_PERMISSIONS: InstancePermissions = {
|
||||
tables: {
|
||||
TrusteeOrganisation: { view: true, read: 'm', create: 'n', update: 'n', delete: 'n' },
|
||||
TrusteeContract: { view: true, read: 'm', create: 'n', update: 'n', delete: 'n' },
|
||||
TrusteeDocument: { view: true, read: 'm', create: 'm', update: 'm', delete: 'n' },
|
||||
TrusteePosition: { view: true, read: 'm', create: 'n', update: 'n', delete: 'n' },
|
||||
},
|
||||
views: {
|
||||
'trustee-dashboard': true,
|
||||
'trustee-contracts': true,
|
||||
'trustee-documents': true,
|
||||
'trustee-positions': true,
|
||||
'trustee-organisations': false,
|
||||
'trustee-roles': false,
|
||||
'trustee-access': false,
|
||||
},
|
||||
};
|
||||
|
||||
const MOCK_WORKFLOW_PERMISSIONS: InstancePermissions = {
|
||||
tables: {
|
||||
WorkflowRun: { view: true, read: 'g', create: 'g', update: 'm', delete: 'n' },
|
||||
WorkflowFile: { view: true, read: 'g', create: 'g', update: 'm', delete: 'm' },
|
||||
},
|
||||
views: {
|
||||
'chatworkflow-dashboard': true,
|
||||
'chatworkflow-runs': true,
|
||||
'chatworkflow-files': true,
|
||||
},
|
||||
};
|
||||
|
||||
const MOCK_RESPONSE: FeaturesMyResponse = {
|
||||
mandates: [
|
||||
{
|
||||
id: 'mand-soha',
|
||||
name: 'soha-treuhand',
|
||||
label: 'Soha Treuhand',
|
||||
code: 'soha',
|
||||
features: [
|
||||
{
|
||||
code: 'trustee',
|
||||
label: 'Treuhand',
|
||||
icon: 'briefcase',
|
||||
instances: [
|
||||
{
|
||||
id: 'inst-soha-pamo',
|
||||
featureCode: 'trustee',
|
||||
mandateId: 'mand-soha',
|
||||
mandateName: 'Soha Treuhand',
|
||||
instanceLabel: 'PamoCreate AG',
|
||||
userRoles: ['admin'],
|
||||
permissions: MOCK_PERMISSIONS,
|
||||
},
|
||||
{
|
||||
id: 'inst-soha-valueon',
|
||||
featureCode: 'trustee',
|
||||
mandateId: 'mand-soha',
|
||||
mandateName: 'Soha Treuhand',
|
||||
instanceLabel: 'ValueOn AG',
|
||||
userRoles: ['customer'],
|
||||
permissions: MOCK_CUSTOMER_PERMISSIONS,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'chatworkflow',
|
||||
label: 'Workflow',
|
||||
icon: 'play_circle',
|
||||
instances: [
|
||||
{
|
||||
id: 'inst-soha-workflow',
|
||||
featureCode: 'chatworkflow',
|
||||
mandateId: 'mand-soha',
|
||||
mandateName: 'Soha Treuhand',
|
||||
instanceLabel: 'Beratung Dynamic',
|
||||
userRoles: ['user'],
|
||||
permissions: MOCK_WORKFLOW_PERMISSIONS,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mand-swiss',
|
||||
name: 'swisstreu',
|
||||
label: 'SwissTreu',
|
||||
code: 'swisstreu',
|
||||
features: [
|
||||
{
|
||||
code: 'trustee',
|
||||
label: 'Treuhand',
|
||||
icon: 'briefcase',
|
||||
instances: [
|
||||
{
|
||||
id: 'inst-swiss-firma-x',
|
||||
featureCode: 'trustee',
|
||||
mandateId: 'mand-swiss',
|
||||
mandateName: 'SwissTreu',
|
||||
instanceLabel: 'Firma X',
|
||||
userRoles: ['customer'],
|
||||
permissions: MOCK_CUSTOMER_PERMISSIONS,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Flag für Mock-Modus (auf false setzen wenn Backend bereit)
|
||||
const USE_MOCK = false;
|
||||
|
||||
// =============================================================================
|
||||
// API FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Lädt alle Mandate + Features + Instanzen + Permissions für den aktuellen User
|
||||
*
|
||||
* Endpoint: GET /api/features/my
|
||||
*
|
||||
* Response enthält:
|
||||
* - Alle Mandanten zu denen der User Zugriff hat
|
||||
* - Pro Mandant: Alle Features mit deren Instanzen
|
||||
* - Pro Instanz: Summarische Berechtigungen (tables, views)
|
||||
*/
|
||||
export async function fetchMyFeatures(): Promise<FeaturesMyResponse> {
|
||||
if (USE_MOCK) {
|
||||
console.log('📦 featuresApi: Using MOCK data');
|
||||
// Simuliere Netzwerk-Latenz
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
return MOCK_RESPONSE;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('📡 featuresApi: Fetching /api/features/my');
|
||||
const response = await api.get<FeaturesMyResponse>('/api/features/my');
|
||||
|
||||
// Get the actual data (response.data contains the FeaturesMyResponse)
|
||||
const data = response.data;
|
||||
|
||||
// DEBUG: Log all chatbot instances and their permissions
|
||||
console.log('🔍 [DEBUG] featuresApi: Full response received', {
|
||||
response,
|
||||
data,
|
||||
hasMandates: !!data?.mandates,
|
||||
mandateCount: data?.mandates?.length || 0,
|
||||
});
|
||||
|
||||
if (data?.mandates) {
|
||||
data.mandates.forEach(mandate => {
|
||||
mandate.features.forEach(feature => {
|
||||
if (feature.code === 'chatbot') {
|
||||
console.log('🔍 [DEBUG] featuresApi: Found chatbot feature', {
|
||||
mandateId: mandate.id,
|
||||
mandateName: mandateDisplayLabel(mandate),
|
||||
featureCode: feature.code,
|
||||
instanceCount: feature.instances.length,
|
||||
});
|
||||
feature.instances.forEach(instance => {
|
||||
console.log('🔍 [DEBUG] featuresApi: Chatbot Instance Details:', {
|
||||
instanceId: instance.id,
|
||||
instanceLabel: instance.instanceLabel,
|
||||
featureCode: instance.featureCode,
|
||||
userRoles: instance.userRoles,
|
||||
permissions: instance.permissions,
|
||||
views: instance.permissions?.views,
|
||||
viewKeys: instance.permissions?.views ? Object.keys(instance.permissions.views) : [],
|
||||
hasConversationsView: instance.permissions?.views?.['chatbot-conversations'] ||
|
||||
instance.permissions?.views?.['ui.feature.chatbot.conversations'] ||
|
||||
instance.permissions?.views?.['_all'],
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
console.log('✅ featuresApi: Loaded features:', {
|
||||
mandateCount: data?.mandates?.length || 0,
|
||||
totalInstances: data?.mandates
|
||||
?.flatMap(m => m.features)
|
||||
?.flatMap(f => f.instances)
|
||||
?.length || 0,
|
||||
});
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('❌ featuresApi: Error fetching features:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt die verfügbaren Features (für Admin - Feature-Instanz erstellen)
|
||||
*
|
||||
* Endpoint: GET /api/features/available
|
||||
*/
|
||||
export async function fetchAvailableFeatures(): Promise<MandateFeature[]> {
|
||||
if (USE_MOCK) {
|
||||
return [
|
||||
{ code: 'trustee', label: 'Treuhand', icon: 'briefcase', instances: [] },
|
||||
{ code: 'chatworkflow', label: 'Workflow', icon: 'play_circle', instances: [] },
|
||||
{ code: 'chatbot', label: 'Chatbot', icon: 'chat', instances: [] },
|
||||
];
|
||||
}
|
||||
|
||||
const response = await api.get<MandateFeature[]>('/api/features/available');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TYPE GUARDS
|
||||
// =============================================================================
|
||||
|
||||
export function isValidAccessLevel(value: string): value is AccessLevel {
|
||||
return ['n', 'm', 'g', 'a'].includes(value);
|
||||
}
|
||||
|
||||
export function isValidMandate(obj: unknown): obj is Mandate {
|
||||
if (!obj || typeof obj !== 'object') return false;
|
||||
const mandate = obj as Record<string, unknown>;
|
||||
return (
|
||||
typeof mandate.id === 'string' &&
|
||||
typeof mandate.name === 'string' &&
|
||||
Array.isArray(mandate.features)
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidFeatureInstance(obj: unknown): obj is FeatureInstance {
|
||||
if (!obj || typeof obj !== 'object') return false;
|
||||
const instance = obj as Record<string, unknown>;
|
||||
return (
|
||||
typeof instance.id === 'string' &&
|
||||
typeof instance.featureCode === 'string' &&
|
||||
typeof instance.mandateId === 'string' &&
|
||||
typeof instance.instanceLabel === 'string'
|
||||
);
|
||||
}
|
||||
|
|
@ -1,387 +0,0 @@
|
|||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES & INTERFACES
|
||||
// ============================================================================
|
||||
|
||||
export interface FileInfo {
|
||||
id: string;
|
||||
mandateId: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
fileHash: string;
|
||||
fileSize: number;
|
||||
creationDate: number;
|
||||
[key: string]: any; // Allow additional properties
|
||||
}
|
||||
|
||||
export interface AttributeDefinition {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'string' | 'number' | 'date' | 'boolean' | 'enum';
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
searchable?: boolean;
|
||||
width?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
filterOptions?: string[];
|
||||
}
|
||||
|
||||
export interface PaginationParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||
filters?: Record<string, any>;
|
||||
search?: string;
|
||||
viewKey?: string;
|
||||
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
pagination?: {
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
};
|
||||
groupLayout?: import('./connectionApi').GroupLayout;
|
||||
appliedView?: { viewKey?: string; displayName?: string };
|
||||
}
|
||||
|
||||
// Type for the request function passed to API functions
|
||||
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
||||
|
||||
// ============================================================================
|
||||
// API REQUEST FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch file attributes from backend
|
||||
* Endpoint: GET /api/attributes/FileItem
|
||||
*/
|
||||
export async function fetchFileAttributes(request: ApiRequestFunction): Promise<AttributeDefinition[]> {
|
||||
const data = await request({
|
||||
url: '/api/attributes/FileItem',
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
// Handle different response formats
|
||||
if (Array.isArray(data)) {
|
||||
return data;
|
||||
}
|
||||
if (data && typeof data === 'object' && 'attributes' in data && Array.isArray(data.attributes)) {
|
||||
return data.attributes;
|
||||
}
|
||||
|
||||
// Try to find any array property in the response
|
||||
if (data && typeof data === 'object') {
|
||||
const keys = Object.keys(data);
|
||||
for (const key of keys) {
|
||||
if (Array.isArray((data as any)[key])) {
|
||||
return (data as any)[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch list of files with optional pagination
|
||||
* Endpoint: GET /api/files/list
|
||||
*/
|
||||
export async function fetchFiles(
|
||||
request: ApiRequestFunction,
|
||||
params?: PaginationParams
|
||||
): Promise<PaginatedResponse<FileInfo> | FileInfo[]> {
|
||||
const requestParams: any = {};
|
||||
|
||||
// Build pagination object if provided
|
||||
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 (params.viewKey) paginationObj.viewKey = params.viewKey;
|
||||
if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
|
||||
|
||||
if (Object.keys(paginationObj).length > 0) {
|
||||
requestParams.pagination = JSON.stringify(paginationObj);
|
||||
}
|
||||
}
|
||||
|
||||
const data = await request({
|
||||
url: '/api/files/list',
|
||||
method: 'get',
|
||||
params: requestParams
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single file by ID
|
||||
* Endpoint: GET /api/files/{fileId}
|
||||
*/
|
||||
export async function fetchFileById(
|
||||
request: ApiRequestFunction,
|
||||
fileId: string
|
||||
): Promise<FileInfo | null> {
|
||||
try {
|
||||
const data = await request({
|
||||
url: `/api/files/${fileId}`,
|
||||
method: 'get'
|
||||
});
|
||||
return data || null;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching file by ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a file
|
||||
* Endpoint: PUT /api/files/{fileId}
|
||||
*/
|
||||
export async function updateFile(
|
||||
request: ApiRequestFunction,
|
||||
fileId: string,
|
||||
fileData: Partial<FileInfo>
|
||||
): Promise<FileInfo> {
|
||||
return await request({
|
||||
url: `/api/files/${fileId}`,
|
||||
method: 'put',
|
||||
data: fileData
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file
|
||||
* Endpoint: DELETE /api/files/{fileId}
|
||||
*/
|
||||
export async function deleteFile(
|
||||
request: ApiRequestFunction,
|
||||
fileId: string
|
||||
): Promise<void> {
|
||||
await request({
|
||||
url: `/api/files/${fileId}`,
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete multiple files
|
||||
* Endpoint: DELETE /api/files/{fileId} (called multiple times)
|
||||
*/
|
||||
export async function deleteFiles(
|
||||
request: ApiRequestFunction,
|
||||
fileIds: string[]
|
||||
): Promise<Array<{ success: boolean; fileId: string; error?: any }>> {
|
||||
const uniqueIds = [...new Set(fileIds.filter(Boolean))];
|
||||
if (uniqueIds.length === 0) return [];
|
||||
await request({
|
||||
url: '/api/files/batch-delete',
|
||||
method: 'post',
|
||||
data: { fileIds: uniqueIds }
|
||||
});
|
||||
return uniqueIds.map(fileId => ({ success: true, fileId }));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GROUP BULK API FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/** Patch scope for all files in a group (recursive) */
|
||||
export async function patchGroupScope(
|
||||
request: ApiRequestFunction,
|
||||
groupId: string,
|
||||
scope: string
|
||||
): Promise<any> {
|
||||
return await request({
|
||||
url: `/api/files/groups/${groupId}/scope`,
|
||||
method: 'patch',
|
||||
data: { scope },
|
||||
});
|
||||
}
|
||||
|
||||
/** Patch neutralize for all files in a group (recursive, incl. knowledge purge/reindex) */
|
||||
export async function patchGroupNeutralize(
|
||||
request: ApiRequestFunction,
|
||||
groupId: string,
|
||||
neutralize: boolean
|
||||
): Promise<any> {
|
||||
return await request({
|
||||
url: `/api/files/groups/${groupId}/neutralize`,
|
||||
method: 'patch',
|
||||
data: { neutralize },
|
||||
});
|
||||
}
|
||||
|
||||
/** Download all files in a group as ZIP */
|
||||
export async function downloadGroupZip(groupId: string): Promise<void> {
|
||||
const { default: api } = await import('../api');
|
||||
const response = await api.get(`/api/files/groups/${groupId}/download`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const url = window.URL.createObjectURL(response.data);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `group-${groupId}.zip`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** Delete a group and optionally all its files */
|
||||
export async function deleteGroup(
|
||||
request: ApiRequestFunction,
|
||||
groupId: string,
|
||||
deleteItems: boolean = false
|
||||
): Promise<any> {
|
||||
return await request({
|
||||
url: `/api/files/groups/${groupId}`,
|
||||
method: 'delete',
|
||||
params: { deleteItems },
|
||||
});
|
||||
}
|
||||
|
||||
/** @deprecated Group tree removed — use view-based grouping (viewKey). Returns empty array. */
|
||||
export function collectGroupItemIds(
|
||||
_groupTree: Array<{ id: string; itemIds: string[]; subGroups: any[] }>,
|
||||
_groupId: string
|
||||
): string[] {
|
||||
const collect = (): string[] | null => null;
|
||||
return collect() ?? [];
|
||||
}
|
||||
|
||||
// Note: The following operations require special handling (FormData, blob responses)
|
||||
// and should use the api instance directly from '../api' rather than the request function:
|
||||
// - uploadFile: Requires FormData with multipart/form-data
|
||||
// - downloadFile: Requires blob responseType
|
||||
// - previewFile: Requires flexible responseType (json or blob)
|
||||
// These are kept in the hooks for now due to their special requirements
|
||||
|
||||
// ============================================================================
|
||||
// FOLDER TYPES & API FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
export interface FolderInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
mandateId: string;
|
||||
featureInstanceId: string;
|
||||
scope: string;
|
||||
neutralize: boolean;
|
||||
contextOrphan?: boolean;
|
||||
sysCreatedBy?: string;
|
||||
sysCreatedAt?: number;
|
||||
sysModifiedAt?: number;
|
||||
}
|
||||
|
||||
export async function getFolderTree(
|
||||
request: ApiRequestFunction,
|
||||
owner: 'me' | 'shared' = 'me',
|
||||
): Promise<FolderInfo[]> {
|
||||
const data = await request({
|
||||
url: '/api/files/folders/tree',
|
||||
method: 'get',
|
||||
params: { owner },
|
||||
});
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
export async function createFolder(
|
||||
request: ApiRequestFunction,
|
||||
name: string,
|
||||
parentId?: string | null,
|
||||
): Promise<FolderInfo> {
|
||||
return await request({
|
||||
url: '/api/files/folders',
|
||||
method: 'post',
|
||||
data: { name, parentId: parentId ?? null },
|
||||
});
|
||||
}
|
||||
|
||||
export async function renameFolder(
|
||||
request: ApiRequestFunction,
|
||||
folderId: string,
|
||||
name: string,
|
||||
): Promise<FolderInfo> {
|
||||
return await request({
|
||||
url: `/api/files/folders/${folderId}`,
|
||||
method: 'patch',
|
||||
data: { name },
|
||||
});
|
||||
}
|
||||
|
||||
export async function moveFolder(
|
||||
request: ApiRequestFunction,
|
||||
folderId: string,
|
||||
parentId: string | null,
|
||||
): Promise<FolderInfo> {
|
||||
return await request({
|
||||
url: `/api/files/folders/${folderId}/move`,
|
||||
method: 'post',
|
||||
data: { parentId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteFolderCascade(
|
||||
request: ApiRequestFunction,
|
||||
folderId: string,
|
||||
): Promise<{ deletedFolders: number; deletedFiles: number }> {
|
||||
return await request({
|
||||
url: `/api/files/folders/${folderId}`,
|
||||
method: 'delete',
|
||||
params: { cascade: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function patchFolderScope(
|
||||
request: ApiRequestFunction,
|
||||
folderId: string,
|
||||
scope: string,
|
||||
cascadeToFiles: boolean = false,
|
||||
): Promise<{ folderId: string; scope: string; filesUpdated: number }> {
|
||||
return await request({
|
||||
url: `/api/files/folders/${folderId}/scope`,
|
||||
method: 'patch',
|
||||
data: { scope, cascadeToFiles },
|
||||
});
|
||||
}
|
||||
|
||||
export async function patchFolderNeutralize(
|
||||
request: ApiRequestFunction,
|
||||
folderId: string,
|
||||
neutralize: boolean,
|
||||
): Promise<{ folderId: string; neutralize: boolean; filesUpdated: number }> {
|
||||
return await request({
|
||||
url: `/api/files/folders/${folderId}/neutralize`,
|
||||
method: 'patch',
|
||||
data: { neutralize },
|
||||
});
|
||||
}
|
||||
|
||||
export async function moveFiles(
|
||||
request: ApiRequestFunction,
|
||||
fileIds: string[],
|
||||
targetFolderId: string | null,
|
||||
): Promise<void> {
|
||||
await Promise.all(
|
||||
fileIds.map((fileId) =>
|
||||
request({
|
||||
url: `/api/files/${fileId}`,
|
||||
method: 'put',
|
||||
data: { folderId: targetFolderId },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,186 +0,0 @@
|
|||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES & INTERFACES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Mandate (Mandant) — represents one tenant in PowerOn PORTA.
|
||||
*
|
||||
* Field semantics (must stay in sync with the backend `Mandate` Pydantic model):
|
||||
* - `id` — UUID, immutable.
|
||||
* - `name` — Kurzzeichen / slug. Globally unique, lowercase [a-z0-9] with
|
||||
* hyphen-separated segments (length 2–32). Used for audit/tracking
|
||||
* and stable references. Only PlatformAdmin can change it after
|
||||
* creation.
|
||||
* - `label` — Voller Name. Mandatory, human-readable display name shown in the
|
||||
* UI. Freely changeable by a Mandate-Admin.
|
||||
*/
|
||||
export interface Mandate {
|
||||
id: string;
|
||||
name: string;
|
||||
label: string;
|
||||
enabled?: boolean;
|
||||
isSystem?: boolean;
|
||||
deletedAt?: number | null;
|
||||
[key: string]: any; // Allow additional properties from backend
|
||||
}
|
||||
|
||||
/** Payload for creating a mandate. `label` is required, `name` is optional. */
|
||||
export interface MandateCreateData {
|
||||
label: string;
|
||||
name?: string;
|
||||
enabled?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for updating a mandate. Only PlatformAdmin may change `name`;
|
||||
* Mandate-Admin can update `label` and other UI fields.
|
||||
*/
|
||||
export type MandateUpdateData = Partial<Omit<Mandate, 'id'>>;
|
||||
|
||||
export interface PaginationParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||
filters?: Record<string, any>;
|
||||
search?: string;
|
||||
viewKey?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
pagination?: {
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Type for the request function passed to API functions
|
||||
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
||||
|
||||
// ============================================================================
|
||||
// API REQUEST FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch list of mandates with optional pagination
|
||||
* Endpoint: GET /api/mandates/
|
||||
*/
|
||||
export async function fetchMandates(
|
||||
request: ApiRequestFunction,
|
||||
params?: PaginationParams
|
||||
): Promise<PaginatedResponse<Mandate> | Mandate[]> {
|
||||
const requestParams: any = {};
|
||||
|
||||
// Build pagination object if provided
|
||||
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 (params.viewKey) paginationObj.viewKey = params.viewKey;
|
||||
|
||||
if (Object.keys(paginationObj).length > 0) {
|
||||
requestParams.pagination = JSON.stringify(paginationObj);
|
||||
}
|
||||
}
|
||||
|
||||
const data = await request({
|
||||
url: '/api/mandates/',
|
||||
method: 'get',
|
||||
params: requestParams
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single mandate by ID
|
||||
* Endpoint: GET /api/mandates/{mandateId}
|
||||
*/
|
||||
export async function fetchMandateById(
|
||||
request: ApiRequestFunction,
|
||||
mandateId: string
|
||||
): Promise<Mandate | null> {
|
||||
try {
|
||||
const data = await request({
|
||||
url: `/api/mandates/${mandateId}`,
|
||||
method: 'get'
|
||||
});
|
||||
return data || null;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching mandate by ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a mandate
|
||||
* Endpoint: PUT /api/mandates/{mandateId}
|
||||
*/
|
||||
export async function updateMandate(
|
||||
request: ApiRequestFunction,
|
||||
mandateId: string,
|
||||
updateData: MandateUpdateData
|
||||
): Promise<Mandate> {
|
||||
return await request({
|
||||
url: `/api/mandates/${mandateId}`,
|
||||
method: 'put',
|
||||
data: updateData
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new mandate
|
||||
* Endpoint: POST /api/mandates/
|
||||
*/
|
||||
export async function createMandate(
|
||||
request: ApiRequestFunction,
|
||||
mandateData: MandateCreateData | Partial<Mandate>
|
||||
): Promise<Mandate> {
|
||||
return await request({
|
||||
url: '/api/mandates/',
|
||||
method: 'post',
|
||||
data: mandateData
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a mandate (sets enabled=false, 30-day retention)
|
||||
* Endpoint: DELETE /api/mandates/{mandateId}
|
||||
*/
|
||||
export async function deleteMandate(
|
||||
request: ApiRequestFunction,
|
||||
mandateId: string
|
||||
): Promise<void> {
|
||||
await request({
|
||||
url: `/api/mandates/${mandateId}`,
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard-delete a mandate with full cascade (irreversible)
|
||||
* Endpoint: DELETE /api/mandates/{mandateId}?force=true
|
||||
*/
|
||||
export async function hardDeleteMandate(
|
||||
request: ApiRequestFunction,
|
||||
mandateId: string,
|
||||
confirmName: string
|
||||
): Promise<void> {
|
||||
await request({
|
||||
url: `/api/mandates/${mandateId}`,
|
||||
method: 'delete',
|
||||
params: { force: true },
|
||||
additionalConfig: {
|
||||
headers: { 'X-Confirm-Name': confirmName }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
/**
|
||||
* Neutralization API
|
||||
*
|
||||
* API functions for the Neutralization feature.
|
||||
* Endpoints use /api/neutralization/*. Context headers (X-Mandate-Id, X-Instance-Id)
|
||||
* are set automatically by the api interceptor when on a feature instance page.
|
||||
*/
|
||||
|
||||
import api from '../api';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface NeutralizationConfig {
|
||||
id?: string;
|
||||
mandateId: string;
|
||||
featureInstanceId: string;
|
||||
userId: string;
|
||||
enabled: boolean;
|
||||
namesToParse: string;
|
||||
sharepointSourcePath: string;
|
||||
sharepointTargetPath: string;
|
||||
}
|
||||
|
||||
export interface NeutralizationResult {
|
||||
neutralized_text?: string;
|
||||
neutralized_bytes?: string | Uint8Array;
|
||||
neutralized_file_base64?: string;
|
||||
neutralized_file_name?: string;
|
||||
mime_type?: string;
|
||||
original_file_id?: string;
|
||||
neutralized_file_id?: string;
|
||||
mapping?: Record<string, string>;
|
||||
attributes?: Array<{
|
||||
id: string;
|
||||
originalText: string;
|
||||
patternType: string;
|
||||
fileId?: string;
|
||||
}>;
|
||||
processed_info?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface NeutralizationAttribute {
|
||||
id: string;
|
||||
mandateId: string;
|
||||
featureInstanceId: string;
|
||||
userId: string;
|
||||
originalText: string;
|
||||
fileId?: string;
|
||||
patternType: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
export async function getNeutralizationConfig(): Promise<NeutralizationConfig> {
|
||||
const { data } = await api.get<NeutralizationConfig>('/api/neutralization/config');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function saveNeutralizationConfig(
|
||||
configData: Partial<NeutralizationConfig> & { enabled: boolean; namesToParse: string; sharepointSourcePath: string; sharepointTargetPath: string }
|
||||
): Promise<NeutralizationConfig> {
|
||||
const { data } = await api.post<NeutralizationConfig>('/api/neutralization/config', configData);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function neutralizeText(text: string, fileId?: string): Promise<NeutralizationResult> {
|
||||
const { data } = await api.post<NeutralizationResult>('/api/neutralization/neutralize-text', {
|
||||
text,
|
||||
...(fileId && { fileId }),
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function resolveText(text: string): Promise<{ resolved_text: string }> {
|
||||
const { data } = await api.post<{ resolved_text: string }>('/api/neutralization/resolve-text', {
|
||||
text,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getNeutralizationAttributes(fileId?: string): Promise<NeutralizationAttribute[]> {
|
||||
const params = fileId ? { fileId } : {};
|
||||
const { data } = await api.get<NeutralizationAttribute[]>('/api/neutralization/attributes', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function neutralizeFile(file: File): Promise<NeutralizationResult> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
// Do NOT set Content-Type - axios sets it with boundary for FormData
|
||||
const { data } = await api.post<NeutralizationResult>('/api/neutralization/neutralize-file', formData);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function processSharepointFiles(
|
||||
sourcePath: string,
|
||||
targetPath: string
|
||||
): Promise<{ success: boolean; message?: string; [key: string]: unknown }> {
|
||||
const { data } = await api.post('/api/neutralization/process-sharepoint', {
|
||||
sourcePath,
|
||||
targetPath,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES & INTERFACES
|
||||
// ============================================================================
|
||||
|
||||
export type PermissionLevel = 'n' | 'o' | 'a';
|
||||
|
||||
export interface UserPermissions {
|
||||
view: boolean;
|
||||
read: PermissionLevel;
|
||||
create: PermissionLevel;
|
||||
update: PermissionLevel;
|
||||
delete: PermissionLevel;
|
||||
}
|
||||
|
||||
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>;
|
||||
|
||||
// ============================================================================
|
||||
// API REQUEST FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch permissions for a given context and item
|
||||
* Endpoint: GET /api/rbac/permissions
|
||||
* Query params: context (required), item (optional)
|
||||
*/
|
||||
export async function fetchPermissions(
|
||||
request: ApiRequestFunction,
|
||||
context: PermissionContext,
|
||||
item?: string
|
||||
): Promise<UserPermissions> {
|
||||
const params: Record<string, string> = { context };
|
||||
if (item) {
|
||||
params.item = item;
|
||||
}
|
||||
|
||||
const data = await request({
|
||||
url: '/api/rbac/permissions',
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -1,198 +0,0 @@
|
|||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES & INTERFACES
|
||||
// ============================================================================
|
||||
|
||||
export interface Prompt {
|
||||
id: string;
|
||||
mandateId: string;
|
||||
content: string;
|
||||
name: string;
|
||||
sysCreatedBy?: string;
|
||||
_hideDelete?: boolean;
|
||||
[key: string]: any; // Allow additional properties
|
||||
}
|
||||
|
||||
export interface AttributeOption {
|
||||
value: string | number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface AttributeDefinition {
|
||||
name: string;
|
||||
type: 'text' | 'email' | 'date' | 'checkbox' | 'select' | 'multiselect' | 'number' | 'textarea';
|
||||
label: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
default?: any;
|
||||
options?: AttributeOption[] | string;
|
||||
validation?: any;
|
||||
ui?: any;
|
||||
readonly?: boolean;
|
||||
editable?: boolean;
|
||||
visible?: boolean;
|
||||
order?: number;
|
||||
placeholder?: string;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
searchable?: boolean;
|
||||
width?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
filterOptions?: string[];
|
||||
}
|
||||
|
||||
export interface PaginationParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||
filters?: Record<string, any>;
|
||||
search?: string;
|
||||
viewKey?: string;
|
||||
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
pagination?: {
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
};
|
||||
groupLayout?: import('./connectionApi').GroupLayout;
|
||||
appliedView?: { viewKey?: string; displayName?: string };
|
||||
}
|
||||
|
||||
export interface CreatePromptData {
|
||||
name: string;
|
||||
content: string;
|
||||
// mandateId wird nicht mehr vom Client gesendet
|
||||
// Das Backend bestimmt den Kontext über die instanceId
|
||||
}
|
||||
|
||||
export interface UpdatePromptData {
|
||||
name: string;
|
||||
content: string;
|
||||
// mandateId wird nicht mehr vom Client gesendet
|
||||
}
|
||||
|
||||
// Type for the request function passed to API functions
|
||||
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
||||
|
||||
// ============================================================================
|
||||
// API REQUEST FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch prompt attributes from backend
|
||||
* Endpoint: GET /api/attributes/Prompt
|
||||
*/
|
||||
export async function fetchPromptAttributes(_request: ApiRequestFunction): Promise<AttributeDefinition[]> {
|
||||
// Note: This uses api.get directly due to response format handling
|
||||
// For now, we'll use api.get directly in the hook as well
|
||||
throw new Error('fetchPromptAttributes should use api instance directly for response format handling');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch list of prompts with optional pagination
|
||||
* Endpoint: GET /api/prompts
|
||||
*/
|
||||
export async function fetchPrompts(
|
||||
request: ApiRequestFunction,
|
||||
params?: PaginationParams
|
||||
): Promise<PaginatedResponse<Prompt> | Prompt[]> {
|
||||
const requestParams: any = {};
|
||||
|
||||
// Build pagination object if provided
|
||||
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 (params.viewKey) paginationObj.viewKey = params.viewKey;
|
||||
if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
|
||||
|
||||
if (Object.keys(paginationObj).length > 0) {
|
||||
requestParams.pagination = JSON.stringify(paginationObj);
|
||||
}
|
||||
}
|
||||
|
||||
const data = await request({
|
||||
url: '/api/prompts',
|
||||
method: 'get',
|
||||
params: requestParams
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single prompt by ID
|
||||
* Endpoint: GET /api/prompts/{promptId}
|
||||
*/
|
||||
export async function fetchPromptById(
|
||||
request: ApiRequestFunction,
|
||||
promptId: string
|
||||
): Promise<Prompt | null> {
|
||||
try {
|
||||
const data = await request({
|
||||
url: `/api/prompts/${promptId}`,
|
||||
method: 'get'
|
||||
});
|
||||
return data || null;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching prompt by ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new prompt
|
||||
* Endpoint: POST /api/prompts
|
||||
*/
|
||||
export async function createPrompt(
|
||||
request: ApiRequestFunction,
|
||||
promptData: CreatePromptData
|
||||
): Promise<Prompt> {
|
||||
return await request({
|
||||
url: '/api/prompts',
|
||||
method: 'post',
|
||||
data: promptData
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a prompt
|
||||
* Endpoint: PUT /api/prompts/{promptId}
|
||||
*/
|
||||
export async function updatePrompt(
|
||||
request: ApiRequestFunction,
|
||||
promptId: string,
|
||||
promptData: UpdatePromptData
|
||||
): Promise<Prompt> {
|
||||
return await request({
|
||||
url: `/api/prompts/${promptId}`,
|
||||
method: 'put',
|
||||
data: promptData
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a prompt
|
||||
* Endpoint: DELETE /api/prompts/{promptId}
|
||||
*/
|
||||
export async function deletePrompt(
|
||||
request: ApiRequestFunction,
|
||||
promptId: string
|
||||
): Promise<void> {
|
||||
await request({
|
||||
url: `/api/prompts/${promptId}`,
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES & INTERFACES
|
||||
// ============================================================================
|
||||
|
||||
export interface RbacRule {
|
||||
id: string;
|
||||
[key: string]: any; // Allow additional properties from backend
|
||||
}
|
||||
|
||||
export type RbacRuleUpdateData = Partial<Omit<RbacRule, 'id'>>;
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
// Type for the request function passed to API functions
|
||||
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
||||
|
||||
// ============================================================================
|
||||
// API REQUEST FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch list of RBAC rules with optional pagination
|
||||
* Endpoint: GET /api/rbac/rules
|
||||
*/
|
||||
export async function fetchRbacRules(
|
||||
request: ApiRequestFunction,
|
||||
params?: PaginationParams
|
||||
): Promise<PaginatedResponse<RbacRule> | RbacRule[]> {
|
||||
const requestParams: any = {};
|
||||
|
||||
// Build pagination object if provided
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const data = await request({
|
||||
url: '/api/rbac/rules',
|
||||
method: 'get',
|
||||
params: requestParams
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single RBAC rule by ID
|
||||
* Endpoint: GET /api/rbac/rules/{ruleId}
|
||||
*/
|
||||
export async function fetchRbacRuleById(
|
||||
request: ApiRequestFunction,
|
||||
ruleId: string
|
||||
): Promise<RbacRule | null> {
|
||||
try {
|
||||
const data = await request({
|
||||
url: `/api/rbac/rules/${ruleId}`,
|
||||
method: 'get'
|
||||
});
|
||||
return data || null;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching RBAC rule by ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a RBAC rule
|
||||
* Endpoint: PUT /api/rbac/rules/{ruleId}
|
||||
*/
|
||||
export async function updateRbacRule(
|
||||
request: ApiRequestFunction,
|
||||
ruleId: string,
|
||||
updateData: RbacRuleUpdateData
|
||||
): Promise<RbacRule> {
|
||||
return await request({
|
||||
url: `/api/rbac/rules/${ruleId}`,
|
||||
method: 'put',
|
||||
data: updateData
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new RBAC rule
|
||||
* Endpoint: POST /api/rbac/rules
|
||||
*/
|
||||
export async function createRbacRule(
|
||||
request: ApiRequestFunction,
|
||||
ruleData: Partial<RbacRule>
|
||||
): Promise<RbacRule> {
|
||||
return await request({
|
||||
url: '/api/rbac/rules',
|
||||
method: 'post',
|
||||
data: ruleData
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a RBAC rule
|
||||
* Endpoint: DELETE /api/rbac/rules/{ruleId}
|
||||
*/
|
||||
export async function deleteRbacRule(
|
||||
request: ApiRequestFunction,
|
||||
ruleId: string
|
||||
): Promise<void> {
|
||||
await request({
|
||||
url: `/api/rbac/rules/${ruleId}`,
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
|
|
@ -1,313 +0,0 @@
|
|||
import api from '../api';
|
||||
import type { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES & INTERFACES
|
||||
// ============================================================================
|
||||
|
||||
export interface AddressSuggestion {
|
||||
label: string;
|
||||
value: string;
|
||||
coordinates?: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Real Estate Project (Projekt). Backend-driven CRUD uses instanceId. */
|
||||
export interface RealEstateProject {
|
||||
id: string;
|
||||
label: string;
|
||||
statusProzess?: string;
|
||||
mandateId?: string;
|
||||
featureInstanceId?: string;
|
||||
perimeter?: any;
|
||||
parzellen?: RealEstateParcel[];
|
||||
sysCreatedAt?: number;
|
||||
sysModifiedAt?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/** Real Estate Parcel (Parzelle). */
|
||||
export interface RealEstateParcel {
|
||||
id: string;
|
||||
label?: string;
|
||||
mandateId?: string;
|
||||
featureInstanceId?: string;
|
||||
strasseNr?: string;
|
||||
plz?: string;
|
||||
perimeter?: any;
|
||||
bauzone?: string;
|
||||
sysCreatedAt?: number;
|
||||
sysModifiedAt?: number;
|
||||
[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;
|
||||
sort?: Array<{ field: string; direction: string }>;
|
||||
filters?: Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS (instanceId-based CRUD)
|
||||
// ============================================================================
|
||||
|
||||
function _getRealEstateBaseUrl(instanceId: string): string {
|
||||
return `/api/realestate/${instanceId}`;
|
||||
}
|
||||
|
||||
function _buildPaginationParams(params?: PaginationParams): Record<string, string | number | boolean> {
|
||||
if (!params) return {};
|
||||
const paginationObj: Record<string, unknown> = {};
|
||||
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) return {};
|
||||
return { pagination: JSON.stringify(paginationObj) } as Record<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PROJECTS CRUD (instanceId-based)
|
||||
// ============================================================================
|
||||
|
||||
export async function fetchProjects(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
params?: PaginationParams
|
||||
): Promise<PaginatedResponse<RealEstateProject>> {
|
||||
return await request({
|
||||
url: `${_getRealEstateBaseUrl(instanceId)}/projects`,
|
||||
method: 'get',
|
||||
params: _buildPaginationParams(params)
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchProjectById(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
id: string
|
||||
): Promise<RealEstateProject | null> {
|
||||
try {
|
||||
return await request({
|
||||
url: `${_getRealEstateBaseUrl(instanceId)}/projects/${id}`,
|
||||
method: 'get'
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createProject(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
data: Partial<RealEstateProject>
|
||||
): Promise<RealEstateProject> {
|
||||
return await request({
|
||||
url: `${_getRealEstateBaseUrl(instanceId)}/projects`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateProject(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
id: string,
|
||||
data: Partial<RealEstateProject>
|
||||
): Promise<RealEstateProject> {
|
||||
return await request({
|
||||
url: `${_getRealEstateBaseUrl(instanceId)}/projects/${id}`,
|
||||
method: 'put',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteProject(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
await request({
|
||||
url: `${_getRealEstateBaseUrl(instanceId)}/projects/${id}`,
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PARCELS CRUD (instanceId-based)
|
||||
// ============================================================================
|
||||
|
||||
export async function fetchParcels(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
params?: PaginationParams
|
||||
): Promise<PaginatedResponse<RealEstateParcel>> {
|
||||
return await request({
|
||||
url: `${_getRealEstateBaseUrl(instanceId)}/parcels`,
|
||||
method: 'get',
|
||||
params: _buildPaginationParams(params)
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchParcelById(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
id: string
|
||||
): Promise<RealEstateParcel | null> {
|
||||
try {
|
||||
return await request({
|
||||
url: `${_getRealEstateBaseUrl(instanceId)}/parcels/${id}`,
|
||||
method: 'get'
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createParcel(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
data: Partial<RealEstateParcel>
|
||||
): Promise<RealEstateParcel> {
|
||||
return await request({
|
||||
url: `${_getRealEstateBaseUrl(instanceId)}/parcels`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateParcel(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
id: string,
|
||||
data: Partial<RealEstateParcel>
|
||||
): Promise<RealEstateParcel> {
|
||||
return await request({
|
||||
url: `${_getRealEstateBaseUrl(instanceId)}/parcels/${id}`,
|
||||
method: 'put',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteParcel(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
await request({
|
||||
url: `${_getRealEstateBaseUrl(instanceId)}/parcels/${id}`,
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ADDRESS AUTOCOMPLETE (legacy, no instanceId)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get address autocomplete suggestions for Swiss addresses
|
||||
* Endpoint: GET /api/realestate/address/autocomplete
|
||||
*
|
||||
* @param query - Search text (minimum 2 characters)
|
||||
* @param limit - Maximum number of results (default: 10, max: 20)
|
||||
* @returns Array of address suggestions
|
||||
*/
|
||||
export async function autocompleteAddress(
|
||||
query: string,
|
||||
limit: number = 10
|
||||
): Promise<AddressSuggestion[]> {
|
||||
if (query.length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const trimmedQuery = query.trim();
|
||||
const requestParams = {
|
||||
query: trimmedQuery,
|
||||
limit: Math.min(Math.max(limit, 1), 20) // Clamp between 1 and 20
|
||||
};
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🔍 [AddressAutocomplete] Requesting suggestions:', {
|
||||
query: trimmedQuery,
|
||||
limit: requestParams.limit,
|
||||
url: '/api/realestate/address/autocomplete'
|
||||
});
|
||||
}
|
||||
|
||||
const response = await api.get<AddressSuggestion[]>('/api/realestate/address/autocomplete', {
|
||||
params: requestParams
|
||||
});
|
||||
|
||||
const results = response.data || [];
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('✅ [AddressAutocomplete] Received suggestions:', {
|
||||
count: results.length,
|
||||
results: results.slice(0, 3) // Log first 3 for debugging
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error: any) {
|
||||
// Detailed error logging
|
||||
const errorDetails: any = {
|
||||
message: error?.message || 'Unknown error',
|
||||
query: query.trim(),
|
||||
limit: limit
|
||||
};
|
||||
|
||||
if (error?.response) {
|
||||
// HTTP error response
|
||||
errorDetails.status = error.response.status;
|
||||
errorDetails.statusText = error.response.statusText;
|
||||
errorDetails.data = error.response.data;
|
||||
errorDetails.headers = error.response.headers;
|
||||
|
||||
console.error('❌ [AddressAutocomplete] API Error Response:', {
|
||||
status: errorDetails.status,
|
||||
statusText: errorDetails.statusText,
|
||||
detail: errorDetails.data?.detail || errorDetails.data,
|
||||
url: error.config?.url,
|
||||
method: error.config?.method
|
||||
});
|
||||
} else if (error?.request) {
|
||||
// Request made but no response received
|
||||
errorDetails.requestError = true;
|
||||
console.error('❌ [AddressAutocomplete] Network Error - No response received:', {
|
||||
message: error.message,
|
||||
url: error.config?.url
|
||||
});
|
||||
} else {
|
||||
// Error setting up request
|
||||
console.error('❌ [AddressAutocomplete] Request Setup Error:', errorDetails);
|
||||
}
|
||||
|
||||
// Log full error in dev mode
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('❌ [AddressAutocomplete] Full error object:', error);
|
||||
}
|
||||
|
||||
// Return empty array on error to allow graceful degradation
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,398 +0,0 @@
|
|||
/**
|
||||
* Redmine API
|
||||
*
|
||||
* Frontend client for the Redmine feature backend.
|
||||
* URL pattern: /api/redmine/{instanceId}/...
|
||||
*/
|
||||
|
||||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
// Types -- mirror gateway/modules/features/redmine/datamodelRedmine.py
|
||||
// ============================================================================
|
||||
|
||||
export interface RedmineConfigDto {
|
||||
id?: string;
|
||||
featureInstanceId: string;
|
||||
mandateId?: string | null;
|
||||
baseUrl: string;
|
||||
projectId: string;
|
||||
hasApiKey: boolean;
|
||||
rootTrackerName: string;
|
||||
defaultPeriodValue?: Record<string, any> | null;
|
||||
schemaCacheTtlSeconds: number;
|
||||
schemaCachedAt?: number | null;
|
||||
isActive: boolean;
|
||||
lastConnectedAt?: number | null;
|
||||
lastSyncAt?: number | null;
|
||||
lastFullSyncAt?: number | null;
|
||||
lastSyncTicketCount?: number | null;
|
||||
lastSyncErrorMessage?: string | null;
|
||||
}
|
||||
|
||||
export interface RedmineConfigUpdateRequest {
|
||||
baseUrl?: string;
|
||||
projectId?: string;
|
||||
apiKey?: string;
|
||||
rootTrackerName?: string;
|
||||
defaultPeriodValue?: Record<string, any> | null;
|
||||
schemaCacheTtlSeconds?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface RedmineFieldChoice {
|
||||
id: number;
|
||||
name: string;
|
||||
isClosed?: boolean | null;
|
||||
}
|
||||
|
||||
export interface RedmineCustomFieldSchema {
|
||||
id: number;
|
||||
name: string;
|
||||
fieldFormat: string;
|
||||
isRequired: boolean;
|
||||
possibleValues: string[];
|
||||
multiple: boolean;
|
||||
defaultValue?: string | null;
|
||||
}
|
||||
|
||||
export interface RedmineFieldSchema {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
trackers: RedmineFieldChoice[];
|
||||
statuses: RedmineFieldChoice[];
|
||||
priorities: RedmineFieldChoice[];
|
||||
users: RedmineFieldChoice[];
|
||||
categories: RedmineFieldChoice[];
|
||||
customFields: RedmineCustomFieldSchema[];
|
||||
rootTrackerName: string;
|
||||
rootTrackerId: number | null;
|
||||
}
|
||||
|
||||
export interface RedmineRelation {
|
||||
id: number;
|
||||
issueId: number;
|
||||
issueToId: number;
|
||||
relationType: string;
|
||||
delay?: number | null;
|
||||
}
|
||||
|
||||
export interface RedmineCustomFieldValue {
|
||||
id: number;
|
||||
name: string;
|
||||
value: any;
|
||||
}
|
||||
|
||||
export interface RedmineTicket {
|
||||
id: number;
|
||||
subject: string;
|
||||
description: string;
|
||||
trackerId?: number | null;
|
||||
trackerName?: string | null;
|
||||
statusId?: number | null;
|
||||
statusName?: string | null;
|
||||
isClosed: boolean;
|
||||
priorityId?: number | null;
|
||||
priorityName?: string | null;
|
||||
assignedToId?: number | null;
|
||||
assignedToName?: string | null;
|
||||
authorId?: number | null;
|
||||
authorName?: string | null;
|
||||
parentId?: number | null;
|
||||
fixedVersionId?: number | null;
|
||||
fixedVersionName?: string | null;
|
||||
categoryId?: number | null;
|
||||
categoryName?: string | null;
|
||||
createdOn?: string | null;
|
||||
updatedOn?: string | null;
|
||||
customFields: RedmineCustomFieldValue[];
|
||||
relations: RedmineRelation[];
|
||||
}
|
||||
|
||||
export interface RedmineSyncResult {
|
||||
instanceId: string;
|
||||
full: boolean;
|
||||
ticketsUpserted: number;
|
||||
relationsUpserted: number;
|
||||
durationMs: number;
|
||||
lastSyncAt: number;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface RedmineSyncStatus {
|
||||
instanceId: string;
|
||||
lastSyncAt?: number | null;
|
||||
lastFullSyncAt?: number | null;
|
||||
lastSyncDurationMs?: number | null;
|
||||
lastSyncTicketCount?: number | null;
|
||||
lastSyncErrorAt?: number | null;
|
||||
lastSyncErrorMessage?: string | null;
|
||||
mirroredTicketCount: number;
|
||||
mirroredRelationCount: number;
|
||||
}
|
||||
|
||||
export interface RedmineConnectionTestResult {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
message?: string;
|
||||
status?: number;
|
||||
user?: { id: number; name: string };
|
||||
project?: { id: number; name: string };
|
||||
}
|
||||
|
||||
export interface RedmineStats {
|
||||
instanceId: string;
|
||||
dateFrom?: string | null;
|
||||
dateTo?: string | null;
|
||||
bucket: string;
|
||||
trackerIds: number[];
|
||||
categoryIds: number[];
|
||||
statusFilter: string;
|
||||
kpis: {
|
||||
total: number;
|
||||
open: number;
|
||||
closed: number;
|
||||
closedInPeriod: number;
|
||||
createdInPeriod: number;
|
||||
orphans: number;
|
||||
};
|
||||
statusByTracker: Array<{
|
||||
trackerId?: number | null;
|
||||
trackerName: string;
|
||||
countsByStatus: Record<string, number>;
|
||||
total: number;
|
||||
}>;
|
||||
throughput: Array<{
|
||||
bucketKey: string;
|
||||
label: string;
|
||||
created: number;
|
||||
closed: number;
|
||||
cumTotal: number;
|
||||
cumOpen: number;
|
||||
}>;
|
||||
topAssignees: Array<{
|
||||
assignedToId?: number | null;
|
||||
name: string;
|
||||
open: number;
|
||||
}>;
|
||||
relationDistribution: Array<{ relationType: string; count: number }>;
|
||||
backlogAging: Array<{
|
||||
bucketKey: string;
|
||||
label: string;
|
||||
minDays: number;
|
||||
maxDays?: number | null;
|
||||
count: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
||||
|
||||
const _baseUrl = (instanceId: string): string => `/api/redmine/${instanceId}`;
|
||||
|
||||
// ============================================================================
|
||||
// Config
|
||||
// ============================================================================
|
||||
|
||||
export async function getRedmineConfigApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
): Promise<RedmineConfigDto> {
|
||||
return await request({ url: `${_baseUrl(instanceId)}/config`, method: 'get' });
|
||||
}
|
||||
|
||||
export async function updateRedmineConfigApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
body: RedmineConfigUpdateRequest,
|
||||
): Promise<RedmineConfigDto> {
|
||||
return await request({ url: `${_baseUrl(instanceId)}/config`, method: 'put', data: body });
|
||||
}
|
||||
|
||||
export async function deleteRedmineConfigApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
): Promise<{ deleted: boolean }> {
|
||||
return await request({ url: `${_baseUrl(instanceId)}/config`, method: 'delete' });
|
||||
}
|
||||
|
||||
export async function testRedmineConnectionApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
): Promise<RedmineConnectionTestResult> {
|
||||
return await request({ url: `${_baseUrl(instanceId)}/config/test`, method: 'post' });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Schema
|
||||
// ============================================================================
|
||||
|
||||
export async function getRedmineSchemaApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
forceRefresh = false,
|
||||
): Promise<RedmineFieldSchema> {
|
||||
return await request({
|
||||
url: `${_baseUrl(instanceId)}/schema`,
|
||||
method: 'get',
|
||||
params: forceRefresh ? { forceRefresh: true } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sync
|
||||
// ============================================================================
|
||||
|
||||
export async function runRedmineSyncApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
force = false,
|
||||
): Promise<RedmineSyncResult> {
|
||||
return await request({
|
||||
url: `${_baseUrl(instanceId)}/sync`,
|
||||
method: 'post',
|
||||
params: force ? { force: true } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRedmineSyncStatusApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
): Promise<RedmineSyncStatus> {
|
||||
return await request({ url: `${_baseUrl(instanceId)}/sync/status`, method: 'get' });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tickets
|
||||
// ============================================================================
|
||||
|
||||
export interface ListTicketsParams {
|
||||
trackerIds?: number[];
|
||||
status?: 'open' | 'closed' | '*';
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
assignedToId?: number;
|
||||
}
|
||||
|
||||
export async function listRedmineTicketsApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
params: ListTicketsParams = {},
|
||||
): Promise<RedmineTicket[]> {
|
||||
const queryParams: Record<string, any> = {};
|
||||
if (params.status) queryParams.status = params.status;
|
||||
if (params.dateFrom) queryParams.dateFrom = params.dateFrom;
|
||||
if (params.dateTo) queryParams.dateTo = params.dateTo;
|
||||
if (params.assignedToId !== undefined) queryParams.assignedToId = params.assignedToId;
|
||||
if (params.trackerIds && params.trackerIds.length > 0) queryParams.trackerIds = params.trackerIds;
|
||||
return await request({
|
||||
url: `${_baseUrl(instanceId)}/tickets`,
|
||||
method: 'get',
|
||||
params: queryParams,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRedmineTicketApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
issueId: number,
|
||||
): Promise<RedmineTicket> {
|
||||
return await request({
|
||||
url: `${_baseUrl(instanceId)}/tickets/${issueId}`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
export interface RedmineTicketUpdateBody {
|
||||
subject?: string;
|
||||
description?: string;
|
||||
trackerId?: number;
|
||||
statusId?: number;
|
||||
priorityId?: number;
|
||||
assignedToId?: number;
|
||||
parentIssueId?: number;
|
||||
fixedVersionId?: number;
|
||||
notes?: string;
|
||||
customFields?: Record<number, any>;
|
||||
}
|
||||
|
||||
export async function updateRedmineTicketApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
issueId: number,
|
||||
body: RedmineTicketUpdateBody,
|
||||
): Promise<RedmineTicket> {
|
||||
return await request({
|
||||
url: `${_baseUrl(instanceId)}/tickets/${issueId}`,
|
||||
method: 'put',
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
|
||||
export interface RedmineTicketCreateBody {
|
||||
subject: string;
|
||||
trackerId: number;
|
||||
description?: string;
|
||||
statusId?: number;
|
||||
priorityId?: number;
|
||||
assignedToId?: number;
|
||||
parentIssueId?: number;
|
||||
fixedVersionId?: number;
|
||||
customFields?: Record<number, any>;
|
||||
}
|
||||
|
||||
export async function createRedmineTicketApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
body: RedmineTicketCreateBody,
|
||||
): Promise<RedmineTicket> {
|
||||
return await request({
|
||||
url: `${_baseUrl(instanceId)}/tickets`,
|
||||
method: 'post',
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteRedmineTicketApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
issueId: number,
|
||||
fallbackStatusId?: number,
|
||||
): Promise<{ deleted: boolean; archived: boolean; statusId: number | null }> {
|
||||
return await request({
|
||||
url: `${_baseUrl(instanceId)}/tickets/${issueId}`,
|
||||
method: 'delete',
|
||||
params: fallbackStatusId !== undefined ? { fallbackStatusId } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Stats
|
||||
// ============================================================================
|
||||
|
||||
export interface RedmineStatsParams {
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
bucket?: 'day' | 'week' | 'month';
|
||||
trackerIds?: number[];
|
||||
categoryIds?: number[];
|
||||
statusFilter?: '*' | 'open' | 'closed';
|
||||
}
|
||||
|
||||
export async function getRedmineStatsApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
params: RedmineStatsParams = {},
|
||||
): Promise<RedmineStats> {
|
||||
const queryParams: Record<string, any> = {};
|
||||
if (params.dateFrom) queryParams.dateFrom = params.dateFrom;
|
||||
if (params.dateTo) queryParams.dateTo = params.dateTo;
|
||||
if (params.bucket) queryParams.bucket = params.bucket;
|
||||
if (params.trackerIds && params.trackerIds.length > 0) queryParams.trackerIds = params.trackerIds;
|
||||
if (params.categoryIds && params.categoryIds.length > 0) queryParams.categoryIds = params.categoryIds;
|
||||
if (params.statusFilter && params.statusFilter !== '*') queryParams.statusFilter = params.statusFilter;
|
||||
return await request({
|
||||
url: `${_baseUrl(instanceId)}/stats`,
|
||||
method: 'get',
|
||||
params: queryParams,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,182 +0,0 @@
|
|||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES & INTERFACES
|
||||
// ============================================================================
|
||||
|
||||
export interface Role {
|
||||
id: string;
|
||||
[key: string]: any; // Allow additional properties from backend
|
||||
}
|
||||
|
||||
export type RoleUpdateData = Partial<Omit<Role, 'id'>>;
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
// Type for the request function passed to API functions
|
||||
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
||||
|
||||
// ============================================================================
|
||||
// API REQUEST FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch list of roles with optional pagination
|
||||
* Endpoint: GET /api/rbac/roles
|
||||
* Query parameter: pagination (optional, JSON-encoded string)
|
||||
* Example: /api/rbac/roles?pagination={"page":1,"pageSize":10}
|
||||
*/
|
||||
export async function fetchRoles(
|
||||
request: ApiRequestFunction,
|
||||
params?: PaginationParams
|
||||
): Promise<PaginatedResponse<Role> | Role[]> {
|
||||
const requestParams: any = {};
|
||||
|
||||
// Build pagination object if provided
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const data = await request({
|
||||
url: '/api/rbac/roles',
|
||||
method: 'get',
|
||||
params: requestParams
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single role by ID
|
||||
* Endpoint: GET /api/rbac/roles/{roleId}
|
||||
*/
|
||||
export async function fetchRoleById(
|
||||
request: ApiRequestFunction,
|
||||
roleId: string
|
||||
): Promise<Role | null> {
|
||||
try {
|
||||
const data = await request({
|
||||
url: `/api/rbac/roles/${roleId}`,
|
||||
method: 'get'
|
||||
});
|
||||
return data || null;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching role by ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new role
|
||||
* Endpoint: POST /api/rbac/roles
|
||||
* Request body: Role object
|
||||
* Required fields:
|
||||
* - roleLabel: string (e.g., "admin", "user")
|
||||
* - description: TextMultilingual object (at least en is required)
|
||||
*/
|
||||
export async function createRole(
|
||||
request: ApiRequestFunction,
|
||||
roleData: Partial<Role>
|
||||
): Promise<Role> {
|
||||
console.log('🔵 createRole - Complete request structure:', {
|
||||
url: '/api/rbac/roles',
|
||||
method: 'post',
|
||||
requestOptions: {
|
||||
url: '/api/rbac/roles',
|
||||
method: 'post',
|
||||
data: roleData
|
||||
},
|
||||
roleData: roleData,
|
||||
roleDataKeys: Object.keys(roleData || {}),
|
||||
roleDataValues: Object.entries(roleData || {}).map(([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
type: typeof value,
|
||||
isObject: typeof value === 'object' && value !== null,
|
||||
isArray: Array.isArray(value),
|
||||
stringified: typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value)
|
||||
})),
|
||||
dataStringified: JSON.stringify(roleData, null, 2),
|
||||
dataStringifiedCompact: JSON.stringify(roleData)
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await request({
|
||||
url: '/api/rbac/roles',
|
||||
method: 'post',
|
||||
data: roleData
|
||||
});
|
||||
|
||||
console.log('✅ createRole - Response:', result);
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
console.error('❌ createRole - Request failed:', {
|
||||
error,
|
||||
errorMessage: error.message,
|
||||
errorResponse: error.response,
|
||||
errorResponseData: error.response?.data,
|
||||
errorResponseStatus: error.response?.status,
|
||||
errorResponseHeaders: error.response?.headers,
|
||||
errorRequest: error.request,
|
||||
errorConfig: error.config
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a role
|
||||
* Endpoint: PUT /api/rbac/roles/{roleId}
|
||||
*/
|
||||
export async function updateRole(
|
||||
request: ApiRequestFunction,
|
||||
roleId: string,
|
||||
updateData: RoleUpdateData
|
||||
): Promise<Role> {
|
||||
return await request({
|
||||
url: `/api/rbac/roles/${roleId}`,
|
||||
method: 'put',
|
||||
data: updateData
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a role
|
||||
* Endpoint: DELETE /api/rbac/roles/{roleId}
|
||||
*/
|
||||
export async function deleteRole(
|
||||
request: ApiRequestFunction,
|
||||
roleId: string
|
||||
): Promise<void> {
|
||||
await request({
|
||||
url: `/api/rbac/roles/${roleId}`,
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
/**
|
||||
* Store API
|
||||
*
|
||||
* API layer for the Feature Store.
|
||||
* Manages feature activation/deactivation in the root mandate's shared instances.
|
||||
*/
|
||||
|
||||
import api from '../api';
|
||||
|
||||
export interface StoreFeatureInstance {
|
||||
instanceId: string;
|
||||
mandateId: string;
|
||||
mandateName: string;
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface StoreFeature {
|
||||
featureCode: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
instances: StoreFeatureInstance[];
|
||||
canActivate: boolean;
|
||||
}
|
||||
|
||||
export interface StoreActivateResponse {
|
||||
featureCode: string;
|
||||
instanceId: string;
|
||||
featureAccessId: string;
|
||||
roleId: string | null;
|
||||
activated: boolean;
|
||||
}
|
||||
|
||||
export interface StoreDeactivateResponse {
|
||||
featureCode: string;
|
||||
instanceId: string;
|
||||
deactivated: boolean;
|
||||
}
|
||||
|
||||
export interface UserMandate {
|
||||
id: string;
|
||||
name: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SubscriptionInfo {
|
||||
plan: string | null;
|
||||
status: string | null;
|
||||
maxDataVolumeMB: number | null;
|
||||
maxFeatureInstances: number | null;
|
||||
includedModules: number;
|
||||
budgetAiCHF: number | null;
|
||||
budgetAiPerUserCHF: number | null;
|
||||
currentFeatureInstances: number;
|
||||
trialEndsAt: string | null;
|
||||
}
|
||||
|
||||
export async function fetchStoreFeatures(): Promise<StoreFeature[]> {
|
||||
const response = await api.get<StoreFeature[]>('/api/store/features');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function fetchUserMandates(): Promise<UserMandate[]> {
|
||||
const response = await api.get<UserMandate[]>('/api/store/mandates');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function fetchSubscriptionInfo(mandateId?: string): Promise<SubscriptionInfo> {
|
||||
const params = mandateId ? { mandateId } : {};
|
||||
const response = await api.get<SubscriptionInfo>('/api/store/subscription-info', { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function activateStoreFeature(featureCode: string, mandateId?: string): Promise<StoreActivateResponse> {
|
||||
const response = await api.post<StoreActivateResponse>('/api/store/activate', { featureCode, mandateId });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deactivateStoreFeature(featureCode: string, mandateId: string, instanceId: string): Promise<StoreDeactivateResponse> {
|
||||
const response = await api.post<StoreDeactivateResponse>('/api/store/deactivate', { featureCode, mandateId, instanceId });
|
||||
return response.data;
|
||||
}
|
||||
|
|
@ -1,240 +0,0 @@
|
|||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES — aligned with State Machine (wiki/concepts/Subscription-State-Machine.md)
|
||||
// ============================================================================
|
||||
|
||||
export type SubscriptionStatus = 'PENDING' | 'SCHEDULED' | 'TRIALING' | 'ACTIVE' | 'PAST_DUE' | 'EXPIRED';
|
||||
export type BillingPeriod = 'MONTHLY' | 'YEARLY' | 'NONE';
|
||||
|
||||
export interface SubscriptionPlan {
|
||||
planKey: string;
|
||||
selectableByUser: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
currency: string;
|
||||
billingPeriod: BillingPeriod;
|
||||
pricePerUserCHF: number;
|
||||
pricePerFeatureInstanceCHF: number;
|
||||
autoRenew: boolean;
|
||||
maxUsers: number | null;
|
||||
maxFeatureInstances: number | null;
|
||||
includedModules: number;
|
||||
maxDataVolumeMB?: number | null;
|
||||
budgetAiCHF?: number;
|
||||
budgetAiPerUserCHF?: number;
|
||||
trialDays: number | null;
|
||||
successorPlanKey: string | null;
|
||||
}
|
||||
|
||||
export interface MandateSubscription {
|
||||
id: string;
|
||||
mandateId: string;
|
||||
planKey: string;
|
||||
status: SubscriptionStatus;
|
||||
recurring: boolean;
|
||||
startedAt: string;
|
||||
effectiveFrom: string | null;
|
||||
endedAt: string | null;
|
||||
currentPeriodStart: string | null;
|
||||
currentPeriodEnd: string | null;
|
||||
trialEndsAt: string | null;
|
||||
snapshotPricePerUserCHF: number;
|
||||
snapshotPricePerInstanceCHF: number;
|
||||
stripeSubscriptionId: string | null;
|
||||
isEnterprise?: boolean;
|
||||
enterpriseFlatPriceCHF?: number | null;
|
||||
enterpriseMaxUsers?: number | null;
|
||||
enterpriseMaxFeatureInstances?: number | null;
|
||||
enterpriseMaxDataVolumeMB?: number | null;
|
||||
enterpriseBudgetAiCHF?: number | null;
|
||||
enterpriseNote?: string | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Enterprise Types
|
||||
// ============================================================================
|
||||
|
||||
export interface EnterpriseCreateParams {
|
||||
mandateId: string;
|
||||
startDate: number;
|
||||
endDate: number;
|
||||
autoRenew: boolean;
|
||||
flatPriceCHF: number;
|
||||
maxUsers?: number | null;
|
||||
maxFeatureInstances?: number | null;
|
||||
maxDataVolumeMB?: number | null;
|
||||
budgetAiCHF?: number | null;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
export interface EnterpriseRenewParams {
|
||||
subscriptionId: string;
|
||||
newEndDate: number;
|
||||
autoRenew?: boolean;
|
||||
flatPriceCHF?: number;
|
||||
maxUsers?: number | null;
|
||||
maxFeatureInstances?: number | null;
|
||||
maxDataVolumeMB?: number | null;
|
||||
budgetAiCHF?: number | null;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
export interface EnterpriseUpdateParams {
|
||||
subscriptionId: string;
|
||||
enterpriseFlatPriceCHF?: number;
|
||||
enterpriseMaxUsers?: number | null;
|
||||
enterpriseMaxFeatureInstances?: number | null;
|
||||
enterpriseMaxDataVolumeMB?: number | null;
|
||||
enterpriseBudgetAiCHF?: number | null;
|
||||
enterpriseNote?: string | null;
|
||||
recurring?: boolean;
|
||||
}
|
||||
|
||||
export interface SubscriptionUsage {
|
||||
activeUsers: number;
|
||||
activeInstances: number;
|
||||
usedStorageMB: number;
|
||||
maxStorageMB: number | null;
|
||||
storagePercent: number | null;
|
||||
}
|
||||
|
||||
export interface SubscriptionStatusResponse {
|
||||
active: boolean;
|
||||
subscription: MandateSubscription | null;
|
||||
plan: SubscriptionPlan | null;
|
||||
scheduled: MandateSubscription | null;
|
||||
usage: SubscriptionUsage | null;
|
||||
}
|
||||
|
||||
export interface ActivatePlanResponse {
|
||||
redirectUrl?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
function _mandateConfig(mandateId?: string): Record<string, any> {
|
||||
if (!mandateId) return {};
|
||||
return { headers: { 'X-Mandate-Id': mandateId } };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
export async function fetchSelectablePlans(
|
||||
request: ApiRequestFunction,
|
||||
mandateId?: string,
|
||||
): Promise<SubscriptionPlan[]> {
|
||||
return await request({
|
||||
url: '/api/subscription/plans',
|
||||
method: 'get',
|
||||
additionalConfig: _mandateConfig(mandateId),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchSubscriptionStatus(
|
||||
request: ApiRequestFunction,
|
||||
mandateId?: string,
|
||||
): Promise<SubscriptionStatusResponse> {
|
||||
return await request({
|
||||
url: '/api/subscription/status',
|
||||
method: 'get',
|
||||
additionalConfig: _mandateConfig(mandateId),
|
||||
});
|
||||
}
|
||||
|
||||
export async function activatePlan(
|
||||
request: ApiRequestFunction,
|
||||
planKey: string,
|
||||
mandateId?: string,
|
||||
returnUrl?: string,
|
||||
): Promise<ActivatePlanResponse> {
|
||||
return await request({
|
||||
url: '/api/subscription/activate',
|
||||
method: 'post',
|
||||
data: { planKey, returnUrl: returnUrl || '' },
|
||||
additionalConfig: _mandateConfig(mandateId),
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelSubscription(
|
||||
request: ApiRequestFunction,
|
||||
subscriptionId: string,
|
||||
mandateId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return await request({
|
||||
url: '/api/subscription/cancel',
|
||||
method: 'post',
|
||||
data: { subscriptionId },
|
||||
additionalConfig: _mandateConfig(mandateId),
|
||||
});
|
||||
}
|
||||
|
||||
export async function reactivateSubscription(
|
||||
request: ApiRequestFunction,
|
||||
subscriptionId: string,
|
||||
mandateId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return await request({
|
||||
url: '/api/subscription/reactivate',
|
||||
method: 'post',
|
||||
data: { subscriptionId },
|
||||
additionalConfig: _mandateConfig(mandateId),
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyCheckout(
|
||||
request: ApiRequestFunction,
|
||||
sessionId: string,
|
||||
mandateId?: string,
|
||||
): Promise<{ status: string; message: string }> {
|
||||
return await request({
|
||||
url: '/api/subscription/checkout/verify',
|
||||
method: 'post',
|
||||
data: { sessionId },
|
||||
additionalConfig: _mandateConfig(mandateId),
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Enterprise API
|
||||
// ============================================================================
|
||||
|
||||
export async function createEnterprise(
|
||||
request: ApiRequestFunction,
|
||||
params: EnterpriseCreateParams,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return await request({
|
||||
url: '/api/subscription/enterprise/create',
|
||||
method: 'post',
|
||||
data: params,
|
||||
});
|
||||
}
|
||||
|
||||
export async function renewEnterprise(
|
||||
request: ApiRequestFunction,
|
||||
params: EnterpriseRenewParams,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return await request({
|
||||
url: '/api/subscription/enterprise/renew',
|
||||
method: 'post',
|
||||
data: params,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateEnterprise(
|
||||
request: ApiRequestFunction,
|
||||
params: EnterpriseUpdateParams,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return await request({
|
||||
url: '/api/subscription/enterprise/update',
|
||||
method: 'put',
|
||||
data: params,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import api from '../api';
|
||||
|
||||
export interface TableListViewRow {
|
||||
id: string;
|
||||
userId?: string;
|
||||
mandateId?: string | null;
|
||||
contextKey: string;
|
||||
viewKey: string;
|
||||
displayName: string;
|
||||
config: TableViewConfig;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
export interface TableViewConfig {
|
||||
schemaVersion?: number;
|
||||
filters?: Record<string, unknown>;
|
||||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||
groupByLevels?: Array<{ field: string; nullLabel?: string }>;
|
||||
/** Section mode (`tableGroupLayoutMode="sections"`): stable keys (`sk`) of collapsed sections. */
|
||||
collapsedSectionKeys?: string[];
|
||||
/** Inline `groupLayout` bands: keys are `band.path.join('///')`. */
|
||||
collapsedGroupKeys?: string[];
|
||||
}
|
||||
|
||||
export async function listTableViews(contextKey: string): Promise<TableListViewRow[]> {
|
||||
const { data } = await api.get<TableListViewRow[]>('/api/table-views', {
|
||||
params: { contextKey },
|
||||
});
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
export async function getTableView(contextKey: string, viewKey: string): Promise<TableListViewRow> {
|
||||
const { data } = await api.get<TableListViewRow>(`/api/table-views/${encodeURIComponent(viewKey)}`, {
|
||||
params: { contextKey },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createTableView(payload: {
|
||||
contextKey: string;
|
||||
viewKey: string;
|
||||
displayName: string;
|
||||
config: TableViewConfig;
|
||||
}): Promise<TableListViewRow> {
|
||||
const { data } = await api.post<TableListViewRow>('/api/table-views', payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateTableView(
|
||||
viewId: string,
|
||||
updates: { displayName?: string; viewKey?: string; config?: TableViewConfig },
|
||||
): Promise<TableListViewRow> {
|
||||
const { data } = await api.put<TableListViewRow>(`/api/table-views/${encodeURIComponent(viewId)}`, updates);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteTableView(viewId: string): Promise<void> {
|
||||
await api.delete(`/api/table-views/${encodeURIComponent(viewId)}`);
|
||||
}
|
||||
|
|
@ -1,664 +0,0 @@
|
|||
import api from '../api';
|
||||
import type { VoiceOption } from './voiceCatalogApi';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES & INTERFACES
|
||||
// ============================================================================
|
||||
|
||||
export interface TeamsbotSession {
|
||||
id: string;
|
||||
instanceId: string;
|
||||
mandateId: string;
|
||||
moduleId?: string;
|
||||
meetingLink: string;
|
||||
botName: string;
|
||||
status: 'pending' | 'joining' | 'active' | 'leaving' | 'ended' | 'error';
|
||||
startedAt?: string;
|
||||
endedAt?: string;
|
||||
startedByUserId: string;
|
||||
bridgeSessionId?: string;
|
||||
meetingChatId?: string;
|
||||
summary?: string;
|
||||
errorMessage?: string;
|
||||
transcriptSegmentCount: number;
|
||||
botResponseCount: number;
|
||||
creationDate?: string;
|
||||
lastModified?: string;
|
||||
}
|
||||
|
||||
export interface TeamsbotTranscript {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
speaker?: string;
|
||||
text: string;
|
||||
timestamp: string;
|
||||
confidence: number;
|
||||
language?: string;
|
||||
isFinal: boolean;
|
||||
isContinuation?: boolean;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface TeamsbotBotResponse {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
responseText: string;
|
||||
responseType: 'audio' | 'chat' | 'both';
|
||||
detectedIntent: 'addressed' | 'question' | 'proactive' | 'none';
|
||||
reasoning?: string;
|
||||
triggeredByTranscriptId?: string;
|
||||
modelName?: string;
|
||||
processingTime: number;
|
||||
priceCHF: number;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export type TeamsbotResponseChannel = 'voice' | 'chat' | 'both';
|
||||
export type TeamsbotJoinMode = 'systemBot' | 'anonymous' | 'userAccount';
|
||||
|
||||
export type TeamsbotTransferMode = 'caption' | 'audio' | 'auto';
|
||||
|
||||
export interface TeamsbotConfig {
|
||||
botName: string;
|
||||
aiSystemPrompt: string;
|
||||
responseMode: 'auto' | 'manual' | 'transcribeOnly';
|
||||
responseChannel: TeamsbotResponseChannel;
|
||||
transferMode: TeamsbotTransferMode;
|
||||
language: string;
|
||||
voiceId?: string;
|
||||
browserBotUrl?: string;
|
||||
triggerIntervalSeconds: number;
|
||||
triggerCooldownSeconds: number;
|
||||
contextWindowSegments: number;
|
||||
debugMode?: boolean;
|
||||
avatarFileId?: string;
|
||||
}
|
||||
|
||||
export interface TeamsbotSessionStats {
|
||||
transcriptSegments: number;
|
||||
botResponses: number;
|
||||
totalCostCHF: number;
|
||||
totalProcessingTime: number;
|
||||
speakers: string[];
|
||||
}
|
||||
|
||||
export interface StartSessionRequest {
|
||||
meetingLink: string;
|
||||
botName?: string;
|
||||
moduleId?: string;
|
||||
connectionId?: string;
|
||||
joinMode?: TeamsbotJoinMode;
|
||||
sessionContext?: string;
|
||||
}
|
||||
|
||||
export interface ConfigUpdateRequest {
|
||||
botName?: string;
|
||||
aiSystemPrompt?: string;
|
||||
responseMode?: 'auto' | 'manual' | 'transcribeOnly';
|
||||
responseChannel?: TeamsbotResponseChannel;
|
||||
transferMode?: TeamsbotTransferMode;
|
||||
language?: string;
|
||||
voiceId?: string;
|
||||
browserBotUrl?: string;
|
||||
triggerIntervalSeconds?: number;
|
||||
triggerCooldownSeconds?: number;
|
||||
contextWindowSegments?: number;
|
||||
debugMode?: boolean;
|
||||
avatarFileId?: string;
|
||||
}
|
||||
|
||||
// Voice option type re-exported from the central voice catalog API
|
||||
// (imported above so it's also in scope for local signatures below).
|
||||
// The legacy teamsbot-specific {code,name} language type is gone — consumers
|
||||
// should use VoiceLanguage from voiceCatalogApi (catalog SSOT).
|
||||
export type { VoiceOption };
|
||||
|
||||
// Auth Detection Test Types
|
||||
export interface StepScreenshot {
|
||||
label: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface AuthTestResult {
|
||||
variantId: string;
|
||||
variantName: string;
|
||||
success: boolean;
|
||||
pageType: 'v2' | 'lightMeetings' | 'error' | 'unknown';
|
||||
finalUrl: string;
|
||||
hasSignInLink: boolean;
|
||||
hasNameInput: boolean;
|
||||
hasJoinButton: boolean;
|
||||
authAttempted: boolean;
|
||||
authSuccess: boolean | null;
|
||||
screenshot?: string;
|
||||
screenshots?: StepScreenshot[];
|
||||
durationMs: number;
|
||||
error?: string;
|
||||
detectedSignals: string[];
|
||||
logs: string[];
|
||||
}
|
||||
|
||||
export interface AuthTestResults {
|
||||
meetingUrl: string;
|
||||
timestamp: string;
|
||||
variants: AuthTestResult[];
|
||||
recommendation: string;
|
||||
credentialsReceived?: { hasEmail: boolean; hasPassword: boolean };
|
||||
credentialDebug?: {
|
||||
mandateId?: string;
|
||||
botFound?: boolean;
|
||||
botEmail?: string;
|
||||
botMandateId?: string;
|
||||
searchStrategy?: string;
|
||||
allBotsCount?: number;
|
||||
passwordDecrypted?: boolean;
|
||||
passwordError?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// User Account (Mein Account) Types
|
||||
export interface UserAccountStatus {
|
||||
hasSavedCredentials: boolean;
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
// MFA Types
|
||||
export interface MfaChallengeEvent {
|
||||
mfaType: 'numberMatch' | 'pushApproval' | 'smsCode' | 'totpCode' | 'timeout' | 'unknown';
|
||||
displayNumber?: string;
|
||||
prompt: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
// SSE Event Types
|
||||
export interface TeamsbotSSEEvent {
|
||||
type:
|
||||
| 'transcript'
|
||||
| 'botResponse'
|
||||
| 'analysis'
|
||||
| 'suggestedResponse'
|
||||
| 'statusChange'
|
||||
| 'error'
|
||||
| 'ping'
|
||||
| 'sessionState'
|
||||
| 'ttsDeliveryStatus'
|
||||
| 'mfaChallenge'
|
||||
| 'mfaResolved'
|
||||
| 'chatSendFailed'
|
||||
| 'directorPrompt'
|
||||
| 'agentRun'
|
||||
| 'botConnectionState';
|
||||
data: any;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Director Prompts (private operator instructions during a live meeting)
|
||||
// =========================================================================
|
||||
|
||||
export type DirectorPromptMode = 'oneShot' | 'persistent';
|
||||
export type DirectorPromptStatus =
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'succeeded'
|
||||
| 'failed'
|
||||
| 'consumed';
|
||||
|
||||
export const DIRECTOR_PROMPT_TEXT_LIMIT = 8000;
|
||||
export const DIRECTOR_PROMPT_FILE_LIMIT = 10;
|
||||
|
||||
export interface DirectorPrompt {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
instanceId: string;
|
||||
operatorUserId: string;
|
||||
text: string;
|
||||
mode: DirectorPromptMode;
|
||||
fileIds: string[];
|
||||
status: DirectorPromptStatus;
|
||||
statusMessage?: string;
|
||||
createdAt: string;
|
||||
consumedAt?: string;
|
||||
agentRunId?: string;
|
||||
responseText?: string;
|
||||
}
|
||||
|
||||
export interface DirectorPromptCreateRequest {
|
||||
text: string;
|
||||
mode: DirectorPromptMode;
|
||||
fileIds?: string[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Start a new Teams Bot session.
|
||||
*/
|
||||
export async function startSession(instanceId: string, request: StartSessionRequest): Promise<{ session: TeamsbotSession }> {
|
||||
const response = await api.post(`/api/teamsbot/${instanceId}/sessions`, request);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all sessions for a feature instance.
|
||||
*/
|
||||
export async function listSessions(instanceId: string, includeEnded = true): Promise<{ sessions: TeamsbotSession[] }> {
|
||||
const response = await api.get(`/api/teamsbot/${instanceId}/sessions`, {
|
||||
params: { includeEnded },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session details with transcripts and bot responses.
|
||||
*/
|
||||
export async function getSession(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
includeTranscripts = true,
|
||||
includeResponses = true,
|
||||
): Promise<{
|
||||
session: TeamsbotSession;
|
||||
transcripts?: TeamsbotTranscript[];
|
||||
botResponses?: TeamsbotBotResponse[];
|
||||
stats?: TeamsbotSessionStats;
|
||||
}> {
|
||||
const response = await api.get(`/api/teamsbot/${instanceId}/sessions/${sessionId}`, {
|
||||
params: { includeTranscripts, includeResponses },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop an active session.
|
||||
*/
|
||||
export async function stopSession(instanceId: string, sessionId: string): Promise<{ status: string; sessionId: string }> {
|
||||
const response = await api.post(`/api/teamsbot/${instanceId}/sessions/${sessionId}/stop`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session and all related data.
|
||||
*/
|
||||
export async function deleteSession(instanceId: string, sessionId: string): Promise<{ deleted: boolean }> {
|
||||
const response = await api.delete(`/api/teamsbot/${instanceId}/sessions/${sessionId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get teamsbot configuration (instance-level defaults).
|
||||
*/
|
||||
export async function getConfig(instanceId: string): Promise<{ config: TeamsbotConfig }> {
|
||||
const response = await api.get(`/api/teamsbot/${instanceId}/config`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update teamsbot configuration (instance-level defaults).
|
||||
*/
|
||||
export async function updateConfig(instanceId: string, updates: ConfigUpdateRequest): Promise<{ config: TeamsbotConfig }> {
|
||||
const response = await api.put(`/api/teamsbot/${instanceId}/config`, updates);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get per-user settings merged with instance defaults.
|
||||
*/
|
||||
export async function getUserSettings(instanceId: string): Promise<{ settings: any; effectiveConfig: TeamsbotConfig }> {
|
||||
const response = await api.get(`/api/teamsbot/${instanceId}/settings`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update per-user settings.
|
||||
*/
|
||||
export async function updateUserSettings(instanceId: string, updates: ConfigUpdateRequest): Promise<{ settings: any; effectiveConfig: TeamsbotConfig }> {
|
||||
const response = await api.put(`/api/teamsbot/${instanceId}/settings`, updates);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset per-user settings to instance defaults.
|
||||
*/
|
||||
export async function resetUserSettings(instanceId: string): Promise<{ settings: null; effectiveConfig: TeamsbotConfig }> {
|
||||
const response = await api.delete(`/api/teamsbot/${instanceId}/settings`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export interface SystemBot {
|
||||
id: string;
|
||||
mandateId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
isActive: boolean;
|
||||
creationDate?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* List system bot accounts for this mandate.
|
||||
*/
|
||||
export async function listSystemBots(instanceId: string): Promise<{ bots: SystemBot[] }> {
|
||||
const response = await api.get(`/api/teamsbot/${instanceId}/system-bots`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new system bot account. The password is encrypted server-side
|
||||
* before storage; the API never returns the password back. SysAdmin only.
|
||||
*/
|
||||
export async function createSystemBot(
|
||||
instanceId: string,
|
||||
payload: { email: string; password: string; name?: string },
|
||||
): Promise<{ bot: SystemBot }> {
|
||||
const response = await api.post(`/api/teamsbot/${instanceId}/system-bots`, payload);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a system bot account. SysAdmin only.
|
||||
*/
|
||||
export async function deleteSystemBot(
|
||||
instanceId: string,
|
||||
botId: string,
|
||||
): Promise<{ deleted: boolean }> {
|
||||
const response = await api.delete(`/api/teamsbot/${instanceId}/system-bots/${botId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test TTS voice with AI-generated sample text. Returns base64-encoded audio.
|
||||
*/
|
||||
export async function testVoice(
|
||||
instanceId: string,
|
||||
botName: string,
|
||||
language: string,
|
||||
voiceId?: string,
|
||||
): Promise<{ success: boolean; audio?: string; format?: string; text?: string; error?: string }> {
|
||||
const response = await api.post(`/api/teamsbot/${instanceId}/voice/test`, {
|
||||
botName,
|
||||
language,
|
||||
voiceId,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the curated voice/language catalog (single source of truth).
|
||||
* Re-exports the central voiceCatalogApi.fetchVoiceCatalog so legacy
|
||||
* teamsbot consumers stay on one import surface.
|
||||
*/
|
||||
export { fetchVoiceCatalog as fetchLanguages } from './voiceCatalogApi';
|
||||
|
||||
/**
|
||||
* Fetch available TTS voices for a language from Google Cloud.
|
||||
*/
|
||||
export async function fetchVoices(languageCode: string): Promise<VoiceOption[]> {
|
||||
try {
|
||||
const response = await api.get('/api/voice/voices', {
|
||||
params: { language: languageCode },
|
||||
});
|
||||
return response.data?.voices || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of available test variants from the Browser Bot.
|
||||
*/
|
||||
export interface TestVariantInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export async function getTestAuthVariants(instanceId: string): Promise<TestVariantInfo[]> {
|
||||
const response = await api.get(`/api/teamsbot/${instanceId}/test-auth/variants`, {
|
||||
timeout: 30000,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single test variant. Call this once per variant sequentially.
|
||||
* Each call stays within Azure's 240s timeout.
|
||||
*/
|
||||
export async function testAuthSingleVariant(
|
||||
instanceId: string,
|
||||
variantId: string,
|
||||
meetingUrl: string,
|
||||
botEmail?: string,
|
||||
botPassword?: string,
|
||||
): Promise<AuthTestResult> {
|
||||
const payload: Record<string, string> = { variantId, meetingUrl };
|
||||
if (botEmail) payload.botEmail = botEmail;
|
||||
if (botPassword) payload.botPassword = botPassword;
|
||||
const response = await api.post(`/api/teamsbot/${instanceId}/test-auth/variant`, payload, {
|
||||
timeout: 200000, // 3+ minutes per variant
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run ALL auth detection tests in one request (legacy — may timeout).
|
||||
*/
|
||||
export async function testAuth(instanceId: string, meetingUrl: string, botEmail?: string, botPassword?: string): Promise<AuthTestResults> {
|
||||
const payload: Record<string, string> = { meetingUrl };
|
||||
if (botEmail) payload.botEmail = botEmail;
|
||||
if (botPassword) payload.botPassword = botPassword;
|
||||
const response = await api.post(`/api/teamsbot/${instanceId}/test-auth`, payload, {
|
||||
timeout: 900000,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an SSE EventSource for live session streaming.
|
||||
* Returns the EventSource instance for the caller to manage.
|
||||
*/
|
||||
export function createSessionStream(instanceId: string, sessionId: string): EventSource {
|
||||
const baseUrl = api.defaults.baseURL || '';
|
||||
const url = `${baseUrl}/api/teamsbot/${instanceId}/sessions/${sessionId}/stream`;
|
||||
return new EventSource(url, { withCredentials: true });
|
||||
}
|
||||
|
||||
/** SSE dashboard stream: periodic { type: 'dashboardState', sessions, modules } */
|
||||
export function createDashboardStream(instanceId: string): EventSource {
|
||||
const baseUrl = api.defaults.baseURL || '';
|
||||
const url = `${baseUrl}/api/teamsbot/${instanceId}/dashboard/stream`;
|
||||
return new EventSource(url, { withCredentials: true });
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Debug Screenshots (SysAdmin only)
|
||||
// =========================================================================
|
||||
|
||||
export interface ScreenshotInfo {
|
||||
name: string;
|
||||
step: string;
|
||||
timestamp: number;
|
||||
sizeBytes: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export async function listScreenshots(instanceId: string, sessionId: string): Promise<{ screenshots: ScreenshotInfo[] }> {
|
||||
const response = await api.get(`/api/teamsbot/${instanceId}/sessions/${sessionId}/screenshots`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export function getScreenshotUrl(instanceId: string, filename: string): string {
|
||||
const baseUrl = api.defaults.baseURL || '';
|
||||
return `${baseUrl}/api/teamsbot/${instanceId}/screenshots/${filename}`;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// User Account (Mein Account)
|
||||
// =========================================================================
|
||||
|
||||
export async function getUserAccount(instanceId: string): Promise<UserAccountStatus> {
|
||||
const response = await api.get(`/api/teamsbot/${instanceId}/user-account`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function saveUserAccount(
|
||||
instanceId: string,
|
||||
email: string,
|
||||
password: string,
|
||||
displayName?: string,
|
||||
): Promise<{ saved: boolean; email: string }> {
|
||||
const response = await api.post(`/api/teamsbot/${instanceId}/user-account`, {
|
||||
email,
|
||||
password,
|
||||
displayName,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deleteUserAccount(instanceId: string): Promise<{ deleted: boolean }> {
|
||||
const response = await api.delete(`/api/teamsbot/${instanceId}/user-account`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MFA
|
||||
// =========================================================================
|
||||
|
||||
export async function submitMfaCode(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
code: string,
|
||||
action: 'code' | 'confirmed' = 'code',
|
||||
): Promise<{ submitted: boolean }> {
|
||||
const response = await api.post(`/api/teamsbot/${instanceId}/sessions/${sessionId}/mfa`, {
|
||||
code,
|
||||
action,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Director Prompts
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Submit a private director prompt to the running bot. Triggers the full
|
||||
* agent path (web, mail, RAG, etc.) and delivers the answer into the meeting.
|
||||
*/
|
||||
export async function submitDirectorPrompt(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
body: DirectorPromptCreateRequest,
|
||||
): Promise<{ prompt: DirectorPrompt }> {
|
||||
const response = await api.post(
|
||||
`/api/teamsbot/${instanceId}/sessions/${sessionId}/directorPrompts`,
|
||||
body,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* List director prompts for a session (operator's own prompts only).
|
||||
*/
|
||||
export async function listDirectorPrompts(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
): Promise<{ prompts: DirectorPrompt[] }> {
|
||||
const response = await api.get(
|
||||
`/api/teamsbot/${instanceId}/sessions/${sessionId}/directorPrompts`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a (typically persistent) director prompt.
|
||||
*/
|
||||
export async function deleteDirectorPrompt(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
promptId: string,
|
||||
): Promise<{ deleted: boolean; promptId: string }> {
|
||||
const response = await api.delete(
|
||||
`/api/teamsbot/${instanceId}/sessions/${sessionId}/directorPrompts/${promptId}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Meeting Module API
|
||||
// ============================================================================
|
||||
|
||||
export interface MeetingModule {
|
||||
id: string;
|
||||
instanceId: string;
|
||||
mandateId: string;
|
||||
ownerUserId: string;
|
||||
title: string;
|
||||
seriesType: string;
|
||||
defaultBotId?: string;
|
||||
defaultDirectorPrompts?: string;
|
||||
goals?: string;
|
||||
kpiTargets?: string;
|
||||
defaultMeetingLink?: string;
|
||||
defaultBotName?: string;
|
||||
defaultAvatarFileId?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export async function listModules(instanceId: string): Promise<MeetingModule[]> {
|
||||
const response = await api.get(`/api/teamsbot/${instanceId}/modules`);
|
||||
return response.data?.modules || [];
|
||||
}
|
||||
|
||||
export async function createModule(instanceId: string, body: {
|
||||
title: string; seriesType?: string; defaultBotId?: string; goals?: string; kpiTargets?: string;
|
||||
defaultMeetingLink?: string; defaultBotName?: string; defaultAvatarFileId?: string;
|
||||
}): Promise<MeetingModule> {
|
||||
const response = await api.post(`/api/teamsbot/${instanceId}/modules`, body);
|
||||
return response.data?.module;
|
||||
}
|
||||
|
||||
export async function getModuleDetail(instanceId: string, moduleId: string): Promise<{ module: MeetingModule; sessions: TeamsbotSession[] }> {
|
||||
const response = await api.get(`/api/teamsbot/${instanceId}/modules/${moduleId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function updateModule(instanceId: string, moduleId: string, body: Partial<MeetingModule>): Promise<MeetingModule> {
|
||||
const response = await api.put(`/api/teamsbot/${instanceId}/modules/${moduleId}`, body);
|
||||
return response.data?.module;
|
||||
}
|
||||
|
||||
export async function deleteModule(instanceId: string, moduleId: string): Promise<void> {
|
||||
await api.delete(`/api/teamsbot/${instanceId}/modules/${moduleId}`);
|
||||
}
|
||||
|
||||
export interface MediaFileInfo {
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export async function listMediaFiles(): Promise<MediaFileInfo[]> {
|
||||
const response = await api.get('/api/files/list', {
|
||||
params: { pagination: JSON.stringify({ pageSize: 500 }) },
|
||||
});
|
||||
const data = response.data;
|
||||
let items: any[];
|
||||
if (Array.isArray(data)) {
|
||||
items = data;
|
||||
} else if (Array.isArray(data?.items)) {
|
||||
items = data.items;
|
||||
} else {
|
||||
console.warn('[listMediaFiles] unexpected response shape:', Object.keys(data || {}));
|
||||
items = [];
|
||||
}
|
||||
const filtered = items.filter((f: any) => {
|
||||
const mime = (f.mimeType || '').toLowerCase();
|
||||
return mime.startsWith('image/') || mime.startsWith('video/');
|
||||
});
|
||||
console.log(`[listMediaFiles] ${items.length} total files, ${filtered.length} media files`);
|
||||
return filtered.map((f: any) => ({ id: f.id, fileName: f.fileName, mimeType: f.mimeType }));
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,252 +0,0 @@
|
|||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES & INTERFACES
|
||||
// ============================================================================
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
language: string;
|
||||
enabled: boolean;
|
||||
roleLabels?: string[]; // Array of role labels from backend (e.g., ["user"])
|
||||
authenticationAuthority: string;
|
||||
isSysAdmin?: boolean; // Infrastructure/System Operator (RBAC bypass)
|
||||
isPlatformAdmin?: boolean; // Cross-Mandate Governance (no RBAC bypass)
|
||||
// mandateId ist nicht mehr Teil des User-Objekts (Multi-Tenant-Konzept)
|
||||
// Der Mandant-Kontext wird über Feature-Instanzen bestimmt
|
||||
[key: string]: any; // Allow additional properties
|
||||
}
|
||||
|
||||
export type UserUpdateData = Partial<Omit<User, 'id' | 'mandateId'>>;
|
||||
|
||||
export interface AttributeDefinition {
|
||||
name: string;
|
||||
type: 'text' | 'email' | 'date' | 'checkbox' | 'select' | 'multiselect' | 'number' | 'textarea';
|
||||
label: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
default?: any;
|
||||
options?: Array<{ value: string | number; label: string }> | string;
|
||||
validation?: any;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
searchable?: boolean;
|
||||
width?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
filterOptions?: string[];
|
||||
readonly?: boolean;
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
export interface PaginationParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||
filters?: Record<string, any>;
|
||||
search?: string;
|
||||
viewKey?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
pagination?: {
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Type for the request function passed to API functions
|
||||
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
||||
|
||||
// ============================================================================
|
||||
// API REQUEST FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch current user data
|
||||
* Endpoint: GET /api/local/me | /api/msft/me | /api/google/me
|
||||
*/
|
||||
export async function fetchCurrentUser(
|
||||
request: ApiRequestFunction,
|
||||
authAuthority?: string
|
||||
): Promise<User> {
|
||||
let endpoint = '/api/local/me';
|
||||
|
||||
if (authAuthority === 'msft') {
|
||||
endpoint = '/api/msft/me';
|
||||
} else if (authAuthority === 'google') {
|
||||
endpoint = '/api/google/me';
|
||||
}
|
||||
|
||||
console.log('📡 fetchCurrentUser: Requesting user data from:', endpoint);
|
||||
const response = await request({
|
||||
url: endpoint,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
console.log('📥 fetchCurrentUser: Received response:', {
|
||||
endpoint,
|
||||
hasData: !!response,
|
||||
username: response?.username,
|
||||
roleLabels: response?.roleLabels,
|
||||
allKeys: response ? Object.keys(response) : [],
|
||||
fullResponse: response
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout current user
|
||||
* Endpoint: POST /api/local/logout | /api/msft/logout | /api/google/logout
|
||||
*/
|
||||
export async function logoutUser(
|
||||
request: ApiRequestFunction,
|
||||
authAuthority: string = 'local'
|
||||
): Promise<void> {
|
||||
let endpoint = '/api/local/logout';
|
||||
|
||||
if (authAuthority === 'msft') {
|
||||
endpoint = '/api/msft/logout';
|
||||
} else if (authAuthority === 'google') {
|
||||
endpoint = '/api/google/logout';
|
||||
}
|
||||
|
||||
await request({
|
||||
url: endpoint,
|
||||
method: 'post'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user attributes from backend
|
||||
* Endpoint: GET /api/attributes/User
|
||||
*/
|
||||
export async function fetchUserAttributes(_request: ApiRequestFunction): Promise<AttributeDefinition[]> {
|
||||
// Note: This uses api.get directly in the hook due to response format handling
|
||||
// Keeping the function signature here for consistency, but implementation may need api instance
|
||||
throw new Error('fetchUserAttributes should use api instance directly for response format handling');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch list of users with optional pagination
|
||||
* Endpoint: GET /api/users/
|
||||
*/
|
||||
export async function fetchUsers(
|
||||
request: ApiRequestFunction,
|
||||
params?: PaginationParams
|
||||
): Promise<PaginatedResponse<User> | User[]> {
|
||||
const requestParams: any = {};
|
||||
|
||||
// Build pagination object if provided
|
||||
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 (params.viewKey) paginationObj.viewKey = params.viewKey;
|
||||
|
||||
if (Object.keys(paginationObj).length > 0) {
|
||||
requestParams.pagination = JSON.stringify(paginationObj);
|
||||
}
|
||||
}
|
||||
|
||||
const data = await request({
|
||||
url: '/api/users/',
|
||||
method: 'get',
|
||||
params: requestParams
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single user by ID
|
||||
* Endpoint: GET /api/users/{userId}
|
||||
*/
|
||||
export async function fetchUserById(
|
||||
request: ApiRequestFunction,
|
||||
userId: string
|
||||
): Promise<User | null> {
|
||||
try {
|
||||
const data = await request({
|
||||
url: `/api/users/${userId}`,
|
||||
method: 'get'
|
||||
});
|
||||
return data || null;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching user by ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user
|
||||
* Endpoint: POST /api/users
|
||||
*/
|
||||
export async function createUser(
|
||||
request: ApiRequestFunction,
|
||||
userData: Partial<User>
|
||||
): Promise<User> {
|
||||
return await request({
|
||||
url: '/api/users',
|
||||
method: 'post',
|
||||
data: userData
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a user
|
||||
* Endpoint: PUT /api/users/{userId}
|
||||
*/
|
||||
export async function updateUser(
|
||||
request: ApiRequestFunction,
|
||||
userId: string,
|
||||
userData: UserUpdateData
|
||||
): Promise<User> {
|
||||
return await request({
|
||||
url: `/api/users/${userId}`,
|
||||
method: 'put',
|
||||
data: userData
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user
|
||||
* Endpoint: DELETE /api/users/{userId}
|
||||
*/
|
||||
export async function deleteUser(
|
||||
request: ApiRequestFunction,
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
await request({
|
||||
url: `/api/users/${userId}`,
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
/**
|
||||
* Voice / Language Catalog API.
|
||||
*
|
||||
* Single source of truth for every voice-language picker, default-voice
|
||||
* lookup, and ISO ⇄ BCP-47 mapping in the frontend. Mirrors
|
||||
* gateway/modules/shared/voiceCatalog.py 1:1.
|
||||
*
|
||||
* Hard-coded language lists or ad-hoc maps in components are forbidden —
|
||||
* consume `useVoiceCatalog()` instead.
|
||||
*/
|
||||
|
||||
import api from '../api';
|
||||
|
||||
export interface VoiceLanguage {
|
||||
bcp47: string;
|
||||
iso: string;
|
||||
label: string;
|
||||
flag: string;
|
||||
defaultVoice: string | null;
|
||||
}
|
||||
|
||||
export interface VoiceOption {
|
||||
name: string;
|
||||
languageCodes: string[];
|
||||
ssmlGender: string;
|
||||
naturalSampleRateHertz: number;
|
||||
}
|
||||
|
||||
interface CatalogResponse {
|
||||
languages: VoiceLanguage[];
|
||||
}
|
||||
|
||||
interface VoicesResponse {
|
||||
voices: VoiceOption[];
|
||||
}
|
||||
|
||||
export async function fetchVoiceCatalog(): Promise<VoiceLanguage[]> {
|
||||
const response = await api.get<CatalogResponse>('/api/voice/languages');
|
||||
return response.data?.languages ?? [];
|
||||
}
|
||||
|
||||
export async function fetchVoicesForLanguage(bcp47: string): Promise<VoiceOption[]> {
|
||||
const response = await api.get<VoicesResponse>('/api/voice/voices', {
|
||||
params: { language: bcp47 },
|
||||
});
|
||||
return response.data?.voices ?? [];
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,62 +0,0 @@
|
|||
/**
|
||||
* AccessLevelSelect
|
||||
*
|
||||
* Dropdown component for selecting RBAC access levels (n/m/g/a).
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { _getAccessLevelOptions, type AccessLevel, getAccessLevelColor } from '../../hooks/useAccessRules';
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import styles from './AccessRules.module.css';
|
||||
|
||||
interface AccessLevelSelectProps {
|
||||
value: AccessLevel | null;
|
||||
onChange: (value: AccessLevel) => void;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
showLabel?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export const AccessLevelSelect: React.FC<AccessLevelSelectProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
label,
|
||||
showLabel = false,
|
||||
compact = false,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const accessLevelOptions = _getAccessLevelOptions(t);
|
||||
const currentColor = getAccessLevelColor(value);
|
||||
|
||||
return (
|
||||
<div className={`${styles.accessLevelSelect} ${compact ? styles.compact : ''}`}>
|
||||
{showLabel && label && (
|
||||
<label className={styles.accessLevelLabel}>{label}</label>
|
||||
)}
|
||||
<select
|
||||
value={value || 'n'}
|
||||
onChange={(e) => onChange(e.target.value as AccessLevel)}
|
||||
disabled={disabled}
|
||||
className={styles.accessLevelDropdown}
|
||||
style={{
|
||||
borderColor: currentColor,
|
||||
color: currentColor,
|
||||
}}
|
||||
>
|
||||
{accessLevelOptions.map(option => (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
style={{ color: option.color }}
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccessLevelSelect;
|
||||
|
|
@ -1,792 +0,0 @@
|
|||
/* =============================================================================
|
||||
* AccessRules Components Styles
|
||||
* ============================================================================= */
|
||||
|
||||
/* =============================================================================
|
||||
* Access Level Select
|
||||
* ============================================================================= */
|
||||
|
||||
.accessLevelSelect {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.accessLevelSelect.compact {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.accessLevelLabel {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.accessLevelDropdown {
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-primary);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
min-width: 80px;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.accessLevelDropdown:hover:not(:disabled) {
|
||||
box-shadow: 0 0 0 2px var(--primary-color-light);
|
||||
}
|
||||
|
||||
.accessLevelDropdown:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--primary-color);
|
||||
}
|
||||
|
||||
.accessLevelDropdown:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* Access Rules Editor
|
||||
* ============================================================================= */
|
||||
|
||||
.accessRulesEditor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.editorHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.editorTitle {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.templateBadge {
|
||||
background: var(--info-color);
|
||||
color: white;
|
||||
font-size: 0.625rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* Tabs
|
||||
* ============================================================================= */
|
||||
|
||||
.tabsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tabList {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
padding-bottom: -2px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--primary-color);
|
||||
border-bottom-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.tabIcon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.tabBadge {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 10px;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tab.active .tabBadge {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tabContent {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* Rules Section
|
||||
* ============================================================================= */
|
||||
|
||||
.rulesSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.addButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.addButton:hover {
|
||||
background: var(--primary-color-dark);
|
||||
}
|
||||
|
||||
.addButton:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* Rule Card
|
||||
* ============================================================================= */
|
||||
|
||||
.ruleCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.ruleHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ruleItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ruleItemIcon {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.ruleItemName {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.ruleActions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-tertiary);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.iconButton:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.iconButton.danger:hover {
|
||||
background: #fed7d7;
|
||||
color: #c53030;
|
||||
border-color: #fc8181;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* Permissions Grid
|
||||
* ============================================================================= */
|
||||
|
||||
.permissionsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.permissionItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.permissionLabel {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* View Toggle */
|
||||
.viewToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.viewCheckbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* Empty State
|
||||
* ============================================================================= */
|
||||
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.75rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.emptyText {
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.emptyHint {
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* Add Rule Modal
|
||||
* ============================================================================= */
|
||||
|
||||
.addRuleForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.formGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.formLabel {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.formInput {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.formInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px var(--primary-color-light);
|
||||
}
|
||||
|
||||
.formSelect {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.formHint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.formActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* Action Bar
|
||||
* ============================================================================= */
|
||||
|
||||
.actionBar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.secondaryButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.secondaryButton:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.secondaryButton:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.primaryButton:hover {
|
||||
background: var(--primary-color-dark);
|
||||
}
|
||||
|
||||
.primaryButton:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* Loading State
|
||||
* ============================================================================= */
|
||||
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--border-color);
|
||||
border-top-color: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* JSON Editor Tab
|
||||
* ============================================================================= */
|
||||
|
||||
.jsonEditor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.jsonTextarea {
|
||||
width: 100%;
|
||||
min-height: 300px;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.jsonTextarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.jsonError {
|
||||
color: #c53030;
|
||||
font-size: 0.8125rem;
|
||||
padding: 0.5rem;
|
||||
background: #fed7d7;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.jsonHint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* Access Rules Table (Checkbox Matrix)
|
||||
* ============================================================================= */
|
||||
|
||||
.tableWrapper {
|
||||
overflow-x: auto;
|
||||
margin: 0 -0.5rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.accessRulesTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8125rem;
|
||||
min-width: 800px;
|
||||
}
|
||||
|
||||
.accessRulesTable th,
|
||||
.accessRulesTable td {
|
||||
padding: 0.5rem 0.375rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.accessRulesTable th {
|
||||
background: var(--bg-secondary);
|
||||
font-weight: 600;
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.accessRulesTable tbody tr:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.colObject {
|
||||
text-align: left !important;
|
||||
min-width: 220px;
|
||||
max-width: 350px;
|
||||
}
|
||||
|
||||
.colView {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.colGroupHeader {
|
||||
border-left: 2px solid var(--border-color);
|
||||
background: var(--bg-tertiary) !important;
|
||||
}
|
||||
|
||||
.colGroupHeader:nth-of-type(3) {
|
||||
background: rgba(72, 187, 120, 0.1) !important;
|
||||
}
|
||||
|
||||
.colGroupHeader:nth-of-type(4) {
|
||||
background: rgba(66, 153, 225, 0.1) !important;
|
||||
}
|
||||
|
||||
.colGroupHeader:nth-of-type(5) {
|
||||
background: rgba(237, 100, 166, 0.1) !important;
|
||||
}
|
||||
|
||||
.subHeader th {
|
||||
font-size: 0.625rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
background: var(--bg-primary) !important;
|
||||
font-weight: 700;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.subHeader th:nth-child(n+3):nth-child(-n+6) {
|
||||
background: rgba(72, 187, 120, 0.05) !important;
|
||||
}
|
||||
|
||||
.subHeader th:nth-child(n+7):nth-child(-n+10) {
|
||||
background: rgba(66, 153, 225, 0.05) !important;
|
||||
}
|
||||
|
||||
.subHeader th:nth-child(n+11):nth-child(-n+14) {
|
||||
background: rgba(237, 100, 166, 0.05) !important;
|
||||
}
|
||||
|
||||
.objectCell {
|
||||
text-align: left !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.objectIcon {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.objectCode {
|
||||
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
|
||||
font-size: 0.75rem;
|
||||
background: var(--bg-tertiary);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.checkboxCell {
|
||||
width: 32px;
|
||||
padding: 0.375rem 0.25rem !important;
|
||||
}
|
||||
|
||||
.checkboxCell input[type="checkbox"] {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--primary-color);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.checkboxCell input[type="checkbox"]:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.actionsCell {
|
||||
width: 40px;
|
||||
padding: 0.375rem !important;
|
||||
}
|
||||
|
||||
.ruleRow td {
|
||||
padding: 0.5rem 0.375rem;
|
||||
}
|
||||
|
||||
.ruleRow td:nth-child(n+3):nth-child(-n+6) {
|
||||
background: rgba(72, 187, 120, 0.02);
|
||||
}
|
||||
|
||||
.ruleRow td:nth-child(n+7):nth-child(-n+10) {
|
||||
background: rgba(66, 153, 225, 0.02);
|
||||
}
|
||||
|
||||
.ruleRow td:nth-child(n+11):nth-child(-n+14) {
|
||||
background: rgba(237, 100, 166, 0.02);
|
||||
}
|
||||
|
||||
/* Toggle between Card and Table View */
|
||||
.viewToggleButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.viewToggleButton:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.viewToggleButton.active {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Object Selector */
|
||||
.objectSelector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.objectSelectorLabel {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toggleCustomButton {
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: none;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
font-size: 0.6875rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.toggleCustomButton:hover {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Add Rule Matrix (Checkbox Style) */
|
||||
.addRuleMatrix {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.matrixHeader {
|
||||
display: grid;
|
||||
grid-template-columns: 80px repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.matrixGroup {
|
||||
text-align: center;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.matrixRow {
|
||||
display: grid;
|
||||
grid-template-columns: 80px repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.matrixLabel {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.matrixCell {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.matrixCell input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--primary-color);
|
||||
}
|
||||
|
|
@ -1,774 +0,0 @@
|
|||
/**
|
||||
* AccessRulesEditor
|
||||
*
|
||||
* Main component for editing RBAC access rules for a role.
|
||||
* Provides tabbed interface for DATA, UI, and RESOURCE rules.
|
||||
*
|
||||
* Features:
|
||||
* - Checkbox-based compact table for DATA rules
|
||||
* - Card view for UI/RESOURCE rules
|
||||
* - Object catalog dropdown for adding new rules
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
FaTable,
|
||||
FaDesktop,
|
||||
FaServer,
|
||||
FaCode,
|
||||
FaPlus,
|
||||
FaTrash,
|
||||
FaSave,
|
||||
FaUndo,
|
||||
FaSpinner,
|
||||
FaThList,
|
||||
FaTh,
|
||||
} from 'react-icons/fa';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import {
|
||||
useAccessRules,
|
||||
type AccessRule,
|
||||
type RuleContext,
|
||||
type AccessLevel,
|
||||
type AccessRuleCreate,
|
||||
} from '../../hooks/useAccessRules';
|
||||
import { useCatalogObjects, type CatalogObject } from '../../hooks/useCatalogObjects';
|
||||
import { AccessLevelSelect } from './AccessLevelSelect';
|
||||
import { AccessRulesTable } from './AccessRulesTable';
|
||||
import styles from './AccessRules.module.css';
|
||||
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface AccessRulesEditorProps {
|
||||
roleId: string;
|
||||
roleName?: string;
|
||||
isTemplate?: boolean;
|
||||
readOnly?: boolean;
|
||||
onSave?: () => void;
|
||||
apiBasePath?: string;
|
||||
mandateId?: string;
|
||||
featureCode?: string; // Filter catalog objects to this feature only
|
||||
}
|
||||
|
||||
type TabType = 'DATA' | 'UI' | 'RESOURCE' | 'JSON';
|
||||
|
||||
// =============================================================================
|
||||
// RULE CARD COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
interface RuleCardProps {
|
||||
rule: AccessRule;
|
||||
readOnly?: boolean;
|
||||
onUpdate: (ruleId: string, updates: Partial<AccessRule>) => void;
|
||||
onDelete: (ruleId: string) => void;
|
||||
}
|
||||
|
||||
const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete }) => {
|
||||
const { t } = useLanguage();
|
||||
const isDataRule = rule.context === 'DATA';
|
||||
|
||||
return (
|
||||
<div className={styles.ruleCard}>
|
||||
<div className={styles.ruleHeader}>
|
||||
<div className={styles.ruleItem}>
|
||||
<span className={styles.ruleItemIcon}>
|
||||
{rule.context === 'DATA' ? <FaTable /> :
|
||||
rule.context === 'UI' ? <FaDesktop /> : <FaServer />}
|
||||
</span>
|
||||
<span className={styles.ruleItemName}>{rule.item || '(global)'}</span>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className={styles.ruleActions}>
|
||||
<button
|
||||
className={`${styles.iconButton} ${styles.danger}`}
|
||||
onClick={() => onDelete(rule.id)}
|
||||
title={t('Regel löschen')}
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.permissionsGrid}>
|
||||
{/* View Toggle */}
|
||||
<div className={styles.permissionItem}>
|
||||
<span className={styles.permissionLabel}>{t('Ansicht')}</span>
|
||||
<div className={styles.viewToggle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rule.view}
|
||||
onChange={(e) => onUpdate(rule.id, { view: e.target.checked })}
|
||||
disabled={readOnly}
|
||||
className={styles.viewCheckbox}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CRUD Levels (only for DATA context) */}
|
||||
{isDataRule ? (
|
||||
<>
|
||||
<div className={styles.permissionItem}>
|
||||
<span className={styles.permissionLabel}>{t('Lesen')}</span>
|
||||
<AccessLevelSelect
|
||||
value={rule.read}
|
||||
onChange={(value) => onUpdate(rule.id, { read: value })}
|
||||
disabled={readOnly}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.permissionItem}>
|
||||
<span className={styles.permissionLabel}>{t('Erstellen')}</span>
|
||||
<AccessLevelSelect
|
||||
value={rule.create}
|
||||
onChange={(value) => onUpdate(rule.id, { create: value })}
|
||||
disabled={readOnly}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.permissionItem}>
|
||||
<span className={styles.permissionLabel}>{t('Bearbeiten')}</span>
|
||||
<AccessLevelSelect
|
||||
value={rule.update}
|
||||
onChange={(value) => onUpdate(rule.id, { update: value })}
|
||||
disabled={readOnly}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.permissionItem}>
|
||||
<span className={styles.permissionLabel}>{t('Löschen')}</span>
|
||||
<AccessLevelSelect
|
||||
value={rule.delete}
|
||||
onChange={(value) => onUpdate(rule.id, { delete: value })}
|
||||
disabled={readOnly}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
// For UI and RESOURCE, show empty placeholders to maintain grid
|
||||
<div style={{ gridColumn: 'span 4' }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ADD RULE FORM
|
||||
// =============================================================================
|
||||
|
||||
interface AddRuleFormProps {
|
||||
context: RuleContext;
|
||||
availableObjects: CatalogObject[];
|
||||
onAdd: (rule: AccessRuleCreate) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, onAdd, onCancel }) => {
|
||||
const { t } = useLanguage();
|
||||
const [item, setItem] = useState('');
|
||||
const [useCustom, setUseCustom] = useState(false);
|
||||
const [view, setView] = useState(true);
|
||||
const [read, setRead] = useState<AccessLevel>('n');
|
||||
const [create, setCreate] = useState<AccessLevel>('n');
|
||||
const [update, setUpdate] = useState<AccessLevel>('n');
|
||||
const [del, setDel] = useState<AccessLevel>('n');
|
||||
|
||||
// Group objects by feature
|
||||
const groupedObjects = useMemo(() => {
|
||||
const grouped: Record<string, CatalogObject[]> = {};
|
||||
availableObjects.forEach(obj => {
|
||||
if (!grouped[obj.featureCode]) {
|
||||
grouped[obj.featureCode] = [];
|
||||
}
|
||||
grouped[obj.featureCode].push(obj);
|
||||
});
|
||||
return grouped;
|
||||
}, [availableObjects]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const newRule: AccessRuleCreate = {
|
||||
context,
|
||||
item: item.trim() || null,
|
||||
view,
|
||||
...(context === 'DATA' ? { read, create, update, delete: del } : {}),
|
||||
};
|
||||
onAdd(newRule);
|
||||
};
|
||||
|
||||
const getPlaceholder = () => {
|
||||
switch (context) {
|
||||
case 'DATA':
|
||||
return 'z.B. data.feature.trustee.TrusteePosition';
|
||||
case 'UI':
|
||||
return 'z.B. ui.feature.trustee.dashboard';
|
||||
case 'RESOURCE':
|
||||
return 'z.B. resource.feature.trustee.documents.create';
|
||||
}
|
||||
};
|
||||
|
||||
const getLabel = (obj: CatalogObject): string => {
|
||||
return (typeof obj.label === 'string' ? obj.label : '') || obj.objectKey;
|
||||
};
|
||||
|
||||
return (
|
||||
<form className={styles.addRuleForm} onSubmit={handleSubmit}>
|
||||
<div className={styles.formGroup}>
|
||||
<div className={styles.objectSelectorLabel}>
|
||||
<label className={styles.formLabel}>{t('select object')}</label>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toggleCustomButton}
|
||||
onClick={() => setUseCustom(!useCustom)}
|
||||
>
|
||||
{useCustom ? t('select from catalog') : t('free input')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{useCustom ? (
|
||||
<input
|
||||
type="text"
|
||||
value={item}
|
||||
onChange={(e) => setItem(e.target.value)}
|
||||
placeholder={getPlaceholder()}
|
||||
className={styles.formInput}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<select
|
||||
value={item}
|
||||
onChange={(e) => setItem(e.target.value)}
|
||||
className={styles.formSelect}
|
||||
>
|
||||
<option value="">{t('global all objects')}</option>
|
||||
{Object.entries(groupedObjects).map(([feature, objs]) => (
|
||||
<optgroup key={feature} label={feature.toUpperCase()}>
|
||||
{objs.map(obj => (
|
||||
<option key={obj.objectKey} value={obj.objectKey}>
|
||||
{obj.objectKey} - {getLabel(obj)}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<span className={styles.formHint}>
|
||||
{t(
|
||||
'Leer lassen für globale Regel. Längster Match gewinnt bei Wildcards (z.B. data.feature.trustee.*).'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.formLabel}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={view}
|
||||
onChange={(e) => setView(e.target.checked)}
|
||||
style={{ marginRight: '0.5rem' }}
|
||||
/>
|
||||
{t('Sichtbar (Ansicht)')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{context === 'DATA' && (
|
||||
<div className={styles.addRuleMatrix}>
|
||||
{/* Header Row */}
|
||||
<div className={styles.matrixHeader}>
|
||||
<div className={styles.matrixLabel}></div>
|
||||
<div className={styles.matrixGroup}>{t('own')}</div>
|
||||
<div className={styles.matrixGroup}>{t('group')}</div>
|
||||
<div className={styles.matrixGroup}>{t('Alle')}</div>
|
||||
</div>
|
||||
|
||||
{/* CRUD Rows */}
|
||||
{(['create', 'read', 'update', 'delete'] as const).map(op => {
|
||||
const value = op === 'delete' ? del : op === 'create' ? create : op === 'update' ? update : read;
|
||||
const setValue = op === 'delete' ? setDel : op === 'create' ? setCreate : op === 'update' ? setUpdate : setRead;
|
||||
const labels = {
|
||||
create: t('Erstellen'),
|
||||
read: t('Lesen'),
|
||||
update: t('Bearbeiten'),
|
||||
delete: t('Löschen'),
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={op} className={styles.matrixRow}>
|
||||
<div className={styles.matrixLabel}>{labels[op]}</div>
|
||||
{(['m', 'g', 'a'] as const).map(level => (
|
||||
<div key={level} className={styles.matrixCell}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value === level || (level === 'm' && (value === 'g' || value === 'a')) || (level === 'g' && value === 'a')}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setValue(level);
|
||||
} else {
|
||||
// Deactivate: set to level below
|
||||
const hierarchy: AccessLevel[] = ['n', 'm', 'g', 'a'];
|
||||
const idx = hierarchy.indexOf(level);
|
||||
setValue(hierarchy[idx - 1] || 'n');
|
||||
}
|
||||
}}
|
||||
title={`${labels[op]} - ${level === 'm' ? t('Eigene') : level === 'g' ? t('Gruppe') : t('Alle')}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.formActions}>
|
||||
<button type="button" className={styles.secondaryButton} onClick={onCancel}>
|
||||
{t('Abbrechen')}
|
||||
</button>
|
||||
<button type="submit" className={styles.primaryButton}>
|
||||
<FaPlus /> {t('Hinzufügen')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// RULES SECTION
|
||||
// =============================================================================
|
||||
|
||||
interface RulesSectionProps {
|
||||
context: RuleContext;
|
||||
rules: AccessRule[];
|
||||
availableObjects: CatalogObject[];
|
||||
readOnly?: boolean;
|
||||
onUpdate: (ruleId: string, updates: Partial<AccessRule>) => void;
|
||||
onDelete: (ruleId: string) => void;
|
||||
onAdd: (rule: AccessRuleCreate) => void;
|
||||
}
|
||||
|
||||
const RulesSection: React.FC<RulesSectionProps> = ({
|
||||
context,
|
||||
rules,
|
||||
availableObjects,
|
||||
readOnly,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onAdd,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [useTableView, setUseTableView] = useState(context === 'DATA'); // Default to table for DATA
|
||||
|
||||
const handleAdd = (rule: AccessRuleCreate) => {
|
||||
onAdd(rule);
|
||||
setShowAddForm(false);
|
||||
};
|
||||
|
||||
const getEmptyIcon = () => {
|
||||
switch (context) {
|
||||
case 'DATA': return <FaTable />;
|
||||
case 'UI': return <FaDesktop />;
|
||||
case 'RESOURCE': return <FaServer />;
|
||||
}
|
||||
};
|
||||
|
||||
const getEmptyText = () => {
|
||||
switch (context) {
|
||||
case 'DATA':
|
||||
return t('Keine Daten-Regeln definiert');
|
||||
case 'UI':
|
||||
return t('Keine UI-Regeln definiert');
|
||||
case 'RESOURCE':
|
||||
return t('Keine Ressourcen-Regeln definiert');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.rulesSection}>
|
||||
{!readOnly && !showAddForm && (
|
||||
<div className={styles.sectionHeader}>
|
||||
<span className={styles.sectionTitle}>
|
||||
{rules.length} {rules.length === 1 ? t('Regel') : t('Regeln')}
|
||||
</span>
|
||||
<div className={styles.headerActions}>
|
||||
{/* View Toggle */}
|
||||
{context === 'DATA' && rules.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
className={`${styles.viewToggleButton} ${useTableView ? styles.active : ''}`}
|
||||
onClick={() => setUseTableView(true)}
|
||||
title={t('Tabellenansicht')}
|
||||
>
|
||||
<FaThList />
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.viewToggleButton} ${!useTableView ? styles.active : ''}`}
|
||||
onClick={() => setUseTableView(false)}
|
||||
title={t('Kartenansicht')}
|
||||
>
|
||||
<FaTh />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className={styles.addButton}
|
||||
onClick={() => setShowAddForm(true)}
|
||||
>
|
||||
<FaPlus /> {t('Neue Regel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAddForm && (
|
||||
<AddRuleForm
|
||||
context={context}
|
||||
availableObjects={availableObjects}
|
||||
onAdd={handleAdd}
|
||||
onCancel={() => setShowAddForm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{rules.length === 0 && !showAddForm ? (
|
||||
<div className={styles.emptyState}>
|
||||
<div className={styles.emptyIcon}>{getEmptyIcon()}</div>
|
||||
<p className={styles.emptyText}>{getEmptyText()}</p>
|
||||
{!readOnly && (
|
||||
<p className={styles.emptyHint}>
|
||||
{t('Klicken Sie auf „Neue Regel“, um eine Berechtigung hinzuzufügen.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : useTableView && context === 'DATA' ? (
|
||||
<AccessRulesTable
|
||||
rules={rules}
|
||||
context={context}
|
||||
readOnly={readOnly}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
) : (
|
||||
rules.map(rule => (
|
||||
<RuleCard
|
||||
key={rule.id}
|
||||
rule={rule}
|
||||
readOnly={readOnly}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// JSON EDITOR
|
||||
// =============================================================================
|
||||
|
||||
interface JsonEditorProps {
|
||||
rules: AccessRule[];
|
||||
readOnly?: boolean;
|
||||
onApply: (rules: AccessRule[]) => void;
|
||||
}
|
||||
|
||||
const JsonEditor: React.FC<JsonEditorProps> = ({ rules, readOnly, onApply }) => {
|
||||
const { t } = useLanguage();
|
||||
const [jsonText, setJsonText] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setJsonText(JSON.stringify(rules, null, 2));
|
||||
setError(null);
|
||||
}, [rules]);
|
||||
|
||||
const handleApply = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText);
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error(t('JSON muss ein Array sein'));
|
||||
}
|
||||
setError(null);
|
||||
onApply(parsed);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.jsonEditor}>
|
||||
<textarea
|
||||
value={jsonText}
|
||||
onChange={(e) => setJsonText(e.target.value)}
|
||||
className={styles.jsonTextarea}
|
||||
readOnly={readOnly}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{error && <div className={styles.jsonError}>{error}</div>}
|
||||
<p className={styles.jsonHint}>
|
||||
{t(
|
||||
'Experten-Modus: Bearbeiten Sie die Regeln direkt als JSON. Änderungen werden erst nach Klick auf „Anwenden“ übernommen.'
|
||||
)}
|
||||
</p>
|
||||
{!readOnly && (
|
||||
<div className={styles.formActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primaryButton}
|
||||
onClick={handleApply}
|
||||
disabled={!!error}
|
||||
>
|
||||
{t('JSON anwenden')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
|
||||
roleId,
|
||||
roleName,
|
||||
isTemplate = false,
|
||||
readOnly = false,
|
||||
onSave,
|
||||
apiBasePath = '/api/rbac',
|
||||
mandateId,
|
||||
featureCode,
|
||||
}) => {
|
||||
const { showError } = useToast();
|
||||
const { t } = useLanguage();
|
||||
const {
|
||||
rules,
|
||||
loading,
|
||||
saving,
|
||||
error,
|
||||
fetchRules,
|
||||
saveRules,
|
||||
getGroupedRules,
|
||||
updateRuleLocally,
|
||||
addRuleLocally,
|
||||
removeRuleLocally,
|
||||
} = useAccessRules(roleId, apiBasePath, mandateId);
|
||||
|
||||
// Catalog objects for dropdown selection
|
||||
const { objects: catalogObjects, fetchObjects } = useCatalogObjects();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabType>('DATA');
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [originalRules, setOriginalRules] = useState<AccessRule[]>([]);
|
||||
|
||||
// Load rules on mount
|
||||
useEffect(() => {
|
||||
fetchRules().then(fetchedRules => {
|
||||
setOriginalRules(fetchedRules);
|
||||
});
|
||||
}, [fetchRules]);
|
||||
|
||||
// Load catalog objects - filter by featureCode if provided
|
||||
useEffect(() => {
|
||||
fetchObjects(undefined, featureCode, mandateId);
|
||||
}, [fetchObjects, featureCode, mandateId]);
|
||||
|
||||
// Track changes
|
||||
useEffect(() => {
|
||||
setHasChanges(JSON.stringify(rules) !== JSON.stringify(originalRules));
|
||||
}, [rules, originalRules]);
|
||||
|
||||
const groupedRules = getGroupedRules();
|
||||
|
||||
// Handlers
|
||||
const handleUpdate = useCallback((ruleId: string, updates: Partial<AccessRule>) => {
|
||||
updateRuleLocally(ruleId, updates);
|
||||
}, [updateRuleLocally]);
|
||||
|
||||
const handleDelete = useCallback((ruleId: string) => {
|
||||
// Direct delete - rules are local until saved
|
||||
removeRuleLocally(ruleId);
|
||||
}, [removeRuleLocally]);
|
||||
|
||||
const handleAdd = useCallback((ruleData: AccessRuleCreate) => {
|
||||
const newRule: AccessRule = {
|
||||
id: `temp-${Date.now()}`, // Temporary ID
|
||||
roleId,
|
||||
context: ruleData.context,
|
||||
item: ruleData.item || null,
|
||||
view: ruleData.view ?? true,
|
||||
read: ruleData.read ?? null,
|
||||
create: ruleData.create ?? null,
|
||||
update: ruleData.update ?? null,
|
||||
delete: ruleData.delete ?? null,
|
||||
};
|
||||
addRuleLocally(newRule);
|
||||
}, [roleId, addRuleLocally]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const result = await saveRules(rules);
|
||||
if (result.success) {
|
||||
setOriginalRules(rules);
|
||||
setHasChanges(false);
|
||||
onSave?.();
|
||||
} else {
|
||||
showError(t('Fehler'), result.error || t('Fehler beim Speichern'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
// Direct reset - user clicked the reset button intentionally
|
||||
fetchRules().then(fetchedRules => {
|
||||
setOriginalRules(fetchedRules);
|
||||
});
|
||||
};
|
||||
|
||||
const handleJsonApply = (newRules: AccessRule[]) => {
|
||||
// Replace all rules
|
||||
newRules.forEach((rule, index) => {
|
||||
if (!rule.id) {
|
||||
rule.id = `temp-${Date.now()}-${index}`;
|
||||
}
|
||||
rule.roleId = roleId;
|
||||
});
|
||||
// This is a bit hacky - we need to update the store
|
||||
// For now, we'll just save directly
|
||||
saveRules(newRules);
|
||||
};
|
||||
|
||||
// Render tabs
|
||||
const tabs: { id: TabType; label: string; icon: React.ReactNode; count: number }[] = [
|
||||
{ id: 'DATA', label: t('data'), icon: <FaTable />, count: groupedRules.DATA.length },
|
||||
{ id: 'UI', label: 'UI', icon: <FaDesktop />, count: groupedRules.UI.length },
|
||||
{ id: 'RESOURCE', label: t('resources'), icon: <FaServer />, count: groupedRules.RESOURCE.length },
|
||||
{ id: 'JSON', label: 'JSON', icon: <FaCode />, count: rules.length },
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.accessRulesEditor}>
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>{t('loading permissions')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.accessRulesEditor}>
|
||||
<div className={styles.editorHeader}>
|
||||
<h3 className={styles.editorTitle}>
|
||||
Berechtigungen{roleName ? `: ${roleName}` : ''}
|
||||
{isTemplate && <span className={styles.templateBadge}>{t('Vorlage')}</span>}
|
||||
</h3>
|
||||
{!readOnly && hasChanges && (
|
||||
<div className={styles.headerActions}>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={handleReset}
|
||||
disabled={saving}
|
||||
>
|
||||
<FaUndo /> Zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={styles.jsonError}>
|
||||
Fehler: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.tabsContainer}>
|
||||
<div className={styles.tabList}>
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`${styles.tab} ${activeTab === tab.id ? styles.active : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
<span className={styles.tabIcon}>{tab.icon}</span>
|
||||
{tab.label}
|
||||
<span className={styles.tabBadge}>{tab.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.tabContent}>
|
||||
{activeTab === 'DATA' && (
|
||||
<RulesSection
|
||||
context="DATA"
|
||||
rules={groupedRules.DATA}
|
||||
availableObjects={catalogObjects.DATA || []}
|
||||
readOnly={readOnly}
|
||||
onUpdate={handleUpdate}
|
||||
onDelete={handleDelete}
|
||||
onAdd={handleAdd}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'UI' && (
|
||||
<RulesSection
|
||||
context="UI"
|
||||
rules={groupedRules.UI}
|
||||
availableObjects={catalogObjects.UI || []}
|
||||
readOnly={readOnly}
|
||||
onUpdate={handleUpdate}
|
||||
onDelete={handleDelete}
|
||||
onAdd={handleAdd}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'RESOURCE' && (
|
||||
<RulesSection
|
||||
context="RESOURCE"
|
||||
rules={groupedRules.RESOURCE}
|
||||
availableObjects={catalogObjects.RESOURCE || []}
|
||||
readOnly={readOnly}
|
||||
onUpdate={handleUpdate}
|
||||
onDelete={handleDelete}
|
||||
onAdd={handleAdd}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'JSON' && (
|
||||
<JsonEditor
|
||||
rules={rules}
|
||||
readOnly={readOnly}
|
||||
onApply={handleJsonApply}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
<div className={styles.actionBar}>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={handleSave}
|
||||
disabled={saving || !hasChanges}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<FaSpinner className="spinning" /> Speichern...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaSave /> Speichern
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccessRulesEditor;
|
||||
|
|
@ -1,252 +0,0 @@
|
|||
/**
|
||||
* AccessRulesTable
|
||||
*
|
||||
* Checkbox-based compact table for editing RBAC access rules.
|
||||
* Shows all permissions in a matrix format similar to Unix permissions.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { FaTable, FaDesktop, FaServer, FaTrash } from 'react-icons/fa';
|
||||
import { type AccessRule, type RuleContext, type AccessLevel } from '../../hooks/useAccessRules';
|
||||
import styles from './AccessRules.module.css';
|
||||
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface AccessRulesTableProps {
|
||||
rules: AccessRule[];
|
||||
context: RuleContext;
|
||||
readOnly?: boolean;
|
||||
onUpdate: (ruleId: string, updates: Partial<AccessRule>) => void;
|
||||
onDelete: (ruleId: string) => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Check if access level is at least the specified minimum level.
|
||||
* Hierarchy: n (none) < m (mine) < g (group) < a (all)
|
||||
*/
|
||||
const hasLevel = (level: AccessLevel | null | undefined, minLevel: 'm' | 'g' | 'a'): boolean => {
|
||||
if (!level || level === 'n') return false;
|
||||
const hierarchy = ['n', 'm', 'g', 'a'];
|
||||
return hierarchy.indexOf(level) >= hierarchy.indexOf(minLevel);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate the new access level when a checkbox is toggled.
|
||||
*/
|
||||
const calculateNewLevel = (
|
||||
_currentLevel: AccessLevel | null | undefined,
|
||||
targetLevel: 'm' | 'g' | 'a',
|
||||
checked: boolean
|
||||
): AccessLevel => {
|
||||
if (checked) {
|
||||
// Activating: set to target level
|
||||
return targetLevel;
|
||||
} else {
|
||||
// Deactivating: set to level below target
|
||||
const hierarchy: AccessLevel[] = ['n', 'm', 'g', 'a'];
|
||||
const targetIndex = hierarchy.indexOf(targetLevel);
|
||||
return hierarchy[targetIndex - 1] || 'n';
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// RULE ROW COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
interface AccessRuleRowProps {
|
||||
rule: AccessRule;
|
||||
isDataContext: boolean;
|
||||
readOnly?: boolean;
|
||||
onUpdate: (ruleId: string, updates: Partial<AccessRule>) => void;
|
||||
onDelete: (ruleId: string) => void;
|
||||
}
|
||||
|
||||
const AccessRuleRow: React.FC<AccessRuleRowProps> = ({
|
||||
rule,
|
||||
isDataContext,
|
||||
readOnly,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const opTitle = (op: 'create' | 'read' | 'update' | 'delete') =>
|
||||
({ create: t('Erstellen'), read: t('Lesen'), update: t('Bearbeiten'), delete: t('Löschen') })[op];
|
||||
const handleLevelToggle = (
|
||||
field: 'read' | 'create' | 'update' | 'delete',
|
||||
targetLevel: 'm' | 'g' | 'a',
|
||||
checked: boolean
|
||||
) => {
|
||||
const currentLevel = rule[field] as AccessLevel | null | undefined;
|
||||
const newLevel = calculateNewLevel(currentLevel, targetLevel, checked);
|
||||
onUpdate(rule.id, { [field]: newLevel });
|
||||
};
|
||||
|
||||
// Get icon for context
|
||||
const getContextIcon = () => {
|
||||
switch (rule.context) {
|
||||
case 'DATA': return <FaTable />;
|
||||
case 'UI': return <FaDesktop />;
|
||||
case 'RESOURCE': return <FaServer />;
|
||||
default: return <FaTable />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<tr className={styles.ruleRow}>
|
||||
{/* Object Name */}
|
||||
<td className={styles.objectCell}>
|
||||
<span className={styles.objectIcon}>{getContextIcon()}</span>
|
||||
<code className={styles.objectCode}>{rule.item || '(global)'}</code>
|
||||
</td>
|
||||
|
||||
{/* View Checkbox */}
|
||||
<td className={styles.checkboxCell}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rule.view}
|
||||
onChange={(e) => onUpdate(rule.id, { view: e.target.checked })}
|
||||
disabled={readOnly}
|
||||
title={t('Sichtbar')}
|
||||
/>
|
||||
</td>
|
||||
|
||||
{/* CRUD Checkboxes for DATA context */}
|
||||
{isDataContext && (
|
||||
<>
|
||||
{/* EIGENE (m) */}
|
||||
{(['create', 'read', 'update', 'delete'] as const).map(op => (
|
||||
<td key={`m-${op}`} className={styles.checkboxCell}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasLevel(rule[op] as AccessLevel, 'm')}
|
||||
onChange={(e) => handleLevelToggle(op, 'm', e.target.checked)}
|
||||
disabled={readOnly}
|
||||
title={`${opTitle(op)} - ${t('Eigene')}`}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
|
||||
{/* GRUPPE (g) */}
|
||||
{(['create', 'read', 'update', 'delete'] as const).map(op => (
|
||||
<td key={`g-${op}`} className={styles.checkboxCell}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasLevel(rule[op] as AccessLevel, 'g')}
|
||||
onChange={(e) => handleLevelToggle(op, 'g', e.target.checked)}
|
||||
disabled={readOnly}
|
||||
title={`${opTitle(op)} - ${t('Gruppe')}`}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
|
||||
{/* ALLE (a) */}
|
||||
{(['create', 'read', 'update', 'delete'] as const).map(op => (
|
||||
<td key={`a-${op}`} className={styles.checkboxCell}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasLevel(rule[op] as AccessLevel, 'a')}
|
||||
onChange={(e) => handleLevelToggle(op, 'a', e.target.checked)}
|
||||
disabled={readOnly}
|
||||
title={`${opTitle(op)} - ${t('Alle')}`}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Delete Button */}
|
||||
<td className={styles.actionsCell}>
|
||||
{!readOnly && (
|
||||
<button
|
||||
className={`${styles.iconButton} ${styles.danger}`}
|
||||
onClick={() => onDelete(rule.id)}
|
||||
title={t('Regel löschen')}
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// MAIN TABLE COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
export const AccessRulesTable: React.FC<AccessRulesTableProps> = ({
|
||||
rules,
|
||||
context,
|
||||
readOnly,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const isDataContext = context === 'DATA';
|
||||
|
||||
if (rules.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.accessRulesTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.colObject}>{t('object dot notation')}</th>
|
||||
<th className={styles.colView}>{t('Ansicht')}</th>
|
||||
{isDataContext && (
|
||||
<>
|
||||
<th className={styles.colGroupHeader} colSpan={4}>{t('own')}</th>
|
||||
<th className={styles.colGroupHeader} colSpan={4}>{t('group')}</th>
|
||||
<th className={styles.colGroupHeader} colSpan={4}>{t('Alle')}</th>
|
||||
</>
|
||||
)}
|
||||
<th className={styles.colActions}></th>
|
||||
</tr>
|
||||
{isDataContext && (
|
||||
<tr className={styles.subHeader}>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th title={t('Erstellen')}>C</th>
|
||||
<th title={t('Lesen')}>R</th>
|
||||
<th title={t('Bearbeiten')}>U</th>
|
||||
<th title={t('Löschen')}>D</th>
|
||||
<th title={t('Erstellen')}>C</th>
|
||||
<th title={t('Lesen')}>R</th>
|
||||
<th title={t('Bearbeiten')}>U</th>
|
||||
<th title={t('Löschen')}>D</th>
|
||||
<th title={t('Erstellen')}>C</th>
|
||||
<th title={t('Lesen')}>R</th>
|
||||
<th title={t('Bearbeiten')}>U</th>
|
||||
<th title={t('Löschen')}>D</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
)}
|
||||
</thead>
|
||||
<tbody>
|
||||
{rules.map(rule => (
|
||||
<AccessRuleRow
|
||||
key={rule.id}
|
||||
rule={rule}
|
||||
isDataContext={isDataContext}
|
||||
readOnly={readOnly}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccessRulesTable;
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
/**
|
||||
* AccessRules Components
|
||||
*
|
||||
* Components for editing RBAC access rules.
|
||||
*/
|
||||
|
||||
export { AccessRulesEditor } from './AccessRulesEditor';
|
||||
export { AccessLevelSelect } from './AccessLevelSelect';
|
||||
export { AccessRulesTable } from './AccessRulesTable';
|
||||
|
|
@ -1,482 +0,0 @@
|
|||
/* AddConnectionWizard styles */
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
padding: 1rem 1.5rem 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.stepDot {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
background: var(--bg-secondary, #f0f0f0);
|
||||
color: var(--text-secondary, #666);
|
||||
border: 2px solid var(--border-color, #ddd);
|
||||
transition: background 0.2s, border-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.stepDotActive {
|
||||
background: var(--primary-color, #f25843);
|
||||
border-color: var(--primary-color, #f25843);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stepDotDone {
|
||||
background: var(--success-color, #22c55e);
|
||||
border-color: var(--success-color, #22c55e);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stepDotHidden {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.stepContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.stepTitle {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stepBody {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stepHint {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary, #666);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Connector grid (Step 0) */
|
||||
.connectorGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.connectorCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 1.25rem 1rem;
|
||||
background: var(--surface-color);
|
||||
border: 2px solid var(--border-color, #ddd);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
.connectorCard:hover {
|
||||
border-color: var(--primary-color, #f25843);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.connectorIcon {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.connectorLabel {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Consent step (Step 1) */
|
||||
.consentIcon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
color: var(--primary-color, #f25843);
|
||||
}
|
||||
|
||||
.consentButtons {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.consentButtonYes {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: var(--primary-color, #f25843);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.consentButtonYes:hover {
|
||||
background: var(--primary-dark, #d94d3a);
|
||||
}
|
||||
|
||||
.consentButtonNo {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: var(--surface-color);
|
||||
color: var(--text-primary);
|
||||
border: 2px solid var(--border-color, #ddd);
|
||||
border-radius: 8px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.consentButtonNo:hover {
|
||||
border-color: var(--text-secondary, #888);
|
||||
background: var(--bg-secondary, #f5f5f5);
|
||||
}
|
||||
|
||||
/* Preferences step (Step 2) */
|
||||
.prefGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--border-color, #eee);
|
||||
}
|
||||
|
||||
.prefGroup:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.prefLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.prefLabelRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.prefIcon {
|
||||
color: var(--text-secondary, #666);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.prefCheck {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--primary-color, #f25843);
|
||||
}
|
||||
|
||||
.prefSelect {
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
background: var(--surface-color);
|
||||
color: var(--text-primary);
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.prefNumber {
|
||||
width: 80px;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
background: var(--surface-color);
|
||||
color: var(--text-primary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.prefHint {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary, #666);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Summary step (Step 3) */
|
||||
.summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.summaryRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.625rem 1rem;
|
||||
gap: 1rem;
|
||||
border-bottom: 1px solid var(--border-color, #eee);
|
||||
}
|
||||
|
||||
.summaryRow:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.summaryKey {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary, #666);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.summaryVal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Back button (step 1 consent screen) */
|
||||
.stepNavLeft {
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.navBack {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.25rem 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary, #666);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.navBack:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Cost estimate hint */
|
||||
.costHint {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.625rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--info-bg, #eff6ff);
|
||||
border: 1px solid var(--info-border, #bfdbfe);
|
||||
border-radius: 8px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.costHintIcon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
color: var(--info-color, #3b82f6);
|
||||
}
|
||||
|
||||
.costHint > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.costHintTitle {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.costTable {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.costLabel {
|
||||
color: var(--text-secondary, #555);
|
||||
padding-right: 1rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.costVal {
|
||||
font-weight: 600;
|
||||
color: var(--info-color, #1d4ed8);
|
||||
}
|
||||
|
||||
.costRowNeut .costLabel,
|
||||
.costRowNeut .costVal {
|
||||
padding-top: 0.125rem;
|
||||
}
|
||||
|
||||
.costRowNeut .costVal {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.costHintWarn {
|
||||
font-size: 0.75rem;
|
||||
color: #b45309;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.costHintNote {
|
||||
color: var(--text-secondary, #555);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
:global(.dark-theme) .costHint {
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .costVal {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
:global(.dark-theme) .costRowNeut .costVal,
|
||||
:global(.dark-theme) .costHintWarn {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.stepNav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
padding-top: 0.5rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.navBack {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--surface-color);
|
||||
color: var(--text-secondary, #666);
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.navBack:hover {
|
||||
background: var(--bg-secondary, #f5f5f5);
|
||||
}
|
||||
|
||||
.navNext {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 1.25rem;
|
||||
background: var(--primary-color, #f25843);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.navNext:hover {
|
||||
background: var(--primary-dark, #d94d3a);
|
||||
}
|
||||
|
||||
.navConnect {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.625rem 1.5rem;
|
||||
background: var(--primary-color, #f25843);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.navConnect:hover:not(:disabled) {
|
||||
background: var(--primary-dark, #d94d3a);
|
||||
}
|
||||
|
||||
.navConnect:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.patInput {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-family: monospace;
|
||||
margin: 12px 0 16px;
|
||||
}
|
||||
|
||||
.patInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary, #2563eb);
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
/* Dark theme */
|
||||
:global(.dark-theme) .connectorCard {
|
||||
background: var(--surface-color);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .prefSelect,
|
||||
:global(.dark-theme) .prefNumber {
|
||||
background: var(--surface-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .summary {
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .summaryRow {
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
|
@ -1,293 +0,0 @@
|
|||
/**
|
||||
* AddConnectionWizard
|
||||
*
|
||||
* Streamlined multi-step modal for adding a new connector.
|
||||
* Steps are connector-type-aware:
|
||||
* Base: Connector → Consent → Connect
|
||||
* Microsoft: Connector → Consent → Admin Consent (optional) → Connect
|
||||
* Infomaniak: Connector → Consent → PAT Input → (done)
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Modal } from '../UiComponents/Modal/Modal';
|
||||
import { FaGoogle, FaMicrosoft, FaTasks, FaCloud, FaCheck, FaArrowRight, FaShieldAlt } from 'react-icons/fa';
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import styles from './AddConnectionWizard.module.css';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ConnectorType = 'google' | 'msft' | 'clickup' | 'infomaniak';
|
||||
|
||||
type StepId = 'connector' | 'consent' | 'msftAdminConsent' | 'infomaniakPat' | 'connect';
|
||||
|
||||
interface WizardState {
|
||||
currentStep: StepId;
|
||||
connector: ConnectorType | null;
|
||||
knowledgeEnabled: boolean;
|
||||
infomaniakToken: string;
|
||||
adminConsentDone: boolean;
|
||||
}
|
||||
|
||||
const CONNECTOR_LABELS: Record<ConnectorType, string> = {
|
||||
google: 'Google',
|
||||
msft: 'Microsoft 365',
|
||||
clickup: 'ClickUp',
|
||||
infomaniak: 'Infomaniak',
|
||||
};
|
||||
|
||||
const CONNECTOR_ICONS: Record<ConnectorType, React.ReactNode> = {
|
||||
google: <FaGoogle style={{ color: '#4285f4' }} />,
|
||||
msft: <FaMicrosoft style={{ color: '#00a4ef' }} />,
|
||||
clickup: <FaTasks style={{ color: '#7b68ee' }} />,
|
||||
infomaniak: <FaCloud style={{ color: '#0098db' }} />,
|
||||
};
|
||||
|
||||
function _getSteps(connector: ConnectorType | null): StepId[] {
|
||||
if (connector === 'msft') return ['connector', 'consent', 'msftAdminConsent', 'connect'];
|
||||
if (connector === 'infomaniak') return ['connector', 'consent', 'infomaniakPat'];
|
||||
return ['connector', 'consent', 'connect'];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AddConnectionWizardProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConnect: (type: ConnectorType, knowledgeEnabled: boolean) => Promise<void>;
|
||||
onInfomaniakConnect?: (token: string, knowledgeEnabled: boolean) => Promise<void>;
|
||||
onMsftAdminConsent?: () => void;
|
||||
isConnecting?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onConnect,
|
||||
onInfomaniakConnect,
|
||||
onMsftAdminConsent,
|
||||
isConnecting = false,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
const [state, setState] = useState<WizardState>({
|
||||
currentStep: 'connector',
|
||||
connector: null,
|
||||
knowledgeEnabled: false,
|
||||
infomaniakToken: '',
|
||||
adminConsentDone: false,
|
||||
});
|
||||
|
||||
const reset = () =>
|
||||
setState({ currentStep: 'connector', connector: null, knowledgeEnabled: false, infomaniakToken: '', adminConsentDone: false });
|
||||
|
||||
const handleClose = () => { reset(); onClose(); };
|
||||
|
||||
const steps = _getSteps(state.connector);
|
||||
const stepIndex = steps.indexOf(state.currentStep);
|
||||
|
||||
const goNext = () => {
|
||||
const nextIdx = stepIndex + 1;
|
||||
if (nextIdx < steps.length) {
|
||||
setState(s => ({ ...s, currentStep: steps[nextIdx] }));
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
const prevIdx = stepIndex - 1;
|
||||
if (prevIdx >= 0) {
|
||||
setState(s => ({ ...s, currentStep: steps[prevIdx] }));
|
||||
}
|
||||
};
|
||||
|
||||
const selectConnector = (c: ConnectorType) => {
|
||||
setState(s => ({ ...s, connector: c, currentStep: 'consent' }));
|
||||
};
|
||||
|
||||
const setConsent = (enabled: boolean) => {
|
||||
setState(s => ({ ...s, knowledgeEnabled: enabled }));
|
||||
goNext();
|
||||
};
|
||||
|
||||
const handleFinalConnect = async () => {
|
||||
if (!state.connector) return;
|
||||
if (state.connector === 'infomaniak' && onInfomaniakConnect) {
|
||||
await onInfomaniakConnect(state.infomaniakToken, state.knowledgeEnabled);
|
||||
} else {
|
||||
await onConnect(state.connector, state.knowledgeEnabled);
|
||||
}
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={handleClose} title={t('Verbindung hinzufügen')} size="md" closeOnEscape>
|
||||
{/* Stepper */}
|
||||
<div className={styles.stepper}>
|
||||
{steps.map((s, i) => (
|
||||
<div
|
||||
key={s}
|
||||
className={[
|
||||
styles.stepDot,
|
||||
stepIndex === i ? styles.stepDotActive : '',
|
||||
stepIndex > i ? styles.stepDotDone : '',
|
||||
].join(' ')}
|
||||
>
|
||||
{stepIndex > i ? <FaCheck size={10} /> : i + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
{/* ---- Step: Connector ---- */}
|
||||
{state.currentStep === 'connector' && (
|
||||
<div className={styles.stepContent}>
|
||||
<h3 className={styles.stepTitle}>{t('Anbieter wählen')}</h3>
|
||||
<p className={styles.stepHint}>{t('Welchen Dienst möchtest du verbinden?')}</p>
|
||||
<div className={styles.connectorGrid}>
|
||||
{(['google', 'msft', 'clickup', 'infomaniak'] as ConnectorType[]).map(type => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
className={styles.connectorCard}
|
||||
onClick={() => selectConnector(type)}
|
||||
>
|
||||
<span className={styles.connectorIcon}>{CONNECTOR_ICONS[type]}</span>
|
||||
<span className={styles.connectorLabel}>{CONNECTOR_LABELS[type]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- Step: Consent ---- */}
|
||||
{state.currentStep === 'consent' && (
|
||||
<div className={styles.stepContent}>
|
||||
<h3 className={styles.stepTitle}>{t('Wissensdatenbank')}</h3>
|
||||
<p className={styles.stepBody}>
|
||||
{t('Möchtest du Inhalte aus dieser Verbindung in deine persönliche Wissensdatenbank aufnehmen, damit die KI beim Antworten auf Informationen aus {provider} zurückgreifen kann?', { provider: state.connector ? CONNECTOR_LABELS[state.connector] : t('diesem Dienst') })}
|
||||
</p>
|
||||
<p className={styles.stepHint}>
|
||||
{t('Du kannst dies später jederzeit in der UDB pro Datenquelle steuern.')}
|
||||
</p>
|
||||
<div className={styles.consentButtons}>
|
||||
<button type="button" className={styles.consentButtonYes} onClick={() => setConsent(true)}>
|
||||
<FaCheck /> {t('Ja, aktivieren')}
|
||||
</button>
|
||||
<button type="button" className={styles.consentButtonNo} onClick={() => setConsent(false)}>
|
||||
{t('Nein, überspringen')}
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.stepNavLeft}>
|
||||
<button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- Step: MSFT Admin Consent ---- */}
|
||||
{state.currentStep === 'msftAdminConsent' && (
|
||||
<div className={styles.stepContent}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||
<FaShieldAlt size={32} style={{ color: '#00a4ef' }} />
|
||||
</div>
|
||||
<h3 className={styles.stepTitle}>{t('Organisations-Zustimmung (optional)')}</h3>
|
||||
<p className={styles.stepBody}>
|
||||
{t('Falls du Mandant-Administrator bist, kannst du jetzt für deine ganze Organisation zustimmen. So müssen andere Benutzer nicht einzeln bestätigen.')}
|
||||
</p>
|
||||
<p className={styles.stepHint}>
|
||||
{t('Wenn du kein Admin bist oder dies später tun möchtest, überspringe diesen Schritt.')}
|
||||
</p>
|
||||
<div className={styles.consentButtons}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.consentButtonYes}
|
||||
onClick={() => { onMsftAdminConsent?.(); setState(s => ({ ...s, adminConsentDone: true })); goNext(); }}
|
||||
>
|
||||
<FaShieldAlt /> {t('Admin-Zustimmung erteilen')}
|
||||
</button>
|
||||
<button type="button" className={styles.consentButtonNo} onClick={goNext}>
|
||||
{t('Überspringen')}
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.stepNavLeft}>
|
||||
<button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- Step: Infomaniak PAT ---- */}
|
||||
{state.currentStep === 'infomaniakPat' && (
|
||||
<div className={styles.stepContent}>
|
||||
<h3 className={styles.stepTitle}>{t('Infomaniak Personal Access Token')}</h3>
|
||||
<p className={styles.stepBody}>
|
||||
{t('Erstelle einen Personal Access Token in deinem Infomaniak-Konto und füge ihn hier ein.')}
|
||||
</p>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="pat_..."
|
||||
value={state.infomaniakToken}
|
||||
onChange={e => setState(s => ({ ...s, infomaniakToken: e.target.value }))}
|
||||
className={styles.patInput}
|
||||
autoFocus
|
||||
/>
|
||||
<div className={styles.stepNav}>
|
||||
<button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.navConnect}
|
||||
onClick={handleFinalConnect}
|
||||
disabled={isConnecting || !state.infomaniakToken.trim()}
|
||||
>
|
||||
{isConnecting ? t('Verbinden…') : t('Verbinden')}
|
||||
{!isConnecting && <FaArrowRight size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- Step: Connect ---- */}
|
||||
{state.currentStep === 'connect' && (
|
||||
<div className={styles.stepContent}>
|
||||
<h3 className={styles.stepTitle}>{t('Verbindung herstellen')}</h3>
|
||||
<div className={styles.summary}>
|
||||
<div className={styles.summaryRow}>
|
||||
<span className={styles.summaryKey}>{t('Anbieter')}</span>
|
||||
<span className={styles.summaryVal}>
|
||||
{state.connector && CONNECTOR_ICONS[state.connector]}
|
||||
{state.connector ? CONNECTOR_LABELS[state.connector] : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.summaryRow}>
|
||||
<span className={styles.summaryKey}>{t('Wissensdatenbank')}</span>
|
||||
<span className={styles.summaryVal}>
|
||||
{state.knowledgeEnabled ? t('Aktiv') : t('Nicht aktiv')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.stepNav}>
|
||||
<button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.navConnect}
|
||||
onClick={handleFinalConnect}
|
||||
disabled={isConnecting}
|
||||
>
|
||||
{isConnecting ? t('Verbinden…') : t('Mit {provider} verbinden', { provider: state.connector ? CONNECTOR_LABELS[state.connector] : '…' })}
|
||||
{!isConnecting && <FaArrowRight size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddConnectionWizard;
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
/**
|
||||
* ChatInput -- Shared chat input component.
|
||||
*
|
||||
* Simple text input with send button, usable by both Workspace and Editor.
|
||||
*/
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string) => void;
|
||||
isProcessing?: boolean;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
autoFocus?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const ChatInput: React.FC<ChatInputProps> = ({
|
||||
onSend,
|
||||
isProcessing,
|
||||
placeholder,
|
||||
disabled,
|
||||
autoFocus = true,
|
||||
style,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const resolvedPlaceholder = placeholder ?? t('Nachricht eingeben…');
|
||||
const [value, setValue] = useState('');
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus) inputRef.current?.focus();
|
||||
}, [autoFocus]);
|
||||
|
||||
const _handleSend = useCallback(() => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || isProcessing || disabled) return;
|
||||
onSend(trimmed);
|
||||
setValue('');
|
||||
}, [value, isProcessing, disabled, onSend]);
|
||||
|
||||
const _handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
_handleSend();
|
||||
}
|
||||
},
|
||||
[_handleSend]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
borderTop: '1px solid var(--border-color, #e0e0e0)',
|
||||
alignItems: 'flex-end',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={_handleKeyDown}
|
||||
placeholder={resolvedPlaceholder}
|
||||
disabled={isProcessing || disabled}
|
||||
rows={1}
|
||||
style={{
|
||||
flex: 1,
|
||||
resize: 'none',
|
||||
border: '1px solid var(--border-color, #ddd)',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
fontSize: '13px',
|
||||
fontFamily: 'inherit',
|
||||
outline: 'none',
|
||||
minHeight: '36px',
|
||||
maxHeight: '120px',
|
||||
background: 'var(--bg-primary, #fff)',
|
||||
color: 'var(--text-primary, #333)',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={_handleSend}
|
||||
disabled={!value.trim() || isProcessing || disabled}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
background: !value.trim() || isProcessing || disabled ? '#ccc' : 'var(--color-primary, #2563eb)',
|
||||
color: '#fff',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
cursor: !value.trim() || isProcessing || disabled ? 'not-allowed' : 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{isProcessing ? '…' : t('Senden')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
/**
|
||||
* ChatMessageList -- Shared chat message display component.
|
||||
*
|
||||
* Renders a scrollable list of messages with Markdown support.
|
||||
* Used by both the Workspace ChatStream and the Editor ChatPanel.
|
||||
*/
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
interface ChatMessageListProps {
|
||||
messages: ChatMessage[];
|
||||
isProcessing?: boolean;
|
||||
emptyMessage?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const _roleColors: Record<string, string> = {
|
||||
user: 'var(--color-primary, #2563eb)',
|
||||
assistant: 'var(--text-primary, #333)',
|
||||
system: 'var(--text-secondary, #888)',
|
||||
};
|
||||
|
||||
export const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
messages,
|
||||
isProcessing,
|
||||
emptyMessage,
|
||||
style,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const resolvedEmpty = emptyMessage ?? t('Noch keine Nachrichten.');
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
padding: '12px 16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{messages.length === 0 && (
|
||||
<div style={{ color: 'var(--text-secondary, #888)', fontSize: '13px', textAlign: 'center', marginTop: '24px' }}>
|
||||
{resolvedEmpty}
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
background: msg.role === 'user' ? 'var(--bg-secondary, #f5f5f5)' : 'transparent',
|
||||
fontSize: '13px',
|
||||
lineHeight: 1.5,
|
||||
color: _roleColors[msg.role] || 'var(--text-primary, #333)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, fontSize: '11px', marginBottom: '4px', textTransform: 'uppercase', color: 'var(--text-secondary, #888)' }}>
|
||||
{msg.role}
|
||||
</div>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{msg.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
))}
|
||||
{isProcessing && (
|
||||
<div style={{ color: 'var(--text-secondary, #888)', fontSize: '12px', fontStyle: 'italic' }}>
|
||||
{t('Wird verarbeitet…')}
|
||||
</div>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export { ChatMessageList } from './ChatMessageList';
|
||||
export type { ChatMessage } from './ChatMessageList';
|
||||
export { ChatInput } from './ChatInput';
|
||||
|
|
@ -1,965 +0,0 @@
|
|||
|
||||
.previewContainer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-background)!important;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Ensure all child elements have white background */
|
||||
.previewContainer * {
|
||||
background-color: var(--color-background) !important;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 2rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid var(--color-primary);
|
||||
border-top: 4px solid var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Error State */
|
||||
.errorContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 2rem;
|
||||
color: var(--color-error);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.errorIcon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.retryButton {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.retryButton:hover {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
/* Image Preview */
|
||||
.previewImage {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Iframe Preview (for PDFs, text files, etc.) */
|
||||
.previewIframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: var(--color-background) !important;
|
||||
color: var(--color-text) !important;
|
||||
}
|
||||
|
||||
/* Force iframe content to have white background */
|
||||
.previewIframe::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--color-background) !important;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* Unsupported File Type */
|
||||
.unsupportedContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 2rem;
|
||||
color: var(--color-text);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.unsupportedIcon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.fileName {
|
||||
font-weight: 500;
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-text);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.previewContainer {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.loadingContainer,
|
||||
.errorContainer,
|
||||
.unsupportedContainer {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.previewImage {
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.previewIframe {
|
||||
height: 70vh;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* JSON Container */
|
||||
.jsonContainer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-background) !important;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* JSON Header */
|
||||
.jsonHeader {
|
||||
background: var(--color-background) !important;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.jsonHeaderRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.jsonTitle {
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.jsonSize {
|
||||
color: var(--color-text);
|
||||
font-size: 12px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Table Layout - Row-wise rendering */
|
||||
.jsonTable {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background: var(--color-background) !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Collapsible functionality */
|
||||
.collapseButton {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--color-gray);
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s;
|
||||
font-weight: bold;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.collapseButton:hover {
|
||||
background-color: var(--color-gray-hover);
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.collapseButton:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.valueContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: auto;
|
||||
overflow: visible !important;
|
||||
min-width: 20rem;
|
||||
}
|
||||
|
||||
.jsonValuePreview {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
background: var(--color-background);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid var(--color-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.jsonValue {
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.collapsedRow {
|
||||
background-color: #f8f9fa;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.collapsedRow .jsonTableKey {
|
||||
border-left: 2px solid var(--color-secondary);
|
||||
}
|
||||
|
||||
.notCollapsedRow {
|
||||
background-color: #f8f9fa;
|
||||
border-left: 2px solid var(--color-background);
|
||||
}
|
||||
|
||||
.collapsedRow:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.jsonTableBody {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: var(--color-background) !important;
|
||||
}
|
||||
|
||||
.jsonTableRow {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--color-primary);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.jsonTableRow:hover {
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.jsonTableKey {
|
||||
flex: 0 0 200px;
|
||||
padding: 12px 16px;
|
||||
border-right: 1px solid var(--color-primary);
|
||||
border-left: 2px solid var(--color-background);
|
||||
background: var(--color-background);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
box-sizing: border-box;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.jsonTableValue {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
background: var(--color-background) !important;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.jsonKey {
|
||||
font-family: 'Fira Code', 'Monaco', 'Cascadia Code', 'Roboto Mono', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
word-break: break-all;
|
||||
background: transparent;
|
||||
line-height: 1.4;
|
||||
width: 100%;
|
||||
|
||||
word-wrap: break-word;
|
||||
white-space: -wrap;
|
||||
}
|
||||
|
||||
.jsonValue {
|
||||
font-family: 'Fira Code', 'Monaco', 'Cascadia Code', 'Roboto Mono', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
background: transparent;
|
||||
line-height: 1.4;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
/* Type-specific styling */
|
||||
.jsonValueString {
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
font-family: 'Fira Code', 'Monaco', 'Cascadia Code', 'Roboto Mono', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.jsonValueNumber {
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
white-space: nowrap !important;
|
||||
word-break: keep-all !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.jsonValueBoolean {
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
white-space: nowrap !important;
|
||||
word-break: keep-all !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.jsonValueNull {
|
||||
color: var(--color-text);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.jsonValueUndefined {
|
||||
color: var(--color-text);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.jsonValueArray {
|
||||
color: #fd7e14;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.jsonValueObject {
|
||||
color: #6f42c1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.jsonValueTimestamp {
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
font-family: 'Fira Code', 'Monaco', 'Cascadia Code', 'Roboto Mono', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* Dark mode support for JSON table layout */
|
||||
[data-theme="dark"] .jsonTableHeader {
|
||||
background: #2d3748;
|
||||
border-bottom-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .jsonTableKeyHeader {
|
||||
background: #2d3748;
|
||||
border-right-color: #4a5568;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .jsonTableValueHeader {
|
||||
background: #2d3748;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .jsonTableBody {
|
||||
background: #1a202c !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .jsonTableRow {
|
||||
border-bottom-color: #2d3748;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .jsonTableRow:hover {
|
||||
background: #2d3748;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .jsonTableKey {
|
||||
background: #2d3748;
|
||||
border-right-color: #4a5568;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .jsonTableValue {
|
||||
background: #1a202c !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .jsonKey {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .jsonValue {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .jsonValueString {
|
||||
color: #63b3ed;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .jsonValueNumber {
|
||||
color: #68d391;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .jsonValueBoolean {
|
||||
color: #fc8181;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .jsonValueNull,
|
||||
[data-theme="dark"] .jsonValueUndefined {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .jsonValueArray {
|
||||
color: #f6ad55;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .jsonValueObject {
|
||||
color: #b794f6;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .jsonValueTimestamp {
|
||||
color: #4fd1c7;
|
||||
}
|
||||
|
||||
/* Dark mode for collapsible functionality */
|
||||
[data-theme="dark"] .collapseButton {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .collapseButton:hover {
|
||||
background-color: #4a5568;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .jsonValuePreview {
|
||||
color: #a0aec0;
|
||||
background: #2d3748;
|
||||
border-left-color: #4a5568;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .collapsedRow {
|
||||
background-color: #2d3748;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .collapsedRow .jsonTableKey {
|
||||
border-left-color: #63b3ed;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .collapsedRow:hover {
|
||||
background-color: #4a5568;
|
||||
}
|
||||
|
||||
/* Nested Table Styles */
|
||||
.nestedTable {
|
||||
margin-top: 8px;
|
||||
border-radius: 4px;
|
||||
background: #f8f9fa;
|
||||
overflow: visible !important;
|
||||
width: auto;
|
||||
min-width: 20rem;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
.nestedTableKeyHeader {
|
||||
flex: 0 0 150px;
|
||||
padding: 8px 12px;
|
||||
border-right: 1px solid #dee2e6;
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.nestedTableValueHeader {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.nestedTableBody {
|
||||
background: var(--color-background) !important;
|
||||
}
|
||||
|
||||
.nestedTableRow {
|
||||
display: flex;
|
||||
transition: background-color 0.2s ease;
|
||||
overflow: visible !important;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.nestedTableRow:hover {
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.nestedTableRow:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.nestedTableKey {
|
||||
flex: 0 0 150px;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-background);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nestedTableValue {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-background) !important;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
white-space: nowrap !important;
|
||||
word-break: keep-all !important;
|
||||
overflow: visible !important;
|
||||
min-width: 20rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.nestedValueSummary {
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Array items display */
|
||||
.arrayItems {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0px;
|
||||
margin-left: -0.4rem;
|
||||
}
|
||||
|
||||
/* Array items when no key is shown (should span full width) */
|
||||
.arrayItemsFullWidth {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
margin-left: -183px;
|
||||
padding-left: 16px;
|
||||
width: calc(100% + 183px);
|
||||
}
|
||||
|
||||
.arrayItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2px 6px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.arrayValue {
|
||||
color: var(--color-text);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.arrayPreview {
|
||||
color: var(--color-light-gray);
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
padding: 4px 8px;
|
||||
background: var(--color-background);
|
||||
border-radius: 3px;
|
||||
border: 1px solid green;
|
||||
}
|
||||
|
||||
/* JSON Syntax Highlighting */
|
||||
.jsonCode {
|
||||
color: #212529;
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
/* Key highlighting */
|
||||
.jsonCode {
|
||||
background: linear-gradient(90deg,
|
||||
transparent 0%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Add some basic syntax highlighting using CSS */
|
||||
.jsonCode::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
/* Strings - green */
|
||||
linear-gradient(90deg, transparent 0%, transparent 100%),
|
||||
/* Numbers - blue */
|
||||
linear-gradient(90deg, transparent 0%, transparent 100%),
|
||||
/* Booleans - purple */
|
||||
linear-gradient(90deg, transparent 0%, transparent 100%),
|
||||
/* Null - gray */
|
||||
linear-gradient(90deg, transparent 0%, transparent 100%);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.jsonPreview::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--color-background) !important;
|
||||
}
|
||||
|
||||
.jsonPreview::-webkit-scrollbar-track {
|
||||
background: var(--color-background) !important;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.jsonPreview::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1 !important;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.jsonPreview::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8 !important;
|
||||
}
|
||||
|
||||
/* JSON Structure Indicators */
|
||||
.jsonPreview {
|
||||
position: relative;
|
||||
background: var(--color-background) !important;
|
||||
}
|
||||
|
||||
/* Add line numbers */
|
||||
.jsonPreview::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 40px;
|
||||
height: 100%;
|
||||
background: var(--color-background) !important;
|
||||
border-right: 1px solid #e9ecef;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.previewIframe {
|
||||
background: white !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
/* Only apply dark background for non-HTML content */
|
||||
.previewIframe[data-mime-type*="application/pdf"] {
|
||||
background: #1a1a1a !important;
|
||||
}
|
||||
|
||||
/* Keep JSON files with light background for readability */
|
||||
.previewIframe[data-mime-type*="application/json"] {
|
||||
background: var(--color-background) !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
.jsonPreview {
|
||||
background: var(--color-background) !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
/* Dark mode for JSON container */
|
||||
.jsonContainer {
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.jsonHeader {
|
||||
background: #2d2d30;
|
||||
border-bottom-color: #3e3e42;
|
||||
}
|
||||
|
||||
.jsonTitle {
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.jsonSize {
|
||||
color: #969696;
|
||||
}
|
||||
|
||||
.jsonPreview {
|
||||
background: #1e1e1e !important;
|
||||
color: #d4d4d4 !important;
|
||||
}
|
||||
|
||||
.jsonPreview::before {
|
||||
background: #2d2d30;
|
||||
border-right-color: #3e3e42;
|
||||
}
|
||||
|
||||
.jsonPreview::-webkit-scrollbar-track {
|
||||
background: #2d2d30;
|
||||
}
|
||||
|
||||
.jsonPreview::-webkit-scrollbar-thumb {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.jsonPreview::-webkit-scrollbar-thumb:hover {
|
||||
background: #777;
|
||||
}
|
||||
}
|
||||
|
||||
/* Text Container Styles */
|
||||
.textContainer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-background);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.textHeader {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.textTitle {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 0.5rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.warningMessage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: 6px;
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.warningIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.2rem;
|
||||
color: var(--color-secondary);
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.1rem;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.warningText {
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.textPreview {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
margin: 0;
|
||||
background: var(--color-background);
|
||||
overflow: auto;
|
||||
font-family: 'Fira Code', 'Monaco', 'Cascadia Code', 'Roboto Mono', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text);
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.textCode {
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
white-space: inherit;
|
||||
word-wrap: inherit;
|
||||
}
|
||||
|
||||
.contentPreviewPopup {
|
||||
/* Popup-specific styles if needed */
|
||||
}
|
||||
|
||||
/* ── Word (docx-preview) ────────────────────────────────────────────── */
|
||||
.docxContainer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background: #e5e5e5;
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
.docxContainer * {
|
||||
background-color: initial !important;
|
||||
}
|
||||
|
||||
.docxLoading {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* The docx-preview library creates a wrapper with .docx-wrapper containing
|
||||
section elements that are sized and styled like real pages. */
|
||||
.docxContainer :global(.docx-wrapper) {
|
||||
background: transparent !important;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.docxContainer :global(.docx-wrapper > section.docx) {
|
||||
background: #fff !important;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
|
||||
margin: 0 auto 1.5rem auto;
|
||||
}
|
||||
|
||||
.docxContainer :global(section.docx) * {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* ── Excel (manual table) ───────────────────────────────────────────── */
|
||||
.excelTabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.excelTab {
|
||||
padding: 0.35rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-bottom: none;
|
||||
border-radius: 4px 4px 0 0;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.excelTabActive {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-on-primary, #fff);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.excelSheet {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.excelTable {
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
font-family: Calibri, "Segoe UI", Arial, sans-serif;
|
||||
table-layout: fixed;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.excelTable td,
|
||||
.excelTable th {
|
||||
border: 1px solid #d0d7de;
|
||||
padding: 2px 6px;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text);
|
||||
background: var(--color-background) !important;
|
||||
}
|
||||
|
||||
.excelCorner,
|
||||
.excelColHeader,
|
||||
.excelRowHeader {
|
||||
background: var(--color-surface, #f3f4f6) !important;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary, #4b5563);
|
||||
text-align: center !important;
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.excelColHeader {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.excelRowHeader {
|
||||
left: 0;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.excelCorner {
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.excelCell {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
|
|
@ -1,265 +0,0 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { IoIosDownload, IoIosCopy } from 'react-icons/io';
|
||||
|
||||
import { Popup, PopupAction } from '../UiComponents/Popup/Popup';
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import { useFileOperations } from '../../hooks/useFiles';
|
||||
import {
|
||||
JsonRenderer,
|
||||
ImageRenderer,
|
||||
TextRenderer,
|
||||
PdfRenderer,
|
||||
HtmlRenderer,
|
||||
ApplicationRenderer,
|
||||
UnsupportedRenderer,
|
||||
LoadingRenderer,
|
||||
ErrorRenderer,
|
||||
WordRenderer,
|
||||
ExcelRenderer,
|
||||
isWordMimeType,
|
||||
isExcelMimeType,
|
||||
} from './renderers';
|
||||
import styles from './ContentPreview.module.css';
|
||||
|
||||
export interface ContentPreviewProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export function ContentPreview({
|
||||
isOpen,
|
||||
onClose,
|
||||
fileId,
|
||||
fileName,
|
||||
mimeType,
|
||||
}: ContentPreviewProps) {
|
||||
const { t } = useLanguage();
|
||||
const {
|
||||
handleFilePreview,
|
||||
handleFileDownload,
|
||||
previewingFiles,
|
||||
previewError,
|
||||
downloadingFiles,
|
||||
} = useFileOperations();
|
||||
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [blob, setBlob] = useState<Blob | null>(null);
|
||||
const [textContent, setTextContent] = useState<string | null>(null);
|
||||
const [resolvedMime, setResolvedMime] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copySuccess, setCopySuccess] = useState(false);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
setPreviewUrl(prev => {
|
||||
if (prev) window.URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
setBlob(null);
|
||||
setTextContent(null);
|
||||
setResolvedMime(null);
|
||||
}, []);
|
||||
|
||||
const loadPreview = useCallback(async () => {
|
||||
setError(null);
|
||||
cleanup();
|
||||
|
||||
const result = await handleFilePreview(fileId, fileName, mimeType);
|
||||
if (!result.success) {
|
||||
setError(result.error || t('Vorschau konnte nicht geladen werden.'));
|
||||
return;
|
||||
}
|
||||
|
||||
setPreviewUrl(result.previewUrl ?? null);
|
||||
setBlob(result.blob ?? null);
|
||||
setTextContent(result.textContent ?? null);
|
||||
setResolvedMime(result.mimeType ?? mimeType ?? null);
|
||||
}, [cleanup, fileId, fileName, handleFilePreview, mimeType, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !fileId) {
|
||||
cleanup();
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileId === 'undefined' || fileId === 'null') {
|
||||
setError(t('Ungültige Datei-ID'));
|
||||
return;
|
||||
}
|
||||
if (!fileName || fileName === 'Unknown Item' || fileName === 'Unbekanntes Element') {
|
||||
setError(t('Dateiname nicht verfügbar'));
|
||||
return;
|
||||
}
|
||||
|
||||
loadPreview();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen, fileId, fileName]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewUrl) window.URL.revokeObjectURL(previewUrl);
|
||||
};
|
||||
}, [previewUrl]);
|
||||
|
||||
const effectiveMime = resolvedMime ?? mimeType;
|
||||
const isPreviewing = previewingFiles.has(fileId);
|
||||
const hasError = error || previewError;
|
||||
|
||||
const handleCopyContent = async () => {
|
||||
if (!textContent) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(textContent);
|
||||
setCopySuccess(true);
|
||||
setTimeout(() => setCopySuccess(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy content:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadFile = async () => {
|
||||
try {
|
||||
await handleFileDownload(fileId, fileName);
|
||||
} catch (err) {
|
||||
console.error('Failed to download file:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const actions: PopupAction[] = [
|
||||
...(textContent
|
||||
? [
|
||||
{
|
||||
label: copySuccess ? t('In die Zwischenablage kopiert') : '',
|
||||
icon: copySuccess ? '✓' : <IoIosCopy />,
|
||||
onClick: handleCopyContent,
|
||||
disabled: !textContent,
|
||||
variant: 'primary' as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: '',
|
||||
icon: downloadingFiles.has(fileId) ? undefined : <IoIosDownload />,
|
||||
onClick: handleDownloadFile,
|
||||
disabled: downloadingFiles.has(fileId),
|
||||
loading: downloadingFiles.has(fileId),
|
||||
variant: 'success' as const,
|
||||
},
|
||||
];
|
||||
|
||||
const renderPreview = () => {
|
||||
if (isPreviewing) return <LoadingRenderer />;
|
||||
if (hasError) return <ErrorRenderer error={hasError} onRetry={loadPreview} />;
|
||||
if (!blob || !effectiveMime) return null;
|
||||
|
||||
if (effectiveMime === 'application/json' && textContent) {
|
||||
return <JsonRenderer previewContent={textContent} fileName={fileName} />;
|
||||
}
|
||||
|
||||
if (isWordMimeType(effectiveMime, fileName)) {
|
||||
return (
|
||||
<WordRenderer
|
||||
blob={blob}
|
||||
fileName={fileName}
|
||||
mimeType={effectiveMime}
|
||||
onError={msg => setError(msg)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isExcelMimeType(effectiveMime, fileName)) {
|
||||
return (
|
||||
<ExcelRenderer
|
||||
blob={blob}
|
||||
fileName={fileName}
|
||||
onError={msg => setError(msg)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const mimePrefix = effectiveMime.split('/')[0];
|
||||
|
||||
switch (mimePrefix) {
|
||||
case 'image':
|
||||
if (!previewUrl) return null;
|
||||
return (
|
||||
<ImageRenderer
|
||||
previewUrl={previewUrl}
|
||||
fileName={fileName}
|
||||
onError={() => setError(t('Bildvorschau konnte nicht geladen werden'))}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'text':
|
||||
if (effectiveMime === 'text/html' && previewUrl) {
|
||||
return (
|
||||
<HtmlRenderer
|
||||
previewUrl={previewUrl}
|
||||
fileName={fileName}
|
||||
onError={() => setError(t('HTML-Vorschau konnte nicht geladen werden'))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TextRenderer
|
||||
previewUrl={previewUrl ?? undefined}
|
||||
previewContent={textContent ?? undefined}
|
||||
fileName={fileName}
|
||||
mimeType={effectiveMime}
|
||||
onError={() => setError(t('Textvorschau konnte nicht geladen werden'))}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'application':
|
||||
if (effectiveMime === 'application/pdf' && previewUrl) {
|
||||
return (
|
||||
<PdfRenderer
|
||||
previewUrl={previewUrl}
|
||||
fileName={fileName}
|
||||
onError={() => setError(t('PDF-Vorschau konnte nicht geladen werden'))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (effectiveMime === 'application/html' && previewUrl) {
|
||||
return (
|
||||
<HtmlRenderer
|
||||
previewUrl={previewUrl}
|
||||
fileName={fileName}
|
||||
onError={() => setError(t('HTML-Vorschau konnte nicht geladen werden'))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ApplicationRenderer
|
||||
previewUrl={previewUrl ?? ''}
|
||||
fileName={fileName}
|
||||
mimeType={effectiveMime}
|
||||
onError={() =>
|
||||
setError(t('Vorschau wird für dieses Format nicht unterstützt'))
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return <UnsupportedRenderer previewUrl={previewUrl ?? ''} fileName={fileName} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popup
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={`${t('Dateivorschau')}: ${fileName}`}
|
||||
size="fullscreen"
|
||||
className={styles.contentPreviewPopup}
|
||||
actions={actions}
|
||||
>
|
||||
<div className={styles.previewContainer}>{renderPreview()}</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
export default ContentPreview;
|
||||
|
|
@ -1,346 +0,0 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { IoIosDownload } from 'react-icons/io';
|
||||
import { Popup, PopupAction } from '../UiComponents/Popup/Popup';
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import { PdfRenderer, LoadingRenderer } from './renderers';
|
||||
import styles from './ContentPreview.module.css';
|
||||
|
||||
export interface UrlContentPreviewProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
url: string;
|
||||
fileName: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export function UrlContentPreview({
|
||||
isOpen,
|
||||
onClose,
|
||||
url,
|
||||
fileName,
|
||||
mimeType = 'application/pdf'
|
||||
}: UrlContentPreviewProps) {
|
||||
const { t } = useLanguage();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const [warning, setWarning] = useState<string | null>(null);
|
||||
const [showPdfAnyway, setShowPdfAnyway] = useState(false);
|
||||
const [usePdfJs, setUsePdfJs] = useState(false);
|
||||
|
||||
// Reset state when modal opens/closes
|
||||
useEffect(() => {
|
||||
if (isOpen && url) {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setWarning(null);
|
||||
setHasLoaded(false);
|
||||
setShowPdfAnyway(false);
|
||||
setUsePdfJs(false); // Start with iframe
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
setWarning(null);
|
||||
setHasLoaded(false);
|
||||
setShowPdfAnyway(false);
|
||||
setUsePdfJs(false);
|
||||
}
|
||||
}, [isOpen, url]);
|
||||
|
||||
const handleDownload = () => {
|
||||
try {
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (err) {
|
||||
console.error('Failed to download file:', err);
|
||||
// Fallback: open in new tab
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
};
|
||||
|
||||
// PDF load is handled by the PdfRenderer's onError callback;
|
||||
// successful load is implicit when no error occurs.
|
||||
|
||||
const handlePdfError = () => {
|
||||
// Try PDF.js as fallback instead of showing error immediately
|
||||
if (!usePdfJs) {
|
||||
console.log('Iframe failed, switching to PDF.js fallback');
|
||||
setUsePdfJs(true);
|
||||
setIsLoading(true); // Restart loading with PDF.js
|
||||
setError(null);
|
||||
setWarning(null);
|
||||
return;
|
||||
}
|
||||
// If PDF.js also fails, show error
|
||||
setIsLoading(false);
|
||||
setError(t('Fehler beim Laden des PDFs'));
|
||||
setShowPdfAnyway(true);
|
||||
};
|
||||
|
||||
const handleOpenInNewTab = () => {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
// Set up progressive timeout for loading (schnellerer Fallback)
|
||||
useEffect(() => {
|
||||
if (isOpen && isLoading && !hasLoaded) {
|
||||
// Schnellerer Timeout für externe PDFs: Warning after 3s, Error after 5s
|
||||
const QUICK_TIMEOUT = 5000; // 5 Sekunden
|
||||
const WARNING_TIMEOUT = 3000; // 3 Sekunden Warnung
|
||||
|
||||
const warningTimeout = setTimeout(() => {
|
||||
if (isLoading && !hasLoaded) {
|
||||
setWarning(
|
||||
t('PDF lädt langsam. Sie können es auch direkt herunterladen oder in einem neuen Tab öffnen.')
|
||||
);
|
||||
// Don't set isLoading to false - let it continue
|
||||
}
|
||||
}, WARNING_TIMEOUT);
|
||||
|
||||
const errorTimeout = setTimeout(() => {
|
||||
if (isLoading && !hasLoaded && !usePdfJs) {
|
||||
// Try PDF.js as fallback after 5 seconds
|
||||
console.log('PDF loading timeout, switching to PDF.js fallback');
|
||||
setUsePdfJs(true);
|
||||
setIsLoading(true); // Restart loading with PDF.js
|
||||
setWarning(t('PDF lädt langsam. Alternative Anzeigemethode wird versucht…'));
|
||||
} else if (isLoading && !hasLoaded && usePdfJs) {
|
||||
// PDF.js also failed, show error
|
||||
setShowPdfAnyway(true);
|
||||
setError(t('PDF lädt langsam, bitte verwenden'));
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, QUICK_TIMEOUT);
|
||||
|
||||
return () => {
|
||||
clearTimeout(warningTimeout);
|
||||
clearTimeout(errorTimeout);
|
||||
};
|
||||
}
|
||||
}, [isOpen, isLoading, hasLoaded, usePdfJs, t]);
|
||||
|
||||
// Validate URL
|
||||
useEffect(() => {
|
||||
if (isOpen && url) {
|
||||
try {
|
||||
new URL(url);
|
||||
} catch (e) {
|
||||
setError(t('Ungültige URL'));
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [isOpen, url]);
|
||||
|
||||
// Create action buttons for the popup header
|
||||
const actions: PopupAction[] = [
|
||||
{
|
||||
label: String(''),
|
||||
icon: <IoIosDownload />,
|
||||
onClick: handleDownload,
|
||||
disabled: false,
|
||||
variant: 'success' as const
|
||||
}
|
||||
];
|
||||
|
||||
const renderPreview = () => {
|
||||
// Show warning but continue loading
|
||||
const showWarning = warning && !error;
|
||||
|
||||
// For PDF files, always try to show PDF (even if there's an error)
|
||||
if (mimeType === 'application/pdf' && (hasLoaded || showPdfAnyway || !error)) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{showWarning && (
|
||||
<div style={{
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'var(--color-warning-bg, #fef3c7)',
|
||||
borderBottom: '1px solid var(--color-border, #e5e7eb)',
|
||||
color: 'var(--color-warning-text, #92400e)',
|
||||
fontSize: '0.875rem'
|
||||
}}>
|
||||
⚠️ {warning}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'var(--color-error-bg, #fee2e2)',
|
||||
borderBottom: '1px solid var(--color-border, #e5e7eb)',
|
||||
color: 'var(--color-error-text, #991b1b)',
|
||||
fontSize: '0.875rem'
|
||||
}}>
|
||||
⚠️ {error}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={handleOpenInNewTab}
|
||||
className={styles.retryButton}
|
||||
style={{
|
||||
background: 'var(--color-primary, #3b82f6)',
|
||||
fontSize: '0.75rem',
|
||||
padding: '0.5rem 1rem'
|
||||
}}
|
||||
>
|
||||
{t('In neuem Tab öffnen')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className={styles.retryButton}
|
||||
style={{
|
||||
background: 'var(--color-success, #10b981)',
|
||||
fontSize: '0.75rem',
|
||||
padding: '0.5rem 1rem'
|
||||
}}
|
||||
>
|
||||
{t('Herunterladen')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, position: 'relative' }}>
|
||||
<PdfRenderer
|
||||
previewUrl={url}
|
||||
fileName={fileName}
|
||||
onError={handlePdfError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error only if we're not showing PDF anyway
|
||||
if (error && !showPdfAnyway) {
|
||||
return (
|
||||
<div className={styles.errorContainer}>
|
||||
<div className={styles.errorIcon}>⚠️</div>
|
||||
<p>{error}</p>
|
||||
<div style={{ display: 'flex', gap: '1rem', marginTop: '1rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setWarning(null);
|
||||
setIsLoading(true);
|
||||
setHasLoaded(false);
|
||||
setShowPdfAnyway(false);
|
||||
setUsePdfJs(false); // Reset to iframe
|
||||
}}
|
||||
className={styles.retryButton}
|
||||
>
|
||||
{t('Wiederholen')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleOpenInNewTab}
|
||||
className={styles.retryButton}
|
||||
style={{
|
||||
background: 'var(--color-primary, #3b82f6)',
|
||||
fontSize: '0.875rem',
|
||||
padding: '0.625rem 1.25rem',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
{t('In neuem Tab öffnen')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className={styles.retryButton}
|
||||
style={{
|
||||
background: 'var(--color-success, #10b981)',
|
||||
fontSize: '0.875rem',
|
||||
padding: '0.625rem 1.25rem',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
{t('Datei herunterladen')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading && !hasLoaded && !showPdfAnyway) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{warning && (
|
||||
<div style={{
|
||||
padding: '1rem',
|
||||
background: 'var(--color-warning-bg, #fef3c7)',
|
||||
borderBottom: '1px solid var(--color-border, #e5e7eb)',
|
||||
color: 'var(--color-warning-text, #92400e)',
|
||||
fontSize: '0.875rem',
|
||||
marginBottom: '1rem'
|
||||
}}>
|
||||
⚠️ {warning}
|
||||
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={handleOpenInNewTab}
|
||||
className={styles.retryButton}
|
||||
style={{
|
||||
background: 'var(--color-primary, #3b82f6)',
|
||||
fontSize: '0.875rem',
|
||||
padding: '0.625rem 1.25rem',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
{t('In neuem Tab öffnen')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className={styles.retryButton}
|
||||
style={{
|
||||
background: 'var(--color-success, #10b981)',
|
||||
fontSize: '0.875rem',
|
||||
padding: '0.625rem 1.25rem',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
{t('Herunterladen')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<LoadingRenderer />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// For other file types, show unsupported message
|
||||
if (mimeType !== 'application/pdf') {
|
||||
return (
|
||||
<div className={styles.unsupportedContainer}>
|
||||
<div className={styles.unsupportedIcon}>📄</div>
|
||||
<div className={styles.fileName}>{fileName}</div>
|
||||
<p>{t('Vorschau wird hierfür nicht unterstützt')}</p>
|
||||
<button onClick={handleDownload} className={styles.retryButton}>
|
||||
{t('Datei herunterladen')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Popup
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={`${t('Dateivorschau')}: ${fileName}`}
|
||||
size="fullscreen"
|
||||
className={styles.contentPreviewPopup}
|
||||
actions={actions}
|
||||
>
|
||||
<div className={styles.previewContainer}>
|
||||
{renderPreview()}
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
export default UrlContentPreview;
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
export { ContentPreview } from './ContentPreview';
|
||||
export type { ContentPreviewProps } from './ContentPreview';
|
||||
export { UrlContentPreview } from './UrlContentPreview';
|
||||
export type { UrlContentPreviewProps } from './UrlContentPreview';
|
||||
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import styles from '../ContentPreview.module.css';
|
||||
|
||||
interface ApplicationRendererProps {
|
||||
previewUrl: string;
|
||||
fileName: string;
|
||||
mimeType?: string;
|
||||
onError: () => void;
|
||||
}
|
||||
|
||||
export function ApplicationRenderer({ previewUrl, fileName, mimeType, onError }: ApplicationRendererProps) {
|
||||
return (
|
||||
<iframe
|
||||
src={previewUrl}
|
||||
className={styles.previewIframe}
|
||||
title={`Preview of ${fileName}`}
|
||||
data-mime-type={mimeType}
|
||||
onError={onError}
|
||||
style={{ background: 'white !important' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import styles from '../ContentPreview.module.css';
|
||||
|
||||
interface ErrorRendererProps {
|
||||
error: string;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
export function ErrorRenderer({ error, onRetry }: ErrorRendererProps) {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<div className={styles.errorContainer}>
|
||||
<div className={styles.errorIcon}>⚠️</div>
|
||||
<p>{error}</p>
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className={styles.retryButton}
|
||||
>
|
||||
{t('Wiederholen')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,281 +0,0 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import styles from '../ContentPreview.module.css';
|
||||
|
||||
interface ExcelRendererProps {
|
||||
blob: Blob;
|
||||
fileName: string;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
const EXCEL_MIME_TYPES = new Set([
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-excel',
|
||||
'text/csv',
|
||||
'application/csv',
|
||||
]);
|
||||
|
||||
export function isExcelMimeType(mimeType?: string, fileName?: string): boolean {
|
||||
if (mimeType && EXCEL_MIME_TYPES.has(mimeType)) return true;
|
||||
if (fileName && /\.(xlsx|xls|xlsm|xlsb|ods|csv)$/i.test(fileName)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
interface RenderedCell {
|
||||
display: string;
|
||||
rawType: 'n' | 's' | 'b' | 'd' | 'e' | 'z' | string;
|
||||
rowspan: number;
|
||||
colspan: number;
|
||||
skip: boolean;
|
||||
}
|
||||
|
||||
interface RenderedSheet {
|
||||
name: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
colWidthsPx: number[];
|
||||
rowHeightsPx: (number | null)[];
|
||||
cells: RenderedCell[][];
|
||||
}
|
||||
|
||||
function renderSheet(ws: XLSX.WorkSheet, name: string): RenderedSheet {
|
||||
const ref = ws['!ref'];
|
||||
if (!ref) {
|
||||
return { name, cols: 0, rows: 0, colWidthsPx: [], rowHeightsPx: [], cells: [] };
|
||||
}
|
||||
|
||||
const range = XLSX.utils.decode_range(ref);
|
||||
const rows = range.e.r - range.s.r + 1;
|
||||
const cols = range.e.c - range.s.c + 1;
|
||||
|
||||
const cells: RenderedCell[][] = Array.from({ length: rows }, () =>
|
||||
Array.from({ length: cols }, () => ({
|
||||
display: '',
|
||||
rawType: 'z',
|
||||
rowspan: 1,
|
||||
colspan: 1,
|
||||
skip: false,
|
||||
})),
|
||||
);
|
||||
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const address = XLSX.utils.encode_cell({ r: r + range.s.r, c: c + range.s.c });
|
||||
const cell = ws[address] as XLSX.CellObject | undefined;
|
||||
if (!cell) continue;
|
||||
const display = cell.w ?? (cell.v !== undefined && cell.v !== null ? String(cell.v) : '');
|
||||
cells[r][c].display = display;
|
||||
cells[r][c].rawType = cell.t ?? 'z';
|
||||
}
|
||||
}
|
||||
|
||||
const merges = ws['!merges'] ?? [];
|
||||
for (const merge of merges) {
|
||||
const rs = merge.s.r - range.s.r;
|
||||
const cs = merge.s.c - range.s.c;
|
||||
const re = merge.e.r - range.s.r;
|
||||
const ce = merge.e.c - range.s.c;
|
||||
if (rs < 0 || cs < 0 || re >= rows || ce >= cols) continue;
|
||||
|
||||
cells[rs][cs].rowspan = re - rs + 1;
|
||||
cells[rs][cs].colspan = ce - cs + 1;
|
||||
|
||||
for (let r = rs; r <= re; r++) {
|
||||
for (let c = cs; c <= ce; c++) {
|
||||
if (r === rs && c === cs) continue;
|
||||
cells[r][c].skip = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const colWidthsPx: number[] = [];
|
||||
const colsMeta = ws['!cols'] ?? [];
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const meta = colsMeta[c + range.s.c];
|
||||
if (meta?.wpx) {
|
||||
colWidthsPx.push(meta.wpx);
|
||||
} else if (meta?.wch) {
|
||||
colWidthsPx.push(Math.round(meta.wch * 7 + 8));
|
||||
} else if (meta?.width) {
|
||||
colWidthsPx.push(Math.round(meta.width * 7 + 8));
|
||||
} else {
|
||||
colWidthsPx.push(80);
|
||||
}
|
||||
}
|
||||
|
||||
const rowHeightsPx: (number | null)[] = [];
|
||||
const rowsMeta = ws['!rows'] ?? [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const meta = rowsMeta[r + range.s.r];
|
||||
if (meta?.hpx) {
|
||||
rowHeightsPx.push(meta.hpx);
|
||||
} else if (meta?.hpt) {
|
||||
rowHeightsPx.push(Math.round((meta.hpt * 4) / 3));
|
||||
} else {
|
||||
rowHeightsPx.push(null);
|
||||
}
|
||||
}
|
||||
|
||||
return { name, cols, rows, colWidthsPx, rowHeightsPx, cells };
|
||||
}
|
||||
|
||||
function alignmentForCell(cell: RenderedCell): 'left' | 'right' | 'center' {
|
||||
if (cell.rawType === 'n' || cell.rawType === 'd') return 'right';
|
||||
if (cell.rawType === 'b') return 'center';
|
||||
return 'left';
|
||||
}
|
||||
|
||||
function colLabel(index: number): string {
|
||||
let result = '';
|
||||
let n = index;
|
||||
do {
|
||||
result = String.fromCharCode(65 + (n % 26)) + result;
|
||||
n = Math.floor(n / 26) - 1;
|
||||
} while (n >= 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function ExcelRenderer({ blob, fileName, onError }: ExcelRendererProps) {
|
||||
const { t } = useLanguage();
|
||||
const [sheets, setSheets] = useState<RenderedSheet[]>([]);
|
||||
const [activeSheet, setActiveSheet] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setLocalError(null);
|
||||
|
||||
blob
|
||||
.arrayBuffer()
|
||||
.then(buffer => {
|
||||
const workbook = XLSX.read(buffer, {
|
||||
type: 'array',
|
||||
cellDates: true,
|
||||
cellNF: true,
|
||||
cellStyles: true,
|
||||
});
|
||||
const parsed = workbook.SheetNames.map(name =>
|
||||
renderSheet(workbook.Sheets[name], name),
|
||||
);
|
||||
if (cancelled) return;
|
||||
setSheets(parsed);
|
||||
setActiveSheet(parsed[0]?.name ?? null);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
if (cancelled) return;
|
||||
const msg = err?.message ?? t('Tabelle konnte nicht gerendert werden.');
|
||||
setLocalError(msg);
|
||||
setLoading(false);
|
||||
onError(msg);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [blob, onError, t]);
|
||||
|
||||
const current = useMemo(
|
||||
() => sheets.find(s => s.name === activeSheet) ?? null,
|
||||
[sheets, activeSheet],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.textContainer}>
|
||||
<div className={styles.textHeader}>
|
||||
<span className={styles.textTitle}>{t('Tabelle wird geladen...')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (localError) {
|
||||
return (
|
||||
<div className={styles.textContainer}>
|
||||
<div className={styles.textHeader}>
|
||||
<span className={styles.textTitle}>{fileName}</span>
|
||||
</div>
|
||||
<div style={{ padding: '1rem', color: 'var(--color-error)' }}>{localError}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.textContainer}>
|
||||
<div className={styles.textHeader}>
|
||||
<span className={styles.textTitle}>{fileName}</span>
|
||||
{sheets.length > 1 && (
|
||||
<div className={styles.excelTabs}>
|
||||
{sheets.map(sheet => (
|
||||
<button
|
||||
key={sheet.name}
|
||||
onClick={() => setActiveSheet(sheet.name)}
|
||||
className={`${styles.excelTab} ${
|
||||
sheet.name === activeSheet ? styles.excelTabActive : ''
|
||||
}`}
|
||||
>
|
||||
{sheet.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.excelSheet}>
|
||||
{current && current.rows > 0 ? (
|
||||
<table className={styles.excelTable}>
|
||||
<colgroup>
|
||||
<col style={{ width: 40 }} />
|
||||
{current.colWidthsPx.map((w, i) => (
|
||||
<col key={i} style={{ width: w }} />
|
||||
))}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.excelCorner} />
|
||||
{current.colWidthsPx.map((_, i) => (
|
||||
<th key={i} className={styles.excelColHeader}>
|
||||
{colLabel(i)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{current.cells.map((row, rIdx) => (
|
||||
<tr
|
||||
key={rIdx}
|
||||
style={{
|
||||
height: current.rowHeightsPx[rIdx] ?? undefined,
|
||||
}}
|
||||
>
|
||||
<th className={styles.excelRowHeader}>{rIdx + 1}</th>
|
||||
{row.map((cell, cIdx) => {
|
||||
if (cell.skip) return null;
|
||||
return (
|
||||
<td
|
||||
key={cIdx}
|
||||
rowSpan={cell.rowspan}
|
||||
colSpan={cell.colspan}
|
||||
className={styles.excelCell}
|
||||
style={{ textAlign: alignmentForCell(cell) }}
|
||||
>
|
||||
{cell.display}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div style={{ padding: '1rem', color: 'var(--color-text-secondary)' }}>
|
||||
{t('Dieses Arbeitsblatt ist leer.')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import styles from '../ContentPreview.module.css';
|
||||
|
||||
interface HtmlRendererProps {
|
||||
previewUrl: string;
|
||||
fileName: string;
|
||||
onError: () => void;
|
||||
}
|
||||
|
||||
export function HtmlRenderer({ previewUrl, fileName, onError }: HtmlRendererProps) {
|
||||
const handleLoad = () => {
|
||||
console.log('🌐 HTML loaded successfully:', { previewUrl, fileName });
|
||||
};
|
||||
|
||||
const handleError = (event: React.SyntheticEvent<HTMLIFrameElement, Event>) => {
|
||||
console.error('❌ HTML failed to load:', { previewUrl, fileName, event });
|
||||
onError();
|
||||
};
|
||||
|
||||
console.log('🔍 HtmlRenderer props:', {
|
||||
previewUrl,
|
||||
fileName,
|
||||
hasPreviewUrl: !!previewUrl
|
||||
});
|
||||
|
||||
return (
|
||||
<iframe
|
||||
src={previewUrl}
|
||||
className={styles.previewIframe}
|
||||
title={`Preview of ${fileName}`}
|
||||
data-mime-type="text/html"
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
style={{
|
||||
background: 'white !important',
|
||||
border: 'none',
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import styles from '../ContentPreview.module.css';
|
||||
|
||||
interface ImageRendererProps {
|
||||
previewUrl: string;
|
||||
fileName: string;
|
||||
onError: () => void;
|
||||
}
|
||||
|
||||
export function ImageRenderer({ previewUrl, fileName, onError }: ImageRendererProps) {
|
||||
const handleLoad = () => {
|
||||
console.log('🖼️ Image loaded successfully:', { previewUrl, fileName });
|
||||
};
|
||||
|
||||
const handleError = (event: React.SyntheticEvent<HTMLImageElement, Event>) => {
|
||||
console.error('❌ Image failed to load:', { previewUrl, fileName, event });
|
||||
onError();
|
||||
};
|
||||
|
||||
console.log('🔍 ImageRenderer props:', {
|
||||
previewUrl,
|
||||
fileName,
|
||||
hasPreviewUrl: !!previewUrl
|
||||
});
|
||||
|
||||
return (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={fileName}
|
||||
className={styles.previewImage}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,507 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import styles from '../ContentPreview.module.css';
|
||||
|
||||
interface JsonRendererProps {
|
||||
previewContent: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export function JsonRenderer({ previewContent, fileName }: JsonRendererProps) {
|
||||
const { t } = useLanguage();
|
||||
const [collapsedRows, setCollapsedRows] = useState<Set<string>>(new Set());
|
||||
|
||||
const handleCopyJson = () => {
|
||||
try {
|
||||
const parsedJson = JSON.parse(previewContent);
|
||||
const formattedJson = JSON.stringify(parsedJson, null, 2);
|
||||
navigator.clipboard.writeText(formattedJson);
|
||||
} catch (error) {
|
||||
navigator.clipboard.writeText(previewContent);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimestamp = (value: string | number): string => {
|
||||
const numValue = typeof value === 'string' ? parseFloat(value) : value;
|
||||
|
||||
if (!isNaN(numValue) && numValue > 1000000000 && numValue < 4102444800) {
|
||||
const date = new Date(numValue * 1000);
|
||||
if (!isNaN(date.getTime())) {
|
||||
return date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const preprocessJson = (obj: any, parentKey = ''): {keys: string[], values: any[], types: (string | 'timestamp')[], isNested: boolean[]} => {
|
||||
const keys: string[] = [];
|
||||
const values: any[] = [];
|
||||
const types: (string | 'timestamp')[] = [];
|
||||
const isNested: boolean[] = [];
|
||||
|
||||
if (obj === null || obj === undefined) {
|
||||
keys.push(parentKey);
|
||||
values.push(String(obj));
|
||||
types.push(typeof obj);
|
||||
isNested.push(false);
|
||||
} else if (typeof obj === 'boolean' || typeof obj === 'number') {
|
||||
keys.push(parentKey);
|
||||
if (typeof obj === 'number' && (parentKey.toLowerCase().includes('timestamp') || parentKey.toLowerCase().includes('time'))) {
|
||||
values.push(formatTimestamp(obj));
|
||||
types.push('timestamp');
|
||||
} else {
|
||||
values.push(obj);
|
||||
types.push(typeof obj);
|
||||
}
|
||||
isNested.push(false);
|
||||
} else if (typeof obj === 'string') {
|
||||
keys.push(parentKey);
|
||||
const numValue = parseFloat(obj);
|
||||
if (!isNaN(numValue) && (parentKey.toLowerCase().includes('timestamp') || parentKey.toLowerCase().includes('time'))) {
|
||||
values.push(formatTimestamp(obj));
|
||||
types.push('timestamp');
|
||||
} else {
|
||||
try {
|
||||
const parsedString = JSON.parse(obj);
|
||||
if (typeof parsedString === 'object' && parsedString !== null) {
|
||||
// For stringified JSON objects, process them recursively
|
||||
const nestedData = preprocessJson(parsedString, '');
|
||||
values.push(nestedData);
|
||||
types.push(Array.isArray(parsedString) ? 'array' : 'object');
|
||||
isNested.push(true);
|
||||
} else {
|
||||
values.push(obj);
|
||||
types.push('string');
|
||||
isNested.push(false);
|
||||
}
|
||||
} catch (e) {
|
||||
values.push(obj);
|
||||
types.push('string');
|
||||
isNested.push(false);
|
||||
}
|
||||
}
|
||||
} else if (Array.isArray(obj)) {
|
||||
if (obj.length === 0) {
|
||||
keys.push(parentKey);
|
||||
values.push('');
|
||||
types.push('array');
|
||||
isNested.push(false);
|
||||
} else {
|
||||
const allPrimitive = obj.every(item =>
|
||||
item === null ||
|
||||
typeof item !== 'object' ||
|
||||
(typeof item === 'object' && !Array.isArray(item) && Object.keys(item).length === 0)
|
||||
);
|
||||
|
||||
if (allPrimitive) {
|
||||
if (parentKey) {
|
||||
keys.push(parentKey);
|
||||
values.push({
|
||||
isArray: true,
|
||||
items: obj,
|
||||
length: obj.length
|
||||
});
|
||||
types.push('array');
|
||||
isNested.push(true);
|
||||
} else {
|
||||
// For root-level arrays, always use compact array display
|
||||
keys.push('');
|
||||
values.push({
|
||||
isArray: true,
|
||||
items: obj,
|
||||
length: obj.length
|
||||
});
|
||||
types.push('array');
|
||||
isNested.push(true);
|
||||
}
|
||||
} else {
|
||||
const nestedKeys: string[] = [];
|
||||
const nestedValues: any[] = [];
|
||||
const nestedTypes: (string | 'timestamp')[] = [];
|
||||
const nestedIsNested: boolean[] = [];
|
||||
|
||||
obj.forEach((item, index) => {
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
const nestedData = preprocessJson(item, `Item ${index + 1}`);
|
||||
nestedKeys.push(...nestedData.keys);
|
||||
nestedValues.push(...nestedData.values);
|
||||
nestedTypes.push(...nestedData.types);
|
||||
nestedIsNested.push(...nestedData.isNested);
|
||||
} else {
|
||||
nestedKeys.push(`Item ${index + 1}`);
|
||||
nestedValues.push(String(item));
|
||||
nestedTypes.push(typeof item);
|
||||
nestedIsNested.push(false);
|
||||
}
|
||||
});
|
||||
|
||||
if (parentKey) {
|
||||
keys.push(parentKey);
|
||||
values.push({
|
||||
keys: nestedKeys,
|
||||
values: nestedValues,
|
||||
types: nestedTypes,
|
||||
isNested: nestedIsNested
|
||||
});
|
||||
types.push('array');
|
||||
isNested.push(true);
|
||||
} else {
|
||||
keys.push(...nestedKeys);
|
||||
values.push(...nestedValues);
|
||||
types.push(...nestedTypes);
|
||||
isNested.push(...nestedIsNested);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (typeof obj === 'object') {
|
||||
const entries = Object.entries(obj);
|
||||
if (entries.length === 0) {
|
||||
keys.push(parentKey);
|
||||
values.push('{}');
|
||||
types.push('object');
|
||||
isNested.push(false);
|
||||
} else {
|
||||
const nestedKeys: string[] = [];
|
||||
const nestedValues: any[] = [];
|
||||
const nestedTypes: (string | 'timestamp')[] = [];
|
||||
const nestedIsNested: boolean[] = [];
|
||||
|
||||
entries.forEach(([key, value]) => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const nestedData = preprocessJson(value, key);
|
||||
nestedKeys.push(...nestedData.keys);
|
||||
nestedValues.push(...nestedData.values);
|
||||
nestedTypes.push(...nestedData.types);
|
||||
nestedIsNested.push(...nestedData.isNested);
|
||||
} else {
|
||||
let processedValue = value;
|
||||
let processedType: string | 'timestamp' = typeof value;
|
||||
|
||||
if (typeof value === 'number' && (key.toLowerCase().includes('timestamp') || key.toLowerCase().includes('time'))) {
|
||||
processedValue = formatTimestamp(value);
|
||||
processedType = 'timestamp';
|
||||
} else if (typeof value === 'string') {
|
||||
const numValue = parseFloat(value);
|
||||
if (!isNaN(numValue) && (key.toLowerCase().includes('timestamp') || key.toLowerCase().includes('time'))) {
|
||||
processedValue = formatTimestamp(value);
|
||||
processedType = 'timestamp';
|
||||
}
|
||||
}
|
||||
|
||||
nestedKeys.push(key);
|
||||
nestedValues.push(processedValue);
|
||||
nestedTypes.push(processedType);
|
||||
nestedIsNested.push(false);
|
||||
}
|
||||
});
|
||||
|
||||
if (parentKey) {
|
||||
keys.push(parentKey);
|
||||
values.push({
|
||||
keys: nestedKeys,
|
||||
values: nestedValues,
|
||||
types: nestedTypes,
|
||||
isNested: nestedIsNested
|
||||
});
|
||||
types.push('object');
|
||||
isNested.push(true);
|
||||
} else {
|
||||
keys.push(...nestedKeys);
|
||||
values.push(...nestedValues);
|
||||
types.push(...nestedTypes);
|
||||
isNested.push(...nestedIsNested);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { keys, values, types, isNested };
|
||||
};
|
||||
|
||||
const renderTable = (data: {keys: string[], values: any[], types: (string | 'timestamp')[], isNested: boolean[]}, level = 0, parentPath = '') => {
|
||||
if (!data || data.keys.length === 0) return null;
|
||||
|
||||
const toggleCollapse = (rowPath: string) => {
|
||||
const newCollapsed = new Set(collapsedRows);
|
||||
if (newCollapsed.has(rowPath)) {
|
||||
newCollapsed.delete(rowPath);
|
||||
} else {
|
||||
newCollapsed.add(rowPath);
|
||||
}
|
||||
setCollapsedRows(newCollapsed);
|
||||
};
|
||||
|
||||
const isLongContent = (value: any, type: string): boolean => {
|
||||
if (typeof value === 'string') {
|
||||
return value.includes('\n') || value.length > 80;
|
||||
}
|
||||
if (type === 'array' && Array.isArray(value)) {
|
||||
return value.length > 8;
|
||||
}
|
||||
if (typeof value === 'object' && value !== null && 'keys' in value) {
|
||||
return value.keys.length > 1;
|
||||
}
|
||||
if (typeof value === 'object' && value !== null && 'isArray' in value) {
|
||||
return value.length > 8;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const getPreview = (value: any, type: string): string => {
|
||||
if (typeof value === 'string') {
|
||||
if (value.includes('\n')) {
|
||||
const firstLine = value.split('\n')[0];
|
||||
return firstLine.length > 60 ? firstLine.substring(0, 60) + '...' : firstLine + '...';
|
||||
}
|
||||
if (value.length > 80) {
|
||||
return value.substring(0, 80) + '...';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (type === 'array' && Array.isArray(value)) {
|
||||
return `[${value.slice(0, 3).map(item => typeof item === 'string' ? `"${item}"` : String(item)).join(', ')}${value.length > 3 ? '...' : ''}]`;
|
||||
}
|
||||
if (typeof value === 'object' && value !== null && 'isArray' in value) {
|
||||
return `[${value.items.slice(0, 3).map((item: any) => typeof item === 'string' ? `"${item}"` : String(item)).join(', ')}${value.length > 3 ? '...' : ''}]`;
|
||||
}
|
||||
if (typeof value === 'object' && value !== null && 'keys' in value) {
|
||||
if (type === 'array') {
|
||||
return `[${value.keys.slice(0, 3).join(', ')}${value.keys.length > 3 ? '...' : ''}]`;
|
||||
} else {
|
||||
return `{${value.keys.slice(0, 3).join(', ')}${value.keys.length > 3 ? '...' : ''}}`;
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${level > 0 ? styles.nestedTable : styles.jsonTable} ${level > 0 ? styles.nestedTableIndented : ''}`}>
|
||||
<div className={level > 0 ? styles.nestedTableBody : styles.jsonTableBody}>
|
||||
{data.keys.map((key, index) => {
|
||||
const typeClass = data.types[index] ? `jsonValue${data.types[index].charAt(0).toUpperCase() + data.types[index].slice(1)}` : '';
|
||||
const typeClassName = styles[typeClass] || '';
|
||||
const rowPath = `${parentPath}.${key}`;
|
||||
const isCollapsed = collapsedRows.has(rowPath);
|
||||
const shouldShowCollapse = isLongContent(data.values[index], data.types[index]);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`${level > 0 ? styles.nestedTableRow : styles.jsonTableRow} ${isCollapsed ? styles.collapsedRow : styles.notCollapsedRow}`}
|
||||
>
|
||||
<div className={level > 0 ? styles.nestedTableKey : styles.jsonTableKey}>
|
||||
<span className={styles.jsonKey}>{key}</span>
|
||||
{shouldShowCollapse && (
|
||||
<button
|
||||
className={styles.collapseButton}
|
||||
onClick={() => toggleCollapse(rowPath)}
|
||||
title={isCollapsed ? t('Aufklappen') : t('Einklappen')}
|
||||
>
|
||||
{isCollapsed ? '▶' : '▼'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className={`${level > 0 ? styles.nestedTableValue : styles.jsonTableValue} ${typeClassName}`}>
|
||||
{data.isNested[index] ? (
|
||||
<div>
|
||||
{shouldShowCollapse && isCollapsed ? (
|
||||
<span className={styles.jsonValuePreview}>
|
||||
{getPreview(data.values[index], data.types[index])}
|
||||
</span>
|
||||
) : (
|
||||
data.values[index].isArray ? (
|
||||
<div className={data.keys[index] === '' ? styles.arrayItemsFullWidth : styles.arrayItems}>
|
||||
{isCollapsed ? (
|
||||
<div className={styles.arrayPreview}>
|
||||
{data.values[index].items.slice(0, 10).map((item: any) => String(item)).join(', ')}
|
||||
{data.values[index].length > 10 && `, ... (${data.values[index].length} items)`}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{data.values[index].items.map((item: any, itemIndex: number) => (
|
||||
<div key={itemIndex} className={styles.arrayItem}>
|
||||
<span className={`${styles.arrayValue} ${typeof item === 'number' ? styles.jsonValueNumber : typeof item === 'string' ? styles.jsonValueString : styles.jsonValue}`}>
|
||||
{String(item)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
typeof data.values[index] === 'object' && data.values[index] !== null && 'keys' in data.values[index] ?
|
||||
renderTable(data.values[index], level + 1, rowPath) :
|
||||
<span className={styles.jsonValue}>{t('Fehler: Ungültige verschachtelte Daten')}</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.valueContainer}>
|
||||
{shouldShowCollapse && isCollapsed ? (
|
||||
<span className={styles.jsonValuePreview}>
|
||||
{getPreview(data.values[index], data.types[index])}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={`${styles.jsonValue} ${
|
||||
data.types[index] === 'number' ? styles.jsonValueNumber :
|
||||
data.types[index] === 'boolean' ? styles.jsonValueBoolean :
|
||||
data.types[index] === 'string' ? styles.jsonValueString : ''
|
||||
}`}
|
||||
>
|
||||
{String(data.values[index])}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const parsedJson = JSON.parse(previewContent);
|
||||
|
||||
let preprocessedData;
|
||||
if (Array.isArray(parsedJson)) {
|
||||
preprocessedData = preprocessJson(parsedJson, 'root');
|
||||
} else if (typeof parsedJson === 'object' && parsedJson !== null) {
|
||||
if (parsedJson.result && typeof parsedJson.result === 'string') {
|
||||
try {
|
||||
const parsedResult = JSON.parse(parsedJson.result);
|
||||
parsedJson.result = parsedResult;
|
||||
} catch (e) {
|
||||
// If parsing fails, keep as string - it will be handled by the string processing logic
|
||||
}
|
||||
}
|
||||
|
||||
const entries = Object.entries(parsedJson);
|
||||
|
||||
preprocessedData = {
|
||||
keys: entries.map(([key]) => key),
|
||||
values: entries.map(([key, value]) => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const processed = preprocessJson(value, '');
|
||||
return processed;
|
||||
} else if (typeof value === 'string') {
|
||||
try {
|
||||
const parsedString = JSON.parse(value);
|
||||
if (typeof parsedString === 'object' && parsedString !== null) {
|
||||
return preprocessJson(parsedString, '');
|
||||
} else {
|
||||
let processedValue = value.replace(/\\n/g, ' ').replace(/\n/g, ' ');
|
||||
const numValue = parseFloat(value);
|
||||
if (!isNaN(numValue) && (key.toLowerCase().includes('timestamp') || key.toLowerCase().includes('time'))) {
|
||||
processedValue = formatTimestamp(value);
|
||||
}
|
||||
return String(processedValue);
|
||||
}
|
||||
} catch (e) {
|
||||
let processedValue = value.replace(/\\n/g, ' ').replace(/\n/g, ' ');
|
||||
const numValue = parseFloat(value);
|
||||
if (!isNaN(numValue) && (key.toLowerCase().includes('timestamp') || key.toLowerCase().includes('time'))) {
|
||||
processedValue = formatTimestamp(value);
|
||||
}
|
||||
return String(processedValue);
|
||||
}
|
||||
} else {
|
||||
let processedValue = value;
|
||||
|
||||
if (typeof value === 'number' && (key.toLowerCase().includes('timestamp') || key.toLowerCase().includes('time'))) {
|
||||
processedValue = formatTimestamp(value);
|
||||
return String(processedValue);
|
||||
}
|
||||
|
||||
return processedValue;
|
||||
}
|
||||
}),
|
||||
types: entries.map(([, value]) => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return Array.isArray(value) ? 'array' : 'object';
|
||||
} else if (typeof value === 'string') {
|
||||
try {
|
||||
const parsedString = JSON.parse(value);
|
||||
if (typeof parsedString === 'object' && parsedString !== null) {
|
||||
return Array.isArray(parsedString) ? 'array' : 'object';
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parsing errors
|
||||
}
|
||||
return 'string';
|
||||
}
|
||||
return typeof value;
|
||||
}),
|
||||
isNested: entries.map(([, value]) => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return true;
|
||||
} else if (typeof value === 'string') {
|
||||
try {
|
||||
const parsedString = JSON.parse(value);
|
||||
return typeof parsedString === 'object' && parsedString !== null;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})
|
||||
};
|
||||
console.log(preprocessedData);
|
||||
} else {
|
||||
preprocessedData = {
|
||||
keys: ['value'],
|
||||
values: [String(parsedJson)],
|
||||
types: [typeof parsedJson],
|
||||
isNested: [false]
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.jsonContainer}>
|
||||
<div className={styles.jsonHeader}>
|
||||
<div className={styles.jsonHeaderRight}>
|
||||
<span className={styles.jsonSize}>{preprocessedData.keys.length} {t('Eigenschaften')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{renderTable(preprocessedData, 0, 'root')}
|
||||
</div>
|
||||
);
|
||||
} catch (parseError) {
|
||||
const rawData = {
|
||||
keys: [t('Rohinhalt')],
|
||||
values: [previewContent],
|
||||
types: ['string'],
|
||||
isNested: [false]
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.jsonContainer}>
|
||||
<div className={styles.jsonHeader}>
|
||||
<span className={styles.jsonTitle}>{t('Ungültiges JSON')}: {fileName}</span>
|
||||
<div className={styles.jsonHeaderRight}>
|
||||
<button
|
||||
className={styles.copyButton}
|
||||
onClick={handleCopyJson}
|
||||
title={t('Rohtext in die Zwischenablage kopieren')}
|
||||
>
|
||||
📋 {t('Kopieren')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{renderTable(rawData)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import styles from '../ContentPreview.module.css';
|
||||
|
||||
export function LoadingRenderer() {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner}></div>
|
||||
<p>{t('Vorschau wird geladen...')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,245 +0,0 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
// @ts-ignore
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import styles from '../ContentPreview.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
||||
// Set worker source for PDF.js
|
||||
if (typeof window !== 'undefined') {
|
||||
// Try to use local worker first, fallback to CDN
|
||||
try {
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'../../../../node_modules/pdfjs-dist/build/pdf.worker.min.js',
|
||||
import.meta.url
|
||||
).toString();
|
||||
} catch (e) {
|
||||
// Fallback to CDN if local worker not available
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.js`;
|
||||
}
|
||||
}
|
||||
|
||||
interface PdfJsRendererProps {
|
||||
previewUrl: string;
|
||||
fileName: string;
|
||||
onError: () => void;
|
||||
onLoad?: () => void;
|
||||
}
|
||||
|
||||
export function PdfJsRenderer({
|
||||
previewUrl, fileName: _fileName, onError, onLoad }: PdfJsRendererProps) {
|
||||
const { t } = useLanguage();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [numPages, setNumPages] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [scale, setScale] = useState(1.5);
|
||||
|
||||
useEffect(() => {
|
||||
let pdfDoc: pdfjsLib.PDFDocumentProxy | null = null;
|
||||
let isMounted = true;
|
||||
|
||||
const loadPdf = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Load PDF using fetch (like download)
|
||||
const response = await fetch(previewUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch PDF: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
|
||||
pdfDoc = await loadingTask.promise;
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
setNumPages(pdfDoc.numPages);
|
||||
setIsLoading(false);
|
||||
if (onLoad) {
|
||||
onLoad();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading PDF with PDF.js:', err);
|
||||
if (isMounted) {
|
||||
setError(err instanceof Error ? err.message : t('PDF konnte nicht geladen werden.'));
|
||||
setIsLoading(false);
|
||||
onError();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadPdf();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [previewUrl, onLoad, onError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || isLoading || error) return;
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const renderPage = async (pageNum: number) => {
|
||||
try {
|
||||
// Load PDF again for rendering (could be optimized with caching)
|
||||
const response = await fetch(previewUrl);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
|
||||
const pdfDoc = await loadingTask.promise;
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
const page = await pdfDoc.getPage(pageNum);
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
canvas.height = viewport.height;
|
||||
canvas.width = viewport.width;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return;
|
||||
|
||||
const renderContext = {
|
||||
canvasContext: context,
|
||||
viewport: viewport,
|
||||
};
|
||||
|
||||
await page.render(renderContext).promise;
|
||||
} catch (err) {
|
||||
console.error('Error rendering PDF page:', err);
|
||||
if (isMounted) {
|
||||
setError(err instanceof Error ? err.message : t('PDF-Seite konnte nicht gerendert werden.'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
renderPage(currentPage);
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [previewUrl, currentPage, scale, isLoading, error]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.errorContainer}>
|
||||
<div className={styles.errorIcon}>⚠️</div>
|
||||
<p>
|
||||
{t('Fehler beim Laden der PDF:')} {error}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner}></div>
|
||||
<p>{t('PDF wird geladen')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ width: '100%', height: '100%', overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Navigation Controls */}
|
||||
{numPages > 1 && (
|
||||
<div style={{
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'var(--color-background-secondary, #f3f4f6)',
|
||||
borderBottom: '1px solid var(--color-border, #e5e7eb)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
background: 'var(--color-primary, #3b82f6)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: currentPage === 1 ? 'not-allowed' : 'pointer',
|
||||
opacity: currentPage === 1 ? 0.5 : 1
|
||||
}}
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
<span style={{ fontSize: '0.875rem', color: 'var(--color-text, #1f2937)' }}>
|
||||
Seite {currentPage} von {numPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.min(numPages, prev + 1))}
|
||||
disabled={currentPage === numPages}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
background: 'var(--color-primary, #3b82f6)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: currentPage === numPages ? 'not-allowed' : 'pointer',
|
||||
opacity: currentPage === numPages ? 0.5 : 1
|
||||
}}
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<button
|
||||
onClick={() => setScale(prev => Math.max(0.5, prev - 0.25))}
|
||||
style={{
|
||||
padding: '0.25rem 0.75rem',
|
||||
background: 'var(--color-background, #ffffff)',
|
||||
border: '1px solid var(--color-border, #e5e7eb)',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.75rem'
|
||||
}}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span style={{ fontSize: '0.75rem', minWidth: '3rem', textAlign: 'center' }}>
|
||||
{Math.round(scale * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setScale(prev => Math.min(3, prev + 0.25))}
|
||||
style={{
|
||||
padding: '0.25rem 0.75rem',
|
||||
background: 'var(--color-background, #ffffff)',
|
||||
border: '1px solid var(--color-border, #e5e7eb)',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.75rem'
|
||||
}}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PDF Canvas */}
|
||||
<div style={{ flex: 1, display: 'flex', justifyContent: 'center', alignItems: 'flex-start', padding: '1rem', overflow: 'auto' }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
background: 'white'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import { IoIosWarning } from 'react-icons/io';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import styles from '../ContentPreview.module.css';
|
||||
|
||||
interface PdfRendererProps {
|
||||
previewUrl?: string;
|
||||
previewContent?: string;
|
||||
fileName: string;
|
||||
onError: () => void;
|
||||
}
|
||||
|
||||
export function PdfRenderer({ previewUrl, previewContent, fileName, onError }: PdfRendererProps) {
|
||||
const { t } = useLanguage();
|
||||
|
||||
|
||||
const handleLoad = () => {
|
||||
console.log('📄 PDF iframe loaded successfully:', { previewUrl, fileName });
|
||||
};
|
||||
|
||||
const handleError = (event: React.SyntheticEvent<HTMLIFrameElement, Event>) => {
|
||||
console.error('❌ PDF iframe failed to load:', { previewUrl, fileName, event });
|
||||
onError();
|
||||
};
|
||||
|
||||
// Handle corrupted PDF files (text content instead of PDF)
|
||||
if (previewContent && !previewUrl) {
|
||||
console.log('📄 Rendering corrupted PDF warning');
|
||||
return (
|
||||
<div className={styles.textContainer}>
|
||||
<div className={styles.textHeader}>
|
||||
<div className={styles.warningMessage}>
|
||||
<span className={styles.warningIcon}><IoIosWarning /></span>
|
||||
<span className={styles.warningText}>
|
||||
{t('Diese Datei scheint beschädigt zu sein. Sie hat eine PDF-Erweiterung, enthält aber Textinhalte. Bitte laden Sie die Datei erneut hoch, falls möglich.')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Normal PDF rendering
|
||||
return (
|
||||
<iframe
|
||||
src={previewUrl}
|
||||
className={styles.previewIframe}
|
||||
title={`Preview of ${fileName}`}
|
||||
data-mime-type="application/pdf"
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import styles from '../ContentPreview.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
||||
// Updated to handle both previewUrl and previewContent
|
||||
|
||||
interface TextRendererProps {
|
||||
previewUrl?: string;
|
||||
previewContent?: string;
|
||||
fileName: string;
|
||||
mimeType?: string;
|
||||
onError: () => void;
|
||||
}
|
||||
|
||||
export function TextRenderer({
|
||||
previewUrl, previewContent, fileName, mimeType, onError }: TextRendererProps) {
|
||||
const { t } = useLanguage();
|
||||
// If we have previewContent directly, display it as text
|
||||
if (previewContent && !previewUrl) {
|
||||
return (
|
||||
<div className={styles.textContainer}>
|
||||
<div className={styles.textHeader}>
|
||||
<span className={styles.textTitle}>{t('Textvorschau')}</span>
|
||||
</div>
|
||||
<pre className={styles.textPreview}>
|
||||
<code className={styles.textCode}>
|
||||
{previewContent}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, use iframe with previewUrl
|
||||
return (
|
||||
<iframe
|
||||
src={previewUrl}
|
||||
className={styles.previewIframe}
|
||||
title={`Preview of ${fileName}`}
|
||||
data-mime-type={mimeType}
|
||||
onError={onError}
|
||||
style={{ background: 'white !important' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import styles from '../ContentPreview.module.css';
|
||||
|
||||
interface UnsupportedRendererProps {
|
||||
previewUrl: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export function UnsupportedRenderer({ previewUrl, fileName }: UnsupportedRendererProps) {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<div className={styles.unsupportedContainer}>
|
||||
<div className={styles.unsupportedIcon}>📄</div>
|
||||
<p>{t('Vorschau für diesen Dateityp nicht verfügbar')}</p>
|
||||
<p className={styles.fileName}>{fileName}</p>
|
||||
<a
|
||||
href={previewUrl}
|
||||
download={fileName}
|
||||
className={styles.downloadButton}
|
||||
>
|
||||
{t('Herunterladen')}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { renderAsync } from 'docx-preview';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import styles from '../ContentPreview.module.css';
|
||||
|
||||
interface WordRendererProps {
|
||||
blob: Blob;
|
||||
fileName: string;
|
||||
mimeType?: string;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
const SUPPORTED_MIME_TYPES = new Set([
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
]);
|
||||
|
||||
export function isWordMimeType(mimeType?: string, fileName?: string): boolean {
|
||||
if (mimeType && SUPPORTED_MIME_TYPES.has(mimeType)) return true;
|
||||
if (fileName && /\.docx$/i.test(fileName)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function WordRenderer({ blob, fileName, mimeType, onError }: WordRendererProps) {
|
||||
const { t } = useLanguage();
|
||||
const bodyRef = useRef<HTMLDivElement | null>(null);
|
||||
const styleRef = useRef<HTMLDivElement | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
const isLegacyDoc = useMemo(
|
||||
() => mimeType === 'application/msword' || /\.doc$/i.test(fileName),
|
||||
[mimeType, fileName],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (isLegacyDoc) {
|
||||
const msg = t(
|
||||
'Das alte Word-Format (.doc) wird nicht unterstützt. Bitte konvertiere die Datei in .docx.',
|
||||
);
|
||||
setLocalError(msg);
|
||||
setLoading(false);
|
||||
onError(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
const body = bodyRef.current;
|
||||
const styleContainer = styleRef.current;
|
||||
if (!body || !styleContainer) return;
|
||||
|
||||
body.innerHTML = '';
|
||||
styleContainer.innerHTML = '';
|
||||
setLoading(true);
|
||||
setLocalError(null);
|
||||
|
||||
renderAsync(blob, body, styleContainer, {
|
||||
className: 'docx-preview',
|
||||
inWrapper: true,
|
||||
ignoreWidth: false,
|
||||
ignoreHeight: false,
|
||||
ignoreFonts: false,
|
||||
breakPages: true,
|
||||
experimental: true,
|
||||
trimXmlDeclaration: true,
|
||||
useBase64URL: true,
|
||||
renderHeaders: true,
|
||||
renderFooters: true,
|
||||
renderFootnotes: true,
|
||||
renderEndnotes: true,
|
||||
})
|
||||
.then(() => {
|
||||
if (cancelled) return;
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
if (cancelled) return;
|
||||
const msg =
|
||||
err?.message ?? t('Word-Dokument konnte nicht gerendert werden.');
|
||||
setLocalError(msg);
|
||||
setLoading(false);
|
||||
onError(msg);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [blob, isLegacyDoc, onError, t]);
|
||||
|
||||
if (localError) {
|
||||
return (
|
||||
<div className={styles.textContainer}>
|
||||
<div className={styles.textHeader}>
|
||||
<span className={styles.textTitle}>{fileName}</span>
|
||||
</div>
|
||||
<div style={{ padding: '1rem', color: 'var(--color-error)' }}>{localError}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.docxContainer}>
|
||||
{loading && (
|
||||
<div className={styles.docxLoading}>{t('Word-Dokument wird geladen...')}</div>
|
||||
)}
|
||||
<div ref={styleRef} />
|
||||
<div ref={bodyRef} className={styles.docxBody} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
export { JsonRenderer } from './JsonRenderer';
|
||||
export { ImageRenderer } from './ImageRenderer';
|
||||
export { TextRenderer } from './TextRenderer';
|
||||
export { PdfRenderer } from './PdfRenderer';
|
||||
export { HtmlRenderer } from './HtmlRenderer';
|
||||
export { ApplicationRenderer } from './ApplicationRenderer';
|
||||
export { UnsupportedRenderer } from './UnsupportedRenderer';
|
||||
export { LoadingRenderer } from './LoadingRenderer';
|
||||
export { ErrorRenderer } from './ErrorRenderer';
|
||||
export { WordRenderer, isWordMimeType } from './WordRenderer';
|
||||
export { ExcelRenderer, isExcelMimeType } from './ExcelRenderer';
|
||||
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
/**
|
||||
* Automation2 Flow Editor - Data flow context for Data Picker and DynamicValueField.
|
||||
* Extended with portTypeCatalog and systemVariables for the Typed Port System.
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import type { CanvasNode, CanvasConnection } from '../editor/FlowCanvas';
|
||||
import { getAvailableSources } from '../nodes/shared/dataFlowGraph';
|
||||
import type { ApiRequestFunction, ConditionOperatorDef, FormFieldType, NodeType, PortField, PortSchema, SystemVariable } from '../../../api/workflowApi';
|
||||
|
||||
export interface Automation2DataFlowContextValue {
|
||||
currentNodeId: string;
|
||||
nodes: CanvasNode[];
|
||||
connections: CanvasConnection[];
|
||||
nodeOutputsPreview: Record<string, unknown>;
|
||||
nodeTypes: NodeType[];
|
||||
language: string;
|
||||
portTypeCatalog: Record<string, PortSchema>;
|
||||
systemVariables: Record<string, SystemVariable>;
|
||||
/** Canonical form field types from the API — maps UI type id to portType primitive. */
|
||||
formFieldTypes: FormFieldType[];
|
||||
/** Backend-driven condition operators per valueKind (flow.ifElse). */
|
||||
conditionOperatorCatalog: Record<string, ConditionOperatorDef[]>;
|
||||
getNodeLabel: (node: { id: string; title?: string; label?: string; type?: string }) => string;
|
||||
getAvailableSourceIds: () => string[];
|
||||
/** Present when rendered inside the flow editor (ConnectionPicker / tools). */
|
||||
instanceId?: string;
|
||||
request?: ApiRequestFunction;
|
||||
/** Build FormPayload-like schema from ``parameters[parameterKey]`` (fieldBuilder JSON). */
|
||||
parseGraphDefinedSchema: (parameterKey: string) => PortSchema | null;
|
||||
}
|
||||
|
||||
const Automation2DataFlowContext = createContext<Automation2DataFlowContextValue | null>(null);
|
||||
|
||||
export function useAutomation2DataFlow(): Automation2DataFlowContextValue | null {
|
||||
return useContext(Automation2DataFlowContext);
|
||||
}
|
||||
|
||||
interface Automation2DataFlowProviderProps {
|
||||
node: CanvasNode | null;
|
||||
nodes: CanvasNode[];
|
||||
connections: CanvasConnection[];
|
||||
nodeOutputsPreview: Record<string, unknown>;
|
||||
nodeTypes: NodeType[];
|
||||
language: string;
|
||||
portTypeCatalog?: Record<string, PortSchema>;
|
||||
systemVariables?: Record<string, SystemVariable>;
|
||||
formFieldTypes?: FormFieldType[];
|
||||
conditionOperatorCatalog?: Record<string, ConditionOperatorDef[]>;
|
||||
instanceId?: string;
|
||||
request?: ApiRequestFunction;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderProps> = ({
|
||||
node,
|
||||
nodes,
|
||||
connections,
|
||||
nodeOutputsPreview,
|
||||
nodeTypes,
|
||||
language,
|
||||
portTypeCatalog = {},
|
||||
systemVariables = {},
|
||||
formFieldTypes = [],
|
||||
conditionOperatorCatalog = {},
|
||||
instanceId,
|
||||
request,
|
||||
children,
|
||||
}) => {
|
||||
const value = useMemo((): Automation2DataFlowContextValue | null => {
|
||||
if (!node) return null;
|
||||
const formTypeToPort: Record<string, string> = Object.fromEntries(
|
||||
formFieldTypes.map((f) => [f.id, f.portType])
|
||||
);
|
||||
const resolvePortType = (rawType: string): string => formTypeToPort[rawType] ?? rawType;
|
||||
|
||||
const parseGraphDefinedSchema = (parameterKey: string): PortSchema | null => {
|
||||
const raw = node.parameters?.[parameterKey];
|
||||
if (!Array.isArray(raw)) return null;
|
||||
const fields: PortField[] = [];
|
||||
for (const item of raw) {
|
||||
if (typeof item !== 'object' || item === null) continue;
|
||||
const rec = item as Record<string, unknown>;
|
||||
if (typeof rec.name !== 'string') continue;
|
||||
const lab = rec.label;
|
||||
const desc =
|
||||
typeof lab === 'string' ? lab : typeof lab === 'object' && lab !== null ? String((lab as Record<string, string>).de ?? '') : '';
|
||||
const rawType = typeof rec.type === 'string' ? rec.type : 'str';
|
||||
if (rawType === 'group' && Array.isArray(rec.fields)) {
|
||||
for (const sub of rec.fields as Record<string, unknown>[]) {
|
||||
if (!sub || typeof sub.name !== 'string') continue;
|
||||
const sl = sub.label;
|
||||
const sdesc =
|
||||
typeof sl === 'string'
|
||||
? sl
|
||||
: typeof sl === 'object' && sl !== null
|
||||
? String((sl as Record<string, string>).de ?? '')
|
||||
: '';
|
||||
fields.push({
|
||||
name: `${rec.name}.${sub.name}`,
|
||||
type: resolvePortType(typeof sub.type === 'string' ? sub.type : 'str'),
|
||||
description: (sdesc && sdesc.trim()) || `${rec.name}.${sub.name}`,
|
||||
required: Boolean(sub.required),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
fields.push({
|
||||
name: rec.name,
|
||||
type: resolvePortType(rawType),
|
||||
description: (desc && desc.trim()) || rec.name,
|
||||
required: Boolean(rec.required),
|
||||
});
|
||||
}
|
||||
return fields.length ? { name: 'FormPayload_dynamic', fields } : null;
|
||||
};
|
||||
return {
|
||||
currentNodeId: node.id,
|
||||
nodes,
|
||||
connections,
|
||||
nodeOutputsPreview,
|
||||
nodeTypes,
|
||||
language,
|
||||
portTypeCatalog,
|
||||
systemVariables,
|
||||
formFieldTypes,
|
||||
conditionOperatorCatalog,
|
||||
getNodeLabel: (n: { id: string; title?: string; label?: string; type?: string }) =>
|
||||
n.title ?? n.label ?? n.type ?? n.id,
|
||||
getAvailableSourceIds: () => getAvailableSources(node.id, nodes, connections),
|
||||
instanceId,
|
||||
request,
|
||||
parseGraphDefinedSchema,
|
||||
};
|
||||
}, [node, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables, formFieldTypes, conditionOperatorCatalog, instanceId, request]);
|
||||
|
||||
return (
|
||||
<Automation2DataFlowContext.Provider value={value}>
|
||||
{children}
|
||||
</Automation2DataFlowContext.Provider>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,653 +0,0 @@
|
|||
/**
|
||||
* CanvasHeader - Workflow controls, version selector, and execute result.
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
FaPlay,
|
||||
FaSpinner,
|
||||
FaCloudUploadAlt,
|
||||
FaCloudDownloadAlt,
|
||||
FaArchive,
|
||||
FaBookmark,
|
||||
FaCaretDown,
|
||||
FaSave,
|
||||
FaPlus,
|
||||
FaChevronLeft,
|
||||
FaChevronRight,
|
||||
} from 'react-icons/fa';
|
||||
import {
|
||||
HiOutlineMagnifyingGlassMinus,
|
||||
HiOutlineMagnifyingGlassPlus,
|
||||
HiOutlineArrowUturnLeft,
|
||||
HiOutlineArrowUturnRight,
|
||||
HiOutlineTrash,
|
||||
HiOutlineDocumentDuplicate,
|
||||
HiOutlineChatBubbleLeftEllipsis,
|
||||
HiOutlineSquares2X2,
|
||||
} from 'react-icons/hi2';
|
||||
import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowApi';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import { getUserDataCache } from '../../../utils/userCache';
|
||||
import { Button } from '../../UiComponents/Button';
|
||||
|
||||
const ZOOM_PRESET_PERCENTS = [25, 50, 75, 100, 125, 150, 200, 400] as const;
|
||||
|
||||
export interface CanvasHeaderCanvasEditProps {
|
||||
zoomPercent: number;
|
||||
selectedNodeCount: number;
|
||||
connectionSelected: boolean;
|
||||
stickyNoteSelected: boolean;
|
||||
connectionToolActive: boolean;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onZoomPercentCommit: (percent: number) => void;
|
||||
onFitWindow: () => void;
|
||||
onResetView: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onDeleteSelection: () => void;
|
||||
onDuplicateNode: () => void;
|
||||
onToggleConnectionTool: () => void;
|
||||
/** Textnotiz auf die Canvas legen (ohne Workflow-Daten). */
|
||||
onAddCanvasComment: () => void;
|
||||
/** Verschachtelte Rasterpfade (4.1 / 4.2 …); Haftnotizen unberührt. */
|
||||
onArrangeNodes: () => void;
|
||||
}
|
||||
|
||||
interface CanvasHeaderProps {
|
||||
workflows: Automation2Workflow[];
|
||||
currentWorkflowId: string | null;
|
||||
onWorkflowSelect: (workflowId: string | null) => void;
|
||||
onNew: () => void;
|
||||
onSave: () => void;
|
||||
onExecute: () => void;
|
||||
onToggleWorkspacePanel?: () => void;
|
||||
workspacePanelOpen?: boolean;
|
||||
saving: boolean;
|
||||
executing: boolean;
|
||||
hasNodes: boolean;
|
||||
/** When set, required-field graph errors block a normal run; message is the
|
||||
* run button tooltip. Click still fires `onExecuteBlockedClick` to focus
|
||||
* the first offending node. */
|
||||
executeBlockedReason?: string | null;
|
||||
onExecuteBlockedClick?: () => void;
|
||||
executeResult: ExecuteGraphResponse | null;
|
||||
versions?: AutoVersion[];
|
||||
currentVersionId?: string | null;
|
||||
onVersionSelect?: (versionId: string | null) => void;
|
||||
onPublishVersion?: (versionId: string) => void;
|
||||
onUnpublishVersion?: (versionId: string) => void;
|
||||
onArchiveVersion?: (versionId: string) => void;
|
||||
onCreateDraft?: () => void;
|
||||
versionLoading?: boolean;
|
||||
onSaveAsTemplate?: (scope: AutoTemplateScope) => void;
|
||||
templateSaving?: boolean;
|
||||
onNewFromTemplate?: () => void;
|
||||
/** Sysadmin-only: when true, NodeConfigPanel renders the static
|
||||
* "Schema (Typ-Referenz)" block and per-parameter type-badges. */
|
||||
verboseSchema?: boolean;
|
||||
onVerboseSchemaChange?: (next: boolean) => void;
|
||||
canvasEdit?: CanvasHeaderCanvasEditProps;
|
||||
}
|
||||
|
||||
function _getStatusBadge(t: (key: string) => string): Record<string, { label: string; color: string }> {
|
||||
return {
|
||||
draft: { label: t('Entwurf'), color: 'var(--warning-color, #ffc107)' },
|
||||
published: { label: t('Veröffentlicht'), color: 'var(--success-color, #28a745)' },
|
||||
archived: { label: t('Archiviert'), color: 'var(--text-secondary, #666)' },
|
||||
};
|
||||
}
|
||||
|
||||
const _tb = 'secondary' as const;
|
||||
const _ts = 'sm' as const;
|
||||
|
||||
export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
||||
workflows,
|
||||
currentWorkflowId,
|
||||
onWorkflowSelect,
|
||||
onNew,
|
||||
onSave,
|
||||
onExecute,
|
||||
onToggleWorkspacePanel,
|
||||
workspacePanelOpen,
|
||||
saving,
|
||||
executing,
|
||||
hasNodes,
|
||||
executeBlockedReason,
|
||||
onExecuteBlockedClick,
|
||||
executeResult,
|
||||
versions,
|
||||
currentVersionId,
|
||||
onVersionSelect,
|
||||
onPublishVersion,
|
||||
onUnpublishVersion,
|
||||
onArchiveVersion,
|
||||
onCreateDraft,
|
||||
versionLoading,
|
||||
onSaveAsTemplate,
|
||||
templateSaving,
|
||||
onNewFromTemplate,
|
||||
verboseSchema,
|
||||
onVerboseSchemaChange,
|
||||
canvasEdit,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const _isSysAdmin = getUserDataCache()?.isSysAdmin === true;
|
||||
const statusBadge = _getStatusBadge(t);
|
||||
const currentVersion = versions?.find((v) => v.id === currentVersionId);
|
||||
const currentStatus = currentVersion?.status || 'draft';
|
||||
const badge = statusBadge[currentStatus] || statusBadge.draft;
|
||||
|
||||
const [newMenuOpen, setNewMenuOpen] = useState(false);
|
||||
const newMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
|
||||
const templateMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [zoomMenuOpen, setZoomMenuOpen] = useState(false);
|
||||
const zoomMenuRef = useRef<HTMLDivElement>(null);
|
||||
const [zoomInputDraft, setZoomInputDraft] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const zp = canvasEdit?.zoomPercent;
|
||||
if (zp !== undefined) setZoomInputDraft(String(zp));
|
||||
}, [canvasEdit?.zoomPercent]);
|
||||
|
||||
useEffect(() => {
|
||||
const _handleClickOutside = (e: MouseEvent) => {
|
||||
if (newMenuRef.current && !newMenuRef.current.contains(e.target as Node)) setNewMenuOpen(false);
|
||||
if (templateMenuRef.current && !templateMenuRef.current.contains(e.target as Node)) setTemplateMenuOpen(false);
|
||||
if (zoomMenuRef.current && !zoomMenuRef.current.contains(e.target as Node)) setZoomMenuOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', _handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', _handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const scopeLabels = useMemo(
|
||||
() =>
|
||||
({
|
||||
user: t('Meine Vorlagen'),
|
||||
instance: t('Instanz'),
|
||||
mandate: t('Mandant'),
|
||||
}) as Record<string, string>,
|
||||
[t]
|
||||
);
|
||||
|
||||
const _panelOpen = workspacePanelOpen ?? false;
|
||||
const _runAriaLabel = executing
|
||||
? t('Ausführen…')
|
||||
: executeBlockedReason
|
||||
? t('Pflicht-Felder fehlen')
|
||||
: t('Ausführen');
|
||||
const _runTitle = executeBlockedReason ?? (hasNodes ? t('Ausführen') : t('Keine Nodes zum Ausführen.'));
|
||||
|
||||
const _executeBannerSegmentClass = !executeResult
|
||||
? ''
|
||||
: executeResult.success
|
||||
? executeResult.warning
|
||||
? styles.canvasHeaderExecuteBannerWarning
|
||||
: styles.canvasHeaderExecuteBannerSuccess
|
||||
: executeResult.paused
|
||||
? styles.canvasHeaderExecuteBannerPaused
|
||||
: styles.canvasHeaderExecuteBannerError;
|
||||
|
||||
const _commitZoomDraft = () => {
|
||||
if (!canvasEdit) return;
|
||||
const raw = zoomInputDraft.replace(/%/g, '').replace(',', '.').trim();
|
||||
const n = parseFloat(raw);
|
||||
if (!Number.isFinite(n)) {
|
||||
setZoomInputDraft(String(canvasEdit.zoomPercent));
|
||||
return;
|
||||
}
|
||||
canvasEdit.onZoomPercentCommit(Math.min(400, Math.max(25, Math.round(n))));
|
||||
setZoomMenuOpen(false);
|
||||
};
|
||||
|
||||
const _canDeleteSelection =
|
||||
!!canvasEdit &&
|
||||
(canvasEdit.selectedNodeCount > 0 ||
|
||||
canvasEdit.connectionSelected ||
|
||||
canvasEdit.stickyNoteSelected);
|
||||
const _singleNodeOnly =
|
||||
!!canvasEdit && canvasEdit.selectedNodeCount === 1 && !canvasEdit.connectionSelected;
|
||||
|
||||
return (
|
||||
<div className={styles.canvasHeader} data-suppress-flow-node-hotkeys="">
|
||||
<div
|
||||
className={styles.canvasHeaderToolbar}
|
||||
role="toolbar"
|
||||
aria-label={t('Workflow-Aktionen')}
|
||||
>
|
||||
{onToggleWorkspacePanel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant={_tb}
|
||||
size={_ts}
|
||||
icon={_panelOpen ? FaChevronLeft : FaChevronRight}
|
||||
className={styles.canvasHeaderIconBtn}
|
||||
onClick={onToggleWorkspacePanel}
|
||||
title={_panelOpen ? t('Workspace-Panel ausblenden') : t('Workspace-Panel öffnen')}
|
||||
aria-label={_panelOpen ? t('Workspace-Panel ausblenden') : t('Workspace-Panel öffnen')}
|
||||
/>
|
||||
)}
|
||||
<div ref={newMenuRef} className={styles.canvasHeaderNewSplit}>
|
||||
<div className={styles.canvasHeaderSplitPair}>
|
||||
<Button
|
||||
type="button"
|
||||
variant={_tb}
|
||||
size={_ts}
|
||||
icon={FaPlus}
|
||||
className={`${styles.canvasHeaderIconBtn} ${onNewFromTemplate ? styles.canvasHeaderNewSplitMain : ''}`}
|
||||
onClick={onNew}
|
||||
title={t('Neuer leerer Workflow')}
|
||||
aria-label={t('Neuer leerer Workflow')}
|
||||
/>
|
||||
{onNewFromTemplate && (
|
||||
<Button
|
||||
type="button"
|
||||
variant={_tb}
|
||||
size={_ts}
|
||||
icon={FaCaretDown}
|
||||
className={`${styles.canvasHeaderIconBtn} ${styles.canvasHeaderNewSplitMenu}`}
|
||||
onClick={() => setNewMenuOpen((p) => !p)}
|
||||
title={t('Aus Vorlage…')}
|
||||
aria-label={t('Neu aus Vorlage')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={newMenuOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{newMenuOpen && onNewFromTemplate && (
|
||||
<div className={styles.canvasHeaderMenuDropdown} role="menu">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.canvasHeaderMenuItem}
|
||||
onClick={() => {
|
||||
onNewFromTemplate();
|
||||
setNewMenuOpen(false);
|
||||
}}
|
||||
role="menuitem"
|
||||
>
|
||||
{t('Aus Vorlage…')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
className={styles.canvasHeaderWorkflowSelect}
|
||||
value={currentWorkflowId ?? ''}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value ? e.target.value : null;
|
||||
onWorkflowSelect(id);
|
||||
}}
|
||||
aria-label={t('Workflow laden')}
|
||||
title={t('Workflow laden')}
|
||||
>
|
||||
<option value="">{t('Workflow laden')}</option>
|
||||
{workflows.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
type="button"
|
||||
variant={_tb}
|
||||
size={_ts}
|
||||
icon={saving ? undefined : FaSave}
|
||||
className={styles.canvasHeaderIconBtn}
|
||||
loading={saving}
|
||||
disabled={saving}
|
||||
onClick={onSave}
|
||||
title={!hasNodes ? t('Workflow ist leer — Speichern legt einen leeren Workflow an.') : t('Speichern')}
|
||||
aria-label={t('Speichern')}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant={_tb}
|
||||
size={_ts}
|
||||
icon={executing ? undefined : FaPlay}
|
||||
loading={executing}
|
||||
disabled={executing || !hasNodes}
|
||||
className={`${styles.canvasHeaderIconBtn} ${executeBlockedReason ? styles.canvasHeaderRunBlocked : ''}`}
|
||||
onClick={() => {
|
||||
if (executeBlockedReason) {
|
||||
onExecuteBlockedClick?.();
|
||||
return;
|
||||
}
|
||||
onExecute();
|
||||
}}
|
||||
aria-label={_runAriaLabel}
|
||||
aria-disabled={executing || !hasNodes || !!executeBlockedReason}
|
||||
title={_runTitle}
|
||||
/>
|
||||
{currentWorkflowId && onSaveAsTemplate && (
|
||||
<div ref={templateMenuRef} className={styles.canvasHeaderNewSplit}>
|
||||
<Button
|
||||
type="button"
|
||||
variant={_tb}
|
||||
size={_ts}
|
||||
icon={FaBookmark}
|
||||
loading={templateSaving}
|
||||
disabled={templateSaving}
|
||||
onClick={() => setTemplateMenuOpen((p) => !p)}
|
||||
title={t('Als Vorlage speichern')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={templateMenuOpen}
|
||||
>
|
||||
{t('Als Vorlage')}
|
||||
</Button>
|
||||
{templateMenuOpen && (
|
||||
<div className={styles.canvasHeaderMenuDropdown} role="menu">
|
||||
{(['user', 'instance', 'mandate'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
className={styles.canvasHeaderMenuItem}
|
||||
onClick={() => {
|
||||
onSaveAsTemplate(s);
|
||||
setTemplateMenuOpen(false);
|
||||
}}
|
||||
role="menuitem"
|
||||
>
|
||||
{scopeLabels[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{_isSysAdmin && onVerboseSchemaChange && (
|
||||
<label
|
||||
className={styles.canvasHeaderSysadmin}
|
||||
title={t('Sysadmin-Ansicht: zeigt im Node-Panel das statische Typ-Schema (Eingabe/Ausgabe) und Parameter-Typ-Badges.')}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!verboseSchema}
|
||||
onChange={(e) => onVerboseSchemaChange(e.target.checked)}
|
||||
className={styles.canvasHeaderSysadminInput}
|
||||
/>
|
||||
{t('Schema-Details')}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canvasEdit && (
|
||||
<div
|
||||
className={styles.canvasHeaderEditRow}
|
||||
role="toolbar"
|
||||
aria-label={t('Canvas bearbeiten')}
|
||||
>
|
||||
<div ref={zoomMenuRef} className={styles.canvasHeaderZoomCombo}>
|
||||
<div className={styles.canvasHeaderZoomInputWrap}>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
className={styles.canvasHeaderZoomInput}
|
||||
value={zoomInputDraft}
|
||||
onChange={(e) => setZoomInputDraft(e.target.value)}
|
||||
onBlur={_commitZoomDraft}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
_commitZoomDraft();
|
||||
}
|
||||
}}
|
||||
aria-label={t('Zoomstufe (Prozent)')}
|
||||
title={t('Zoomstufe (Prozent)')}
|
||||
/>
|
||||
<span className={styles.canvasHeaderZoomSuffix} aria-hidden>
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.canvasHeaderZoomChevronBtn}
|
||||
onClick={() => setZoomMenuOpen((p) => !p)}
|
||||
aria-label={t('Zoom-Voreinstellungen')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={zoomMenuOpen}
|
||||
title={t('Zoom-Voreinstellungen')}
|
||||
>
|
||||
<FaCaretDown aria-hidden />
|
||||
</button>
|
||||
{zoomMenuOpen && (
|
||||
<div className={styles.canvasHeaderMenuDropdown} role="menu">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.canvasHeaderMenuItem}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
canvasEdit.onFitWindow();
|
||||
setZoomMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
{t('Ansicht an Fenster anpassen')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.canvasHeaderMenuItem}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
canvasEdit.onResetView();
|
||||
setZoomMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
{t('Ansicht zurücksetzen')}
|
||||
</button>
|
||||
{ZOOM_PRESET_PERCENTS.map((pct) => (
|
||||
<button
|
||||
key={pct}
|
||||
type="button"
|
||||
className={styles.canvasHeaderMenuItem}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
canvasEdit.onZoomPercentCommit(pct);
|
||||
setZoomMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
{pct}%
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.canvasHeaderGhostIconBtn}
|
||||
onClick={canvasEdit.onZoomIn}
|
||||
title={t('Vergrößern')}
|
||||
aria-label={t('Vergrößern')}
|
||||
>
|
||||
<HiOutlineMagnifyingGlassPlus size={18} strokeWidth={2} aria-hidden />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.canvasHeaderGhostIconBtn}
|
||||
onClick={canvasEdit.onZoomOut}
|
||||
title={t('Verkleinern')}
|
||||
aria-label={t('Verkleinern')}
|
||||
>
|
||||
<HiOutlineMagnifyingGlassMinus size={18} strokeWidth={2} aria-hidden />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.canvasHeaderGhostIconBtn}
|
||||
disabled={!canvasEdit.canUndo}
|
||||
onClick={canvasEdit.onUndo}
|
||||
title={t('Rückgängig')}
|
||||
aria-label={t('Rückgängig')}
|
||||
>
|
||||
<HiOutlineArrowUturnLeft size={18} strokeWidth={2} aria-hidden />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.canvasHeaderGhostIconBtn}
|
||||
disabled={!canvasEdit.canRedo}
|
||||
onClick={canvasEdit.onRedo}
|
||||
title={t('Wiederholen')}
|
||||
aria-label={t('Wiederholen')}
|
||||
>
|
||||
<HiOutlineArrowUturnRight size={18} strokeWidth={2} aria-hidden />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.canvasHeaderGhostIconBtn}
|
||||
disabled={!_canDeleteSelection}
|
||||
onClick={canvasEdit.onDeleteSelection}
|
||||
title={t('Auswahl löschen')}
|
||||
aria-label={t('Auswahl löschen')}
|
||||
>
|
||||
<HiOutlineTrash size={18} strokeWidth={2} aria-hidden />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.canvasHeaderGhostIconBtn}
|
||||
disabled={!_singleNodeOnly}
|
||||
onClick={canvasEdit.onDuplicateNode}
|
||||
title={t('Knoten duplizieren')}
|
||||
aria-label={t('Knoten duplizieren')}
|
||||
>
|
||||
<HiOutlineDocumentDuplicate size={18} strokeWidth={2} aria-hidden />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.canvasHeaderGhostIconBtn}
|
||||
disabled={!hasNodes}
|
||||
onClick={canvasEdit.onArrangeNodes}
|
||||
title={t('Knoten im Raster anordnen')}
|
||||
aria-label={t('Knoten im Raster anordnen')}
|
||||
>
|
||||
<HiOutlineSquares2X2 size={18} strokeWidth={2} aria-hidden />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.canvasHeaderGhostIconBtn}
|
||||
onClick={canvasEdit.onAddCanvasComment}
|
||||
title={t('Kommentar auf dem Canvas einfügen')}
|
||||
aria-label={t('Kommentar auf dem Canvas einfügen')}
|
||||
>
|
||||
<HiOutlineChatBubbleLeftEllipsis size={18} strokeWidth={2} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentWorkflowId && versions && versions.length > 0 && (
|
||||
<div className={styles.canvasHeaderVersionRow}>
|
||||
<span className={styles.canvasHeaderVersionLabel}>{t('Version:')}</span>
|
||||
<select
|
||||
className={styles.canvasHeaderVersionSelect}
|
||||
value={currentVersionId ?? ''}
|
||||
onChange={(e) => onVersionSelect?.(e.target.value || null)}
|
||||
disabled={versionLoading}
|
||||
aria-label={t('Version')}
|
||||
>
|
||||
<option value="">{t('Aktuelle')}</option>
|
||||
{versions.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
v{v.versionNumber} ({statusBadge[v.status]?.label ?? v.status})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span
|
||||
className={styles.canvasHeaderVersionBadge}
|
||||
style={
|
||||
{
|
||||
'--canvasHeaderBadgeBg': `${badge.color}22`,
|
||||
'--canvasHeaderBadgeFg': badge.color,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
{currentVersion && currentStatus === 'draft' && onPublishVersion && (
|
||||
<Button
|
||||
type="button"
|
||||
variant={_tb}
|
||||
size={_ts}
|
||||
icon={FaCloudUploadAlt}
|
||||
className={styles.canvasHeaderVersionAction}
|
||||
onClick={() => onPublishVersion(currentVersion.id)}
|
||||
disabled={versionLoading}
|
||||
title={t('Version veröffentlichen')}
|
||||
>
|
||||
{t('Veröffentlichen')}
|
||||
</Button>
|
||||
)}
|
||||
{currentVersion && currentStatus === 'published' && onUnpublishVersion && (
|
||||
<Button
|
||||
type="button"
|
||||
variant={_tb}
|
||||
size={_ts}
|
||||
icon={FaCloudDownloadAlt}
|
||||
className={styles.canvasHeaderVersionAction}
|
||||
onClick={() => onUnpublishVersion(currentVersion.id)}
|
||||
disabled={versionLoading}
|
||||
title={t('Veröffentlichung zurücknehmen')}
|
||||
>
|
||||
{t('Veröffentlichung aufheben')}
|
||||
</Button>
|
||||
)}
|
||||
{currentVersion && currentStatus !== 'archived' && onArchiveVersion && (
|
||||
<Button
|
||||
type="button"
|
||||
variant={_tb}
|
||||
size={_ts}
|
||||
icon={FaArchive}
|
||||
className={styles.canvasHeaderVersionAction}
|
||||
onClick={() => onArchiveVersion(currentVersion.id)}
|
||||
disabled={versionLoading}
|
||||
title={t('Version archivieren')}
|
||||
>
|
||||
{t('Archiv')}
|
||||
</Button>
|
||||
)}
|
||||
{onCreateDraft && (
|
||||
<Button
|
||||
type="button"
|
||||
variant={_tb}
|
||||
size={_ts}
|
||||
icon={FaPlus}
|
||||
className={styles.canvasHeaderVersionAction}
|
||||
onClick={onCreateDraft}
|
||||
disabled={versionLoading}
|
||||
title={t('Neuen Entwurf erstellen')}
|
||||
>
|
||||
{t('+ Entwurf')}
|
||||
</Button>
|
||||
)}
|
||||
{versionLoading && <FaSpinner className={`${styles.spinner} ${styles.canvasHeaderVersionSpinner}`} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{executeResult && (
|
||||
<div
|
||||
className={`${styles.canvasHeaderExecuteBanner} ${_executeBannerSegmentClass}`}
|
||||
>
|
||||
{executeResult.success ? (
|
||||
executeResult.warning ? (
|
||||
<>{executeResult.warning}</>
|
||||
) : (
|
||||
<>{t('Ausführung abgeschlossen')}</>
|
||||
)
|
||||
) : executeResult.paused ? (
|
||||
<>
|
||||
{t('Workflow pausiert. Öffne ')}
|
||||
<strong>{t('Workflows/Tasks')}</strong>
|
||||
{t(' in der Sidebar, um den Task zu bearbeiten.')}
|
||||
</>
|
||||
) : (
|
||||
<>{executeResult.error ?? t('Unbekannter Fehler')}</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,501 +0,0 @@
|
|||
/**
|
||||
* EditorChatPanel
|
||||
*
|
||||
* AI Chat sidebar for the GraphicalEditor.
|
||||
* Streams responses via SSE (same pattern as Workspace chat).
|
||||
* File & data-source attachment UX mirrors WorkspaceInput:
|
||||
* - Files: drag & drop from FilesTab (UDB) onto input area, or click in UDB
|
||||
* - Data Sources: 🔗 picker button next to input (toggle-select from active sources)
|
||||
*/
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { startSseStream } from '../../../utils/sseClient';
|
||||
import { ChatMessageList } from '../../Chat';
|
||||
import type { ChatMessage } from '../../Chat';
|
||||
import { getPageIcon } from '../../../config/pageRegistry';
|
||||
import api from '../../../api';
|
||||
|
||||
interface PersistedEditorChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp?: number;
|
||||
sequenceNr?: number;
|
||||
}
|
||||
|
||||
interface PersistedEditorChatResponse {
|
||||
chatWorkflowId: string | null;
|
||||
messages: PersistedEditorChatMessage[];
|
||||
}
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
||||
export interface PendingFile {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
itemType?: 'file' | 'group';
|
||||
}
|
||||
|
||||
export interface EditorDataSource {
|
||||
id: string;
|
||||
label: string;
|
||||
path?: string;
|
||||
sourceType?: string;
|
||||
}
|
||||
|
||||
export interface EditorFeatureDataSource {
|
||||
id: string;
|
||||
featureInstanceId: string;
|
||||
featureCode: string;
|
||||
tableName: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface EditorChatPanelProps {
|
||||
instanceId: string;
|
||||
workflowId: string | null;
|
||||
onGraphUpdated?: () => void;
|
||||
pendingFiles?: PendingFile[];
|
||||
onRemovePendingFile?: (fileId: string) => void;
|
||||
dataSources?: EditorDataSource[];
|
||||
featureDataSources?: EditorFeatureDataSource[];
|
||||
}
|
||||
|
||||
let _msgCounter = 0;
|
||||
|
||||
export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
||||
workflowId,
|
||||
onGraphUpdated,
|
||||
pendingFiles = [],
|
||||
onRemovePendingFile,
|
||||
dataSources = [],
|
||||
featureDataSources = [],
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
|
||||
const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]);
|
||||
const [showSourcePicker, setShowSourcePicker] = useState(false);
|
||||
const [treeDropOver, setTreeDropOver] = useState(false);
|
||||
const [stopping, setStopping] = useState(false);
|
||||
const abortRef = useRef<(() => void) | null>(null);
|
||||
const assistantIdRef = useRef<string | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const pickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load persisted chat history from the backend whenever the workflow changes.
|
||||
// The chat is stored in `ChatWorkflow.linkedWorkflowId == workflowId` and is
|
||||
// returned by `GET /api/workflows/{instanceId}/{workflowId}/chat/messages`.
|
||||
// For an unsaved workflow (workflowId == null) we just clear the panel.
|
||||
useEffect(() => {
|
||||
if (!workflowId) {
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const _loadHistory = async () => {
|
||||
setHistoryLoading(true);
|
||||
try {
|
||||
const res = await api.get<PersistedEditorChatResponse>(
|
||||
`/api/workflows/${instanceId}/${workflowId}/chat/messages`,
|
||||
);
|
||||
if (cancelled) return;
|
||||
const persisted = (res.data?.messages || []).map((m): ChatMessage => ({
|
||||
id: m.id || `persisted-${++_msgCounter}`,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
timestamp: m.timestamp ? Math.round(Number(m.timestamp) * 1000) : Date.now(),
|
||||
}));
|
||||
setMessages(persisted);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
console.warn('EditorChatPanel: failed to load chat history', err);
|
||||
setMessages([]);
|
||||
} finally {
|
||||
if (!cancelled) setHistoryLoading(false);
|
||||
}
|
||||
};
|
||||
_loadHistory();
|
||||
return () => { cancelled = true; };
|
||||
}, [instanceId, workflowId]);
|
||||
|
||||
const _toggleDataSource = useCallback((dsId: string) => {
|
||||
setAttachedDataSourceIds(prev =>
|
||||
prev.includes(dsId) ? prev.filter(id => id !== dsId) : [...prev, dsId],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const _toggleFeatureDataSource = useCallback((fdsId: string) => {
|
||||
setAttachedFeatureDataSourceIds(prev =>
|
||||
prev.includes(fdsId) ? prev.filter(id => id !== fdsId) : [...prev, fdsId],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const _handleSend = useCallback(() => {
|
||||
const trimmed = prompt.trim();
|
||||
if (!workflowId || loading || !trimmed) return;
|
||||
|
||||
const fileIds = pendingFiles.map(f => f.fileId);
|
||||
// Note: conversationHistory is no longer sent — the backend loads it
|
||||
// server-side from the persisted ChatWorkflow (linkedWorkflowId).
|
||||
const body: Record<string, unknown> = {
|
||||
message: trimmed,
|
||||
userLanguage: navigator.language?.slice(0, 2) || 'de',
|
||||
};
|
||||
if (fileIds.length > 0) body.fileIds = fileIds;
|
||||
if (attachedDataSourceIds.length > 0) body.dataSourceIds = attachedDataSourceIds;
|
||||
if (attachedFeatureDataSourceIds.length > 0) body.featureDataSourceIds = attachedFeatureDataSourceIds;
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
id: `user-${++_msgCounter}`,
|
||||
role: 'user',
|
||||
content: trimmed,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setPrompt('');
|
||||
setShowSourcePicker(false);
|
||||
setLoading(true);
|
||||
|
||||
const assistantId = `asst-${++_msgCounter}`;
|
||||
assistantIdRef.current = assistantId;
|
||||
let accumulated = '';
|
||||
setMessages(prev => [...prev, { id: assistantId, role: 'assistant', content: '', timestamp: Date.now() }]);
|
||||
|
||||
const baseURL = api.defaults.baseURL || '';
|
||||
const cleanup = startSseStream({
|
||||
url: `${baseURL}/api/workflows/${instanceId}/${workflowId}/chat/stream`,
|
||||
body,
|
||||
handlers: {
|
||||
onChunk: (event) => {
|
||||
if (event.content) {
|
||||
accumulated += event.content;
|
||||
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: accumulated } : m));
|
||||
}
|
||||
},
|
||||
onRawEvent: (event) => {
|
||||
if (event.type === 'message' && event.content) {
|
||||
accumulated += event.content;
|
||||
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: accumulated } : m));
|
||||
}
|
||||
if (event.type === 'toolResult' || event.type === 'toolCall') {
|
||||
onGraphUpdated?.();
|
||||
}
|
||||
},
|
||||
onComplete: () => {
|
||||
if (!accumulated) {
|
||||
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: t('Fertig.') } : m));
|
||||
}
|
||||
onGraphUpdated?.();
|
||||
setLoading(false);
|
||||
},
|
||||
onError: (event) => {
|
||||
const errText = event.content || t('Anfrage fehlgeschlagen');
|
||||
if (!accumulated) {
|
||||
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: `${t('Fehler')}: ${errText}` } : m));
|
||||
}
|
||||
setLoading(false);
|
||||
},
|
||||
onStopped: () => {
|
||||
setMessages(prev => prev.map(m => m.id === assistantId
|
||||
? { ...m, content: (m.content ? m.content + '\n\n' : '') + `_${t('Gestoppt.')}_` }
|
||||
: m));
|
||||
setLoading(false);
|
||||
setStopping(false);
|
||||
},
|
||||
},
|
||||
onConnectionError: (err) => {
|
||||
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: `${t('Fehler')}: ${err.message}` } : m));
|
||||
setLoading(false);
|
||||
setStopping(false);
|
||||
},
|
||||
onStreamEnd: () => { setLoading(false); setStopping(false); },
|
||||
});
|
||||
|
||||
abortRef.current = cleanup;
|
||||
}, [prompt, loading, workflowId, instanceId, onGraphUpdated, pendingFiles, attachedDataSourceIds, attachedFeatureDataSourceIds, t]);
|
||||
|
||||
const _handleStop = useCallback(async () => {
|
||||
if (!workflowId || stopping) return;
|
||||
setStopping(true);
|
||||
const assistantId = assistantIdRef.current;
|
||||
if (assistantId) {
|
||||
setMessages(prev => prev.map(m => m.id === assistantId
|
||||
? { ...m, content: (m.content ? m.content + '\n\n' : '') + `_${t('Stoppen…')}_` }
|
||||
: m));
|
||||
}
|
||||
try {
|
||||
await api.post(`/api/workflows/${instanceId}/${workflowId}/chat/stop`);
|
||||
} catch {
|
||||
}
|
||||
abortRef.current?.();
|
||||
}, [workflowId, instanceId, stopping, t]);
|
||||
|
||||
const _handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
_handleSend();
|
||||
}
|
||||
}, [_handleSend]);
|
||||
|
||||
const _handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
if (
|
||||
e.dataTransfer.types.includes('application/tree-items') ||
|
||||
e.dataTransfer.types.includes('application/group-id') ||
|
||||
e.dataTransfer.types.includes('application/file-id') ||
|
||||
e.dataTransfer.types.includes('application/file-ids')
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
setTreeDropOver(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const _handleDragLeave = useCallback(() => setTreeDropOver(false), []);
|
||||
|
||||
const _handleDrop = useCallback((e: React.DragEvent) => {
|
||||
setTreeDropOver(false);
|
||||
const groupId = e.dataTransfer.getData('application/group-id');
|
||||
if (groupId) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
|
||||
if (treeItemsJson) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const hasAttachments = pendingFiles.length > 0 || attachedDataSourceIds.length > 0 || attachedFeatureDataSourceIds.length > 0;
|
||||
const sourceCount = attachedDataSourceIds.length + attachedFeatureDataSourceIds.length;
|
||||
const hasSourceOptions = dataSources.length > 0 || featureDataSources.length > 0;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'var(--bg-secondary, #fafafa)' }}>
|
||||
<ChatMessageList
|
||||
messages={messages}
|
||||
isProcessing={loading || historyLoading}
|
||||
emptyMessage={historyLoading ? t('Lade Verlauf…') : t('Beschreiben Sie, was Sie tun möchten')}
|
||||
/>
|
||||
|
||||
{/* Pending files (from UDB drag/click) */}
|
||||
{pendingFiles.length > 0 && (
|
||||
<div style={{
|
||||
padding: '6px 12px', display: 'flex', gap: 4, flexWrap: 'wrap',
|
||||
borderTop: '1px solid var(--border-color, #e0e0e0)',
|
||||
background: 'var(--bg-secondary, #fafafa)',
|
||||
}}>
|
||||
{pendingFiles.map(pf => (
|
||||
<span key={pf.fileId} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
padding: '2px 8px', borderRadius: 12, fontSize: 11,
|
||||
background: pf.itemType === 'group' ? '#e3f2fd' : '#fff3e0',
|
||||
color: pf.itemType === 'group' ? '#1565c0' : '#e65100',
|
||||
fontWeight: 500, border: `1px solid ${pf.itemType === 'group' ? '#bbdefb' : '#ffe0b2'}`,
|
||||
}}>
|
||||
{pf.itemType === 'group' ? '\uD83D\uDCC2' : '\uD83D\uDCCE'} {pf.fileName.length > 20 ? pf.fileName.slice(0, 20) + '...' : pf.fileName}
|
||||
{onRemovePendingFile && (
|
||||
<button onClick={() => onRemovePendingFile(pf.fileId)} style={{
|
||||
border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#e65100', padding: 0, lineHeight: 1,
|
||||
}}>x</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attached data sources chips */}
|
||||
{(attachedDataSourceIds.length > 0 || attachedFeatureDataSourceIds.length > 0) && (
|
||||
<div style={{
|
||||
padding: '6px 12px', display: 'flex', gap: 4, flexWrap: 'wrap',
|
||||
borderTop: pendingFiles.length > 0 ? 'none' : '1px solid var(--border-color, #e0e0e0)',
|
||||
background: '#fafafa',
|
||||
}}>
|
||||
{attachedDataSourceIds.map(dsId => {
|
||||
const ds = dataSources.find(d => d.id === dsId);
|
||||
return (
|
||||
<span key={dsId} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
padding: '2px 8px', borderRadius: 12, fontSize: 11,
|
||||
background: '#e8f5e9', color: '#2e7d32', fontWeight: 500,
|
||||
}}>
|
||||
\uD83D\uDD17 {ds?.label || dsId}
|
||||
<button onClick={() => _toggleDataSource(dsId)} style={{
|
||||
border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#2e7d32', padding: 0, lineHeight: 1,
|
||||
}}>x</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{attachedFeatureDataSourceIds.map(fdsId => {
|
||||
const fds = featureDataSources.find(d => d.id === fdsId);
|
||||
const fdsIcon = fds ? getPageIcon(`feature.${fds.featureCode}`) : null;
|
||||
return (
|
||||
<span key={fdsId} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
padding: '2px 8px', borderRadius: 12, fontSize: 11,
|
||||
background: '#f3e5f5', color: '#7b1fa2', fontWeight: 500,
|
||||
}}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', fontSize: 11 }}>{fdsIcon || '\uD83D\uDDC3\uFE0F'}</span>
|
||||
{fds?.label || fdsId}
|
||||
<button onClick={() => _toggleFeatureDataSource(fdsId)} style={{
|
||||
border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#7b1fa2', padding: 0, lineHeight: 1,
|
||||
}}>x</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input area */}
|
||||
<div
|
||||
style={{
|
||||
borderTop: hasAttachments ? 'none' : '1px solid var(--border-color, #e0e0e0)',
|
||||
padding: '8px 12px',
|
||||
display: 'flex', gap: 6, alignItems: 'flex-end',
|
||||
outline: treeDropOver ? '2px dashed var(--primary-color, #F25843)' : 'none',
|
||||
background: treeDropOver ? 'rgba(242, 88, 67, 0.08)' : undefined,
|
||||
transition: 'background 0.15s, outline 0.15s',
|
||||
}}
|
||||
onDragOver={_handleDragOver}
|
||||
onDragLeave={_handleDragLeave}
|
||||
onDrop={_handleDrop}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={prompt}
|
||||
onChange={e => setPrompt(e.target.value)}
|
||||
onKeyDown={_handleKeyDown}
|
||||
placeholder={workflowId ? t('Beschreiben Sie eine Änderung') : t('Speichern Sie zuerst den Workflow')}
|
||||
disabled={!workflowId || loading}
|
||||
style={{
|
||||
flex: 1, minHeight: 36, maxHeight: 100, resize: 'vertical',
|
||||
padding: '8px 10px', borderRadius: 8,
|
||||
border: '1px solid var(--border-color, #ccc)',
|
||||
fontSize: 13, fontFamily: 'inherit', outline: 'none',
|
||||
}}
|
||||
rows={1}
|
||||
/>
|
||||
|
||||
{/* Source picker button */}
|
||||
{hasSourceOptions && (
|
||||
<div style={{ position: 'relative' }} ref={pickerRef}>
|
||||
<button
|
||||
onClick={() => setShowSourcePicker(prev => !prev)}
|
||||
disabled={loading || !workflowId}
|
||||
title={t('Datenquellen anhängen')}
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: 8,
|
||||
border: '1px solid var(--border-color, #ddd)',
|
||||
background: sourceCount > 0 ? '#e8f5e9' : 'var(--secondary-bg, #f5f5f5)',
|
||||
color: sourceCount > 0 ? '#2e7d32' : '#666',
|
||||
cursor: loading || !workflowId ? 'not-allowed' : 'pointer',
|
||||
fontSize: 14, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
opacity: loading ? 0.5 : 1, position: 'relative',
|
||||
}}
|
||||
>
|
||||
{'\uD83D\uDD17'}
|
||||
{sourceCount > 0 && (
|
||||
<span style={{
|
||||
position: 'absolute', top: -4, right: -4,
|
||||
background: '#2e7d32', color: '#fff', fontSize: 9, fontWeight: 700,
|
||||
borderRadius: '50%', width: 16, height: 16,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>{sourceCount}</span>
|
||||
)}
|
||||
</button>
|
||||
{showSourcePicker && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: '100%', right: 0, marginBottom: 4,
|
||||
background: '#fff', border: '1px solid var(--border-color, #e0e0e0)',
|
||||
borderRadius: 8, boxShadow: '0 -2px 8px rgba(0,0,0,0.1)', zIndex: 20,
|
||||
minWidth: 220, maxHeight: 260, overflowY: 'auto',
|
||||
}}>
|
||||
<div style={{ padding: '8px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderBottom: '1px solid #f0f0f0' }}>
|
||||
{t('Aktive Quellen auswählen')}
|
||||
</div>
|
||||
{dataSources.map(ds => {
|
||||
const isSelected = attachedDataSourceIds.includes(ds.id);
|
||||
return (
|
||||
<div key={ds.id} onClick={() => _toggleDataSource(ds.id)} style={{
|
||||
padding: '8px 12px', cursor: 'pointer', fontSize: 12,
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
background: isSelected ? '#e8f5e9' : 'transparent',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = '#f5f5f5'; }}
|
||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isSelected ? '#e8f5e9' : ''; }}
|
||||
>
|
||||
<span style={{
|
||||
width: 14, height: 14, borderRadius: 3,
|
||||
border: isSelected ? '2px solid #2e7d32' : '2px solid #ccc',
|
||||
background: isSelected ? '#2e7d32' : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#fff', fontSize: 9, fontWeight: 700, flexShrink: 0,
|
||||
}}>{isSelected ? '\u2713' : ''}</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{ds.label || ds.path || ds.id}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{featureDataSources.length > 0 && (
|
||||
<>
|
||||
<div style={{ padding: '8px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderTop: '1px solid #f0f0f0', borderBottom: '1px solid #f0f0f0' }}>
|
||||
{t('Feature-Datenquellen')}
|
||||
</div>
|
||||
{featureDataSources.map(fds => {
|
||||
const isSelected = attachedFeatureDataSourceIds.includes(fds.id);
|
||||
return (
|
||||
<div key={fds.id} onClick={() => _toggleFeatureDataSource(fds.id)} style={{
|
||||
padding: '8px 12px', cursor: 'pointer', fontSize: 12,
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
background: isSelected ? '#f3e5f5' : 'transparent',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = '#f5f5f5'; }}
|
||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isSelected ? '#f3e5f5' : ''; }}
|
||||
>
|
||||
<span style={{
|
||||
width: 14, height: 14, borderRadius: 3,
|
||||
border: isSelected ? '2px solid #7b1fa2' : '2px solid #ccc',
|
||||
background: isSelected ? '#7b1fa2' : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#fff', fontSize: 9, fontWeight: 700, flexShrink: 0,
|
||||
}}>{isSelected ? '\u2713' : ''}</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', fontSize: 12, color: '#7b1fa2', flexShrink: 0 }}>
|
||||
{getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'}
|
||||
</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{fds.label || fds.featureCode} – {fds.tableName}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<button onClick={_handleStop} disabled={stopping} title={stopping ? t('Stoppen…') : t('Anfrage stoppen')} style={{
|
||||
padding: '8px 14px', borderRadius: 8, border: 'none',
|
||||
background: stopping ? '#9e9e9e' : '#f44336', color: '#fff',
|
||||
cursor: stopping ? 'wait' : 'pointer', fontWeight: 600, fontSize: 12,
|
||||
opacity: stopping ? 0.7 : 1,
|
||||
}}>{stopping ? t('Stoppen…') : t('Stopp')}</button>
|
||||
) : (
|
||||
<button onClick={_handleSend} disabled={!prompt.trim() || !workflowId} style={{
|
||||
padding: '8px 14px', borderRadius: 8, border: 'none',
|
||||
background: prompt.trim() && workflowId ? 'var(--primary-color, #F25843)' : '#ccc',
|
||||
color: '#fff', cursor: prompt.trim() && workflowId ? 'pointer' : 'default',
|
||||
fontWeight: 600, fontSize: 12,
|
||||
}}>{t('Senden')}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
/**
|
||||
* EditorWorkflowChatList
|
||||
*
|
||||
* UDB "Chats" tab content for the GraphicalEditor: each AutoWorkflow is treated
|
||||
* as one editor chat session. Lists workflows already loaded by the parent
|
||||
* editor (no extra fetch), supports search and "+ Neu" to start a fresh
|
||||
* workflow chat. Mirrors the spirit of the Workspace ChatsTab but uses
|
||||
* GraphicalEditor data instead of the workspace endpoint.
|
||||
*/
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import type { Automation2Workflow } from '../../../api/workflowApi';
|
||||
|
||||
interface EditorWorkflowChatListProps {
|
||||
workflows: Automation2Workflow[];
|
||||
currentWorkflowId: string | null;
|
||||
onSelect: (workflowId: string | null) => void;
|
||||
onNew: () => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
function _formatRelative(ts?: number): string {
|
||||
if (!ts) return '';
|
||||
const date = new Date(ts * 1000);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
const diffMs = Date.now() - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60_000);
|
||||
const diffH = Math.floor(diffMs / 3_600_000);
|
||||
const diffDays = Math.floor(diffMs / 86_400_000);
|
||||
if (diffMin < 1) return 'gerade eben';
|
||||
if (diffMin < 60) return `${diffMin}m`;
|
||||
if (diffH < 24) return `${diffH}h`;
|
||||
if (diffDays === 1) return 'gestern';
|
||||
if (diffDays < 7) return `vor ${diffDays}d`;
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
export const EditorWorkflowChatList: React.FC<EditorWorkflowChatListProps> = ({
|
||||
workflows,
|
||||
currentWorkflowId,
|
||||
onSelect,
|
||||
onNew,
|
||||
t,
|
||||
}) => {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
const list = q
|
||||
? workflows.filter((w) => (w.label || '').toLowerCase().includes(q))
|
||||
: [...workflows];
|
||||
list.sort((a, b) => (b.lastStartedAt || b.sysCreatedAt || 0) - (a.lastStartedAt || a.sysCreatedAt || 0));
|
||||
return list;
|
||||
}, [workflows, search]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'var(--bg-primary, #fff)' }}>
|
||||
<div style={{ padding: '8px 10px', display: 'flex', gap: 6, borderBottom: '1px solid var(--border-color, #e0e0e0)' }}>
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('Workflow suchen…')}
|
||||
style={{
|
||||
flex: 1, padding: '6px 8px', borderRadius: 6,
|
||||
border: '1px solid var(--border-color, #ddd)', fontSize: 12, outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={onNew}
|
||||
title={t('Neuer Workflow')}
|
||||
style={{
|
||||
padding: '6px 10px', borderRadius: 6, border: '1px solid var(--border-color, #ddd)',
|
||||
background: 'var(--secondary-bg, #f5f5f5)', cursor: 'pointer', fontSize: 12, fontWeight: 600,
|
||||
}}
|
||||
>+ {t('Neu')}</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{filtered.length === 0 ? (
|
||||
<div style={{ padding: 16, fontSize: 12, color: '#999', textAlign: 'center' }}>
|
||||
{workflows.length === 0
|
||||
? t('Noch keine Workflows. Klicken Sie auf „+ Neu", um einen Workflow-Chat zu starten.')
|
||||
: t('Keine Treffer.')}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((wf) => {
|
||||
const isActive = wf.id === currentWorkflowId;
|
||||
const ts = wf.lastStartedAt || wf.sysCreatedAt;
|
||||
return (
|
||||
<div
|
||||
key={wf.id}
|
||||
onClick={() => onSelect(wf.id)}
|
||||
style={{
|
||||
padding: '10px 12px', cursor: 'pointer',
|
||||
borderBottom: '1px solid var(--border-color-soft, #f0f0f0)',
|
||||
background: isActive ? 'rgba(242, 88, 67, 0.08)' : 'transparent',
|
||||
borderLeft: isActive ? '3px solid var(--primary-color, #F25843)' : '3px solid transparent',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = '#f7f7f7'; }}
|
||||
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: '#333', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{wf.label || t('(unbenannt)')}
|
||||
</span>
|
||||
{wf.isRunning && (
|
||||
<span title={t('läuft')} style={{
|
||||
width: 8, height: 8, borderRadius: '50%', background: '#4caf50', flexShrink: 0,
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 4, fontSize: 11, color: '#999' }}>
|
||||
{typeof wf.runCount === 'number' && (
|
||||
<span>{wf.runCount} {wf.runCount === 1 ? t('Lauf') : t('Läufe')}</span>
|
||||
)}
|
||||
{ts ? <span>· {_formatRelative(ts)}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditorWorkflowChatList;
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,670 +0,0 @@
|
|||
/**
|
||||
* NodeConfigPanel - Generic parameter renderer for all node types.
|
||||
* Renders each parameter using FRONTEND_TYPE_RENDERERS based on frontendType.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import type { CanvasNode } from './FlowCanvas';
|
||||
import type { GraphDefinedSchemaRef, NodeType, NodeTypeParameter, PortSchema } from '../../../api/workflowApi';
|
||||
import type { ApiRequestFunction } from '../../../api/workflowApi';
|
||||
import { getLabel } from '../nodes/shared/utils';
|
||||
import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers';
|
||||
import { RequiredAttributePicker } from '../nodes/shared/RequiredAttributePicker';
|
||||
import { findRequiredErrors } from '../nodes/shared/paramValidation';
|
||||
import { useAutomation2DataFlow } from '../context/Automation2DataFlowContext';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import { AccordionList } from '../../UiComponents/AccordionList';
|
||||
import type { AccordionListItem } from '../../UiComponents/AccordionList';
|
||||
|
||||
const CONTEXT_EXTRACT_CONTENT_NODE_TYPE = 'context.extractContent';
|
||||
const CONTEXT_EXTRACT_CHUNK_PARAM_NAMES = ['chunkSizeUnit', 'chunkSize', 'chunkOverlap'] as const;
|
||||
const CONTEXT_EXTRACT_CHUNK_SET = new Set<string>(CONTEXT_EXTRACT_CHUNK_PARAM_NAMES);
|
||||
|
||||
/** Optional params use stored value only (unset ⇒ no chip). Required uses schema default as fallback. */
|
||||
export function workflowParamUiValue(stored: Record<string, unknown>, param: NodeTypeParameter): unknown {
|
||||
const raw = stored[param.name];
|
||||
if (param.required) {
|
||||
return raw !== undefined && raw !== null ? raw : param.default;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function effectiveSchemaParamString(name: string, currentParams: Record<string, unknown>, nt: NodeType): string {
|
||||
const raw = currentParams[name];
|
||||
const s = raw !== undefined && raw !== null ? String(raw) : '';
|
||||
if (s !== '') return s;
|
||||
const meta = nt.parameters?.find((p) => p.name === name);
|
||||
const d = meta?.default;
|
||||
return d !== undefined && d !== null ? String(d) : '';
|
||||
}
|
||||
|
||||
function accordionExtractParamTitle(param: NodeTypeParameter, t: (key: string) => string): React.ReactNode {
|
||||
return (
|
||||
<span style={{ fontWeight: 700, fontSize: 12 }}>
|
||||
{param.required ? (
|
||||
<span style={{ color: 'var(--danger-color, #dc3545)', marginRight: 3 }} title={t('Pflichtfeld')}>
|
||||
*
|
||||
</span>
|
||||
) : null}
|
||||
{param.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function verboseSchemaTypeBadge(
|
||||
verboseSchema: boolean,
|
||||
param: NodeTypeParameter,
|
||||
t: (key: string) => string,
|
||||
): React.ReactElement | null {
|
||||
if (!verboseSchema || !param.type) return null;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
marginBottom: 6,
|
||||
flexWrap: 'wrap',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
title={t('Parameter-Typ')}
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-secondary)',
|
||||
background: 'var(--bg-secondary)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: 4,
|
||||
padding: '1px 6px',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{param.type}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface NodeConfigPanelProps {
|
||||
node: CanvasNode | null;
|
||||
nodeType: NodeType | undefined;
|
||||
language: string;
|
||||
onParametersChange: (nodeId: string, parameters: Record<string, unknown>) => void;
|
||||
onMergeNodeParameters?: (nodeId: string, patch: Record<string, unknown>) => void;
|
||||
onNodeUpdate?: (nodeId: string, updates: Partial<Pick<CanvasNode, 'title' | 'comment'>>) => void;
|
||||
instanceId?: string;
|
||||
request?: ApiRequestFunction;
|
||||
/** When true, render developer-oriented sections (Schema-Typ-Referenz,
|
||||
* parameter type-badges). Toggle in CanvasHeader, sysadmin-only. */
|
||||
verboseSchema?: boolean;
|
||||
}
|
||||
|
||||
/** When ``frontendOptions.dependsOn`` and ``frontendOptions.showWhen`` are set
|
||||
* (same convention as trustee / gateway nodeAdapter ``visibleWhen``), hide the
|
||||
* parameter unless the referenced parameter's effective value matches.
|
||||
*/
|
||||
export function parameterVisibleForFrontendOptions(
|
||||
param: NodeTypeParameter,
|
||||
params: Record<string, unknown>,
|
||||
nodeType: NodeType,
|
||||
): boolean {
|
||||
const fo = param.frontendOptions;
|
||||
if (!fo || typeof fo !== 'object') return true;
|
||||
const dependsOnRaw = fo.dependsOn as unknown;
|
||||
const showWhenRaw = fo.showWhen as unknown;
|
||||
if (typeof dependsOnRaw !== 'string' || dependsOnRaw.length === 0 || showWhenRaw === undefined || showWhenRaw === null) {
|
||||
return true;
|
||||
}
|
||||
const depMeta = nodeType.parameters?.find((p) => p.name === dependsOnRaw);
|
||||
const rawSibling = params[dependsOnRaw];
|
||||
const siblingValue =
|
||||
rawSibling !== undefined && rawSibling !== null ? String(rawSibling) : '';
|
||||
const fallback =
|
||||
depMeta?.default !== undefined && depMeta?.default !== null ? String(depMeta.default) : '';
|
||||
const effective = siblingValue !== '' ? siblingValue : fallback;
|
||||
const allowed: string[] = Array.isArray(showWhenRaw)
|
||||
? showWhenRaw.map((x) => String(x))
|
||||
: [String(showWhenRaw)];
|
||||
return allowed.includes(effective);
|
||||
}
|
||||
|
||||
export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
||||
nodeType,
|
||||
language,
|
||||
onParametersChange,
|
||||
onMergeNodeParameters: _onMergeNodeParameters,
|
||||
onNodeUpdate,
|
||||
instanceId,
|
||||
request,
|
||||
verboseSchema = false,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const [params, setParams] = useState<Record<string, unknown>>({});
|
||||
const nodeIdRef = useRef<string | undefined>(undefined);
|
||||
nodeIdRef.current = node?.id;
|
||||
const notifyParentTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setParams(node?.parameters ?? {});
|
||||
}, [node?.id, node?.parameters]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (notifyParentTimeoutRef.current != null) {
|
||||
clearTimeout(notifyParentTimeoutRef.current);
|
||||
notifyParentTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [node?.id]);
|
||||
|
||||
const updateParam = useCallback(
|
||||
(key: string, value: unknown) => {
|
||||
setParams((prev) => {
|
||||
const next = { ...prev };
|
||||
if (value === undefined) {
|
||||
delete next[key];
|
||||
} else {
|
||||
next[key] = value;
|
||||
}
|
||||
const id = nodeIdRef.current;
|
||||
if (id) {
|
||||
if (notifyParentTimeoutRef.current != null) {
|
||||
clearTimeout(notifyParentTimeoutRef.current);
|
||||
}
|
||||
notifyParentTimeoutRef.current = setTimeout(() => {
|
||||
notifyParentTimeoutRef.current = null;
|
||||
onParametersChange(id, next);
|
||||
}, 0);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[onParametersChange]
|
||||
);
|
||||
|
||||
const patchParams = useCallback(
|
||||
(patch: Record<string, unknown>) => {
|
||||
setParams((prev) => {
|
||||
const next = { ...prev, ...patch };
|
||||
const id = nodeIdRef.current;
|
||||
if (id) {
|
||||
if (notifyParentTimeoutRef.current != null) {
|
||||
clearTimeout(notifyParentTimeoutRef.current);
|
||||
}
|
||||
notifyParentTimeoutRef.current = setTimeout(() => {
|
||||
notifyParentTimeoutRef.current = null;
|
||||
onParametersChange(id, next);
|
||||
}, 0);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[onParametersChange]
|
||||
);
|
||||
|
||||
const dataFlow = useAutomation2DataFlow();
|
||||
const portTypeCatalog: Record<string, PortSchema> = (dataFlow?.portTypeCatalog as Record<string, PortSchema> | undefined) ?? {};
|
||||
|
||||
// Phase-4 Schicht-4 — Pflicht-Params zuerst sortieren, damit der User
|
||||
// nicht nach unten scrollen muss, um zu sehen was fehlt.
|
||||
const sortedParameters: NodeTypeParameter[] = useMemo(() => {
|
||||
const all = nodeType?.parameters ?? [];
|
||||
const required = all.filter((p) => p.required);
|
||||
const optional = all.filter((p) => !p.required);
|
||||
return [...required, ...optional];
|
||||
}, [nodeType?.parameters]);
|
||||
|
||||
// Pre-compute which required params are unbound on this node so we can
|
||||
// surface a panel-level summary banner. The hidden-param safety net lives
|
||||
// inside `findRequiredErrors` so banner, canvas badges and Run-button stay
|
||||
// in lockstep.
|
||||
// Banner labels are kept short (`param.name`); the full description is
|
||||
// attached as the tooltip below.
|
||||
const requiredErrors = useMemo(() => {
|
||||
if (!node || !nodeType) return [];
|
||||
return findRequiredErrors(node, nodeType, (p) => p.name);
|
||||
}, [node, nodeType]);
|
||||
|
||||
// Resolve full descriptions per missing param (for the banner tooltip).
|
||||
const requiredErrorTooltip = useMemo(() => {
|
||||
if (!requiredErrors.length || !nodeType) return '';
|
||||
const byName = new Map((nodeType.parameters ?? []).map((p) => [p.name, p]));
|
||||
return requiredErrors
|
||||
.map((e) => {
|
||||
const p = byName.get(e.paramName);
|
||||
const desc = p ? (getLabel(p.description, language) || '') : '';
|
||||
return desc ? `${e.paramName}: ${desc}` : e.paramName;
|
||||
})
|
||||
.join('\n');
|
||||
}, [requiredErrors, nodeType, language]);
|
||||
|
||||
const extractContentAccordionItems = useMemo((): AccordionListItem<string>[] | null => {
|
||||
if (!node || !nodeType || node.type !== CONTEXT_EXTRACT_CONTENT_NODE_TYPE) return null;
|
||||
|
||||
const byName = new Map((nodeType.parameters ?? []).map((p) => [p.name, p]));
|
||||
const out: AccordionListItem<string>[] = [];
|
||||
|
||||
for (const param of sortedParameters) {
|
||||
if (param.frontendType === 'hidden') continue;
|
||||
if (CONTEXT_EXTRACT_CHUNK_SET.has(param.name)) continue;
|
||||
if (!parameterVisibleForFrontendOptions(param, params, nodeType)) continue;
|
||||
|
||||
const usePicker = _shouldUseRequiredPicker(param);
|
||||
if (usePicker) {
|
||||
out.push({
|
||||
id: param.name,
|
||||
title: accordionExtractParamTitle(param, t),
|
||||
children: (
|
||||
<div style={{ minWidth: 0 }}>
|
||||
{verboseSchemaTypeBadge(verboseSchema, param, t)}
|
||||
<RequiredAttributePicker
|
||||
label={getLabel(param.description, language) || param.name}
|
||||
expectedType={param.type}
|
||||
value={workflowParamUiValue(params, param)}
|
||||
onChange={(val) => updateParam(param.name, val)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const frontendType = param.frontendType || 'text';
|
||||
const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
|
||||
|
||||
if (param.name === 'outputMode') {
|
||||
const chunksNested = effectiveSchemaParamString('outputMode', params, nodeType) === 'chunks';
|
||||
out.push({
|
||||
id: param.name,
|
||||
title: accordionExtractParamTitle(param, t),
|
||||
children: (
|
||||
<div style={{ minWidth: 0 }}>
|
||||
{verboseSchemaTypeBadge(verboseSchema, param, t)}
|
||||
<Renderer
|
||||
param={param}
|
||||
value={workflowParamUiValue(params, param)}
|
||||
onChange={(val: unknown) => updateParam(param.name, val)}
|
||||
allParams={params}
|
||||
instanceId={instanceId}
|
||||
request={request}
|
||||
nodeType={node.type}
|
||||
onPatchParams={patchParams}
|
||||
hideAccordionTitle
|
||||
/>
|
||||
{chunksNested ? (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<AccordionList<string>
|
||||
key={`extract-chunks-${node.id}`}
|
||||
defaultOpenId={null}
|
||||
items={CONTEXT_EXTRACT_CHUNK_PARAM_NAMES.map((chunkName): AccordionListItem<string> => {
|
||||
const cp = byName.get(chunkName);
|
||||
if (!cp) {
|
||||
return { id: chunkName, title: chunkName, children: <></> };
|
||||
}
|
||||
const ft = cp.frontendType || 'text';
|
||||
const ChunkRenderer = FRONTEND_TYPE_RENDERERS[ft] ?? FRONTEND_TYPE_RENDERERS.text;
|
||||
return {
|
||||
id: chunkName,
|
||||
title: accordionExtractParamTitle(cp, t),
|
||||
children: (
|
||||
<div style={{ minWidth: 0 }}>
|
||||
{verboseSchemaTypeBadge(verboseSchema, cp, t)}
|
||||
<ChunkRenderer
|
||||
param={cp}
|
||||
value={workflowParamUiValue(params, cp)}
|
||||
onChange={(val: unknown) => updateParam(cp.name, val)}
|
||||
allParams={params}
|
||||
instanceId={instanceId}
|
||||
request={request}
|
||||
nodeType={node.type}
|
||||
onPatchParams={patchParams}
|
||||
hideAccordionTitle
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push({
|
||||
id: param.name,
|
||||
title: accordionExtractParamTitle(param, t),
|
||||
children: (
|
||||
<div style={{ minWidth: 0 }}>
|
||||
{verboseSchemaTypeBadge(verboseSchema, param, t)}
|
||||
<Renderer
|
||||
param={param}
|
||||
value={workflowParamUiValue(params, param)}
|
||||
onChange={(val: unknown) => updateParam(param.name, val)}
|
||||
allParams={params}
|
||||
instanceId={instanceId}
|
||||
request={request}
|
||||
nodeType={node.type}
|
||||
onPatchParams={patchParams}
|
||||
hideAccordionTitle
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}, [
|
||||
sortedParameters,
|
||||
params,
|
||||
nodeType,
|
||||
language,
|
||||
node?.id,
|
||||
node?.type,
|
||||
verboseSchema,
|
||||
instanceId,
|
||||
request,
|
||||
patchParams,
|
||||
updateParam,
|
||||
t,
|
||||
]);
|
||||
|
||||
if (!node || !nodeType) return null;
|
||||
|
||||
const isTrigger = node.type.startsWith('trigger.');
|
||||
const showNameField = onNodeUpdate && !isTrigger;
|
||||
const parameters = sortedParameters;
|
||||
|
||||
const inputPortDefs = nodeType.inputPorts ?? {};
|
||||
const outputPortDefs = nodeType.outputPorts ?? {};
|
||||
const inputPortEntries = Object.entries(inputPortDefs);
|
||||
const outputPortEntries = Object.entries(outputPortDefs);
|
||||
const hasPortInfo = inputPortEntries.length > 0 || outputPortEntries.length > 0;
|
||||
|
||||
return (
|
||||
<div className={styles.nodeConfigPanel}>
|
||||
{showNameField && (
|
||||
<div className={styles.nodeConfigNameRow}>
|
||||
<label htmlFor="node-config-name">{t('Bezeichnung')}</label>
|
||||
<input
|
||||
id="node-config-name"
|
||||
type="text"
|
||||
value={node.title ?? ''}
|
||||
onChange={(e) => onNodeUpdate(node.id, { title: e.target.value })}
|
||||
placeholder={t('z.B. Kundenformular prüfen, Land')}
|
||||
/>
|
||||
<p className={styles.nodeConfigNameHint}>
|
||||
{t('Wird im Data Picker angezeigt, um diesen Node zu identifizieren.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<h4>{getLabel(nodeType?.label, language) || node.type}</h4>
|
||||
{nodeType?.description && (
|
||||
<p className={styles.nodeConfigDescription}>
|
||||
{getLabel(nodeType.description, language)}
|
||||
</p>
|
||||
)}
|
||||
{hasPortInfo && verboseSchema && (
|
||||
<details className={styles.nodeConfigPorts ?? ''} style={{ margin: '0 0 0.75rem', fontSize: '0.7rem' }}>
|
||||
<summary
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
color: 'var(--text-secondary)',
|
||||
fontWeight: 500,
|
||||
padding: '0.15rem 0',
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
title={t('Statische Schema-Referenz f\u00fcr diesen Node-Typ \u2014 keine Live-Daten')}
|
||||
>
|
||||
{t('Schema (Typ-Referenz, Sysadmin-Ansicht)')}
|
||||
</summary>
|
||||
{inputPortEntries.length > 0 && (
|
||||
<div style={{ marginTop: '0.4rem' }}>
|
||||
<div style={{ color: 'var(--text-secondary)', fontWeight: 600, marginBottom: 2 }}>
|
||||
{'\u2B07'} {t('Eingabe')}
|
||||
</div>
|
||||
{inputPortEntries.map(([idx, def]) => (
|
||||
<_PortFieldList
|
||||
key={`in-${idx}`}
|
||||
portIndex={Number(idx)}
|
||||
schemaNames={def?.accepts ?? []}
|
||||
catalog={portTypeCatalog}
|
||||
emptyLabel={t('keine Felder')}
|
||||
language={language}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{outputPortEntries.length > 0 && (
|
||||
<div style={{ marginTop: '0.4rem' }}>
|
||||
<div style={{ color: 'var(--text-secondary)', fontWeight: 600, marginBottom: 2 }}>
|
||||
{'\u2B06'} {t('Ausgabe')}
|
||||
</div>
|
||||
{outputPortEntries.map(([idx, def]) => (
|
||||
<_PortFieldList
|
||||
key={`out-${idx}`}
|
||||
portIndex={Number(idx)}
|
||||
schemaNames={_schemaNamesFromOutputPort(def)}
|
||||
catalog={portTypeCatalog}
|
||||
emptyLabel={t('keine Felder')}
|
||||
language={language}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
)}
|
||||
{requiredErrors.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
padding: '6px 10px',
|
||||
background: 'rgba(220,53,69,0.10)',
|
||||
borderLeft: '3px solid var(--danger-color, #dc3545)',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
color: 'var(--danger-color, #dc3545)',
|
||||
overflowWrap: 'anywhere',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
title={requiredErrorTooltip || undefined}
|
||||
>
|
||||
{t('Pflicht-Felder ohne Quelle:')}{' '}
|
||||
<strong>{requiredErrors.map((e) => e.paramLabel).join(', ')}</strong>
|
||||
</div>
|
||||
)}
|
||||
{extractContentAccordionItems !== null ? (
|
||||
<AccordionList<string>
|
||||
key={`${node.id}-extract-accordion`}
|
||||
defaultOpenId={null}
|
||||
items={extractContentAccordionItems}
|
||||
/>
|
||||
) : (
|
||||
parameters.map((param: NodeTypeParameter) => {
|
||||
// Safety net: hidden params have no UI footprint at all — no row,
|
||||
// no required-mark, no type-badge. Their value is system-set.
|
||||
if (param.frontendType === 'hidden') return null;
|
||||
if (!parameterVisibleForFrontendOptions(param, params, nodeType)) return null;
|
||||
const useRequiredPicker = _shouldUseRequiredPicker(param);
|
||||
if (useRequiredPicker) {
|
||||
return (
|
||||
<div key={param.name} style={{ marginBottom: 8 }}>
|
||||
<RequiredAttributePicker
|
||||
label={getLabel(param.description, language) || param.name}
|
||||
expectedType={param.type}
|
||||
value={workflowParamUiValue(params, param)}
|
||||
onChange={(val) => updateParam(param.name, val)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const frontendType = param.frontendType || 'text';
|
||||
const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
|
||||
return (
|
||||
<div key={`${node.id}-${param.name}`} style={{ marginBottom: 4, minWidth: 0, maxWidth: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
marginBottom: 2,
|
||||
flexWrap: 'wrap',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{param.required && (
|
||||
<span
|
||||
title={t('Pflichtfeld')}
|
||||
style={{ color: 'var(--danger-color, #dc3545)', fontWeight: 700, flexShrink: 0 }}
|
||||
>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
{verboseSchema && param.type && (
|
||||
<span
|
||||
title={t('Parameter-Typ')}
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-secondary)',
|
||||
background: 'var(--bg-secondary)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: 4,
|
||||
padding: '1px 6px',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{param.type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Renderer
|
||||
param={param}
|
||||
value={workflowParamUiValue(params, param)}
|
||||
onChange={(val: unknown) => updateParam(param.name, val)}
|
||||
allParams={params}
|
||||
instanceId={instanceId}
|
||||
request={request}
|
||||
nodeType={node.type}
|
||||
onPatchParams={patchParams}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Heuristic: required params with a Schicht-1 catalog type (non-primitive
|
||||
* ref/record/list) get the typed RequiredAttributePicker; primitive scalars
|
||||
* fall through to the legacy frontend-type renderer (text/number/select etc.)
|
||||
* unless they have no frontendType at all and a non-trivial type. */
|
||||
function _shouldUseRequiredPicker(param: NodeTypeParameter): boolean {
|
||||
if (!param.required) return false;
|
||||
if (!param.type) return false;
|
||||
// Hidden params never get a picker — they are system-set or rendered to
|
||||
// nothing on purpose. The render loop above also skips hidden rows entirely.
|
||||
if (param.frontendType === 'hidden') return false;
|
||||
// Always defer to specialized FE renderers when explicitly chosen.
|
||||
if (param.frontendType && _LEGACY_RENDERERS_THAT_HANDLE_BINDINGS.has(param.frontendType)) {
|
||||
return false;
|
||||
}
|
||||
// Catalog ref/record/list types are best handled by RequiredAttributePicker.
|
||||
if (/^(List\[|Dict\[)/.test(param.type)) return true;
|
||||
if (/^[A-Z]/.test(param.type)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const _LEGACY_RENDERERS_THAT_HANDLE_BINDINGS = new Set([
|
||||
'userConnection',
|
||||
'featureInstance',
|
||||
'sharepointFolder',
|
||||
'sharepointFile',
|
||||
'userFileFolder',
|
||||
'clickupList',
|
||||
'clickupTask',
|
||||
'dataRef',
|
||||
'caseList',
|
||||
'fieldBuilder',
|
||||
'keyValueRows',
|
||||
'cron',
|
||||
'condition',
|
||||
'mappingTable',
|
||||
'filterExpression',
|
||||
'attachmentBuilder',
|
||||
'json',
|
||||
'modelMultiSelect',
|
||||
]);
|
||||
|
||||
function _schemaNamesFromOutputPort(def: { schema?: string | GraphDefinedSchemaRef } | undefined): string[] {
|
||||
if (!def?.schema) return [];
|
||||
if (typeof def.schema === 'string') return [def.schema];
|
||||
if (typeof def.schema === 'object' && def.schema.kind === 'fromGraph') return ['FormPayload', 'FormPayload_dynamic'];
|
||||
return [];
|
||||
}
|
||||
|
||||
interface _PortFieldListProps {
|
||||
portIndex: number;
|
||||
schemaNames: string[];
|
||||
catalog: Record<string, PortSchema>;
|
||||
emptyLabel: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
const _PortFieldList: React.FC<_PortFieldListProps> = ({ portIndex, schemaNames, catalog, emptyLabel, language }) => {
|
||||
if (!schemaNames.length) return null;
|
||||
return (
|
||||
<div style={{ marginLeft: 4, marginBottom: 4 }}>
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: '0.7rem' }}>
|
||||
{`#${portIndex} `}{schemaNames.join(' | ')}
|
||||
</div>
|
||||
{schemaNames.map((name) => {
|
||||
const schema = catalog[name];
|
||||
const fields = schema?.fields ?? [];
|
||||
if (name === 'Transit') {
|
||||
return (
|
||||
<div key={name} style={{ marginLeft: 8, color: 'var(--text-tertiary)', fontStyle: 'italic', fontSize: '0.7rem' }}>
|
||||
{'\u00B7 Transit (durchgereichte Daten)'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!fields.length) {
|
||||
return (
|
||||
<div key={name} style={{ marginLeft: 8, color: 'var(--text-tertiary)', fontSize: '0.7rem' }}>
|
||||
{`\u00B7 ${emptyLabel}`}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ul key={name} style={{ margin: '2px 0 4px 16px', padding: 0, listStyle: 'none' }}>
|
||||
{fields.map((f) => (
|
||||
<li key={f.name} style={{ fontSize: '0.7rem', lineHeight: 1.4, color: 'var(--text-secondary)' }}>
|
||||
<span style={{ fontFamily: 'monospace', color: 'var(--text-primary)' }}>{f.name}</span>
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>{`: ${f.type}`}</span>
|
||||
{!f.required && <span style={{ color: 'var(--text-tertiary)' }}>{' (optional)'}</span>}
|
||||
{f.description && (
|
||||
<div style={{ color: 'var(--text-secondary)', marginLeft: 4 }}>
|
||||
{getLabel(f.description, language)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
/**
|
||||
* NodeListItem - Draggable node type item for the sidebar.
|
||||
* Used in both regular categories and I/O sub-groups.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { NodeType } from '../../../api/workflowApi';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import { getCategoryIcon } from '../nodes/shared/utils';
|
||||
import type { GetLabelFn } from '../nodes/shared/utils';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
import { AiBadge } from '../nodes/shared/AiBadge';
|
||||
|
||||
interface NodeListItemProps {
|
||||
node: NodeType;
|
||||
language: string;
|
||||
getLabel: GetLabelFn;
|
||||
getCategoryIcon?: (categoryId: string) => React.ReactNode;
|
||||
}
|
||||
|
||||
export const NodeListItem: React.FC<NodeListItemProps> = ({
|
||||
node,
|
||||
language,
|
||||
getLabel,
|
||||
getCategoryIcon: getIcon = getCategoryIcon,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const desc = getLabel(node.description, language);
|
||||
return (
|
||||
<div
|
||||
className={styles.nodeItem}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('application/json', JSON.stringify({ type: node.id }));
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={styles.nodeItemIcon}
|
||||
style={{
|
||||
backgroundColor: node.meta?.color
|
||||
? `${node.meta.color}20`
|
||||
: 'var(--bg-tertiary, #e9ecef)',
|
||||
color: node.meta?.color ?? 'var(--text-secondary, #666)',
|
||||
}}
|
||||
>
|
||||
{getIcon(node.category)}
|
||||
</div>
|
||||
<div className={styles.nodeItemInfo}>
|
||||
<span className={styles.nodeItemLabelRow}>
|
||||
<span className={styles.nodeItemLabel}>{getLabel(node.label, language)}</span>
|
||||
{node.meta?.usesAi === true && (
|
||||
<AiBadge
|
||||
variant="palette"
|
||||
title={t('Dieser Schritt nutzt AI und verbraucht Credits')}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className={styles.nodeItemDesc}>{desc}</span>
|
||||
</div>
|
||||
{desc && <div className={styles.nodeItemTooltip}>{desc}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
/**
|
||||
* NodeSidebar - Sidebar with searchable, collapsible node list.
|
||||
* Groups node types by category (start, input, flow, data, ai, email, sharepoint).
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { FaChevronDown, FaChevronRight } from 'react-icons/fa';
|
||||
import type { NodeType, NodeTypeCategory } from '../../../api/workflowApi';
|
||||
import { CATEGORY_ORDER, HIDDEN_NODE_IDS } from '../nodes/shared/constants';
|
||||
import { getLabel } from '../nodes/shared/utils';
|
||||
import { NodeListItem } from './NodeListItem';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
||||
interface NodeSidebarProps {
|
||||
nodeTypes: NodeType[];
|
||||
categories: NodeTypeCategory[];
|
||||
filter: string;
|
||||
onFilterChange: (value: string) => void;
|
||||
language: string;
|
||||
expandedCategories: Set<string>;
|
||||
onToggleCategory: (id: string) => void;
|
||||
/** Hide palette categories (optional; e.g. feature flags) */
|
||||
excludedCategories?: Set<string>;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const NodeSidebar: React.FC<NodeSidebarProps> = ({ nodeTypes,
|
||||
categories,
|
||||
filter,
|
||||
onFilterChange,
|
||||
language,
|
||||
expandedCategories,
|
||||
onToggleCategory,
|
||||
excludedCategories,
|
||||
style,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const filteredNodeTypes = useMemo(() => {
|
||||
const visible = nodeTypes.filter(
|
||||
(n) =>
|
||||
!HIDDEN_NODE_IDS.has(n.id) &&
|
||||
!(excludedCategories?.has(n.category || ''))
|
||||
);
|
||||
if (!filter.trim()) return visible;
|
||||
const q = filter.toLowerCase();
|
||||
return visible.filter(
|
||||
(n) =>
|
||||
n.id.toLowerCase().includes(q) ||
|
||||
getLabel(n.label, language).toLowerCase().includes(q) ||
|
||||
getLabel(n.description, language).toLowerCase().includes(q)
|
||||
);
|
||||
}, [nodeTypes, filter, language]);
|
||||
|
||||
const groupedByCategory = useMemo(() => {
|
||||
const map: Record<string, NodeType[]> = {};
|
||||
filteredNodeTypes.forEach((n) => {
|
||||
const cat = n.category || 'other';
|
||||
if (!map[cat]) map[cat] = [];
|
||||
map[cat].push(n);
|
||||
});
|
||||
return map;
|
||||
}, [filteredNodeTypes]);
|
||||
|
||||
const orderedCategories = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
CATEGORY_ORDER.forEach((id) => {
|
||||
if (groupedByCategory[id]) {
|
||||
result.push(id);
|
||||
seen.add(id);
|
||||
}
|
||||
});
|
||||
Object.keys(groupedByCategory).forEach((id) => {
|
||||
if (!seen.has(id)) result.push(id);
|
||||
});
|
||||
return result;
|
||||
}, [groupedByCategory]);
|
||||
|
||||
const getLabelFn = (multilingual: string | Record<string, string> | undefined, lang?: string) =>
|
||||
getLabel(multilingual, lang ?? language);
|
||||
|
||||
return (
|
||||
<div className={styles.sidebar} style={style}>
|
||||
<div className={styles.sidebarHeader}>
|
||||
<h3 className={styles.sidebarTitle}>{t('Knoten')}</h3>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.sidebarSearch}
|
||||
placeholder={t('Nodes durchsuchen')}
|
||||
value={filter}
|
||||
onChange={(e) => onFilterChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.nodeList}>
|
||||
{orderedCategories.map((catId) => {
|
||||
const isExpanded = expandedCategories.has(catId);
|
||||
const catLabel = categories.find((c) => c.id === catId);
|
||||
const label = getLabel(catLabel?.label, language) || catId;
|
||||
const items = groupedByCategory[catId] || [];
|
||||
return (
|
||||
<div key={catId} className={styles.categoryGroup}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.categoryHeader}
|
||||
onClick={() => onToggleCategory(catId)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<FaChevronDown className={styles.categoryIcon} />
|
||||
) : (
|
||||
<FaChevronRight className={styles.categoryIcon} />
|
||||
)}
|
||||
<span className={styles.categoryLabel}>{label}</span>
|
||||
<span className={styles.categoryCount}>{items.length}</span>
|
||||
</button>
|
||||
{isExpanded &&
|
||||
items.map((node) => (
|
||||
<NodeListItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
language={language}
|
||||
getLabel={getLabelFn}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,263 +0,0 @@
|
|||
/**
|
||||
* RunTracingPanel
|
||||
*
|
||||
* Shows AutoStepLog entries for a workflow run with live SSE push.
|
||||
* Falls back to polling if SSE connection fails.
|
||||
* Displays per-node status, timing, I/O snapshots, and retry info.
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useApiRequest } from '../../../hooks/useApi';
|
||||
import type { AutoStepLog } from '../../../api/workflowApi';
|
||||
import api from '../../../api';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
||||
interface RunTracingPanelProps {
|
||||
instanceId: string;
|
||||
runId: string | null;
|
||||
onNodeSelect?: (nodeId: string) => void;
|
||||
onActiveStepsChange?: (nodeStatuses: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
pending: '#999',
|
||||
running: '#f0ad4e',
|
||||
completed: '#28a745',
|
||||
failed: '#dc3545',
|
||||
skipped: '#6c757d',
|
||||
};
|
||||
|
||||
const STATUS_ICONS: Record<string, string> = {
|
||||
pending: '○',
|
||||
running: '◉',
|
||||
completed: '✓',
|
||||
failed: '✗',
|
||||
skipped: '—',
|
||||
};
|
||||
|
||||
function _formatTimestamp(ts: number | string | null | undefined): string {
|
||||
if (!ts) return '';
|
||||
const d = typeof ts === 'number' ? new Date(ts * 1000) : new Date(ts);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
function _truncateJson(obj: unknown, maxLen = 300): string {
|
||||
if (!obj || (typeof obj === 'object' && Object.keys(obj as object).length === 0)) return '';
|
||||
try {
|
||||
const s = JSON.stringify(obj, null, 2);
|
||||
return s.length > maxLen ? s.slice(0, maxLen) + '\n...' : s;
|
||||
} catch {
|
||||
return String(obj);
|
||||
}
|
||||
}
|
||||
|
||||
const CollapsibleSection: React.FC<{
|
||||
label: string; content: string;
|
||||
}> = ({ label, content }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!content) return null;
|
||||
return (
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setOpen(!open); }}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
|
||||
color: 'var(--text-link, #0969da)', fontSize: '11px', textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
{open ? '▾' : '▸'} {label}
|
||||
</button>
|
||||
{open && (
|
||||
<pre style={{
|
||||
margin: '4px 0 0', padding: '6px', borderRadius: '4px',
|
||||
background: 'var(--bg-secondary, #f6f8fa)', fontSize: '11px',
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-all', maxHeight: '200px', overflowY: 'auto',
|
||||
}}>
|
||||
{content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
|
||||
instanceId,
|
||||
runId,
|
||||
onNodeSelect,
|
||||
onActiveStepsChange,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const [steps, setSteps] = useState<AutoStepLog[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sseConnected, setSseConnected] = useState(false);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const { request } = useApiRequest();
|
||||
|
||||
const loadSteps = useCallback(async () => {
|
||||
if (!runId || !instanceId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await request({
|
||||
url: `/api/workflows/${instanceId}/runs/${runId}/steps`,
|
||||
method: 'get',
|
||||
});
|
||||
setSteps(data?.steps || []);
|
||||
} catch (e) {
|
||||
console.error('[RunTracing] Failed to load steps:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [runId, instanceId, request]);
|
||||
|
||||
// SSE live-push connection
|
||||
useEffect(() => {
|
||||
if (!runId || !instanceId) return;
|
||||
loadSteps();
|
||||
|
||||
const baseUrl = api.defaults.baseURL || '';
|
||||
const url = `${baseUrl}/api/workflows/${instanceId}/runs/${runId}/stream`;
|
||||
const es = new EventSource(url, { withCredentials: true });
|
||||
eventSourceRef.current = es;
|
||||
|
||||
es.onopen = () => setSseConnected(true);
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data);
|
||||
if (payload.type === 'keepalive') return;
|
||||
if (payload.type === 'run_complete' || payload.type === 'run_failed') {
|
||||
loadSteps();
|
||||
es.close();
|
||||
setSseConnected(false);
|
||||
return;
|
||||
}
|
||||
if (payload.status === 'running') {
|
||||
setSteps((prev) => {
|
||||
const exists = prev.some((s) => s.id === payload.id);
|
||||
if (exists) return prev.map((s) => s.id === payload.id ? { ...s, ...payload } : s);
|
||||
return [...prev, payload as AutoStepLog];
|
||||
});
|
||||
} else {
|
||||
setSteps((prev) => prev.map((s) => s.id === payload.id ? { ...s, ...payload } : s));
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
};
|
||||
es.onerror = () => {
|
||||
setSseConnected(false);
|
||||
es.close();
|
||||
};
|
||||
|
||||
return () => {
|
||||
es.close();
|
||||
eventSourceRef.current = null;
|
||||
setSseConnected(false);
|
||||
};
|
||||
}, [runId, instanceId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Fallback polling when SSE is not connected
|
||||
useEffect(() => {
|
||||
if (sseConnected || !runId || !instanceId) return;
|
||||
const interval = setInterval(loadSteps, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [sseConnected, runId, instanceId, loadSteps]);
|
||||
|
||||
// Emit active node statuses for canvas highlighting
|
||||
useEffect(() => {
|
||||
if (!onActiveStepsChange) return;
|
||||
const nodeStatuses: Record<string, string> = {};
|
||||
for (const step of steps) {
|
||||
nodeStatuses[step.nodeId] = step.status;
|
||||
}
|
||||
onActiveStepsChange(nodeStatuses);
|
||||
}, [steps, onActiveStepsChange]);
|
||||
|
||||
if (!runId) {
|
||||
return (
|
||||
<div style={{ padding: '16px', color: 'var(--text-secondary, #888)', fontSize: '13px' }}>
|
||||
{t('Run auswählen, um Tracing-Details zu sehen.')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', overflowY: 'auto', height: '100%' }}>
|
||||
<div style={{ fontWeight: 600, fontSize: '14px', marginBottom: '12px' }}>
|
||||
{t('Run-Schritte')}{' '}
|
||||
{loading && (
|
||||
<span style={{ fontWeight: 400, fontSize: '12px', color: '#888' }}>({t('wird geladen…')})</span>
|
||||
)}
|
||||
</div>
|
||||
{steps.length === 0 && !loading && (
|
||||
<div style={{ color: 'var(--text-secondary, #888)', fontSize: '13px' }}>{t('Noch keine Schritte aufgezeichnet')}</div>
|
||||
)}
|
||||
{steps.map((step: any) => {
|
||||
const startStr = _formatTimestamp(step.startedAt);
|
||||
const endStr = _formatTimestamp(step.completedAt);
|
||||
const inputStr = _truncateJson(step.inputSnapshot);
|
||||
const outputStr = _truncateJson(step.output);
|
||||
const isLoop = step.inputSnapshot?._loopIndex != null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
onClick={() => onNodeSelect?.(step.nodeId)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
marginBottom: '6px',
|
||||
borderRadius: '6px',
|
||||
border: `1px solid ${STATUS_COLORS[step.status] || '#ddd'}`,
|
||||
background: 'var(--bg-primary, #fff)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
marginLeft: isLoop ? '16px' : '0',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>
|
||||
<span style={{ color: STATUS_COLORS[step.status] || '#999', marginRight: '6px' }}>
|
||||
{STATUS_ICONS[step.status] || '?'}
|
||||
</span>
|
||||
<strong>{step.nodeType}</strong>
|
||||
<span style={{ color: '#888', marginLeft: '6px' }}>({step.nodeId})</span>
|
||||
{isLoop && (
|
||||
<span style={{ color: '#666', marginLeft: '6px', fontSize: '11px' }}>
|
||||
[iter {step.inputSnapshot._loopIndex}]
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
{step.retryCount > 0 && (
|
||||
<span style={{ color: '#f0ad4e', fontSize: '11px' }} title={t('Wiederholungsanzahl')}>
|
||||
{step.retryCount}x {t('Wiederholung')}
|
||||
</span>
|
||||
)}
|
||||
{step.durationMs != null && (
|
||||
<span style={{ color: '#888', fontSize: '12px' }}>{step.durationMs}ms</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{(startStr || endStr) && (
|
||||
<div style={{ color: '#888', fontSize: '11px', marginTop: '2px' }}>
|
||||
{startStr && <span>{startStr}</span>}
|
||||
{startStr && endStr && <span> → </span>}
|
||||
{endStr && <span>{endStr}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.error && (
|
||||
<div style={{ color: '#dc3545', fontSize: '12px', marginTop: '4px' }}>{step.error}</div>
|
||||
)}
|
||||
{step.tokensUsed > 0 && (
|
||||
<div style={{ color: '#888', fontSize: '11px', marginTop: '2px' }}>
|
||||
{step.tokensUsed} {t('Tokens')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CollapsibleSection label={t('Eingabe')} content={inputStr} />
|
||||
<CollapsibleSection label={t('Ausgabe')} content={outputStr} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
/**
|
||||
* TemplatePicker - modal to browse and select a workflow template for creating a new workflow.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { FaSpinner } from 'react-icons/fa';
|
||||
import {
|
||||
fetchTemplates,
|
||||
type AutoWorkflowTemplate,
|
||||
type AutoTemplateScope,
|
||||
type ApiRequestFunction,
|
||||
} from '../../../api/workflowApi';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
||||
interface TemplatePickerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (templateId: string) => void;
|
||||
instanceId: string;
|
||||
request: ApiRequestFunction;
|
||||
}
|
||||
|
||||
export const TemplatePicker: React.FC<TemplatePickerProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
instanceId,
|
||||
request,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const scopeLabels = useMemo(
|
||||
() =>
|
||||
({
|
||||
all: t('Alle'),
|
||||
user: t('Meine'),
|
||||
instance: t('Instanz'),
|
||||
mandate: t('Mandant'),
|
||||
system: t('System'),
|
||||
}) as Record<AutoTemplateScope | 'all', string>,
|
||||
[t]
|
||||
);
|
||||
const [templates, setTemplates] = useState<AutoWorkflowTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeScope, setActiveScope] = useState<AutoTemplateScope | 'all'>('all');
|
||||
const [copying, setCopying] = useState<string | null>(null);
|
||||
|
||||
const _load = useCallback(async () => {
|
||||
if (!instanceId || !open) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const scope = activeScope === 'all' ? undefined : activeScope;
|
||||
const result = await fetchTemplates(request, instanceId, scope);
|
||||
setTemplates(Array.isArray(result) ? result : result.items);
|
||||
} catch {
|
||||
setTemplates([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [instanceId, request, open, activeScope]);
|
||||
|
||||
useEffect(() => {
|
||||
_load();
|
||||
}, [_load]);
|
||||
|
||||
const _handleSelect = useCallback(
|
||||
async (templateId: string) => {
|
||||
setCopying(templateId);
|
||||
try {
|
||||
await onSelect(templateId);
|
||||
} finally {
|
||||
setCopying(null);
|
||||
}
|
||||
},
|
||||
[onSelect]
|
||||
);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.workflowModalBackdrop} role="dialog" aria-modal="true" aria-labelledby="tpl-picker-title">
|
||||
<div className={styles.workflowModal} style={{ maxWidth: 600, maxHeight: '80vh', display: 'flex', flexDirection: 'column' }}>
|
||||
<h3 id="tpl-picker-title" className={styles.workflowModalTitle}>
|
||||
{t('Neu aus Vorlage')}
|
||||
</h3>
|
||||
<p className={styles.workflowModalHint}>
|
||||
{t('Wählen Sie eine Vorlage, um einen neuen Workflow zu erstellen.')}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
{(['all', 'user', 'instance', 'mandate', 'system'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
className={activeScope === s ? styles.workflowModalBtnPrimary : styles.workflowModalBtnSecondary}
|
||||
onClick={() => setActiveScope(s)}
|
||||
style={{ fontSize: '0.8rem', padding: '4px 10px' }}
|
||||
>
|
||||
{scopeLabels[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 120 }}>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 24 }}>
|
||||
<FaSpinner className={styles.spinner} />
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: 24, color: 'var(--text-secondary, #888)' }}>
|
||||
{t('Keine Vorlagen gefunden.')}
|
||||
</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.85rem' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid var(--border-color, #e0e0e0)', textAlign: 'left' }}>
|
||||
<th style={{ padding: '6px 8px' }}>{t('Name')}</th>
|
||||
<th style={{ padding: '6px 8px', width: 80 }}>{t('Scope')}</th>
|
||||
<th style={{ padding: '6px 8px', width: 100 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{templates.map((tpl) => (
|
||||
<tr key={tpl.id} style={{ borderBottom: '1px solid var(--border-color, #eee)' }}>
|
||||
<td style={{ padding: '8px' }}>{tpl.label}</td>
|
||||
<td style={{ padding: '8px', fontSize: '0.8rem', color: 'var(--text-secondary, #888)' }}>
|
||||
{scopeLabels[(tpl.templateScope as AutoTemplateScope) || 'user']}
|
||||
</td>
|
||||
<td style={{ padding: '8px', textAlign: 'right' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.workflowModalBtnPrimary}
|
||||
style={{ fontSize: '0.8rem', padding: '4px 10px' }}
|
||||
onClick={() => _handleSelect(tpl.id)}
|
||||
disabled={copying !== null}
|
||||
>
|
||||
{copying === tpl.id ? <FaSpinner className={styles.spinner} /> : t('Übernehmen')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.workflowModalActions} style={{ marginTop: 12 }}>
|
||||
<button type="button" className={styles.workflowModalBtnSecondary} onClick={onClose}>
|
||||
{t('Abbrechen')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
export { Automation2FlowEditor, Automation2FlowEditor as FlowEditor } from './editor/Automation2FlowEditor';
|
||||
export type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './editor/EditorChatPanel';
|
||||
export { FlowCanvas, STICKY_NOTE_PALETTE, STICKY_NOTE_DEFAULT_COLOR_ID, STICKY_NOTE_DEFAULT_HEIGHT, getStickyNotePaletteEntry } from './editor/FlowCanvas';
|
||||
export type { CanvasNode, CanvasConnection, CanvasStickyNote, FlowCanvasHandle, FlowCanvasViewportEditState } from './editor/FlowCanvas';
|
||||
export { NodeConfigPanel } from './editor/NodeConfigPanel';
|
||||
export { NodeSidebar } from './editor/NodeSidebar';
|
||||
export { NodeListItem } from './editor/NodeListItem';
|
||||
export { CanvasHeader } from './editor/CanvasHeader';
|
||||
export type { CanvasHeaderCanvasEditProps } from './editor/CanvasHeader';
|
||||
export * from './nodes/shared/utils';
|
||||
export * from './nodes/shared/constants';
|
||||
export * from './nodes/shared/graphUtils';
|
||||
export { getAcceptStringFromConfig } from './nodes/shared/utils';
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
/**
|
||||
* One text field per option — the text the end user sees in the dropdown.
|
||||
* Stored as { value, label } with the same string so payload and UI stay in sync.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { FaTimes } from 'react-icons/fa';
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
import type { FormFieldOptionRow } from './formFieldOptionsUtils';
|
||||
|
||||
export interface FormFieldOptionsEditorProps {
|
||||
options: FormFieldOptionRow[];
|
||||
onChange: (next: FormFieldOptionRow[]) => void;
|
||||
className?: string;
|
||||
rowClassName?: string;
|
||||
}
|
||||
|
||||
export const FormFieldOptionsEditor: React.FC<FormFieldOptionsEditorProps> = ({
|
||||
options,
|
||||
onChange,
|
||||
className,
|
||||
rowClassName,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const rootClass = className ?? '';
|
||||
const lineClass = rowClassName ?? '';
|
||||
|
||||
const setOptionText = (idx: number, text: string) => {
|
||||
const next = options.map((o, i) =>
|
||||
i === idx ? { value: text, label: text } : o,
|
||||
);
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={rootClass}>
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-secondary, #666)', marginBottom: 4 }}>
|
||||
{t('Auswahloptionen')}
|
||||
</div>
|
||||
{options.map((opt, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={lineClass}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 6,
|
||||
alignItems: 'center',
|
||||
marginBottom: 6,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('z.B. On hold')}
|
||||
value={opt.label || opt.value}
|
||||
onChange={(e) => setOptionText(idx, e.target.value)}
|
||||
style={{
|
||||
flex: '1 1 120px',
|
||||
minWidth: 80,
|
||||
padding: '4px 6px',
|
||||
fontSize: '0.8rem',
|
||||
borderRadius: 4,
|
||||
border: '1px solid var(--border-color, #ddd)',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
title={t('Option entfernen')}
|
||||
onClick={() => onChange(options.filter((_, i) => i !== idx))}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--text-tertiary, #999)',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<FaTimes />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange([...options, { value: '', label: '' }])}
|
||||
style={{
|
||||
marginTop: 2,
|
||||
padding: '4px 10px',
|
||||
fontSize: '0.75rem',
|
||||
borderRadius: 4,
|
||||
border: '1px dashed var(--border-color, #bbb)',
|
||||
background: 'var(--bg-primary, #fff)',
|
||||
color: 'var(--text-secondary, #555)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
+ {t('Option')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue