commit
e1d06e2a9d
79 changed files with 7381 additions and 548 deletions
13
src/App.tsx
13
src/App.tsx
|
|
@ -38,10 +38,10 @@ import { SettingsPage } from './pages/Settings';
|
||||||
import { GDPRPage } from './pages/GDPR';
|
import { GDPRPage } from './pages/GDPR';
|
||||||
import StorePage from './pages/Store';
|
import StorePage from './pages/Store';
|
||||||
import { FeatureViewPage } from './pages/FeatureView';
|
import { FeatureViewPage } from './pages/FeatureView';
|
||||||
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminAutomationEventsPage, AdminLogsPage } from './pages/admin';
|
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminAutomationEventsPage, AdminAutomationLogsPage, AdminLogsPage } from './pages/admin';
|
||||||
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
|
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
|
||||||
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
||||||
import { BillingDataView, BillingAdmin, BillingMandateView } from './pages/billing';
|
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
|
||||||
function App() {
|
function App() {
|
||||||
// Load saved theme preference and set app name on app mount
|
// Load saved theme preference and set app name on app mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -154,8 +154,13 @@ function App() {
|
||||||
<Route path="templates" element={<FeatureViewPage view="templates" />} />
|
<Route path="templates" element={<FeatureViewPage view="templates" />} />
|
||||||
<Route path="logs" element={<FeatureViewPage view="logs" />} />
|
<Route path="logs" element={<FeatureViewPage view="logs" />} />
|
||||||
|
|
||||||
{/* Workspace Editor */}
|
{/* Workspace + Automation2 Editor */}
|
||||||
<Route path="editor" element={<FeatureViewPage view="editor" />} />
|
<Route path="editor" element={<FeatureViewPage view="editor" />} />
|
||||||
|
<Route path="rag-insights" element={<FeatureViewPage view="rag-insights" />} />
|
||||||
|
|
||||||
|
{/* Automation2 Workflows & Tasks */}
|
||||||
|
<Route path="workflows" element={<FeatureViewPage view="workflows" />} />
|
||||||
|
<Route path="workflows-tasks" element={<FeatureViewPage view="workflows-tasks" />} />
|
||||||
|
|
||||||
{/* Teams Bot Feature Views */}
|
{/* Teams Bot Feature Views */}
|
||||||
<Route path="sessions" element={<FeatureViewPage view="sessions" />} />
|
<Route path="sessions" element={<FeatureViewPage view="sessions" />} />
|
||||||
|
|
@ -189,7 +194,9 @@ function App() {
|
||||||
<Route index element={<BillingAdmin />} />
|
<Route index element={<BillingAdmin />} />
|
||||||
<Route path="mandates" element={<BillingMandateView />} />
|
<Route path="mandates" element={<BillingMandateView />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="subscriptions" element={<AdminSubscriptionsPage />} />
|
||||||
<Route path="automation-events" element={<AdminAutomationEventsPage />} />
|
<Route path="automation-events" element={<AdminAutomationEventsPage />} />
|
||||||
|
<Route path="automation-logs" element={<AdminAutomationLogsPage />} />
|
||||||
<Route path="logs" element={<AdminLogsPage />} />
|
<Route path="logs" element={<AdminLogsPage />} />
|
||||||
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
|
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
|
||||||
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />
|
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />
|
||||||
|
|
|
||||||
356
src/api/automation2Api.ts
Normal file
356
src/api/automation2Api.ts
Normal file
|
|
@ -0,0 +1,356 @@
|
||||||
|
/**
|
||||||
|
* Automation2 API
|
||||||
|
* Node types and graph execution for n8n-style flows.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ApiRequestOptions } from '../hooks/useApi';
|
||||||
|
|
||||||
|
const LOG = '[Automation2]';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface NodeTypeParameter {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
required?: boolean;
|
||||||
|
description?: string;
|
||||||
|
default?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeType {
|
||||||
|
id: string;
|
||||||
|
category: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
parameters: NodeTypeParameter[];
|
||||||
|
inputs: number;
|
||||||
|
outputs: number;
|
||||||
|
executor: string;
|
||||||
|
meta?: {
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
|
method?: string;
|
||||||
|
action?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeTypeCategory {
|
||||||
|
id: string;
|
||||||
|
label: Record<string, string> | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeTypesResponse {
|
||||||
|
nodeTypes: NodeType[];
|
||||||
|
categories: NodeTypeCategory[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Automation2GraphNode {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
parameters?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Automation2Connection {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
sourceOutput?: number;
|
||||||
|
targetInput?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Automation2Graph {
|
||||||
|
nodes: Automation2GraphNode[];
|
||||||
|
connections: Automation2Connection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecuteGraphResponse {
|
||||||
|
success: boolean;
|
||||||
|
nodeOutputs?: Record<string, unknown>;
|
||||||
|
error?: string;
|
||||||
|
stopped?: boolean;
|
||||||
|
failedNode?: string;
|
||||||
|
paused?: boolean;
|
||||||
|
taskId?: string;
|
||||||
|
runId?: string;
|
||||||
|
nodeId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Automation2Workflow {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
graph: Automation2Graph;
|
||||||
|
active?: boolean;
|
||||||
|
/** Enriched: run count */
|
||||||
|
runCount?: number;
|
||||||
|
/** Enriched: has active (running/paused) run */
|
||||||
|
isRunning?: boolean;
|
||||||
|
/** Enriched: status of active run */
|
||||||
|
runStatus?: string;
|
||||||
|
/** Enriched: nodeId where workflow is stuck (paused) */
|
||||||
|
stuckAtNodeId?: string;
|
||||||
|
/** Enriched: human-readable label for stuck node */
|
||||||
|
stuckAtNodeLabel?: string;
|
||||||
|
/** Enriched: created timestamp (seconds) */
|
||||||
|
createdAt?: number;
|
||||||
|
/** Enriched: last run started timestamp (seconds) */
|
||||||
|
lastStartedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// API FUNCTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch node types for the flow builder (backend-driven).
|
||||||
|
* GET /api/automation2/{instanceId}/node-types?language=de
|
||||||
|
*/
|
||||||
|
export async function fetchNodeTypes(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
language = 'de'
|
||||||
|
): Promise<NodeTypesResponse> {
|
||||||
|
console.log(`${LOG} fetchNodeTypes: instanceId=${instanceId} language=${language}`);
|
||||||
|
const data = await request({
|
||||||
|
url: `/api/automation2/${instanceId}/node-types`,
|
||||||
|
method: 'get',
|
||||||
|
params: { language },
|
||||||
|
});
|
||||||
|
const nodeTypes = data?.nodeTypes ?? [];
|
||||||
|
const categories = data?.categories ?? [];
|
||||||
|
console.log(`${LOG} fetchNodeTypes response: ${nodeTypes.length} nodeTypes, ${categories.length} categories`);
|
||||||
|
return { nodeTypes, categories };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute an automation2 graph.
|
||||||
|
* POST /api/automation2/{instanceId}/execute
|
||||||
|
*/
|
||||||
|
export async function executeGraph(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
graph: Automation2Graph,
|
||||||
|
workflowId?: string
|
||||||
|
): Promise<ExecuteGraphResponse> {
|
||||||
|
console.log(
|
||||||
|
`${LOG} executeGraph request: instanceId=${instanceId} workflowId=${workflowId} nodes=${graph.nodes.length} connections=${graph.connections.length}`,
|
||||||
|
{ nodes: graph.nodes, connections: graph.connections }
|
||||||
|
);
|
||||||
|
const start = performance.now();
|
||||||
|
try {
|
||||||
|
const result = await request({
|
||||||
|
url: `/api/automation2/${instanceId}/execute`,
|
||||||
|
method: 'post',
|
||||||
|
data: { graph, workflowId },
|
||||||
|
});
|
||||||
|
const ms = Math.round(performance.now() - start);
|
||||||
|
console.log(
|
||||||
|
`${LOG} executeGraph response (${ms}ms): success=${result?.success} error=${result?.error ?? 'none'} nodeOutputs_keys=${Object.keys(result?.nodeOutputs ?? {}).join(',')} failedNode=${result?.failedNode ?? '-'}`,
|
||||||
|
result
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
const ms = Math.round(performance.now() - start);
|
||||||
|
console.error(
|
||||||
|
`${LOG} executeGraph FAILED (${ms}ms): instanceId=${instanceId}`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Workflows CRUD
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function fetchWorkflows(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string
|
||||||
|
): Promise<Automation2Workflow[]> {
|
||||||
|
const data = await request({
|
||||||
|
url: `/api/automation2/${instanceId}/workflows`,
|
||||||
|
method: 'get',
|
||||||
|
});
|
||||||
|
return data?.workflows ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWorkflow(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
workflowId: string
|
||||||
|
): Promise<Automation2Workflow> {
|
||||||
|
return await request({
|
||||||
|
url: `/api/automation2/${instanceId}/workflows/${workflowId}`,
|
||||||
|
method: 'get',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWorkflow(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
body: { label: string; graph: Automation2Graph }
|
||||||
|
): Promise<Automation2Workflow> {
|
||||||
|
return await request({
|
||||||
|
url: `/api/automation2/${instanceId}/workflows`,
|
||||||
|
method: 'post',
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateWorkflow(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
workflowId: string,
|
||||||
|
body: { label?: string; graph?: Automation2Graph }
|
||||||
|
): Promise<Automation2Workflow> {
|
||||||
|
return await request({
|
||||||
|
url: `/api/automation2/${instanceId}/workflows/${workflowId}`,
|
||||||
|
method: 'put',
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteWorkflow(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
workflowId: string
|
||||||
|
): Promise<void> {
|
||||||
|
await request({
|
||||||
|
url: `/api/automation2/${instanceId}/workflows/${workflowId}`,
|
||||||
|
method: 'delete',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Automation2Run {
|
||||||
|
id: string;
|
||||||
|
workflowId: string;
|
||||||
|
status: string;
|
||||||
|
nodeOutputs?: Record<string, unknown>;
|
||||||
|
currentNodeId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWorkflowRuns(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
workflowId: string
|
||||||
|
): Promise<Automation2Run[]> {
|
||||||
|
const data = await request({
|
||||||
|
url: `/api/automation2/${instanceId}/workflows/${workflowId}/runs`,
|
||||||
|
method: 'get',
|
||||||
|
});
|
||||||
|
return data?.runs ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Tasks
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface Automation2Task {
|
||||||
|
id: string;
|
||||||
|
runId: string;
|
||||||
|
workflowId: string;
|
||||||
|
nodeId: string;
|
||||||
|
nodeType: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
status: string;
|
||||||
|
result?: Record<string, unknown>;
|
||||||
|
/** Workflow label (enriched by API) */
|
||||||
|
workflowLabel?: string;
|
||||||
|
/** Unix timestamp ms (from _createdAt) */
|
||||||
|
createdAt?: number;
|
||||||
|
/** Optional due date - configurable in future */
|
||||||
|
dueAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTasks(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
params?: { workflowId?: string; status?: string }
|
||||||
|
): Promise<Automation2Task[]> {
|
||||||
|
const data = await request({
|
||||||
|
url: `/api/automation2/${instanceId}/tasks`,
|
||||||
|
method: 'get',
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return data?.tasks ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeTask(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
taskId: string,
|
||||||
|
result: Record<string, unknown>
|
||||||
|
): Promise<ExecuteGraphResponse> {
|
||||||
|
return await request({
|
||||||
|
url: `/api/automation2/${instanceId}/tasks/${taskId}/complete`,
|
||||||
|
method: 'post',
|
||||||
|
data: { result },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Connections and Browse (for Email/SharePoint node config)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface UserConnection {
|
||||||
|
id: string;
|
||||||
|
authority: string;
|
||||||
|
externalUsername?: string;
|
||||||
|
externalEmail?: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchConnections(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string
|
||||||
|
): Promise<UserConnection[]> {
|
||||||
|
const data = await request({
|
||||||
|
url: `/api/automation2/${instanceId}/connections`,
|
||||||
|
method: 'get',
|
||||||
|
});
|
||||||
|
return data?.connections ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectionService {
|
||||||
|
service: string;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchConnectionServices(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
connectionId: string
|
||||||
|
): Promise<ConnectionService[]> {
|
||||||
|
const data = await request({
|
||||||
|
url: `/api/automation2/${instanceId}/connections/${connectionId}/services`,
|
||||||
|
method: 'get',
|
||||||
|
});
|
||||||
|
return data?.services ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowseEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
isFolder: boolean;
|
||||||
|
size?: number;
|
||||||
|
mimeType?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBrowse(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
connectionId: string,
|
||||||
|
service: string,
|
||||||
|
path = '/'
|
||||||
|
): Promise<{ items: BrowseEntry[]; path: string; service: string }> {
|
||||||
|
const data = await request({
|
||||||
|
url: `/api/automation2/${instanceId}/connections/${connectionId}/browse`,
|
||||||
|
method: 'get',
|
||||||
|
params: { service, path },
|
||||||
|
});
|
||||||
|
return { items: data?.items ?? [], path: data?.path ?? path, service: data?.service ?? service };
|
||||||
|
}
|
||||||
|
|
@ -254,17 +254,26 @@ export async function fetchAutomationAttributes(
|
||||||
* Endpoint: GET /api/automation-templates
|
* Endpoint: GET /api/automation-templates
|
||||||
*/
|
*/
|
||||||
export async function fetchAutomationTemplates(
|
export async function fetchAutomationTemplates(
|
||||||
request: ApiRequestFunction
|
request: ApiRequestFunction,
|
||||||
): Promise<AutomationTemplate[]> {
|
params?: any
|
||||||
const data = await request({
|
): Promise<any> {
|
||||||
url: '/api/automation-templates',
|
const requestParams: Record<string, string> = {};
|
||||||
method: 'get'
|
if (params && typeof params === 'object') {
|
||||||
});
|
const paginationObj: any = {};
|
||||||
|
if (params.page !== undefined) paginationObj.page = params.page;
|
||||||
if (data?.items && Array.isArray(data.items)) {
|
if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
|
||||||
return data.items;
|
if (params.sort) paginationObj.sort = params.sort;
|
||||||
|
if (params.filters) paginationObj.filters = params.filters;
|
||||||
|
if (params.search) paginationObj.search = params.search;
|
||||||
|
if (Object.keys(paginationObj).length > 0) {
|
||||||
|
requestParams.pagination = JSON.stringify(paginationObj);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Array.isArray(data) ? data : [];
|
return await request({
|
||||||
|
url: '/api/automation-templates',
|
||||||
|
method: 'get',
|
||||||
|
params: requestParams,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
143
src/api/subscriptionApi.ts
Normal file
143
src/api/subscriptionApi.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
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: Record<string, string>;
|
||||||
|
description: Record<string, string>;
|
||||||
|
currency: string;
|
||||||
|
billingPeriod: BillingPeriod;
|
||||||
|
pricePerUserCHF: number;
|
||||||
|
pricePerFeatureInstanceCHF: number;
|
||||||
|
autoRenew: boolean;
|
||||||
|
maxUsers: number | null;
|
||||||
|
maxFeatureInstances: number | null;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionStatusResponse {
|
||||||
|
active: boolean;
|
||||||
|
subscription: MandateSubscription | null;
|
||||||
|
plan: SubscriptionPlan | null;
|
||||||
|
scheduled: MandateSubscription | 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,499 @@
|
||||||
|
/**
|
||||||
|
* Automation2 Flow Editor Styles
|
||||||
|
* Sidebar with node list + canvas area.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
SIDEBAR - Node List
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 280px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-secondary, #f8f9fa);
|
||||||
|
border-right: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarHeader {
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
background: var(--bg-primary, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarSearch {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background: var(--bg-primary, #fff);
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarSearch::placeholder {
|
||||||
|
color: var(--text-tertiary, #999);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarSearch:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color, #007bff);
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodeList {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Category Groups */
|
||||||
|
.categoryGroup {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.categoryHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryIcon {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryLabel {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryCount {
|
||||||
|
background: var(--bg-tertiary, #e9ecef);
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Node Items */
|
||||||
|
.nodeItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: grab;
|
||||||
|
transition: background 0.15s;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodeItem:hover {
|
||||||
|
background: var(--bg-hover, #e9ecef);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodeItem:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodeItemIcon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodeItemInfo {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodeItemLabel {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodeItemDesc {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading / Error */
|
||||||
|
.loading,
|
||||||
|
.error {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--danger-color, #dc3545);
|
||||||
|
}
|
||||||
|
|
||||||
|
.retryButton {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--primary-color, #007bff);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retryButton:hover {
|
||||||
|
background: var(--primary-hover, #0056b3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
CANVAS
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
|
.canvas {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--canvas-bg, #fafafa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeader {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
background: var(--bg-primary, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasArea {
|
||||||
|
flex: 1;
|
||||||
|
padding: 2rem;
|
||||||
|
min-height: 400px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasDropZone {
|
||||||
|
position: relative;
|
||||||
|
min-height: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 8px;
|
||||||
|
/* Infinite grid: on viewport, moves with pan/zoom via inline style */
|
||||||
|
background-image: radial-gradient(circle, var(--border-color, #e0e0e0) 1px, transparent 1px);
|
||||||
|
background-repeat: repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasContent {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
will-change: transform;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasGrab {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasPanning {
|
||||||
|
cursor: grabbing;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasPlaceholder {
|
||||||
|
position: absolute;
|
||||||
|
left: 2rem;
|
||||||
|
top: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-tertiary, #999);
|
||||||
|
border: 2px dashed var(--border-color, #dee2e6);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 2rem 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasPlaceholder p {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Canvas Nodes */
|
||||||
|
.canvasNode {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px solid;
|
||||||
|
cursor: grab;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasNode:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasNodeSelected {
|
||||||
|
box-shadow: 0 0 0 2px var(--primary-color, #007bff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasNodeContent {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasNodeIcon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasNodeText {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasNodeTitle {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasNodeTitle:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasNodeComment {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-tertiary, #999);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: text;
|
||||||
|
min-height: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasNodeComment:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasNodeInput {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.15rem 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border: 1px solid var(--primary-color, #007bff);
|
||||||
|
border-radius: 4px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Connection Handles */
|
||||||
|
.handle {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-primary, #fff);
|
||||||
|
border: 2px solid var(--border-color, #666);
|
||||||
|
cursor: crosshair;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle:hover,
|
||||||
|
.handleConnectable {
|
||||||
|
border-color: var(--primary-color, #007bff);
|
||||||
|
background: var(--primary-color, #007bff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.handleInput {
|
||||||
|
cursor: copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Node Config Panel */
|
||||||
|
.nodeConfigPanel {
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-primary, #fff);
|
||||||
|
border-left: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
width: 280px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodeConfigPanel h4 {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodeConfigPanel label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodeConfigPanel input[type='text'],
|
||||||
|
.nodeConfigPanel input[type='number'],
|
||||||
|
.nodeConfigPanel select,
|
||||||
|
.nodeConfigPanel textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.4rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodeConfigPanel textarea {
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodeConfigPanel button {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background: var(--primary-color, #007bff);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form fields editor (input.form) */
|
||||||
|
.formFieldsList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formFieldRow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: var(--bg-secondary, #f8f9fa);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formFieldRowHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formFieldDragHandle {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0.25rem;
|
||||||
|
cursor: grab;
|
||||||
|
color: var(--text-tertiary, #999);
|
||||||
|
align-self: stretch;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formFieldDragHandle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formFieldDragHandle:hover {
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formFieldInputs {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formFieldRowFooter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formFieldRequiredLabel {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formFieldRemoveButton {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-tertiary, #999);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formFieldRemoveButton:hover {
|
||||||
|
color: var(--danger-color, #dc3545);
|
||||||
|
background: rgba(220, 53, 69, 0.1);
|
||||||
|
}
|
||||||
328
src/components/Automation2FlowEditor/Automation2FlowEditor.tsx
Normal file
328
src/components/Automation2FlowEditor/Automation2FlowEditor.tsx
Normal file
|
|
@ -0,0 +1,328 @@
|
||||||
|
/**
|
||||||
|
* Automation2FlowEditor
|
||||||
|
*
|
||||||
|
* n8n-style flow builder with backend-driven node list.
|
||||||
|
* Composes: NodeSidebar, FlowCanvas, NodeConfigPanel, CanvasHeader.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { FaSpinner } from 'react-icons/fa';
|
||||||
|
import { useApiRequest } from '../../hooks/useApi';
|
||||||
|
import {
|
||||||
|
fetchNodeTypes,
|
||||||
|
executeGraph,
|
||||||
|
fetchWorkflows,
|
||||||
|
fetchWorkflow,
|
||||||
|
createWorkflow,
|
||||||
|
updateWorkflow,
|
||||||
|
type NodeType,
|
||||||
|
type NodeTypeCategory,
|
||||||
|
type Automation2Graph,
|
||||||
|
type Automation2Workflow,
|
||||||
|
type ExecuteGraphResponse,
|
||||||
|
} from '../../api/automation2Api';
|
||||||
|
import { FlowCanvas, type CanvasNode, type CanvasConnection } from './FlowCanvas';
|
||||||
|
import { NodeConfigPanel } from './NodeConfigPanel';
|
||||||
|
import { NodeSidebar } from './NodeSidebar';
|
||||||
|
import { CanvasHeader } from './CanvasHeader';
|
||||||
|
import { getCategoryIcon } from './utils';
|
||||||
|
import { fromApiGraph, toApiGraph } from './graphUtils';
|
||||||
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
|
const LOG = '[Automation2]';
|
||||||
|
|
||||||
|
interface Automation2FlowEditorProps {
|
||||||
|
instanceId: string;
|
||||||
|
language?: string;
|
||||||
|
/** When set, load this workflow on mount (e.g. from workflows list edit) */
|
||||||
|
initialWorkflowId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
|
instanceId,
|
||||||
|
language = 'de',
|
||||||
|
initialWorkflowId,
|
||||||
|
}) => {
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
|
||||||
|
const [categories, setCategories] = useState<NodeTypeCategory[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||||
|
new Set(['trigger', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint'])
|
||||||
|
);
|
||||||
|
const [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]);
|
||||||
|
const [canvasConnections, setCanvasConnections] = useState<CanvasConnection[]>([]);
|
||||||
|
const [executing, setExecuting] = useState(false);
|
||||||
|
const [executeResult, setExecuteResult] = useState<ExecuteGraphResponse | null>(null);
|
||||||
|
const [workflows, setWorkflows] = useState<Automation2Workflow[]>([]);
|
||||||
|
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null);
|
||||||
|
const [selectedNode, setSelectedNode] = useState<CanvasNode | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const handleFromApiGraph = useCallback(
|
||||||
|
(graph: Automation2Graph) => {
|
||||||
|
const { nodes, connections } = fromApiGraph(graph, nodeTypes);
|
||||||
|
setCanvasNodes(nodes);
|
||||||
|
setCanvasConnections(connections);
|
||||||
|
},
|
||||||
|
[nodeTypes]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleExecute = useCallback(async () => {
|
||||||
|
const graph = toApiGraph(canvasNodes, canvasConnections);
|
||||||
|
if (graph.nodes.length === 0) {
|
||||||
|
setExecuteResult({ success: false, error: 'Keine Nodes im Workflow.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExecuting(true);
|
||||||
|
setExecuteResult(null);
|
||||||
|
try {
|
||||||
|
const result = await executeGraph(
|
||||||
|
request,
|
||||||
|
instanceId,
|
||||||
|
graph,
|
||||||
|
currentWorkflowId ?? undefined
|
||||||
|
);
|
||||||
|
setExecuteResult(result);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) });
|
||||||
|
} finally {
|
||||||
|
setExecuting(false);
|
||||||
|
}
|
||||||
|
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId]);
|
||||||
|
|
||||||
|
const loadWorkflows = useCallback(async () => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
try {
|
||||||
|
const items = await fetchWorkflows(request, instanceId);
|
||||||
|
setWorkflows(items);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`${LOG} loadWorkflows failed`, e);
|
||||||
|
}
|
||||||
|
}, [instanceId, request]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
const graph = toApiGraph(canvasNodes, canvasConnections);
|
||||||
|
if (graph.nodes.length === 0) {
|
||||||
|
setExecuteResult({ success: false, error: 'Keine Nodes zum Speichern.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (currentWorkflowId) {
|
||||||
|
await updateWorkflow(request, instanceId, currentWorkflowId, { graph });
|
||||||
|
setExecuteResult({ success: true } as ExecuteGraphResponse);
|
||||||
|
} else {
|
||||||
|
const label = prompt('Workflow-Name:', 'Neuer Workflow') || 'Neuer Workflow';
|
||||||
|
const created = await createWorkflow(request, instanceId, { label, graph });
|
||||||
|
setCurrentWorkflowId(created.id);
|
||||||
|
setWorkflows((prev) => [...prev, created]);
|
||||||
|
setExecuteResult({ success: true } as ExecuteGraphResponse);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId]);
|
||||||
|
|
||||||
|
const handleLoad = useCallback(
|
||||||
|
async (workflowId: string) => {
|
||||||
|
try {
|
||||||
|
const wf = await fetchWorkflow(request, instanceId, workflowId);
|
||||||
|
if (wf.graph) handleFromApiGraph(wf.graph);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setExecuteResult({
|
||||||
|
success: false,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[request, instanceId, handleFromApiGraph]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleWorkflowSelect = useCallback(
|
||||||
|
(workflowId: string | null) => {
|
||||||
|
setCurrentWorkflowId(workflowId);
|
||||||
|
if (workflowId) handleLoad(workflowId);
|
||||||
|
else {
|
||||||
|
setCanvasNodes([]);
|
||||||
|
setCanvasConnections([]);
|
||||||
|
setExecuteResult(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleLoad]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleNew = useCallback(() => {
|
||||||
|
setCanvasNodes([]);
|
||||||
|
setCanvasConnections([]);
|
||||||
|
setCurrentWorkflowId(null);
|
||||||
|
setExecuteResult(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleNodeParametersChange = useCallback((nodeId: string, parameters: Record<string, unknown>) => {
|
||||||
|
setCanvasNodes((prev) => prev.map((n) => (n.id === nodeId ? { ...n, parameters } : n)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadNodeTypes = useCallback(async () => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await fetchNodeTypes(request, instanceId, language);
|
||||||
|
setNodeTypes(data.nodeTypes);
|
||||||
|
setCategories(data.categories);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
setNodeTypes([]);
|
||||||
|
setCategories([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [instanceId, language, request]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadNodeTypes();
|
||||||
|
}, [loadNodeTypes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadWorkflows();
|
||||||
|
}, [loadWorkflows]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialWorkflowId && workflows.length > 0 && !currentWorkflowId) {
|
||||||
|
handleWorkflowSelect(initialWorkflowId);
|
||||||
|
}
|
||||||
|
}, [initialWorkflowId, workflows, currentWorkflowId, handleWorkflowSelect]);
|
||||||
|
|
||||||
|
const toggleCategory = useCallback((id: string) => {
|
||||||
|
setExpandedCategories((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDropNodeType = useCallback(
|
||||||
|
(nodeTypeId: string, x: number, y: number) => {
|
||||||
|
const nt = nodeTypes.find((n) => n.id === nodeTypeId);
|
||||||
|
if (!nt) return;
|
||||||
|
const id = `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
const label =
|
||||||
|
typeof nt.label === 'string' ? nt.label : (nt.label as Record<string, string>)?.[language] ?? nt.id;
|
||||||
|
setCanvasNodes((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
type: nodeTypeId,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
label,
|
||||||
|
title: label,
|
||||||
|
color: nt.meta?.color,
|
||||||
|
inputs: nt.inputs ?? 1,
|
||||||
|
outputs: nt.outputs ?? 1,
|
||||||
|
parameters: {},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
[nodeTypes, language]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderSidebar = () => {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className={styles.sidebar}>
|
||||||
|
<div className={styles.sidebarHeader}>
|
||||||
|
<h3 className={styles.sidebarTitle}>Nodes</h3>
|
||||||
|
</div>
|
||||||
|
<div className={styles.loading}>
|
||||||
|
<FaSpinner className={styles.spinner} style={{ marginBottom: '0.5rem' }} />
|
||||||
|
<p>Lade Node-Typen...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className={styles.sidebar}>
|
||||||
|
<div className={styles.sidebarHeader}>
|
||||||
|
<h3 className={styles.sidebarTitle}>Nodes</h3>
|
||||||
|
</div>
|
||||||
|
<div className={styles.error}>
|
||||||
|
<p>{error}</p>
|
||||||
|
<button className={styles.retryButton} onClick={loadNodeTypes}>
|
||||||
|
Erneut versuchen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<NodeSidebar
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
categories={categories}
|
||||||
|
filter={filter}
|
||||||
|
onFilterChange={setFilter}
|
||||||
|
language={language}
|
||||||
|
expandedCategories={expandedCategories}
|
||||||
|
onToggleCategory={toggleCategory}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{renderSidebar()}
|
||||||
|
|
||||||
|
<div className={styles.canvas}>
|
||||||
|
<CanvasHeader
|
||||||
|
workflows={workflows}
|
||||||
|
currentWorkflowId={currentWorkflowId}
|
||||||
|
onWorkflowSelect={handleWorkflowSelect}
|
||||||
|
onNew={handleNew}
|
||||||
|
onSave={handleSave}
|
||||||
|
onExecute={handleExecute}
|
||||||
|
saving={saving}
|
||||||
|
executing={executing}
|
||||||
|
hasNodes={canvasNodes.length > 0}
|
||||||
|
executeResult={executeResult}
|
||||||
|
/>
|
||||||
|
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<FlowCanvas
|
||||||
|
nodes={canvasNodes}
|
||||||
|
connections={canvasConnections}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
onNodesChange={setCanvasNodes}
|
||||||
|
onConnectionsChange={setCanvasConnections}
|
||||||
|
onDropNodeType={handleDropNodeType}
|
||||||
|
getLabel={(node) => node.title ?? node.label ?? node.type}
|
||||||
|
getCategoryIcon={getCategoryIcon}
|
||||||
|
onSelectionChange={setSelectedNode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{selectedNode &&
|
||||||
|
['input.', 'ai.', 'email.', 'sharepoint.'].some((p) =>
|
||||||
|
selectedNode.type.startsWith(p)
|
||||||
|
) && (
|
||||||
|
<NodeConfigPanel
|
||||||
|
node={selectedNode}
|
||||||
|
nodeType={nodeTypes.find((nt) => nt.id === selectedNode.type)}
|
||||||
|
language={language}
|
||||||
|
onParametersChange={handleNodeParametersChange}
|
||||||
|
instanceId={instanceId}
|
||||||
|
request={request}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Automation2FlowEditor;
|
||||||
117
src/components/Automation2FlowEditor/CanvasHeader.tsx
Normal file
117
src/components/Automation2FlowEditor/CanvasHeader.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
/**
|
||||||
|
* CanvasHeader - Workflow controls (Neu, Speichern, laden, Ausführen) and execute result.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { FaPlay, FaSpinner } from 'react-icons/fa';
|
||||||
|
import type { Automation2Workflow, ExecuteGraphResponse } from '../../api/automation2Api';
|
||||||
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
|
interface CanvasHeaderProps {
|
||||||
|
workflows: Automation2Workflow[];
|
||||||
|
currentWorkflowId: string | null;
|
||||||
|
onWorkflowSelect: (workflowId: string | null) => void;
|
||||||
|
onNew: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onExecute: () => void;
|
||||||
|
saving: boolean;
|
||||||
|
executing: boolean;
|
||||||
|
hasNodes: boolean;
|
||||||
|
executeResult: ExecuteGraphResponse | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
||||||
|
workflows,
|
||||||
|
currentWorkflowId,
|
||||||
|
onWorkflowSelect,
|
||||||
|
onNew,
|
||||||
|
onSave,
|
||||||
|
onExecute,
|
||||||
|
saving,
|
||||||
|
executing,
|
||||||
|
hasNodes,
|
||||||
|
executeResult,
|
||||||
|
}) => (
|
||||||
|
<div className={styles.canvasHeader}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||||
|
<h4 className={styles.canvasTitle} style={{ margin: 0 }}>
|
||||||
|
Workflow-Editor
|
||||||
|
</h4>
|
||||||
|
<button type="button" className={styles.retryButton} onClick={onNew}>
|
||||||
|
Neu
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.retryButton}
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={saving || !hasNodes}
|
||||||
|
>
|
||||||
|
{saving ? <FaSpinner className={styles.spinner} /> : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
<select
|
||||||
|
value={currentWorkflowId ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const id = e.target.value ? e.target.value : null;
|
||||||
|
onWorkflowSelect(id);
|
||||||
|
}}
|
||||||
|
style={{ padding: '0.4rem', minWidth: 180 }}
|
||||||
|
>
|
||||||
|
<option value="">— Workflow laden —</option>
|
||||||
|
{workflows.map((w) => (
|
||||||
|
<option key={w.id} value={w.id}>
|
||||||
|
{w.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.retryButton}
|
||||||
|
onClick={onExecute}
|
||||||
|
disabled={executing || !hasNodes}
|
||||||
|
>
|
||||||
|
{executing ? (
|
||||||
|
<>
|
||||||
|
<FaSpinner className={styles.spinner} style={{ marginRight: '0.5rem', display: 'inline-block' }} />
|
||||||
|
Ausführen…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FaPlay style={{ marginRight: '0.5rem' }} />
|
||||||
|
Ausführen
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{executeResult && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '0.5rem',
|
||||||
|
padding: '0.5rem',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
background: executeResult.success
|
||||||
|
? 'rgba(40,167,69,0.15)'
|
||||||
|
: (executeResult as { paused?: boolean }).paused
|
||||||
|
? 'rgba(0,123,255,0.15)'
|
||||||
|
: 'rgba(220,53,69,0.15)',
|
||||||
|
color: executeResult.success
|
||||||
|
? 'var(--success-color,#28a745)'
|
||||||
|
: (executeResult as { paused?: boolean }).paused
|
||||||
|
? 'var(--primary-color,#007bff)'
|
||||||
|
: 'var(--danger-color,#dc3545)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{executeResult.success ? (
|
||||||
|
<>✓ Ausführung abgeschlossen.</>
|
||||||
|
) : (executeResult as { paused?: boolean }).paused ? (
|
||||||
|
<>
|
||||||
|
⏸ Workflow pausiert. Öffne <strong>Workflows & Tasks</strong> in der Sidebar, um den
|
||||||
|
Task zu bearbeiten.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>✗ {executeResult.error ?? 'Unbekannter Fehler'}</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
579
src/components/Automation2FlowEditor/FlowCanvas.tsx
Normal file
579
src/components/Automation2FlowEditor/FlowCanvas.tsx
Normal file
|
|
@ -0,0 +1,579 @@
|
||||||
|
/**
|
||||||
|
* FlowCanvas - Workflow graph canvas with nodes and connection lines.
|
||||||
|
* Nodes have 4 connection handles (one per side), drag nodes to add, connect with arrows.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { NodeType } from '../../api/automation2Api';
|
||||||
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
|
export interface CanvasNode {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
label?: string;
|
||||||
|
title?: string;
|
||||||
|
comment?: string;
|
||||||
|
color?: string;
|
||||||
|
inputs: number;
|
||||||
|
outputs: number;
|
||||||
|
parameters?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CanvasConnection {
|
||||||
|
id: string;
|
||||||
|
sourceId: string;
|
||||||
|
sourceHandle: number;
|
||||||
|
targetId: string;
|
||||||
|
targetHandle: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NODE_WIDTH = 200;
|
||||||
|
const NODE_HEIGHT = 72;
|
||||||
|
const HANDLE_SIZE = 12;
|
||||||
|
const HANDLE_OFFSET = HANDLE_SIZE / 2;
|
||||||
|
|
||||||
|
interface FlowCanvasProps {
|
||||||
|
nodes: CanvasNode[];
|
||||||
|
connections: CanvasConnection[];
|
||||||
|
nodeTypes: NodeType[];
|
||||||
|
onNodesChange: (nodes: CanvasNode[]) => void;
|
||||||
|
onConnectionsChange: (connections: CanvasConnection[]) => void;
|
||||||
|
onDropNodeType: (nodeTypeId: string, x: number, y: number) => void;
|
||||||
|
getLabel: (node: CanvasNode) => string;
|
||||||
|
getCategoryIcon: (category: string) => React.ReactNode;
|
||||||
|
onSelectionChange?: (node: CanvasNode | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
||||||
|
nodes,
|
||||||
|
connections,
|
||||||
|
nodeTypes,
|
||||||
|
onNodesChange,
|
||||||
|
onConnectionsChange,
|
||||||
|
onDropNodeType,
|
||||||
|
getLabel,
|
||||||
|
getCategoryIcon,
|
||||||
|
onSelectionChange,
|
||||||
|
}) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||||
|
const [editingNodeId, setEditingNodeId] = useState<string | null>(null);
|
||||||
|
const [editingField, setEditingField] = useState<'title' | 'comment' | null>(null);
|
||||||
|
const [connectingFrom, setConnectingFrom] = useState<{
|
||||||
|
nodeId: string;
|
||||||
|
handleIndex: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const [draggingNodeId, setDraggingNodeId] = useState<string | null>(null);
|
||||||
|
const [dragOffset, setDragOffset] = useState({
|
||||||
|
startClientX: 0,
|
||||||
|
startClientY: 0,
|
||||||
|
startNodeX: 0,
|
||||||
|
startNodeY: 0,
|
||||||
|
});
|
||||||
|
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
|
const [panning, setPanning] = useState<{
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
startPanX: number;
|
||||||
|
startPanY: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const nodeTypeMap = useMemo(() => {
|
||||||
|
const m: Record<string, NodeType> = {};
|
||||||
|
nodeTypes.forEach((nt) => {
|
||||||
|
m[nt.id] = nt;
|
||||||
|
});
|
||||||
|
return m;
|
||||||
|
}, [nodeTypes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (onSelectionChange) {
|
||||||
|
const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) ?? null : null;
|
||||||
|
onSelectionChange(node);
|
||||||
|
}
|
||||||
|
}, [selectedNodeId, nodes, onSelectionChange]);
|
||||||
|
|
||||||
|
const getHandlePosition = useCallback(
|
||||||
|
(node: CanvasNode, handleIndex: number): { x: number; y: number; side: string } => {
|
||||||
|
const isOutput = handleIndex >= node.inputs;
|
||||||
|
const ioIndex = isOutput ? handleIndex - node.inputs : handleIndex;
|
||||||
|
const ioCount = isOutput ? node.outputs : node.inputs;
|
||||||
|
|
||||||
|
const w = NODE_WIDTH;
|
||||||
|
const h = NODE_HEIGHT;
|
||||||
|
const centerY = node.y + h / 2;
|
||||||
|
|
||||||
|
if (isOutput) {
|
||||||
|
if (ioCount === 1) return { x: node.x + w, y: centerY, side: 'right' };
|
||||||
|
if (ioCount === 2) {
|
||||||
|
return ioIndex === 0
|
||||||
|
? { x: node.x + w, y: node.y + h / 3, side: 'right' }
|
||||||
|
: { x: node.x + w, y: node.y + (2 * h) / 3, side: 'right' };
|
||||||
|
}
|
||||||
|
const step = h / (ioCount + 1);
|
||||||
|
return { x: node.x + w, y: node.y + step * (ioIndex + 1), side: 'right' };
|
||||||
|
} else {
|
||||||
|
if (ioCount === 1) return { x: node.x, y: centerY, side: 'left' };
|
||||||
|
if (ioCount === 2) {
|
||||||
|
return ioIndex === 0
|
||||||
|
? { x: node.x, y: node.y + h / 3, side: 'left' }
|
||||||
|
: { x: node.x, y: node.y + (2 * h) / 3, side: 'left' };
|
||||||
|
}
|
||||||
|
const step = h / (ioCount + 1);
|
||||||
|
return { x: node.x, y: node.y + step * (ioIndex + 1), side: 'left' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getUsedTargetHandles = useMemo(() => {
|
||||||
|
const used = new Set<string>();
|
||||||
|
connections.forEach((c) => used.add(`${c.targetId}-${c.targetHandle}`));
|
||||||
|
return used;
|
||||||
|
}, [connections]);
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const raw = e.dataTransfer.getData('application/json');
|
||||||
|
if (!raw || !containerRef.current) return;
|
||||||
|
try {
|
||||||
|
const { type } = JSON.parse(raw);
|
||||||
|
const el = containerRef.current;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const x = (e.clientX - rect.left - panOffset.x) / zoom - NODE_WIDTH / 2;
|
||||||
|
const y = (e.clientY - rect.top - panOffset.y) / zoom - NODE_HEIGHT / 2;
|
||||||
|
onDropNodeType(type, Math.max(0, x), Math.max(0, y));
|
||||||
|
} catch (_) {}
|
||||||
|
},
|
||||||
|
[onDropNodeType, panOffset, zoom]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleHandleMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent, nodeId: string, handleIndex: number, isOutput: boolean) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!isOutput) return;
|
||||||
|
const node = nodes.find((n) => n.id === nodeId);
|
||||||
|
if (!node) return;
|
||||||
|
const pos = getHandlePosition(node, handleIndex);
|
||||||
|
setConnectingFrom({ nodeId, handleIndex, x: pos.x, y: pos.y });
|
||||||
|
setDragPos({ x: e.clientX, y: e.clientY });
|
||||||
|
},
|
||||||
|
[nodes, getHandlePosition]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleHandleMouseUp = useCallback(
|
||||||
|
(e: React.MouseEvent, targetNodeId: string, targetHandleIndex: number) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!connectingFrom || connectingFrom.nodeId === targetNodeId) {
|
||||||
|
setConnectingFrom(null);
|
||||||
|
setDragPos(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = `${targetNodeId}-${targetHandleIndex}`;
|
||||||
|
if (getUsedTargetHandles.has(key)) {
|
||||||
|
setConnectingFrom(null);
|
||||||
|
setDragPos(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetNode = nodes.find((n) => n.id === targetNodeId);
|
||||||
|
if (!targetNode) return;
|
||||||
|
if (targetHandleIndex >= targetNode.inputs) return;
|
||||||
|
const newConn: CanvasConnection = {
|
||||||
|
id: `c_${Date.now()}`,
|
||||||
|
sourceId: connectingFrom.nodeId,
|
||||||
|
sourceHandle: connectingFrom.handleIndex,
|
||||||
|
targetId: targetNodeId,
|
||||||
|
targetHandle: targetHandleIndex,
|
||||||
|
};
|
||||||
|
onConnectionsChange([...connections, newConn]);
|
||||||
|
setConnectingFrom(null);
|
||||||
|
setDragPos(null);
|
||||||
|
},
|
||||||
|
[connectingFrom, connections, nodes, getUsedTargetHandles, onConnectionsChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!connectingFrom || !dragPos) return;
|
||||||
|
const onMove = (e: MouseEvent) => setDragPos({ x: e.clientX, y: e.clientY });
|
||||||
|
const onUp = () => {
|
||||||
|
setConnectingFrom(null);
|
||||||
|
setDragPos(null);
|
||||||
|
};
|
||||||
|
window.addEventListener('mousemove', onMove);
|
||||||
|
window.addEventListener('mouseup', onUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', onMove);
|
||||||
|
window.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
}, [connectingFrom, dragPos]);
|
||||||
|
|
||||||
|
const handleNodeMouseDown = useCallback((e: React.MouseEvent, nodeId: string) => {
|
||||||
|
const node = nodes.find((n) => n.id === nodeId);
|
||||||
|
if (!node) return;
|
||||||
|
setDraggingNodeId(nodeId);
|
||||||
|
setDragOffset({
|
||||||
|
startClientX: e.clientX,
|
||||||
|
startClientY: e.clientY,
|
||||||
|
startNodeX: node.x,
|
||||||
|
startNodeY: node.y,
|
||||||
|
});
|
||||||
|
}, [nodes]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!draggingNodeId) return;
|
||||||
|
const onMove = (e: MouseEvent) => {
|
||||||
|
const dx = (e.clientX - dragOffset.startClientX) / zoom;
|
||||||
|
const dy = (e.clientY - dragOffset.startClientY) / zoom;
|
||||||
|
onNodesChange(
|
||||||
|
nodes.map((n) =>
|
||||||
|
n.id === draggingNodeId
|
||||||
|
? { ...n, x: dragOffset.startNodeX + dx, y: dragOffset.startNodeY + dy }
|
||||||
|
: n
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const onUp = () => setDraggingNodeId(null);
|
||||||
|
window.addEventListener('mousemove', onMove);
|
||||||
|
window.addEventListener('mouseup', onUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', onMove);
|
||||||
|
window.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
}, [draggingNodeId, dragOffset, nodes, onNodesChange, zoom]);
|
||||||
|
|
||||||
|
const handleCanvasMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
|
const hitNode = (e.target as HTMLElement).closest(`.${styles.canvasNode}`);
|
||||||
|
if (hitNode || connectingFrom) return;
|
||||||
|
setPanning({
|
||||||
|
startX: e.clientX,
|
||||||
|
startY: e.clientY,
|
||||||
|
startPanX: panOffset.x,
|
||||||
|
startPanY: panOffset.y,
|
||||||
|
});
|
||||||
|
}, [connectingFrom, panOffset]);
|
||||||
|
|
||||||
|
const handleWheel = useCallback((e: WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
||||||
|
setZoom((z) => Math.min(2, Math.max(0.25, z + delta)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.addEventListener('wheel', handleWheel, { passive: false });
|
||||||
|
return () => el.removeEventListener('wheel', handleWheel);
|
||||||
|
}, [handleWheel]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!panning) return;
|
||||||
|
const onMove = (e: MouseEvent) => {
|
||||||
|
setPanOffset({
|
||||||
|
x: panning.startPanX + (e.clientX - panning.startX),
|
||||||
|
y: panning.startPanY + (e.clientY - panning.startY),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const onUp = () => setPanning(null);
|
||||||
|
window.addEventListener('mousemove', onMove);
|
||||||
|
window.addEventListener('mouseup', onUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', onMove);
|
||||||
|
window.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
}, [panning]);
|
||||||
|
|
||||||
|
const [containerBounds, setContainerBounds] = useState({ left: 0, top: 0 });
|
||||||
|
React.useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const update = () => {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
setContainerBounds({ left: r.left, top: r.top });
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
window.addEventListener('resize', update);
|
||||||
|
return () => window.removeEventListener('resize', update);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const CANVAS_SIZE = 8000;
|
||||||
|
const svgBounds = useMemo(() => {
|
||||||
|
if (nodes.length === 0) return { width: CANVAS_SIZE, height: CANVAS_SIZE };
|
||||||
|
let maxX = 0, maxY = 0;
|
||||||
|
nodes.forEach((n) => {
|
||||||
|
maxX = Math.max(maxX, n.x + NODE_WIDTH + 200);
|
||||||
|
maxY = Math.max(maxY, n.y + NODE_HEIGHT + 200);
|
||||||
|
});
|
||||||
|
return { width: Math.max(maxX, CANVAS_SIZE), height: Math.max(maxY, CANVAS_SIZE) };
|
||||||
|
}, [nodes]);
|
||||||
|
|
||||||
|
const screenToSvg = useCallback(
|
||||||
|
(clientX: number, clientY: number) => ({
|
||||||
|
x: (clientX - containerBounds.left - panOffset.x) / zoom,
|
||||||
|
y: (clientY - containerBounds.top - panOffset.y) / zoom,
|
||||||
|
}),
|
||||||
|
[containerBounds, panOffset, zoom]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteNode = useCallback(() => {
|
||||||
|
if (!selectedNodeId) return;
|
||||||
|
onNodesChange(nodes.filter((n) => n.id !== selectedNodeId));
|
||||||
|
onConnectionsChange(
|
||||||
|
connections.filter((c) => c.sourceId !== selectedNodeId && c.targetId !== selectedNodeId)
|
||||||
|
);
|
||||||
|
setSelectedNodeId(null);
|
||||||
|
setEditingNodeId(null);
|
||||||
|
setEditingField(null);
|
||||||
|
}, [selectedNodeId, nodes, connections, onNodesChange, onConnectionsChange]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedNodeId) {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
|
||||||
|
e.preventDefault();
|
||||||
|
handleDeleteNode();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [handleDeleteNode, selectedNodeId]);
|
||||||
|
|
||||||
|
const handleNodeUpdate = useCallback(
|
||||||
|
(nodeId: string, updates: Partial<Pick<CanvasNode, 'title' | 'comment'>>) => {
|
||||||
|
onNodesChange(
|
||||||
|
nodes.map((n) => (n.id === nodeId ? { ...n, ...updates } : n))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[nodes, onNodesChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`${styles.canvasDropZone} ${panning ? styles.canvasPanning : styles.canvasGrab}`}
|
||||||
|
style={{
|
||||||
|
backgroundSize: `${20 * zoom}px ${20 * zoom}px`,
|
||||||
|
backgroundPosition: `${-panOffset.x}px ${-panOffset.y}px`,
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onMouseDown={handleCanvasMouseDown}
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => setSelectedNodeId(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={styles.canvasContent}
|
||||||
|
style={{
|
||||||
|
width: svgBounds.width,
|
||||||
|
height: svgBounds.height,
|
||||||
|
transform: `translate(${panOffset.x}px, ${panOffset.y}px) scale(${zoom})`,
|
||||||
|
transformOrigin: '0 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={styles.connectionsLayer}
|
||||||
|
width={svgBounds.width}
|
||||||
|
height={svgBounds.height}
|
||||||
|
style={{ position: 'absolute', left: 0, top: 0, pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<marker
|
||||||
|
id="arrowhead"
|
||||||
|
markerWidth="10"
|
||||||
|
markerHeight="7"
|
||||||
|
refX="9"
|
||||||
|
refY="3.5"
|
||||||
|
orient="auto"
|
||||||
|
>
|
||||||
|
<polygon points="0 0, 10 3.5, 0 7" fill="var(--text-secondary, #666)" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
{connections.map((c) => {
|
||||||
|
const srcNode = nodes.find((n) => n.id === c.sourceId);
|
||||||
|
const tgtNode = nodes.find((n) => n.id === c.targetId);
|
||||||
|
if (!srcNode || !tgtNode) return null;
|
||||||
|
const src = getHandlePosition(srcNode, c.sourceHandle);
|
||||||
|
const tgt = getHandlePosition(tgtNode, c.targetHandle);
|
||||||
|
const dx = tgt.x - src.x;
|
||||||
|
const path = `M ${src.x} ${src.y} C ${src.x + Math.abs(dx) / 2} ${src.y}, ${tgt.x - Math.abs(dx) / 2} ${tgt.y}, ${tgt.x} ${tgt.y}`;
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
key={c.id}
|
||||||
|
d={path}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--text-secondary, #666)"
|
||||||
|
strokeWidth="2"
|
||||||
|
markerEnd="url(#arrowhead)"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{connectingFrom && dragPos && (() => {
|
||||||
|
const end = screenToSvg(dragPos.x, dragPos.y);
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
d={`M ${connectingFrom.x} ${connectingFrom.y} L ${end.x} ${end.y}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--primary-color, #007bff)"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</svg>
|
||||||
|
{nodes.map((node) => {
|
||||||
|
const nt = nodeTypeMap[node.type];
|
||||||
|
const category = nt?.category ?? 'io';
|
||||||
|
const color = node.color ?? nt?.meta?.color ?? '#00BCD4';
|
||||||
|
const handles: Array<{ index: number; isOutput: boolean }> = [];
|
||||||
|
for (let i = 0; i < node.inputs; i++) handles.push({ index: i, isOutput: false });
|
||||||
|
for (let i = 0; i < node.outputs; i++) handles.push({ index: node.inputs + i, isOutput: true });
|
||||||
|
|
||||||
|
const isSelected = selectedNodeId === node.id;
|
||||||
|
const isEditingTitle = editingNodeId === node.id && editingField === 'title';
|
||||||
|
const isEditingComment = editingNodeId === node.id && editingField === 'comment';
|
||||||
|
const displayTitle = node.title ?? node.label ?? getLabel(node);
|
||||||
|
const displayComment = node.comment ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={node.id}
|
||||||
|
className={`${styles.canvasNode} ${isSelected ? styles.canvasNodeSelected : ''}`}
|
||||||
|
style={{
|
||||||
|
left: node.x,
|
||||||
|
top: node.y,
|
||||||
|
width: NODE_WIDTH,
|
||||||
|
height: NODE_HEIGHT,
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: `${color}15`,
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedNodeId(node.id);
|
||||||
|
handleNodeMouseDown(e, node.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{handles.map(({ index, isOutput }) => {
|
||||||
|
const pos = getHandlePosition(node, index);
|
||||||
|
const used = !isOutput && getUsedTargetHandles.has(`${node.id}-${index}`);
|
||||||
|
const canConnect = isOutput || (!used && connectingFrom);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`${styles.handle} ${isOutput ? styles.handleOutput : styles.handleInput} ${canConnect ? styles.handleConnectable : ''}`}
|
||||||
|
style={{
|
||||||
|
left: pos.side === 'left' ? -HANDLE_OFFSET : undefined,
|
||||||
|
right: pos.side === 'right' ? -HANDLE_OFFSET : undefined,
|
||||||
|
top: pos.y - node.y - HANDLE_OFFSET,
|
||||||
|
width: HANDLE_SIZE,
|
||||||
|
height: HANDLE_SIZE,
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => handleHandleMouseDown(e, node.id, index, isOutput)}
|
||||||
|
onMouseUp={(e) => !isOutput && handleHandleMouseUp(e, node.id, index)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className={styles.canvasNodeContent}>
|
||||||
|
<div
|
||||||
|
className={styles.canvasNodeIcon}
|
||||||
|
style={{ backgroundColor: `${color}40`, color }}
|
||||||
|
>
|
||||||
|
{getCategoryIcon(category)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.canvasNodeText}>
|
||||||
|
{isEditingTitle ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.canvasNodeInput}
|
||||||
|
value={node.title ?? displayTitle}
|
||||||
|
autoFocus
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleNodeUpdate(node.id, { title: (e.target as HTMLInputElement).value });
|
||||||
|
setEditingNodeId(null);
|
||||||
|
setEditingField(null);
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setEditingNodeId(null);
|
||||||
|
setEditingField(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
handleNodeUpdate(node.id, { title: e.target.value });
|
||||||
|
setEditingNodeId(null);
|
||||||
|
setEditingField(null);
|
||||||
|
}}
|
||||||
|
onChange={(e) => handleNodeUpdate(node.id, { title: e.target.value })}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={styles.canvasNodeTitle}
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditingNodeId(node.id);
|
||||||
|
setEditingField('title');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayTitle}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isEditingComment ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.canvasNodeInput}
|
||||||
|
placeholder="Kommentar..."
|
||||||
|
value={node.comment ?? ''}
|
||||||
|
autoFocus
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleNodeUpdate(node.id, { comment: (e.target as HTMLInputElement).value });
|
||||||
|
setEditingNodeId(null);
|
||||||
|
setEditingField(null);
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setEditingNodeId(null);
|
||||||
|
setEditingField(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
handleNodeUpdate(node.id, { comment: e.target.value });
|
||||||
|
setEditingNodeId(null);
|
||||||
|
setEditingField(null);
|
||||||
|
}}
|
||||||
|
onChange={(e) => handleNodeUpdate(node.id, { comment: e.target.value })}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={styles.canvasNodeComment}
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditingNodeId(node.id);
|
||||||
|
setEditingField('comment');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayComment || 'Doppelklick für Kommentar'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{nodes.length === 0 && (
|
||||||
|
<div className={styles.canvasPlaceholder}>
|
||||||
|
<p>Nodes aus der Liste links hierher ziehen.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
70
src/components/Automation2FlowEditor/NodeConfigPanel.tsx
Normal file
70
src/components/Automation2FlowEditor/NodeConfigPanel.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
/**
|
||||||
|
* NodeConfigPanel - Configures parameters for input, ai, email, sharepoint nodes.
|
||||||
|
* Delegates to config components from configs/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import type { CanvasNode } from './FlowCanvas';
|
||||||
|
import type { NodeType } from '../../api/automation2Api';
|
||||||
|
import type { ApiRequestFunction } from '../../api/automation2Api';
|
||||||
|
import { getLabel } from './utils';
|
||||||
|
import { NODE_CONFIG_REGISTRY } from './configs';
|
||||||
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
|
interface NodeConfigPanelProps {
|
||||||
|
node: CanvasNode | null;
|
||||||
|
nodeType: NodeType | undefined;
|
||||||
|
language: string;
|
||||||
|
onParametersChange: (nodeId: string, parameters: Record<string, unknown>) => void;
|
||||||
|
instanceId?: string;
|
||||||
|
request?: ApiRequestFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFIGURABLE_PREFIXES = ['input.', 'ai.', 'email.', 'sharepoint.'];
|
||||||
|
|
||||||
|
export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({
|
||||||
|
node,
|
||||||
|
nodeType,
|
||||||
|
language,
|
||||||
|
onParametersChange,
|
||||||
|
instanceId,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const [params, setParams] = useState<Record<string, unknown>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setParams(node?.parameters ?? {});
|
||||||
|
}, [node?.id, node?.parameters]);
|
||||||
|
|
||||||
|
const updateParam = (key: string, value: unknown) => {
|
||||||
|
const next = { ...params, [key]: value };
|
||||||
|
setParams(next);
|
||||||
|
if (node) onParametersChange(node.id, next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isConfigurable = node && CONFIGURABLE_PREFIXES.some((p) => node.type.startsWith(p));
|
||||||
|
if (!node || !isConfigurable) return null;
|
||||||
|
|
||||||
|
const ConfigRenderer = NODE_CONFIG_REGISTRY[node.type];
|
||||||
|
if (!ConfigRenderer) {
|
||||||
|
return (
|
||||||
|
<div className={styles.nodeConfigPanel}>
|
||||||
|
<h4>{getLabel(nodeType?.label, language) || node.type}</h4>
|
||||||
|
<p>No configuration for {node.type}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.nodeConfigPanel}>
|
||||||
|
<h4>{getLabel(nodeType?.label, language) || node.type}</h4>
|
||||||
|
<ConfigRenderer
|
||||||
|
params={params}
|
||||||
|
updateParam={updateParam}
|
||||||
|
instanceId={instanceId}
|
||||||
|
request={request}
|
||||||
|
nodeType={node.type}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
49
src/components/Automation2FlowEditor/NodeListItem.tsx
Normal file
49
src/components/Automation2FlowEditor/NodeListItem.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
/**
|
||||||
|
* 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/automation2Api';
|
||||||
|
import { getCategoryIcon } from './utils';
|
||||||
|
import type { GetLabelFn } from './utils';
|
||||||
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
|
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,
|
||||||
|
}) => (
|
||||||
|
<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.nodeItemLabel}>{getLabel(node.label, language)}</span>
|
||||||
|
<span className={styles.nodeItemDesc}>{getLabel(node.description, language)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
121
src/components/Automation2FlowEditor/NodeSidebar.tsx
Normal file
121
src/components/Automation2FlowEditor/NodeSidebar.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
/**
|
||||||
|
* NodeSidebar - Sidebar with searchable, collapsible node list.
|
||||||
|
* Groups node types by category (trigger, input, flow, data, ai, email, sharepoint).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { FaChevronDown, FaChevronRight } from 'react-icons/fa';
|
||||||
|
import type { NodeType, NodeTypeCategory } from '../../api/automation2Api';
|
||||||
|
import { CATEGORY_ORDER, HIDDEN_NODE_IDS } from './constants';
|
||||||
|
import { getLabel } from './utils';
|
||||||
|
import { NodeListItem } from './NodeListItem';
|
||||||
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
|
interface NodeSidebarProps {
|
||||||
|
nodeTypes: NodeType[];
|
||||||
|
categories: NodeTypeCategory[];
|
||||||
|
filter: string;
|
||||||
|
onFilterChange: (value: string) => void;
|
||||||
|
language: string;
|
||||||
|
expandedCategories: Set<string>;
|
||||||
|
onToggleCategory: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NodeSidebar: React.FC<NodeSidebarProps> = ({
|
||||||
|
nodeTypes,
|
||||||
|
categories,
|
||||||
|
filter,
|
||||||
|
onFilterChange,
|
||||||
|
language,
|
||||||
|
expandedCategories,
|
||||||
|
onToggleCategory,
|
||||||
|
}) => {
|
||||||
|
const filteredNodeTypes = useMemo(() => {
|
||||||
|
const visible = nodeTypes.filter((n) => !HIDDEN_NODE_IDS.has(n.id));
|
||||||
|
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 = (t: string | Record<string, string> | undefined, lang?: string) =>
|
||||||
|
getLabel(t, lang ?? language);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.sidebar}>
|
||||||
|
<div className={styles.sidebarHeader}>
|
||||||
|
<h3 className={styles.sidebarTitle}>Nodes</h3>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.sidebarSearch}
|
||||||
|
placeholder="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>
|
||||||
|
);
|
||||||
|
};
|
||||||
19
src/components/Automation2FlowEditor/categoryIcons.tsx
Normal file
19
src/components/Automation2FlowEditor/categoryIcons.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
/**
|
||||||
|
* Category icons for node types
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { FaPlay, FaCodeBranch, FaDatabase, FaPlug, FaUser, FaRobot, FaEnvelope, FaCloud } from 'react-icons/fa';
|
||||||
|
|
||||||
|
export const CATEGORY_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
trigger: <FaPlay />,
|
||||||
|
input: <FaUser />,
|
||||||
|
flow: <FaCodeBranch />,
|
||||||
|
data: <FaDatabase />,
|
||||||
|
ai: <FaRobot />,
|
||||||
|
email: <FaEnvelope />,
|
||||||
|
sharepoint: <FaCloud />,
|
||||||
|
human: <FaUser />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_CATEGORY_ICON = <FaPlug />;
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
/**
|
||||||
|
* AI node config - prompt, query, document options per node type.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type { NodeConfigRendererProps } from './types';
|
||||||
|
|
||||||
|
const AI_FIELD_CONFIG: Record<string, { label: string; key: string; type: 'textarea' | 'input' | 'select'; options?: string[] }[]> = {
|
||||||
|
'ai.prompt': [
|
||||||
|
{ label: 'Prompt', key: 'prompt', type: 'textarea' },
|
||||||
|
{ label: 'Output format', key: 'resultType', type: 'select', options: ['txt', 'json', 'md', 'html', 'csv'] },
|
||||||
|
],
|
||||||
|
'ai.webResearch': [{ label: 'Query', key: 'query', type: 'textarea' }],
|
||||||
|
'ai.summarizeDocument': [
|
||||||
|
{ label: 'Summary length', key: 'summaryLength', type: 'select', options: ['short', 'medium', 'long'] },
|
||||||
|
],
|
||||||
|
'ai.translateDocument': [{ label: 'Target language', key: 'targetLanguage', type: 'input' }],
|
||||||
|
'ai.convertDocument': [
|
||||||
|
{ label: 'Target format', key: 'targetFormat', type: 'select', options: ['pdf', 'docx', 'txt', 'md'] },
|
||||||
|
],
|
||||||
|
'ai.generateDocument': [
|
||||||
|
{ label: 'Prompt', key: 'prompt', type: 'textarea' },
|
||||||
|
{ label: 'Format', key: 'format', type: 'select', options: ['docx', 'txt', 'md'] },
|
||||||
|
],
|
||||||
|
'ai.generateCode': [
|
||||||
|
{ label: 'Prompt', key: 'prompt', type: 'textarea' },
|
||||||
|
{ label: 'Language', key: 'language', type: 'select', options: ['python', 'javascript', 'typescript', 'sql'] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AiNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam, nodeType = 'ai.prompt' }) => {
|
||||||
|
const fields = AI_FIELD_CONFIG[nodeType] ?? AI_FIELD_CONFIG['ai.prompt'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{fields.map((f) => (
|
||||||
|
<div key={f.key}>
|
||||||
|
<label>{f.label}</label>
|
||||||
|
{f.type === 'textarea' ? (
|
||||||
|
<textarea
|
||||||
|
value={(params[f.key] as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam(f.key, e.target.value)}
|
||||||
|
placeholder={f.label}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
) : f.type === 'select' ? (
|
||||||
|
<select
|
||||||
|
value={(params[f.key] as string) ?? (f.options?.[0] ?? '')}
|
||||||
|
onChange={(e) => updateParam(f.key, e.target.value)}
|
||||||
|
>
|
||||||
|
{(f.options ?? []).map((opt) => (
|
||||||
|
<option key={opt} value={opt}>
|
||||||
|
{opt}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
value={(params[f.key] as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam(f.key, e.target.value)}
|
||||||
|
placeholder={f.label}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
/**
|
||||||
|
* Approval node config
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type { NodeConfigRendererProps } from './types';
|
||||||
|
|
||||||
|
export const ApprovalNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label>Titel</label>
|
||||||
|
<input
|
||||||
|
value={(params.title as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('title', e.target.value)}
|
||||||
|
placeholder="Genehmigungstitel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Beschreibung</label>
|
||||||
|
<textarea
|
||||||
|
value={(params.description as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('description', e.target.value)}
|
||||||
|
placeholder="Was genehmigt werden soll"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
/**
|
||||||
|
* Comment node config
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type { NodeConfigRendererProps } from './types';
|
||||||
|
|
||||||
|
export const CommentNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label>Platzhalter</label>
|
||||||
|
<input
|
||||||
|
value={(params.placeholder as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('placeholder', e.target.value)}
|
||||||
|
placeholder="Kommentar eingeben..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(params.required as boolean) ?? true}
|
||||||
|
onChange={(e) => updateParam('required', e.target.checked)}
|
||||||
|
/>
|
||||||
|
Pflichtfeld
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
/**
|
||||||
|
* Confirmation node config
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type { NodeConfigRendererProps } from './types';
|
||||||
|
|
||||||
|
export const ConfirmationNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label>Frage</label>
|
||||||
|
<input
|
||||||
|
value={(params.question as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('question', e.target.value)}
|
||||||
|
placeholder="Möchten Sie bestätigen?"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Bestätigen-Button</label>
|
||||||
|
<input
|
||||||
|
value={(params.confirmLabel as string) ?? 'Confirm'}
|
||||||
|
onChange={(e) => updateParam('confirmLabel', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Ablehnen-Button</label>
|
||||||
|
<input
|
||||||
|
value={(params.rejectLabel as string) ?? 'Reject'}
|
||||||
|
onChange={(e) => updateParam('rejectLabel', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
240
src/components/Automation2FlowEditor/configs/EmailNodeConfig.tsx
Normal file
240
src/components/Automation2FlowEditor/configs/EmailNodeConfig.tsx
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
/**
|
||||||
|
* Email node config - connection selector, folder dropdown, query, subject, body.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import type { NodeConfigRendererProps } from './types';
|
||||||
|
import { fetchConnections, fetchBrowse, type UserConnection, type BrowseEntry } from '../../../api/automation2Api';
|
||||||
|
|
||||||
|
export const EmailNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
||||||
|
params,
|
||||||
|
updateParam,
|
||||||
|
instanceId,
|
||||||
|
request,
|
||||||
|
nodeType = 'email.checkEmail',
|
||||||
|
}) => {
|
||||||
|
const [connections, setConnections] = useState<UserConnection[]>([]);
|
||||||
|
const [folders, setFolders] = useState<BrowseEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [foldersLoading, setFoldersLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (instanceId && request) {
|
||||||
|
setLoading(true);
|
||||||
|
fetchConnections(request, instanceId)
|
||||||
|
.then(setConnections)
|
||||||
|
.catch(() => setConnections([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
}, [instanceId, request]);
|
||||||
|
|
||||||
|
const connectionId = (params.connectionId as string) ?? '';
|
||||||
|
const selectedConn = connections.find((c) => c.id === connectionId);
|
||||||
|
const mailService = selectedConn?.authority === 'google' ? 'gmail' : 'outlook';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (instanceId && request && connectionId) {
|
||||||
|
setFoldersLoading(true);
|
||||||
|
fetchBrowse(request, instanceId, connectionId, mailService, '/')
|
||||||
|
.then((r) => setFolders(r.items.filter((e) => e.isFolder)))
|
||||||
|
.catch(() => setFolders([]))
|
||||||
|
.finally(() => setFoldersLoading(false));
|
||||||
|
} else {
|
||||||
|
setFolders([]);
|
||||||
|
}
|
||||||
|
}, [instanceId, request, connectionId, mailService]);
|
||||||
|
|
||||||
|
const isDraft = nodeType === 'email.draftEmail';
|
||||||
|
const isSearch = nodeType === 'email.searchEmail';
|
||||||
|
const folderValue = (params.folder as string) ?? (isSearch ? 'All' : 'Inbox');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label>Account</label>
|
||||||
|
<select
|
||||||
|
value={connectionId}
|
||||||
|
onChange={(e) => updateParam('connectionId', e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<option value="">{loading ? 'Loading...' : 'Select connection'}</option>
|
||||||
|
{connections.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.externalEmail ?? c.externalUsername ?? c.id}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{!isDraft && (
|
||||||
|
<div>
|
||||||
|
<label>Folder</label>
|
||||||
|
<select
|
||||||
|
value={folderValue}
|
||||||
|
onChange={(e) => updateParam('folder', e.target.value)}
|
||||||
|
disabled={foldersLoading || !connectionId}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{foldersLoading ? 'Loading folders...' : !connectionId ? 'Select account first' : 'Select folder'}
|
||||||
|
</option>
|
||||||
|
{isSearch && <option value="All">All</option>}
|
||||||
|
{folders.length > 0
|
||||||
|
? folders.map((f) => {
|
||||||
|
const folderId = (f.path ?? '').replace(/^\//, '') || (f.metadata as { id?: string })?.id || '';
|
||||||
|
const value = folderId || f.name;
|
||||||
|
if (!value) return null;
|
||||||
|
return (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{f.name}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: !isSearch && (
|
||||||
|
<>
|
||||||
|
<option value="Inbox">Inbox</option>
|
||||||
|
<option value="Drafts">Drafts</option>
|
||||||
|
<option value="SentItems">Sent Items</option>
|
||||||
|
<option value="DeletedItems">Deleted Items</option>
|
||||||
|
<option value="JunkEmail">Junk Email</option>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{folderValue &&
|
||||||
|
!folders.some(
|
||||||
|
(f) =>
|
||||||
|
((f.path ?? '').replace(/^\//, '') || (f.metadata as { id?: string })?.id) === folderValue
|
||||||
|
) &&
|
||||||
|
folderValue !== 'All' && (
|
||||||
|
<option value={folderValue}>{folderValue}</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isSearch && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label>Search query (optional)</label>
|
||||||
|
<input
|
||||||
|
value={(params.query as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('query', e.target.value)}
|
||||||
|
placeholder="General search term (subject, body, from)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>From address (optional)</label>
|
||||||
|
<input
|
||||||
|
value={(params.fromAddress as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('fromAddress', e.target.value)}
|
||||||
|
placeholder="e.g. sender@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>To address (optional)</label>
|
||||||
|
<input
|
||||||
|
value={(params.toAddress as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('toAddress', e.target.value)}
|
||||||
|
placeholder="e.g. recipient@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Subject contains (optional)</label>
|
||||||
|
<input
|
||||||
|
value={(params.subjectContains as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('subjectContains', e.target.value)}
|
||||||
|
placeholder="Word or phrase in subject"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Body/content contains (optional)</label>
|
||||||
|
<input
|
||||||
|
value={(params.bodyContains as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('bodyContains', e.target.value)}
|
||||||
|
placeholder="Word or phrase in email body"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="searchHasAttachment"
|
||||||
|
checked={!!(params.hasAttachment as boolean)}
|
||||||
|
onChange={(e) => updateParam('hasAttachment', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="searchHasAttachment">Only emails with attachment</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Limit</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={(params.limit as number) ?? 100}
|
||||||
|
onChange={(e) => updateParam('limit', parseInt(e.target.value, 10) || 100)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{nodeType === 'email.checkEmail' && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label>From address (optional)</label>
|
||||||
|
<input
|
||||||
|
value={(params.fromAddress as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('fromAddress', e.target.value)}
|
||||||
|
placeholder="e.g. sender@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Subject contains (optional)</label>
|
||||||
|
<input
|
||||||
|
value={(params.subjectContains as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('subjectContains', e.target.value)}
|
||||||
|
placeholder="Word or phrase in subject"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="hasAttachment"
|
||||||
|
checked={!!(params.hasAttachment as boolean)}
|
||||||
|
onChange={(e) => updateParam('hasAttachment', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="hasAttachment">Only emails with attachment</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Limit</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={(params.limit as number) ?? 100}
|
||||||
|
onChange={(e) => updateParam('limit', parseInt(e.target.value, 10) || 100)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isDraft && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label>Subject</label>
|
||||||
|
<input
|
||||||
|
value={(params.subject as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('subject', e.target.value)}
|
||||||
|
placeholder="Email subject (or leave empty if connected to AI node above)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Body</label>
|
||||||
|
<textarea
|
||||||
|
value={(params.body as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('body', e.target.value)}
|
||||||
|
placeholder="Email body (or leave empty if connected to AI node above)"
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>To (optional)</label>
|
||||||
|
<input
|
||||||
|
value={(params.to as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('to', e.target.value)}
|
||||||
|
placeholder="Recipient(s) (or from AI when connected)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
126
src/components/Automation2FlowEditor/configs/FormNodeConfig.tsx
Normal file
126
src/components/Automation2FlowEditor/configs/FormNodeConfig.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
/**
|
||||||
|
* Form node config - draggable fields, types, required toggle
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { FaGripVertical, FaTimes } from 'react-icons/fa';
|
||||||
|
import type { FormField, NodeConfigRendererProps } from './types';
|
||||||
|
import styles from '../Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
|
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
|
||||||
|
const fields = (params.fields as FormField[]) ?? [];
|
||||||
|
|
||||||
|
const moveField = (fromIndex: number, toIndex: number) => {
|
||||||
|
if (fromIndex < 0 || toIndex < 0 || fromIndex >= fields.length || toIndex >= fields.length) return;
|
||||||
|
const next = [...fields];
|
||||||
|
const [removed] = next.splice(fromIndex, 1);
|
||||||
|
next.splice(toIndex, 0, removed);
|
||||||
|
updateParam('fields', next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeField = (index: number) => {
|
||||||
|
const next = fields.filter((_, i) => i !== index);
|
||||||
|
updateParam('fields', next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label>Felder</label>
|
||||||
|
<div className={styles.formFieldsList}>
|
||||||
|
{fields.map((f, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={styles.formFieldRow}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const from = parseInt(e.dataTransfer.getData('text/plain'), 10);
|
||||||
|
if (!Number.isNaN(from) && from !== i) moveField(from, i);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles.formFieldRowHeader}>
|
||||||
|
<span
|
||||||
|
className={styles.formFieldDragHandle}
|
||||||
|
title="Zum Verschieben ziehen"
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.dataTransfer.setData('text/plain', String(i));
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaGripVertical />
|
||||||
|
</span>
|
||||||
|
<div className={styles.formFieldInputs}>
|
||||||
|
<input
|
||||||
|
placeholder="name"
|
||||||
|
value={f.name ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...fields];
|
||||||
|
next[i] = { ...next[i], name: e.target.value };
|
||||||
|
updateParam('fields', next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="label"
|
||||||
|
value={f.label ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...fields];
|
||||||
|
next[i] = { ...next[i], label: e.target.value };
|
||||||
|
updateParam('fields', next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formFieldRowFooter}>
|
||||||
|
<select
|
||||||
|
value={f.type ?? 'string'}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...fields];
|
||||||
|
next[i] = { ...next[i], type: e.target.value };
|
||||||
|
updateParam('fields', next);
|
||||||
|
}}
|
||||||
|
style={{ width: 'auto', minWidth: 90 }}
|
||||||
|
>
|
||||||
|
<option value="string">Text</option>
|
||||||
|
<option value="number">Number</option>
|
||||||
|
<option value="date">Date</option>
|
||||||
|
<option value="boolean">Checkbox</option>
|
||||||
|
</select>
|
||||||
|
<label className={styles.formFieldRequiredLabel}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={f.required ?? false}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...fields];
|
||||||
|
next[i] = { ...next[i], required: e.target.checked };
|
||||||
|
updateParam('fields', next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Pflichtfeld
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeField(i)}
|
||||||
|
title="Feld entfernen"
|
||||||
|
className={styles.formFieldRemoveButton}
|
||||||
|
>
|
||||||
|
<FaTimes />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
updateParam('fields', [...fields, { name: '', type: 'string', label: '', required: false }])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
+ Feld
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
/**
|
||||||
|
* Review node config
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type { NodeConfigRendererProps } from './types';
|
||||||
|
|
||||||
|
export const ReviewNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => (
|
||||||
|
<div>
|
||||||
|
<label>Content-Referenz</label>
|
||||||
|
<input
|
||||||
|
value={(params.contentRef as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('contentRef', e.target.value)}
|
||||||
|
placeholder="{{nodeId.field}}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
/**
|
||||||
|
* Selection node config
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type { NodeConfigRendererProps } from './types';
|
||||||
|
|
||||||
|
export const SelectionNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
|
||||||
|
const options = (params.options as Array<{ value?: string; label?: string }>) ?? [];
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label>Optionen</label>
|
||||||
|
{options.map((o, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
|
||||||
|
<input
|
||||||
|
placeholder="value"
|
||||||
|
value={o.value ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...options];
|
||||||
|
next[i] = { ...next[i], value: e.target.value };
|
||||||
|
updateParam('options', next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="label"
|
||||||
|
value={o.label ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...options];
|
||||||
|
next[i] = { ...next[i], label: e.target.value };
|
||||||
|
updateParam('options', next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" onClick={() => updateParam('options', [...options, { value: '', label: '' }])}>
|
||||||
|
+ Option
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(params.multiple as boolean) ?? false}
|
||||||
|
onChange={(e) => updateParam('multiple', e.target.checked)}
|
||||||
|
/>
|
||||||
|
Mehrfachauswahl
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,245 @@
|
||||||
|
/**
|
||||||
|
* SharePoint node config - connection selector, path, search query.
|
||||||
|
* Uses SharepointBrowseTree (FolderTree-style) for file selection.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import type { NodeConfigRendererProps } from './types';
|
||||||
|
import { fetchConnections, fetchBrowse, type UserConnection, type BrowseEntry } from '../../../api/automation2Api';
|
||||||
|
import { SharepointBrowseTree } from '../../FolderTree/SharepointBrowseTree';
|
||||||
|
|
||||||
|
export const SharePointNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
||||||
|
params,
|
||||||
|
updateParam,
|
||||||
|
instanceId,
|
||||||
|
request,
|
||||||
|
nodeType = 'sharepoint.findFile',
|
||||||
|
}) => {
|
||||||
|
const [connections, setConnections] = useState<UserConnection[]>([]);
|
||||||
|
const [browseExpanded, setBrowseExpanded] = useState(false);
|
||||||
|
const [copySourceExpanded, setCopySourceExpanded] = useState(false);
|
||||||
|
const [copyDestExpanded, setCopyDestExpanded] = useState(false);
|
||||||
|
const [connectionsLoading, setConnectionsLoading] = useState(false);
|
||||||
|
|
||||||
|
const connectionId = (params.connectionId as string) ?? '';
|
||||||
|
const pathParam = 'path';
|
||||||
|
const path = (params.path as string) ?? (params.filePath as string) ?? '';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (instanceId && request) {
|
||||||
|
setConnectionsLoading(true);
|
||||||
|
fetchConnections(request, instanceId)
|
||||||
|
.then(setConnections)
|
||||||
|
.catch(() => setConnections([]))
|
||||||
|
.finally(() => setConnectionsLoading(false));
|
||||||
|
}
|
||||||
|
}, [instanceId, request]);
|
||||||
|
|
||||||
|
const loadChildren = useCallback(
|
||||||
|
async (pathToLoad: string): Promise<BrowseEntry[]> => {
|
||||||
|
if (!instanceId || !request || !connectionId) return [];
|
||||||
|
const r = await fetchBrowse(request, instanceId, connectionId, 'sharepoint', pathToLoad);
|
||||||
|
return r?.items ?? [];
|
||||||
|
},
|
||||||
|
[instanceId, request, connectionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectPath = useCallback(
|
||||||
|
(p: string) => {
|
||||||
|
updateParam(pathParam, p);
|
||||||
|
setBrowseExpanded(false);
|
||||||
|
},
|
||||||
|
[updateParam, pathParam]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectSourcePath = useCallback(
|
||||||
|
(p: string) => {
|
||||||
|
updateParam('sourcePath', p);
|
||||||
|
setCopySourceExpanded(false);
|
||||||
|
},
|
||||||
|
[updateParam]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectDestPath = useCallback(
|
||||||
|
(p: string) => {
|
||||||
|
updateParam('destPath', p);
|
||||||
|
setCopyDestExpanded(false);
|
||||||
|
},
|
||||||
|
[updateParam]
|
||||||
|
);
|
||||||
|
|
||||||
|
const needsPath = !['sharepoint.findFile'].includes(nodeType);
|
||||||
|
const needsSearch = nodeType === 'sharepoint.findFile';
|
||||||
|
const needsSiteId = false;
|
||||||
|
const hasPathInput = ['sharepoint.readFile', 'sharepoint.uploadFile', 'sharepoint.downloadFile', 'sharepoint.copyFile'].includes(nodeType);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label>Connection</label>
|
||||||
|
<select
|
||||||
|
value={connectionId}
|
||||||
|
onChange={(e) => updateParam('connectionId', e.target.value)}
|
||||||
|
disabled={connectionsLoading}
|
||||||
|
>
|
||||||
|
<option value="">{connectionsLoading ? 'Loading...' : 'Select connection'}</option>
|
||||||
|
{connections.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.externalUsername ?? c.id}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{needsSearch && (
|
||||||
|
<div>
|
||||||
|
<label>Search query / path</label>
|
||||||
|
<input
|
||||||
|
value={(params.searchQuery as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('searchQuery', e.target.value)}
|
||||||
|
placeholder="/sites/SiteName/Shared Documents or search term"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{needsPath && nodeType === 'sharepoint.listFiles' && (
|
||||||
|
<div>
|
||||||
|
<label>Folder path</label>
|
||||||
|
<input
|
||||||
|
value={path}
|
||||||
|
onChange={(e) => updateParam('path', e.target.value)}
|
||||||
|
placeholder="/ or /sites/SiteName/Shared Documents/Folder"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{needsPath && ['sharepoint.readFile', 'sharepoint.uploadFile', 'sharepoint.downloadFile'].includes(nodeType) && (
|
||||||
|
<div>
|
||||||
|
<label>{nodeType === 'sharepoint.uploadFile' ? 'Target folder path' : 'Path'}</label>
|
||||||
|
<input
|
||||||
|
value={(params.path as string) ?? (params.filePath as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('path', e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
nodeType === 'sharepoint.downloadFile'
|
||||||
|
? '/sites/SiteName/Shared Documents/file.pdf'
|
||||||
|
: nodeType === 'sharepoint.uploadFile'
|
||||||
|
? '/sites/.../Shared Documents/TargetFolder/'
|
||||||
|
: 'File or folder path'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{needsSiteId && (
|
||||||
|
<div>
|
||||||
|
<label>Site ID</label>
|
||||||
|
<input
|
||||||
|
value={(params.siteId as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('siteId', e.target.value)}
|
||||||
|
placeholder="SharePoint site ID"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{nodeType === 'sharepoint.copyFile' && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label>Source file</label>
|
||||||
|
<input
|
||||||
|
value={(params.sourcePath as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('sourcePath', e.target.value)}
|
||||||
|
placeholder="/sites/.../folder/file.pdf"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Destination folder</label>
|
||||||
|
<input
|
||||||
|
value={(params.destPath as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('destPath', e.target.value)}
|
||||||
|
placeholder="/sites/.../target-folder/"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{connectionId && (
|
||||||
|
<>
|
||||||
|
<details
|
||||||
|
open={copySourceExpanded}
|
||||||
|
onToggle={(e) => setCopySourceExpanded((e.target as HTMLDetailsElement).open)}
|
||||||
|
style={{
|
||||||
|
marginTop: 12,
|
||||||
|
border: '1px solid var(--border-color, #e0e0e0)',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'var(--bg-secondary, #f8f9fa)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<summary style={{ padding: '0.5rem 0.75rem', cursor: 'pointer', fontWeight: 500, fontSize: '0.875rem' }}>
|
||||||
|
📂 Source file durchsuchen
|
||||||
|
</summary>
|
||||||
|
<div style={{ padding: '0.5rem 0.75rem', borderTop: '1px solid var(--border-color, #e0e0e0)', maxHeight: 280, overflowY: 'auto' }}>
|
||||||
|
<SharepointBrowseTree rootPath="/" onLoadChildren={loadChildren} onSelectFile={selectSourcePath} selectedPath={(params.sourcePath as string) || null} />
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<details
|
||||||
|
open={copyDestExpanded}
|
||||||
|
onToggle={(e) => setCopyDestExpanded((e.target as HTMLDetailsElement).open)}
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
border: '1px solid var(--border-color, #e0e0e0)',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'var(--bg-secondary, #f8f9fa)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<summary style={{ padding: '0.5rem 0.75rem', cursor: 'pointer', fontWeight: 500, fontSize: '0.875rem' }}>
|
||||||
|
📂 Zielordner durchsuchen
|
||||||
|
</summary>
|
||||||
|
<div style={{ padding: '0.5rem 0.75rem', borderTop: '1px solid var(--border-color, #e0e0e0)', maxHeight: 280, overflowY: 'auto' }}>
|
||||||
|
<SharepointBrowseTree rootPath="/" onLoadChildren={loadChildren} onSelectFile={() => {}} onSelectFolder={selectDestPath} selectedPath={(params.destPath as string) || null} />
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{connectionId && needsPath && hasPathInput && !['sharepoint.copyFile'].includes(nodeType) && (
|
||||||
|
<details
|
||||||
|
open={browseExpanded}
|
||||||
|
onToggle={(e) => setBrowseExpanded((e.target as HTMLDetailsElement).open)}
|
||||||
|
style={{
|
||||||
|
marginTop: 12,
|
||||||
|
border: '1px solid var(--border-color, #e0e0e0)',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'var(--bg-secondary, #f8f9fa)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<summary
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ opacity: browseExpanded ? 0.7 : 1 }}>📂</span>
|
||||||
|
SharePoint durchsuchen
|
||||||
|
</summary>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
borderTop: '1px solid var(--border-color, #e0e0e0)',
|
||||||
|
maxHeight: 280,
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SharepointBrowseTree
|
||||||
|
rootPath="/"
|
||||||
|
onLoadChildren={loadChildren}
|
||||||
|
onSelectFile={selectPath}
|
||||||
|
selectedPath={path || null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
/**
|
||||||
|
* Upload node config
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type { NodeConfigRendererProps } from './types';
|
||||||
|
|
||||||
|
export const UploadNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label>Accept (MIME)</label>
|
||||||
|
<input
|
||||||
|
value={(params.accept as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('accept', e.target.value)}
|
||||||
|
placeholder=".pdf,image/*"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Max Größe (MB)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={(params.maxSize as number) ?? 10}
|
||||||
|
onChange={(e) => updateParam('maxSize', parseFloat(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(params.multiple as boolean) ?? false}
|
||||||
|
onChange={(e) => updateParam('multiple', e.target.checked)}
|
||||||
|
/>
|
||||||
|
Mehrere Dateien
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
44
src/components/Automation2FlowEditor/configs/index.ts
Normal file
44
src/components/Automation2FlowEditor/configs/index.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
/**
|
||||||
|
* Node config renderers - one per node type (input, ai, email, sharepoint).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ComponentType } from 'react';
|
||||||
|
import type { NodeConfigRendererProps } from './types';
|
||||||
|
import { FormNodeConfig } from './FormNodeConfig';
|
||||||
|
import { ApprovalNodeConfig } from './ApprovalNodeConfig';
|
||||||
|
import { UploadNodeConfig } from './UploadNodeConfig';
|
||||||
|
import { CommentNodeConfig } from './CommentNodeConfig';
|
||||||
|
import { ReviewNodeConfig } from './ReviewNodeConfig';
|
||||||
|
import { SelectionNodeConfig } from './SelectionNodeConfig';
|
||||||
|
import { ConfirmationNodeConfig } from './ConfirmationNodeConfig';
|
||||||
|
import { AiNodeConfig } from './AiNodeConfig';
|
||||||
|
import { EmailNodeConfig } from './EmailNodeConfig';
|
||||||
|
import { SharePointNodeConfig } from './SharePointNodeConfig';
|
||||||
|
|
||||||
|
export type NodeConfigComponent = ComponentType<NodeConfigRendererProps>;
|
||||||
|
|
||||||
|
export const NODE_CONFIG_REGISTRY: Record<string, NodeConfigComponent> = {
|
||||||
|
'input.form': FormNodeConfig,
|
||||||
|
'input.approval': ApprovalNodeConfig,
|
||||||
|
'input.upload': UploadNodeConfig,
|
||||||
|
'input.comment': CommentNodeConfig,
|
||||||
|
'input.review': ReviewNodeConfig,
|
||||||
|
'input.selection': SelectionNodeConfig,
|
||||||
|
'input.confirmation': ConfirmationNodeConfig,
|
||||||
|
'ai.prompt': AiNodeConfig,
|
||||||
|
'ai.webResearch': AiNodeConfig,
|
||||||
|
'ai.summarizeDocument': AiNodeConfig,
|
||||||
|
'ai.translateDocument': AiNodeConfig,
|
||||||
|
'ai.convertDocument': AiNodeConfig,
|
||||||
|
'ai.generateDocument': AiNodeConfig,
|
||||||
|
'ai.generateCode': AiNodeConfig,
|
||||||
|
'email.checkEmail': EmailNodeConfig,
|
||||||
|
'email.searchEmail': EmailNodeConfig,
|
||||||
|
'email.draftEmail': EmailNodeConfig,
|
||||||
|
'sharepoint.findFile': SharePointNodeConfig,
|
||||||
|
'sharepoint.readFile': SharePointNodeConfig,
|
||||||
|
'sharepoint.uploadFile': SharePointNodeConfig,
|
||||||
|
'sharepoint.listFiles': SharePointNodeConfig,
|
||||||
|
'sharepoint.downloadFile': SharePointNodeConfig,
|
||||||
|
'sharepoint.copyFile': SharePointNodeConfig,
|
||||||
|
};
|
||||||
16
src/components/Automation2FlowEditor/configs/types.ts
Normal file
16
src/components/Automation2FlowEditor/configs/types.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
/**
|
||||||
|
* Shared types for node config renderers
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ApiRequestFunction } from '../../../api/automation2Api';
|
||||||
|
|
||||||
|
export type FormField = { name?: string; type?: string; label?: string; required?: boolean };
|
||||||
|
|
||||||
|
export interface NodeConfigRendererProps {
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
updateParam: (key: string, value: unknown) => void;
|
||||||
|
/** For Email/SharePoint: fetch connections and browse */
|
||||||
|
instanceId?: string;
|
||||||
|
request?: ApiRequestFunction;
|
||||||
|
nodeType?: string;
|
||||||
|
}
|
||||||
38
src/components/Automation2FlowEditor/constants.ts
Normal file
38
src/components/Automation2FlowEditor/constants.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
/**
|
||||||
|
* Automation2 Flow Editor - Constants
|
||||||
|
* Category ordering for node sidebar.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Node type IDs hidden from the sidebar (hidden, not removed – still work when present in saved graphs) */
|
||||||
|
export const HIDDEN_NODE_IDS = new Set([
|
||||||
|
'trigger.schedule', // zeitplan
|
||||||
|
'trigger.formSubmit', // formular-absendung
|
||||||
|
'flow.ifElse',
|
||||||
|
'flow.switch',
|
||||||
|
'flow.merge',
|
||||||
|
'flow.loop',
|
||||||
|
'flow.wait',
|
||||||
|
'flow.stop', // alle abschnitt ablauf
|
||||||
|
'data.setFields',
|
||||||
|
'data.filter',
|
||||||
|
'data.parseJson',
|
||||||
|
'data.template', // alle abschnitt daten
|
||||||
|
'ai.webResearch',
|
||||||
|
'ai.summarizeDocument',
|
||||||
|
'ai.translateDocument',
|
||||||
|
'ai.convertDocument',
|
||||||
|
'ai.generateDocument',
|
||||||
|
'ai.generateCode', // alle KI ausser ai.prompt
|
||||||
|
'sharepoint.listFiles', // dateien auflisten
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Default category display order */
|
||||||
|
export const CATEGORY_ORDER = [
|
||||||
|
'trigger',
|
||||||
|
'input',
|
||||||
|
'flow',
|
||||||
|
'data',
|
||||||
|
'ai',
|
||||||
|
'email',
|
||||||
|
'sharepoint',
|
||||||
|
] as const;
|
||||||
78
src/components/Automation2FlowEditor/graphUtils.ts
Normal file
78
src/components/Automation2FlowEditor/graphUtils.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
/**
|
||||||
|
* Automation2 Flow Editor - Graph conversion utilities
|
||||||
|
* Converts between API graph format and canvas internal format.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { NodeType } from '../../api/automation2Api';
|
||||||
|
import type { CanvasNode, CanvasConnection } from './FlowCanvas';
|
||||||
|
import type { Automation2Graph } from '../../api/automation2Api';
|
||||||
|
|
||||||
|
export function fromApiGraph(
|
||||||
|
graph: Automation2Graph,
|
||||||
|
nodeTypes: NodeType[]
|
||||||
|
): { nodes: CanvasNode[]; connections: CanvasConnection[] } {
|
||||||
|
const nodeMap = new Map<string, { inputs: number; outputs: number }>();
|
||||||
|
nodeTypes.forEach((nt) => {
|
||||||
|
nodeMap.set(nt.id, { inputs: nt.inputs ?? 1, outputs: nt.outputs ?? 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const nodes: CanvasNode[] = (graph.nodes || []).map((n) => {
|
||||||
|
const io = nodeMap.get(n.type) ?? { inputs: 1, outputs: 1 };
|
||||||
|
return {
|
||||||
|
id: n.id,
|
||||||
|
type: n.type,
|
||||||
|
x: (n as { x?: number }).x ?? 0,
|
||||||
|
y: (n as { y?: number }).y ?? 0,
|
||||||
|
title: (n as { title?: string }).title ?? (typeof n.type === 'string' ? n.type : ''),
|
||||||
|
comment: (n as { comment?: string }).comment,
|
||||||
|
inputs: io.inputs,
|
||||||
|
outputs: io.outputs,
|
||||||
|
parameters: n.parameters ?? {},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const connId = (s: string, t: string, so: number, ti: number) => `c_${s}_${so}_${t}_${ti}`;
|
||||||
|
const connections: CanvasConnection[] = (graph.connections || []).map((c) => {
|
||||||
|
const srcNode = nodes.find((n) => n.id === c.source);
|
||||||
|
const sourceOutput = c.sourceOutput ?? 0;
|
||||||
|
const sourceHandle = srcNode ? srcNode.inputs + sourceOutput : 0;
|
||||||
|
return {
|
||||||
|
id: connId(c.source, c.target, sourceOutput, c.targetInput ?? 0),
|
||||||
|
sourceId: c.source,
|
||||||
|
sourceHandle,
|
||||||
|
targetId: c.target,
|
||||||
|
targetHandle: c.targetInput ?? 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { nodes, connections };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toApiGraph(
|
||||||
|
nodes: CanvasNode[],
|
||||||
|
connections: CanvasConnection[]
|
||||||
|
): Automation2Graph {
|
||||||
|
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
|
||||||
|
return {
|
||||||
|
nodes: nodes.map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
type: n.type,
|
||||||
|
x: n.x,
|
||||||
|
y: n.y,
|
||||||
|
title: n.title,
|
||||||
|
comment: n.comment,
|
||||||
|
parameters: n.parameters ?? {},
|
||||||
|
})),
|
||||||
|
connections: connections.map((c) => {
|
||||||
|
const srcNode = nodeMap.get(c.sourceId);
|
||||||
|
const sourceOutput =
|
||||||
|
srcNode && c.sourceHandle >= srcNode.inputs ? c.sourceHandle - srcNode.inputs : 0;
|
||||||
|
return {
|
||||||
|
source: c.sourceId,
|
||||||
|
target: c.targetId,
|
||||||
|
sourceOutput,
|
||||||
|
targetInput: c.targetHandle,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
9
src/components/Automation2FlowEditor/index.ts
Normal file
9
src/components/Automation2FlowEditor/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export { Automation2FlowEditor } from './Automation2FlowEditor';
|
||||||
|
export { FlowCanvas } from './FlowCanvas';
|
||||||
|
export { NodeConfigPanel } from './NodeConfigPanel';
|
||||||
|
export { NodeSidebar } from './NodeSidebar';
|
||||||
|
export { NodeListItem } from './NodeListItem';
|
||||||
|
export { CanvasHeader } from './CanvasHeader';
|
||||||
|
export * from './utils';
|
||||||
|
export * from './constants';
|
||||||
|
export * from './graphUtils';
|
||||||
25
src/components/Automation2FlowEditor/utils.ts
Normal file
25
src/components/Automation2FlowEditor/utils.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
/**
|
||||||
|
* Automation2 Flow Editor - Utility functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type React from 'react';
|
||||||
|
import { CATEGORY_ICONS, DEFAULT_CATEGORY_ICON } from './categoryIcons';
|
||||||
|
|
||||||
|
/** Resolve localized label from string or { de, en, fr } object */
|
||||||
|
export function getLabel(
|
||||||
|
text: string | Record<string, string> | undefined,
|
||||||
|
lang = 'de'
|
||||||
|
): string {
|
||||||
|
if (!text) return '';
|
||||||
|
if (typeof text === 'string') return text;
|
||||||
|
const rec = text as Record<string, string>;
|
||||||
|
return rec[lang] ?? rec.en ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get icon for a category */
|
||||||
|
export function getCategoryIcon(categoryId: string): React.ReactNode {
|
||||||
|
return CATEGORY_ICONS[categoryId] ?? DEFAULT_CATEGORY_ICON;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Function type for resolving localized labels */
|
||||||
|
export type GetLabelFn = (text: string | Record<string, string> | undefined, lang?: string) => string;
|
||||||
306
src/components/FolderTree/SharepointBrowseTree.tsx
Normal file
306
src/components/FolderTree/SharepointBrowseTree.tsx
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
/**
|
||||||
|
* SharepointBrowseTree – Lazy-loading tree for SharePoint browse.
|
||||||
|
* Same look & feel as FolderTree (chevron, FaFolder/FaFolderOpen, styling).
|
||||||
|
* Loads children on expand via onLoadChildren(path).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { FaFolder, FaFolderOpen, FaChevronRight, FaGlobe } from 'react-icons/fa';
|
||||||
|
import styles from './FolderTree.module.css';
|
||||||
|
|
||||||
|
export interface BrowseEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
isFolder: boolean;
|
||||||
|
size?: number;
|
||||||
|
mimeType?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharepointBrowseTreeProps {
|
||||||
|
/** Root path (usually "/") - children loaded via onLoadChildren */
|
||||||
|
rootPath?: string;
|
||||||
|
/** Load children for a given path. Returns folders and files. */
|
||||||
|
onLoadChildren: (path: string) => Promise<BrowseEntry[]>;
|
||||||
|
/** Called when user selects a file path */
|
||||||
|
onSelectFile: (path: string) => void;
|
||||||
|
/** Called when user selects a folder path (e.g. for destination). If provided, folder rows are selectable. */
|
||||||
|
onSelectFolder?: (path: string) => void;
|
||||||
|
/** Currently selected path (for highlight) */
|
||||||
|
selectedPath?: string | null;
|
||||||
|
/** Optional: pre-seed root children (e.g. from initial load) */
|
||||||
|
initialChildren?: BrowseEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function _fileIcon(mime?: string): string {
|
||||||
|
if (!mime) return '\uD83D\uDCC4';
|
||||||
|
if (mime.startsWith('image/')) return '\uD83D\uDDBC\uFE0F';
|
||||||
|
if (mime.includes('pdf')) return '\uD83D\uDCD5';
|
||||||
|
if (mime.includes('word') || mime.includes('docx')) return '\uD83D\uDCD8';
|
||||||
|
if (mime.includes('sheet') || mime.includes('xlsx') || mime.includes('csv')) return '\uD83D\uDCCA';
|
||||||
|
if (mime.includes('presentation') || mime.includes('pptx')) return '\uD83D\uDCD9';
|
||||||
|
if (mime.includes('zip') || mime.includes('tar') || mime.includes('gz')) return '\uD83D\uDCE6';
|
||||||
|
if (mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')) return '\uD83D\uDCDD';
|
||||||
|
return '\uD83D\uDCC4';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── File row ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function _FileRow({
|
||||||
|
entry,
|
||||||
|
selectedPath,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
entry: BrowseEntry;
|
||||||
|
selectedPath: string | null | undefined;
|
||||||
|
onSelect: (path: string) => void;
|
||||||
|
}) {
|
||||||
|
const isSelected = selectedPath === entry.path;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.treeNode} ${styles.fileNode} ${isSelected ? styles.selected : ''}`}
|
||||||
|
onClick={() => onSelect(entry.path)}
|
||||||
|
title={entry.path}
|
||||||
|
>
|
||||||
|
<span className={styles.chevron + ' ' + styles.empty} />
|
||||||
|
<span className={styles.fileIcon}>{_fileIcon(entry.mimeType)}</span>
|
||||||
|
<span className={styles.folderName}>{entry.name}</span>
|
||||||
|
{entry.size != null && (
|
||||||
|
<span className={styles.fileSize}>
|
||||||
|
{(entry.size / 1024).toFixed(0)}K
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Folder row (expandable, lazy-loads children) ───────────────────────── */
|
||||||
|
|
||||||
|
function _FolderRow({
|
||||||
|
entry,
|
||||||
|
selectedPath,
|
||||||
|
expandedPaths,
|
||||||
|
loadedChildren,
|
||||||
|
loadingPaths,
|
||||||
|
onToggle,
|
||||||
|
onSelectFile,
|
||||||
|
onSelectFolder,
|
||||||
|
}: {
|
||||||
|
entry: BrowseEntry;
|
||||||
|
selectedPath: string | null | undefined;
|
||||||
|
expandedPaths: Set<string>;
|
||||||
|
loadedChildren: Record<string, BrowseEntry[]>;
|
||||||
|
loadingPaths: Set<string>;
|
||||||
|
onToggle: (path: string) => void;
|
||||||
|
onSelectFile: (path: string) => void;
|
||||||
|
onSelectFolder?: (path: string) => void;
|
||||||
|
}) {
|
||||||
|
const isExpanded = expandedPaths.has(entry.path);
|
||||||
|
const isSelected = selectedPath === entry.path;
|
||||||
|
const children = loadedChildren[entry.path] ?? [];
|
||||||
|
const folders = children.filter((c) => c.isFolder).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const files = children.filter((c) => !c.isFolder).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const isLoading = isExpanded && loadingPaths.has(entry.path);
|
||||||
|
|
||||||
|
const handleRowClick = (e: React.MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest(`.${styles.chevron}`)) return;
|
||||||
|
if (onSelectFolder) {
|
||||||
|
onSelectFolder(entry.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onToggle(entry.path);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChevronClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggle(entry.path);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className={`${styles.treeNode} ${onSelectFolder && isSelected ? styles.selected : ''}`}
|
||||||
|
onClick={handleRowClick}
|
||||||
|
title={entry.path}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`${styles.chevron} ${isExpanded ? styles.expanded : ''}`}
|
||||||
|
onClick={handleChevronClick}
|
||||||
|
title={isExpanded ? 'Einklappen' : 'Erweitern'}
|
||||||
|
>
|
||||||
|
<FaChevronRight />
|
||||||
|
</span>
|
||||||
|
<span className={styles.folderIcon}>
|
||||||
|
{isExpanded ? <FaFolderOpen /> : <FaFolder />}
|
||||||
|
</span>
|
||||||
|
<span className={styles.folderName}>{entry.name}</span>
|
||||||
|
{isLoading && (
|
||||||
|
<span style={{ fontSize: 10, color: 'var(--color-text-secondary,#999)', marginLeft: 4 }}>…</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isExpanded && (
|
||||||
|
<div className={styles.children}>
|
||||||
|
{isLoading ? (
|
||||||
|
<div style={{ padding: '0.5rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#666)' }}>
|
||||||
|
Wird geladen…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{folders.map((child) => (
|
||||||
|
<_FolderRow
|
||||||
|
key={child.path}
|
||||||
|
entry={child}
|
||||||
|
selectedPath={selectedPath}
|
||||||
|
expandedPaths={expandedPaths}
|
||||||
|
loadedChildren={loadedChildren}
|
||||||
|
loadingPaths={loadingPaths}
|
||||||
|
onToggle={onToggle}
|
||||||
|
onSelectFile={onSelectFile}
|
||||||
|
onSelectFolder={onSelectFolder}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{files.map((child) => (
|
||||||
|
<_FileRow
|
||||||
|
key={child.path}
|
||||||
|
entry={child}
|
||||||
|
selectedPath={selectedPath}
|
||||||
|
onSelect={onSelectFile}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{children.length === 0 && (
|
||||||
|
<div style={{ padding: '0.4rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#999)' }}>
|
||||||
|
Leer
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Root component ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function SharepointBrowseTree({
|
||||||
|
rootPath = '/',
|
||||||
|
onLoadChildren,
|
||||||
|
onSelectFile,
|
||||||
|
onSelectFolder,
|
||||||
|
selectedPath,
|
||||||
|
initialChildren = [],
|
||||||
|
}: SharepointBrowseTreeProps) {
|
||||||
|
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set([rootPath]));
|
||||||
|
const [loadedChildren, setLoadedChildren] = useState<Record<string, BrowseEntry[]>>(() =>
|
||||||
|
initialChildren.length > 0 ? { [rootPath]: initialChildren } : {}
|
||||||
|
);
|
||||||
|
const [loadingPaths, setLoadingPaths] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const loadPath = useCallback(
|
||||||
|
async (path: string) => {
|
||||||
|
setLoadingPaths((p) => new Set(p).add(path));
|
||||||
|
try {
|
||||||
|
const items = await onLoadChildren(path);
|
||||||
|
setLoadedChildren((prev) => ({ ...prev, [path]: items }));
|
||||||
|
} catch {
|
||||||
|
setLoadedChildren((prev) => ({ ...prev, [path]: [] }));
|
||||||
|
} finally {
|
||||||
|
setLoadingPaths((p) => {
|
||||||
|
const next = new Set(p);
|
||||||
|
next.delete(path);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onLoadChildren]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggle = useCallback(
|
||||||
|
(path: string) => {
|
||||||
|
setExpandedPaths((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(path)) {
|
||||||
|
next.delete(path);
|
||||||
|
} else {
|
||||||
|
next.add(path);
|
||||||
|
loadPath(path);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[loadPath]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (rootPath in loadedChildren) return;
|
||||||
|
if (initialChildren.length > 0) return;
|
||||||
|
loadPath(rootPath);
|
||||||
|
}, [rootPath, initialChildren.length, loadPath]);
|
||||||
|
|
||||||
|
const rootItems = loadedChildren[rootPath] ?? [];
|
||||||
|
const rootLoading = loadingPaths.has(rootPath);
|
||||||
|
const rootFolders = rootItems.filter((e) => e.isFolder).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const rootFiles = rootItems.filter((e) => !e.isFolder).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const isRootExpanded = expandedPaths.has(rootPath);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.folderTree}>
|
||||||
|
<div
|
||||||
|
className={`${styles.treeNode} ${selectedPath === null || selectedPath === undefined ? styles.selected : ''}`}
|
||||||
|
style={{ fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`${styles.chevron} ${isRootExpanded ? styles.expanded : ''}`}
|
||||||
|
onClick={() => handleToggle(rootPath)}
|
||||||
|
>
|
||||||
|
<FaChevronRight />
|
||||||
|
</span>
|
||||||
|
<span className={styles.folderIcon}><FaGlobe /></span>
|
||||||
|
<span className={`${styles.folderName} ${styles.rootLabel}`}>SharePoint</span>
|
||||||
|
{rootLoading && (
|
||||||
|
<span style={{ fontSize: 10, color: 'var(--color-text-secondary,#999)', marginLeft: 4 }}>…</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isRootExpanded && (
|
||||||
|
<div className={styles.children}>
|
||||||
|
{rootLoading ? (
|
||||||
|
<div style={{ padding: '0.5rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#666)' }}>
|
||||||
|
Sites werden geladen…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{rootFolders.map((entry) => (
|
||||||
|
<_FolderRow
|
||||||
|
key={entry.path}
|
||||||
|
entry={entry}
|
||||||
|
selectedPath={selectedPath}
|
||||||
|
expandedPaths={expandedPaths}
|
||||||
|
loadedChildren={loadedChildren}
|
||||||
|
loadingPaths={loadingPaths}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
onSelectFile={onSelectFile}
|
||||||
|
onSelectFolder={onSelectFolder}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{rootFiles.map((entry) => (
|
||||||
|
<_FileRow
|
||||||
|
key={entry.path}
|
||||||
|
entry={entry}
|
||||||
|
selectedPath={selectedPath}
|
||||||
|
onSelect={onSelectFile}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{rootItems.length === 0 && !rootLoading && (
|
||||||
|
<div style={{ padding: '0.5rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#999)' }}>
|
||||||
|
Keine Einträge
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -100,11 +100,10 @@ export function FormGeneratorControls({
|
||||||
onPageChange,
|
onPageChange,
|
||||||
onPageSizeChange,
|
onPageSizeChange,
|
||||||
supportsBackendPagination = false,
|
supportsBackendPagination = false,
|
||||||
hookData: _hookData, // Reserved for future use
|
hookData,
|
||||||
onCsvExport,
|
onCsvExport,
|
||||||
csvExporting = false
|
csvExporting = false
|
||||||
}: FormGeneratorControlsProps) {
|
}: FormGeneratorControlsProps) {
|
||||||
void _hookData; // Suppress unused variable warning
|
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
||||||
// Check if all items are selected
|
// Check if all items are selected
|
||||||
|
|
@ -290,9 +289,8 @@ export function FormGeneratorControls({
|
||||||
»»
|
»»
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Total items count - always show actual displayed data length */}
|
|
||||||
<span className={styles.paginationInfo}>
|
<span className={styles.paginationInfo}>
|
||||||
({loading ? '...' : displayData.length.toString()} {t('formgen.pagination.items', 'items')})
|
({loading ? '...' : (hookData?.pagination?.totalItems ?? displayData.length).toString()} {t('formgen.pagination.items', 'items')})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow-x: hidden; /* Horizontal scroll handled by topScrollbar */
|
overflow-x: hidden; /* Horizontal scroll handled by topScrollbar */
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
/* Fill remaining space but constrain to available height */
|
/* Fill remaining space but constrain to available height */
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,58 @@
|
||||||
|
/**
|
||||||
|
* FormGeneratorTable — Backend-driven data table.
|
||||||
|
*
|
||||||
|
* ARCHITECTURE:
|
||||||
|
* This table does NO client-side filtering, sorting, or pagination.
|
||||||
|
* All data processing is delegated to the backend via hookData.refetch().
|
||||||
|
* The `data` prop is rendered as-is (displayData = data).
|
||||||
|
*
|
||||||
|
* REQUIRED CONTRACT for interactive features (search, filter, sort, pagination):
|
||||||
|
*
|
||||||
|
* hookData={{
|
||||||
|
* refetch, // (params?: PaginationParams) => Promise<void>
|
||||||
|
* // Called on every search/filter/sort/page change.
|
||||||
|
* // Must fetch from backend with pagination query param
|
||||||
|
* // and update the data + pagination states.
|
||||||
|
* pagination, // { currentPage, pageSize, totalItems, totalPages } | null
|
||||||
|
* // Drives pagination controls. Comes from backend response.
|
||||||
|
* fetchFilterValues, // (columnKey: string) => Promise<string[]> (Optional)
|
||||||
|
* // If provided, called when a filter dropdown opens.
|
||||||
|
* // If NOT provided but apiEndpoint is set, the table
|
||||||
|
* // auto-fetches from `{apiEndpoint}/filter-values?column=xxx`.
|
||||||
|
* }}
|
||||||
|
*
|
||||||
|
* Without hookData.refetch, interactive controls (sort, filter, search,
|
||||||
|
* pagination) are inert — the table renders data but actions have no effect.
|
||||||
|
*
|
||||||
|
* FILTER VALUES (autofilter):
|
||||||
|
* When a filterable column's dropdown opens, distinct values are loaded from:
|
||||||
|
* 1. column.filterOptions (static enum — used as-is, no backend call)
|
||||||
|
* 2. hookData.fetchFilterValues(columnKey) if provided
|
||||||
|
* 3. GET {apiEndpoint}/filter-values?column=xxx&pagination={currentFilters}
|
||||||
|
* Cross-filtering is supported: changing a filter invalidates the cache,
|
||||||
|
* so re-opening another column's dropdown re-fetches with current filters.
|
||||||
|
* Boolean columns render as "Ja"/"Nein"; date columns render as range picker.
|
||||||
|
*
|
||||||
|
* BACKEND RESPONSE FORMAT (for refetch):
|
||||||
|
* { items: T[], pagination: PaginationMetadata | null }
|
||||||
|
*
|
||||||
|
* BACKEND RESPONSE FORMAT (for filter-values):
|
||||||
|
* string[]
|
||||||
|
*
|
||||||
|
* EXAMPLE (minimal integration):
|
||||||
|
*
|
||||||
|
* const { data, pagination, loading, refetch } = useMyEntityHook();
|
||||||
|
*
|
||||||
|
* <FormGeneratorTable
|
||||||
|
* data={data}
|
||||||
|
* columns={columns}
|
||||||
|
* loading={loading}
|
||||||
|
* hookData={{ refetch, pagination }}
|
||||||
|
* apiEndpoint="/api/my-entity/" // enables CSV export + auto filter values
|
||||||
|
* />
|
||||||
|
*
|
||||||
|
* See useOrgUsers / AdminUsersPage for a full reference implementation.
|
||||||
|
*/
|
||||||
import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react';
|
import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react';
|
||||||
import type { IconType } from 'react-icons';
|
import type { IconType } from 'react-icons';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
|
@ -175,6 +230,67 @@ export interface FormGeneratorTableProps<T = any> {
|
||||||
onRowDragStart?: (e: React.DragEvent<HTMLTableRowElement>, row: T) => void;
|
onRowDragStart?: (e: React.DragEvent<HTMLTableRowElement>, row: T) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _FILTER_PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a scrollable list of filter values with IntersectionObserver-based lazy loading.
|
||||||
|
* Shows _FILTER_PAGE_SIZE items initially, loads more as the user scrolls.
|
||||||
|
*/
|
||||||
|
function FilterValuesList({
|
||||||
|
columnKey,
|
||||||
|
allValues,
|
||||||
|
activeFilter,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
columnKey: string;
|
||||||
|
allValues: string[];
|
||||||
|
activeFilter: any;
|
||||||
|
onSelect: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
const [displayCount, setDisplayCount] = useState(_FILTER_PAGE_SIZE);
|
||||||
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDisplayCount(_FILTER_PAGE_SIZE);
|
||||||
|
}, [columnKey, allValues.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sentinel = sentinelRef.current;
|
||||||
|
if (!sentinel || displayCount >= allValues.length) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0]?.isIntersecting) {
|
||||||
|
setDisplayCount(prev => Math.min(prev + _FILTER_PAGE_SIZE, allValues.length));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 }
|
||||||
|
);
|
||||||
|
observer.observe(sentinel);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [displayCount, allValues.length]);
|
||||||
|
|
||||||
|
const visibleValues = allValues.slice(0, displayCount);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{visibleValues.map(value => (
|
||||||
|
<div
|
||||||
|
key={value}
|
||||||
|
className={`${styles.filterOption} ${activeFilter === value ? styles.filterOptionSelected : ''}`}
|
||||||
|
onClick={() => onSelect(value)}
|
||||||
|
title={value}
|
||||||
|
>
|
||||||
|
{value.length > 30 ? value.substring(0, 30) + '...' : value}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{displayCount < allValues.length && (
|
||||||
|
<div ref={sentinelRef} style={{ height: 1, opacity: 0 }} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function FormGeneratorTable<T extends Record<string, any>>({
|
export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
data,
|
data,
|
||||||
columns: providedColumns,
|
columns: providedColumns,
|
||||||
|
|
@ -294,8 +410,11 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setDebouncedSearchTerm(searchTerm);
|
setDebouncedSearchTerm(prev => {
|
||||||
}, 300); // 300ms debounce
|
if (prev !== searchTerm) setCurrentPage(1);
|
||||||
|
return searchTerm;
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [searchTerm]);
|
}, [searchTerm]);
|
||||||
|
|
@ -718,21 +837,19 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
const existingIndex = current.findIndex(sc => sc.key === key);
|
const existingIndex = current.findIndex(sc => sc.key === key);
|
||||||
|
|
||||||
if (existingIndex === -1) {
|
if (existingIndex === -1) {
|
||||||
// Column not in sort list → add as ascending (lowest priority)
|
|
||||||
return [...current, { key, direction: 'asc' }];
|
return [...current, { key, direction: 'asc' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = current[existingIndex];
|
const existing = current[existingIndex];
|
||||||
if (existing.direction === 'asc') {
|
if (existing.direction === 'asc') {
|
||||||
// Ascending → change to descending (keep same position)
|
|
||||||
const newConfigs = [...current];
|
const newConfigs = [...current];
|
||||||
newConfigs[existingIndex] = { key, direction: 'desc' };
|
newConfigs[existingIndex] = { key, direction: 'desc' };
|
||||||
return newConfigs;
|
return newConfigs;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Descending → remove from sort list
|
|
||||||
return current.filter(sc => sc.key !== key);
|
return current.filter(sc => sc.key !== key);
|
||||||
});
|
});
|
||||||
|
setCurrentPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get sort info for a column (returns { direction, position } or null)
|
// Get sort info for a column (returns { direction, position } or null)
|
||||||
|
|
@ -743,7 +860,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
}, [sortConfigs]);
|
}, [sortConfigs]);
|
||||||
|
|
||||||
// Handle filtering
|
// Handle filtering
|
||||||
const handleFilter = (key: string, value: any) => {
|
const handleFilter = (key: string, value: any, keepOpen = false) => {
|
||||||
setFilters(prev => {
|
setFilters(prev => {
|
||||||
const newFilters = { ...prev };
|
const newFilters = { ...prev };
|
||||||
if (value === undefined || value === '' || value === null) {
|
if (value === undefined || value === '' || value === null) {
|
||||||
|
|
@ -753,8 +870,10 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
}
|
}
|
||||||
return newFilters;
|
return newFilters;
|
||||||
});
|
});
|
||||||
setCurrentPage(1); // Reset to first page when filtering
|
setCurrentPage(1);
|
||||||
setOpenFilterColumn(null); // Close filter dropdown
|
if (!keepOpen) {
|
||||||
|
setOpenFilterColumn(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle filter input focus
|
// Handle filter input focus
|
||||||
|
|
@ -782,22 +901,17 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
}, [filters]);
|
}, [filters]);
|
||||||
|
|
||||||
// Track which filter columns show all values (expanded beyond initial 100)
|
// Track which filter columns show all values (expanded beyond initial 100)
|
||||||
const [expandedFilterColumns, setExpandedFilterColumns] = useState<Set<string>>(new Set());
|
|
||||||
// Async-loaded filter values per column (from backend via hookData.fetchFilterValues)
|
|
||||||
const [asyncFilterValues, setAsyncFilterValues] = useState<Record<string, string[]>>({});
|
const [asyncFilterValues, setAsyncFilterValues] = useState<Record<string, string[]>>({});
|
||||||
const [filterValuesLoading, setFilterValuesLoading] = useState<Record<string, boolean>>({});
|
const [filterValuesLoading, setFilterValuesLoading] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const _toggleFilterExpand = useCallback((columnKey: string) => {
|
// Invalidate cached filter values when filters change (cross-filtering)
|
||||||
setExpandedFilterColumns(prev => {
|
const filtersRef = useRef(filters);
|
||||||
const next = new Set(prev);
|
useEffect(() => {
|
||||||
if (next.has(columnKey)) {
|
if (filtersRef.current !== filters) {
|
||||||
next.delete(columnKey);
|
filtersRef.current = filters;
|
||||||
} else {
|
setAsyncFilterValues({});
|
||||||
next.add(columnKey);
|
}
|
||||||
}
|
}, [filters]);
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Load filter values on-demand when a filter dropdown is opened
|
// Load filter values on-demand when a filter dropdown is opened
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -811,58 +925,61 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
// Skip if already loaded or currently loading
|
// Skip if already loaded or currently loading
|
||||||
if (asyncFilterValues[openFilterColumn] || filterValuesLoading[openFilterColumn]) return;
|
if (asyncFilterValues[openFilterColumn] || filterValuesLoading[openFilterColumn]) return;
|
||||||
|
|
||||||
// If the hook provides fetchFilterValues, use it (backend distinct query)
|
const _fetchValues = async (columnKey: string) => {
|
||||||
if (hookData?.fetchFilterValues && typeof hookData.fetchFilterValues === 'function') {
|
setFilterValuesLoading(prev => ({ ...prev, [columnKey]: true }));
|
||||||
setFilterValuesLoading(prev => ({ ...prev, [openFilterColumn]: true }));
|
try {
|
||||||
hookData.fetchFilterValues(openFilterColumn).then((values: string[]) => {
|
let values: string[];
|
||||||
setAsyncFilterValues(prev => ({ ...prev, [openFilterColumn]: values }));
|
if (hookData?.fetchFilterValues && typeof hookData.fetchFilterValues === 'function') {
|
||||||
}).catch(() => {
|
values = await hookData.fetchFilterValues(columnKey);
|
||||||
// On error, fall back to current page data (set empty to prevent re-fetch)
|
} else if (apiEndpoint && supportsBackendPagination) {
|
||||||
setAsyncFilterValues(prev => ({ ...prev, [openFilterColumn]: [] }));
|
const endpoint = apiEndpoint.endsWith('/') ? apiEndpoint.slice(0, -1) : apiEndpoint;
|
||||||
}).finally(() => {
|
const params: Record<string, string> = { column: columnKey };
|
||||||
setFilterValuesLoading(prev => ({ ...prev, [openFilterColumn]: false }));
|
if (Object.keys(filters).length > 0) {
|
||||||
});
|
params.pagination = JSON.stringify({ filters });
|
||||||
}
|
}
|
||||||
}, [openFilterColumn, detectedColumns, asyncFilterValues, filterValuesLoading, hookData]);
|
const response = await api.get(`${endpoint}/filter-values`, { params });
|
||||||
|
values = Array.isArray(response.data) ? response.data : [];
|
||||||
|
} else {
|
||||||
|
values = [];
|
||||||
|
}
|
||||||
|
setAsyncFilterValues(prev => ({ ...prev, [columnKey]: values }));
|
||||||
|
} catch {
|
||||||
|
setAsyncFilterValues(prev => ({ ...prev, [columnKey]: [] }));
|
||||||
|
} finally {
|
||||||
|
setFilterValuesLoading(prev => ({ ...prev, [columnKey]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_fetchValues(openFilterColumn);
|
||||||
|
}, [openFilterColumn, detectedColumns, asyncFilterValues, filterValuesLoading, hookData, apiEndpoint, supportsBackendPagination, filters]);
|
||||||
|
|
||||||
// Get unique values for a column (for filter dropdown)
|
// Get unique values for a column (for filter dropdown)
|
||||||
// Priority: 1) column.filterOptions (static enum)
|
// Sources: 1) column.filterOptions (static enum)
|
||||||
// 2) asyncFilterValues (loaded from backend)
|
// 2) asyncFilterValues (loaded from backend via hookData.fetchFilterValues)
|
||||||
// 3) data (current page - fallback)
|
// 3) data — ONLY when no backend pagination (data = full dataset)
|
||||||
|
// With backend pagination, data is a single page, so extracting filter
|
||||||
|
// values from it would be incomplete and misleading.
|
||||||
const getUniqueValuesForColumn = useCallback((columnKey: string): string[] => {
|
const getUniqueValuesForColumn = useCallback((columnKey: string): string[] => {
|
||||||
const column = detectedColumns.find(c => c.key === columnKey);
|
const column = detectedColumns.find(c => c.key === columnKey);
|
||||||
|
|
||||||
// Static enum options defined in the column config
|
|
||||||
if (column?.filterOptions && column.filterOptions.length > 0) {
|
if (column?.filterOptions && column.filterOptions.length > 0) {
|
||||||
return column.filterOptions;
|
return column.filterOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Values loaded asynchronously from the backend (all data, not just page)
|
|
||||||
if (asyncFilterValues[columnKey] && asyncFilterValues[columnKey].length > 0) {
|
if (asyncFilterValues[columnKey] && asyncFilterValues[columnKey].length > 0) {
|
||||||
return asyncFilterValues[columnKey];
|
return asyncFilterValues[columnKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: extract from current page data
|
if (!apiEndpoint && !hookData?.fetchFilterValues) {
|
||||||
const values = new Set<string>();
|
console.warn(
|
||||||
data.forEach(row => {
|
`FormGeneratorTable: Column "${columnKey}" is filterable ` +
|
||||||
const value = row[columnKey];
|
`but has no filterOptions, no hookData.fetchFilterValues, and no apiEndpoint. ` +
|
||||||
if (value !== undefined && value !== null && value !== '') {
|
`Filter dropdown will be empty. Provide apiEndpoint (auto-fetches /filter-values) ` +
|
||||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
`or add filterOptions to the column config.`
|
||||||
if (isTextMultilingual(value)) {
|
);
|
||||||
const text = value.en || Object.values(value)[0];
|
}
|
||||||
if (text) values.add(String(text));
|
return [];
|
||||||
} else {
|
}, [detectedColumns, asyncFilterValues, apiEndpoint, hookData]);
|
||||||
values.add(JSON.stringify(value));
|
|
||||||
}
|
|
||||||
} else if (typeof value === 'boolean') {
|
|
||||||
values.add(value ? 'true' : 'false');
|
|
||||||
} else {
|
|
||||||
values.add(String(value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return Array.from(values).sort();
|
|
||||||
}, [data, detectedColumns, asyncFilterValues]);
|
|
||||||
|
|
||||||
// Close filter dropdown when clicking outside
|
// Close filter dropdown when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1131,7 +1248,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
topScrollbar.removeEventListener('scroll', syncTopToContainer);
|
topScrollbar.removeEventListener('scroll', syncTopToContainer);
|
||||||
tableContainer.removeEventListener('scroll', syncContainerToTop);
|
tableContainer.removeEventListener('scroll', syncContainerToTop);
|
||||||
};
|
};
|
||||||
}, [displayData, detectedColumns, columnWidths]); // Re-run when data or columns change
|
}, [detectedColumns, columnWidths]); // ResizeObserver handles data-driven size changes
|
||||||
|
|
||||||
// Track which cells are currently being updated (for loading state)
|
// Track which cells are currently being updated (for loading state)
|
||||||
const [updatingCells, setUpdatingCells] = useState<Set<string>>(new Set());
|
const [updatingCells, setUpdatingCells] = useState<Set<string>>(new Set());
|
||||||
|
|
@ -1828,54 +1945,104 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.filterDropdownOptions}>
|
<div className={styles.filterDropdownOptions}>
|
||||||
{/* "All" option to clear filter */}
|
{(() => {
|
||||||
<div
|
const colType = column.type || 'text';
|
||||||
className={`${styles.filterOption} ${!filters[column.key] ? styles.filterOptionSelected : ''}`}
|
const isBool = isCheckboxType(colType as AttributeType);
|
||||||
onClick={() => clearFilter(column.key)}
|
const isDate = isDateTimeType(colType as AttributeType);
|
||||||
>
|
|
||||||
({t('formgen.filter.all', 'All')})
|
if (isBool) {
|
||||||
</div>
|
const currentVal = filters[column.key];
|
||||||
{/* Filter values - loaded from backend or static filterOptions */}
|
return (
|
||||||
{filterValuesLoading[column.key] ? (
|
<>
|
||||||
<div className={styles.filterOptionMore} style={{ textAlign: 'center', padding: '8px' }}>
|
<div
|
||||||
{t('formgen.filter.loading', 'Lade Filterwerte...')}
|
className={`${styles.filterOption} ${!currentVal ? styles.filterOptionSelected : ''}`}
|
||||||
</div>
|
onClick={() => clearFilter(column.key)}
|
||||||
) : (() => {
|
>
|
||||||
const allValues = getUniqueValuesForColumn(column.key);
|
({t('formgen.filter.all', 'Alle')})
|
||||||
const isExpanded = expandedFilterColumns.has(column.key);
|
</div>
|
||||||
const displayLimit = isExpanded ? allValues.length : 100;
|
<div
|
||||||
const visibleValues = allValues.slice(0, displayLimit);
|
className={`${styles.filterOption} ${currentVal === 'true' ? styles.filterOptionSelected : ''}`}
|
||||||
const remaining = allValues.length - displayLimit;
|
onClick={() => handleFilter(column.key, 'true')}
|
||||||
|
>
|
||||||
|
{t('formgen.filter.yes', 'Ja')}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`${styles.filterOption} ${currentVal === 'false' ? styles.filterOptionSelected : ''}`}
|
||||||
|
onClick={() => handleFilter(column.key, 'false')}
|
||||||
|
>
|
||||||
|
{t('formgen.filter.no', 'Nein')}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDate) {
|
||||||
|
const rangeVal = (typeof filters[column.key] === 'object' && filters[column.key]?.value) || {};
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '6px 8px', display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||||
|
<div
|
||||||
|
className={`${styles.filterOption} ${!filters[column.key] ? styles.filterOptionSelected : ''}`}
|
||||||
|
onClick={() => clearFilter(column.key)}
|
||||||
|
>
|
||||||
|
({t('formgen.filter.all', 'Alle')})
|
||||||
|
</div>
|
||||||
|
<label style={{ fontSize: '11px', color: 'var(--text-muted, #64748b)' }}>
|
||||||
|
{t('formgen.filter.from', 'Von')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={rangeVal.from || ''}
|
||||||
|
style={{ width: '100%', padding: '4px 6px', fontSize: '12px', border: '1px solid var(--color-border, #ddd)', borderRadius: '4px' }}
|
||||||
|
onChange={(e) => {
|
||||||
|
const from = e.target.value;
|
||||||
|
const to = rangeVal.to || '';
|
||||||
|
if (!from && !to) {
|
||||||
|
clearFilter(column.key);
|
||||||
|
} else {
|
||||||
|
handleFilter(column.key, { operator: 'between', value: { from, to } }, true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label style={{ fontSize: '11px', color: 'var(--text-muted, #64748b)' }}>
|
||||||
|
{t('formgen.filter.to', 'Bis')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={rangeVal.to || ''}
|
||||||
|
style={{ width: '100%', padding: '4px 6px', fontSize: '12px', border: '1px solid var(--color-border, #ddd)', borderRadius: '4px' }}
|
||||||
|
onChange={(e) => {
|
||||||
|
const to = e.target.value;
|
||||||
|
const from = rangeVal.from || '';
|
||||||
|
if (!from && !to) {
|
||||||
|
clearFilter(column.key);
|
||||||
|
} else {
|
||||||
|
handleFilter(column.key, { operator: 'between', value: { from, to } }, true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{visibleValues.map(value => (
|
<div
|
||||||
<div
|
className={`${styles.filterOption} ${!filters[column.key] ? styles.filterOptionSelected : ''}`}
|
||||||
key={value}
|
onClick={() => clearFilter(column.key)}
|
||||||
className={`${styles.filterOption} ${filters[column.key] === value ? styles.filterOptionSelected : ''}`}
|
>
|
||||||
onClick={() => handleFilter(column.key, value)}
|
({t('formgen.filter.all', 'Alle')})
|
||||||
title={value}
|
</div>
|
||||||
>
|
{filterValuesLoading[column.key] ? (
|
||||||
{value.length > 30 ? value.substring(0, 30) + '...' : value}
|
<div className={styles.filterOptionMore} style={{ textAlign: 'center', padding: '8px' }}>
|
||||||
</div>
|
{t('formgen.filter.loading', 'Lade Filterwerte...')}
|
||||||
))}
|
|
||||||
{remaining > 0 && (
|
|
||||||
<div
|
|
||||||
className={styles.filterOptionMore}
|
|
||||||
onClick={() => _toggleFilterExpand(column.key)}
|
|
||||||
style={{ cursor: 'pointer', color: 'var(--primary-blue, #003d7a)' }}
|
|
||||||
>
|
|
||||||
+ {remaining} {t('formgen.filter.more', 'weitere anzeigen')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isExpanded && allValues.length > 100 && (
|
|
||||||
<div
|
|
||||||
className={styles.filterOptionMore}
|
|
||||||
onClick={() => _toggleFilterExpand(column.key)}
|
|
||||||
style={{ cursor: 'pointer', color: 'var(--primary-blue, #003d7a)' }}
|
|
||||||
>
|
|
||||||
{t('formgen.filter.less', 'Weniger anzeigen')}
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<FilterValuesList
|
||||||
|
columnKey={column.key}
|
||||||
|
allValues={getUniqueValuesForColumn(column.key)}
|
||||||
|
activeFilter={filters[column.key]}
|
||||||
|
onSelect={(value) => handleFilter(column.key, value)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
FaListAlt, FaCogs, FaChartLine, FaFileAlt, FaUserShield, FaDatabase,
|
FaListAlt, FaCogs, FaChartLine, FaFileAlt, FaUserShield, FaDatabase,
|
||||||
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock,
|
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock,
|
||||||
FaHeadset, FaVideo, FaHatWizard, FaStore, FaUserTie, FaClipboardList,
|
FaHeadset, FaVideo, FaHatWizard, FaStore, FaUserTie, FaClipboardList,
|
||||||
|
FaFileContract,
|
||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -66,8 +67,11 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
||||||
'page.admin.user-access-overview': <FaUserShield />,
|
'page.admin.user-access-overview': <FaUserShield />,
|
||||||
'page.admin.userAccessOverview': <FaUserShield />,
|
'page.admin.userAccessOverview': <FaUserShield />,
|
||||||
'page.admin.billing': <FaMoneyBillAlt />,
|
'page.admin.billing': <FaMoneyBillAlt />,
|
||||||
|
'page.admin.subscriptions': <FaFileContract />,
|
||||||
'page.admin.automationEvents': <FaClock />,
|
'page.admin.automationEvents': <FaClock />,
|
||||||
'page.admin.automation-events': <FaClock />,
|
'page.admin.automation-events': <FaClock />,
|
||||||
|
'page.admin.automationLogs': <FaClipboardList />,
|
||||||
|
'page.admin.automation-logs': <FaClipboardList />,
|
||||||
'page.admin.logs': <FaFileAlt />,
|
'page.admin.logs': <FaFileAlt />,
|
||||||
'page.admin.mandate-wizard': <FaHatWizard />,
|
'page.admin.mandate-wizard': <FaHatWizard />,
|
||||||
'page.admin.mandateWizard': <FaHatWizard />,
|
'page.admin.mandateWizard': <FaHatWizard />,
|
||||||
|
|
@ -111,6 +115,10 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
||||||
'feature.realestate': <FaBuilding />,
|
'feature.realestate': <FaBuilding />,
|
||||||
'feature.chatworkflow': <FaPlay />,
|
'feature.chatworkflow': <FaPlay />,
|
||||||
'feature.automation': <FaCogs />,
|
'feature.automation': <FaCogs />,
|
||||||
|
'feature.automation2': <FaProjectDiagram />,
|
||||||
|
'page.feature.automation2.editor': <FaProjectDiagram />,
|
||||||
|
'page.feature.automation2.workflows': <FaProjectDiagram />,
|
||||||
|
'page.feature.automation2.workflows-tasks': <FaClipboardList />,
|
||||||
'page.feature.chatbot.conversations': <FaComments />,
|
'page.feature.chatbot.conversations': <FaComments />,
|
||||||
'feature.chatbot': <FaComments />,
|
'feature.chatbot': <FaComments />,
|
||||||
'feature.teamsbot': <FaHeadset />,
|
'feature.teamsbot': <FaHeadset />,
|
||||||
|
|
|
||||||
84
src/hooks/useAdminSubscriptions.ts
Normal file
84
src/hooks/useAdminSubscriptions.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useApiRequest } from './useApi';
|
||||||
|
|
||||||
|
interface PaginationParams {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||||
|
filters?: Record<string, any>;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaginationState {
|
||||||
|
currentPage: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _STATUS_LABELS: Record<string, string> = {
|
||||||
|
PENDING: 'Ausstehend',
|
||||||
|
SCHEDULED: 'Geplant',
|
||||||
|
TRIALING: 'Testphase',
|
||||||
|
ACTIVE: 'Aktiv',
|
||||||
|
PAST_DUE: 'Überfällig',
|
||||||
|
EXPIRED: 'Abgelaufen',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAdminSubscriptions() {
|
||||||
|
const [subscriptions, setSubscriptions] = useState<any[]>([]);
|
||||||
|
const [pagination, setPagination] = useState<PaginationState | null>(null);
|
||||||
|
const { request, isLoading: loading, error } = useApiRequest();
|
||||||
|
|
||||||
|
const refetch = useCallback(async (params?: PaginationParams) => {
|
||||||
|
try {
|
||||||
|
const requestParams: Record<string, string> = {};
|
||||||
|
|
||||||
|
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/subscription/admin/all',
|
||||||
|
method: 'get',
|
||||||
|
params: requestParams,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data && typeof data === 'object' && 'items' in data) {
|
||||||
|
const items = Array.isArray(data.items) ? data.items : [];
|
||||||
|
setSubscriptions(items.map(_enrichRow));
|
||||||
|
if (data.pagination) {
|
||||||
|
setPagination(data.pagination);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const items = Array.isArray(data) ? data : [];
|
||||||
|
setSubscriptions(items.map(_enrichRow));
|
||||||
|
setPagination(null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setSubscriptions([]);
|
||||||
|
setPagination(null);
|
||||||
|
}
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
useEffect(() => { refetch(); }, [refetch]);
|
||||||
|
|
||||||
|
return { data: subscriptions, pagination, loading, error, refetch };
|
||||||
|
}
|
||||||
|
|
||||||
|
function _enrichRow(row: any): any {
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
_rawStatus: row.status,
|
||||||
|
status: _STATUS_LABELS[row.status] || row.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -472,22 +472,30 @@ export function useAutomationOperations() {
|
||||||
export function useAutomationTemplates() {
|
export function useAutomationTemplates() {
|
||||||
const [templates, setTemplates] = useState<AutomationTemplate[]>([]);
|
const [templates, setTemplates] = useState<AutomationTemplate[]>([]);
|
||||||
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
|
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
|
||||||
|
const [pagination, setPagination] = useState<any>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
const { checkPermission } = usePermissions();
|
const { checkPermission } = usePermissions();
|
||||||
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
|
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
|
||||||
|
|
||||||
const fetchTemplates = useCallback(async () => {
|
const fetchTemplates = useCallback(async (params?: any) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const data = await fetchTemplatesApi(request);
|
const data = await fetchTemplatesApi(request, params);
|
||||||
setTemplates(data);
|
if (data && typeof data === 'object' && 'items' in data) {
|
||||||
|
setTemplates(Array.isArray(data.items) ? data.items : []);
|
||||||
|
if (data.pagination) setPagination(data.pagination);
|
||||||
|
} else {
|
||||||
|
setTemplates(Array.isArray(data) ? data : []);
|
||||||
|
setPagination(null);
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('Error fetching templates:', e);
|
console.error('Error fetching templates:', e);
|
||||||
setError(e.message || 'Failed to fetch templates');
|
setError(e.message || 'Failed to fetch templates');
|
||||||
setTemplates([]);
|
setTemplates([]);
|
||||||
|
setPagination(null);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -555,11 +563,12 @@ export function useAutomationTemplates() {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
templates,
|
templates,
|
||||||
data: templates, // Alias for FormGenerator compatibility
|
data: templates,
|
||||||
attributes,
|
attributes,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
permissions,
|
permissions,
|
||||||
|
pagination,
|
||||||
refetch,
|
refetch,
|
||||||
fetchTemplates,
|
fetchTemplates,
|
||||||
fetchAttributes,
|
fetchAttributes,
|
||||||
|
|
|
||||||
133
src/hooks/useConfirm.tsx
Normal file
133
src/hooks/useConfirm.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
/**
|
||||||
|
* useConfirm — application-level confirm dialog replacing native browser confirm().
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const { confirm, ConfirmDialog } = useConfirm();
|
||||||
|
* const ok = await confirm('Wirklich löschen?', { confirmLabel: 'Löschen', variant: 'danger' });
|
||||||
|
* // Render <ConfirmDialog /> once in the component tree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
export interface ConfirmOptions {
|
||||||
|
title?: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
variant?: 'primary' | 'danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmState {
|
||||||
|
message: string;
|
||||||
|
options: Required<ConfirmOptions>;
|
||||||
|
resolve: (value: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _defaults: Required<ConfirmOptions> = {
|
||||||
|
title: 'Bestätigung',
|
||||||
|
confirmLabel: 'Bestätigen',
|
||||||
|
cancelLabel: 'Abbrechen',
|
||||||
|
variant: 'primary',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useConfirm() {
|
||||||
|
const [state, setState] = useState<ConfirmState | null>(null);
|
||||||
|
const resolveRef = useRef<((v: boolean) => void) | null>(null);
|
||||||
|
|
||||||
|
const confirm = useCallback((message: string, options?: ConfirmOptions): Promise<boolean> => {
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
resolveRef.current = resolve;
|
||||||
|
setState({
|
||||||
|
message,
|
||||||
|
options: { ..._defaults, ...options },
|
||||||
|
resolve,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _handleConfirm = useCallback(() => {
|
||||||
|
resolveRef.current?.(true);
|
||||||
|
resolveRef.current = null;
|
||||||
|
setState(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _handleCancel = useCallback(() => {
|
||||||
|
resolveRef.current?.(false);
|
||||||
|
resolveRef.current = null;
|
||||||
|
setState(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const ConfirmDialog: React.FC = useCallback(() => {
|
||||||
|
if (!state) return null;
|
||||||
|
|
||||||
|
const { message, options } = state;
|
||||||
|
const isDanger = options.variant === 'danger';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={_handleCancel}
|
||||||
|
style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 10000,
|
||||||
|
background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(2px)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface-color, #1a1a2e)',
|
||||||
|
border: '1px solid var(--color-border, #333)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '1.5rem',
|
||||||
|
minWidth: 340, maxWidth: 480,
|
||||||
|
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
|
||||||
|
display: 'flex', flexDirection: 'column', gap: '1.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{
|
||||||
|
margin: 0, fontSize: '1.05rem', fontWeight: 600,
|
||||||
|
color: 'var(--text-primary, #e0e0e0)',
|
||||||
|
}}>
|
||||||
|
{options.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p style={{
|
||||||
|
margin: 0, fontSize: '0.9rem', lineHeight: 1.5,
|
||||||
|
color: 'var(--text-secondary, #999)',
|
||||||
|
}}>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={_handleCancel}
|
||||||
|
style={{
|
||||||
|
padding: '8px 18px', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 500,
|
||||||
|
border: '1px solid var(--color-border, #444)',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--text-secondary, #aaa)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{options.cancelLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={_handleConfirm}
|
||||||
|
autoFocus
|
||||||
|
style={{
|
||||||
|
padding: '8px 18px', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 600,
|
||||||
|
border: 'none',
|
||||||
|
background: isDanger ? '#ef4444' : 'var(--color-primary, #3b82f6)',
|
||||||
|
color: '#fff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{options.confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [state, _handleConfirm, _handleCancel]);
|
||||||
|
|
||||||
|
return { confirm, ConfirmDialog };
|
||||||
|
}
|
||||||
161
src/hooks/useSubscription.ts
Normal file
161
src/hooks/useSubscription.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
/**
|
||||||
|
* useSubscription Hook — state-machine-aligned subscription management.
|
||||||
|
*
|
||||||
|
* Exposes the operative subscription, any scheduled successor, available plans,
|
||||||
|
* and ID-based mutation functions (activate, cancel, reactivate).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useApiRequest } from './useApi';
|
||||||
|
import {
|
||||||
|
fetchSelectablePlans,
|
||||||
|
fetchSubscriptionStatus,
|
||||||
|
activatePlan as activatePlanApi,
|
||||||
|
cancelSubscription as cancelSubscriptionApi,
|
||||||
|
reactivateSubscription as reactivateSubscriptionApi,
|
||||||
|
verifyCheckout as verifyCheckoutApi,
|
||||||
|
type SubscriptionPlan,
|
||||||
|
type MandateSubscription,
|
||||||
|
type SubscriptionStatusResponse,
|
||||||
|
} from '../api/subscriptionApi';
|
||||||
|
|
||||||
|
export interface UseSubscriptionReturn {
|
||||||
|
plans: SubscriptionPlan[];
|
||||||
|
subscription: MandateSubscription | null;
|
||||||
|
plan: SubscriptionPlan | null;
|
||||||
|
scheduled: MandateSubscription | null;
|
||||||
|
active: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
loadPlans: () => Promise<void>;
|
||||||
|
loadStatus: () => Promise<void>;
|
||||||
|
activatePlan: (planKey: string) => Promise<void>;
|
||||||
|
cancelSubscription: (subscriptionId: string) => Promise<void>;
|
||||||
|
reactivateSubscription: (subscriptionId: string) => Promise<void>;
|
||||||
|
verifyCheckout: (sessionId: string) => Promise<{ status: string; message: string }>;
|
||||||
|
refetch: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSubscription(mandateId?: string): UseSubscriptionReturn {
|
||||||
|
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
|
||||||
|
const [subscription, setSubscription] = useState<MandateSubscription | null>(null);
|
||||||
|
const [plan, setPlan] = useState<SubscriptionPlan | null>(null);
|
||||||
|
const [scheduled, setScheduled] = useState<MandateSubscription | null>(null);
|
||||||
|
const [active, setActive] = useState(false);
|
||||||
|
const { request, isLoading: loading, error: apiError } = useApiRequest();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadPlans = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await fetchSelectablePlans(request, mandateId);
|
||||||
|
setPlans(Array.isArray(data) ? data : []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading plans:', err);
|
||||||
|
setPlans([]);
|
||||||
|
}
|
||||||
|
}, [request, mandateId]);
|
||||||
|
|
||||||
|
const loadStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data: SubscriptionStatusResponse = await fetchSubscriptionStatus(request, mandateId);
|
||||||
|
setActive(data.active);
|
||||||
|
setSubscription(data.subscription ?? null);
|
||||||
|
setPlan(data.plan ?? null);
|
||||||
|
setScheduled(data.scheduled ?? null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading subscription status:', err);
|
||||||
|
setActive(false);
|
||||||
|
setSubscription(null);
|
||||||
|
setPlan(null);
|
||||||
|
setScheduled(null);
|
||||||
|
}
|
||||||
|
}, [request, mandateId]);
|
||||||
|
|
||||||
|
const activatePlan = useCallback(async (planKey: string) => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
const currentUrl = new URL(window.location.href);
|
||||||
|
currentUrl.searchParams.delete('success');
|
||||||
|
currentUrl.searchParams.delete('canceled');
|
||||||
|
currentUrl.searchParams.delete('session_id');
|
||||||
|
currentUrl.searchParams.set('tab', 'subscription');
|
||||||
|
if (mandateId) currentUrl.searchParams.set('mandate', mandateId);
|
||||||
|
const returnUrl = `${currentUrl.origin}${currentUrl.pathname}${currentUrl.search}`;
|
||||||
|
|
||||||
|
const result = await activatePlanApi(request, planKey, mandateId, returnUrl);
|
||||||
|
if (result?.redirectUrl) {
|
||||||
|
window.location.href = result.redirectUrl;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadStatus();
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.response?.data?.detail || err.message || 'Fehler beim Aktivieren';
|
||||||
|
setError(msg);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [request, mandateId, loadStatus]);
|
||||||
|
|
||||||
|
const cancelSub = useCallback(async (subscriptionId: string) => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
await cancelSubscriptionApi(request, subscriptionId, mandateId);
|
||||||
|
await loadStatus();
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.response?.data?.detail || err.message || 'Fehler beim Kündigen';
|
||||||
|
setError(msg);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [request, mandateId, loadStatus]);
|
||||||
|
|
||||||
|
const reactivateSub = useCallback(async (subscriptionId: string) => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
await reactivateSubscriptionApi(request, subscriptionId, mandateId);
|
||||||
|
await loadStatus();
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.response?.data?.detail || err.message || 'Fehler beim Reaktivieren';
|
||||||
|
setError(msg);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [request, mandateId, loadStatus]);
|
||||||
|
|
||||||
|
const verifyCheckout = useCallback(async (sessionId: string) => {
|
||||||
|
const result = await verifyCheckoutApi(request, sessionId, mandateId);
|
||||||
|
await loadStatus();
|
||||||
|
return result;
|
||||||
|
}, [request, mandateId, loadStatus]);
|
||||||
|
|
||||||
|
const refetch = useCallback(async () => {
|
||||||
|
await Promise.all([loadPlans(), loadStatus()]);
|
||||||
|
}, [loadPlans, loadStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mandateId) {
|
||||||
|
loadPlans();
|
||||||
|
loadStatus();
|
||||||
|
} else {
|
||||||
|
setPlans([]);
|
||||||
|
setSubscription(null);
|
||||||
|
setPlan(null);
|
||||||
|
setScheduled(null);
|
||||||
|
setActive(false);
|
||||||
|
}
|
||||||
|
}, [mandateId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
plans,
|
||||||
|
subscription,
|
||||||
|
plan,
|
||||||
|
scheduled,
|
||||||
|
active,
|
||||||
|
loading,
|
||||||
|
error: error || (apiError ? String(apiError) : null),
|
||||||
|
loadPlans,
|
||||||
|
loadStatus,
|
||||||
|
activatePlan,
|
||||||
|
cancelSubscription: cancelSub,
|
||||||
|
reactivateSubscription: reactivateSub,
|
||||||
|
verifyCheckout,
|
||||||
|
refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -108,8 +108,10 @@
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow-x: hidden;
|
/* Allow horizontal scroll when inner content has a true minimum width; hiding X clips dashboard/store grids on narrow viewports */
|
||||||
|
overflow-x: auto;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobileTopBar {
|
.mobileTopBar {
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,12 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.dashboard {
|
.dashboard {
|
||||||
padding: 2rem;
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header */
|
/* Header */
|
||||||
|
|
@ -50,10 +53,10 @@
|
||||||
color: var(--text-primary, #1a1a1a);
|
color: var(--text-primary, #1a1a1a);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Instance Grid */
|
/* Instance Grid — min(100%, Npx) keeps one fluid column on narrow viewports (no horizontal clip) */
|
||||||
.instanceGrid {
|
.instanceGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr));
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,6 +66,7 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
|
min-width: 0;
|
||||||
background: var(--surface-color, #ffffff);
|
background: var(--surface-color, #ffffff);
|
||||||
border: 1px solid var(--border-color, #e0e0e0);
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|
@ -123,9 +127,8 @@
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary, #1a1a1a);
|
color: var(--text-primary, #1a1a1a);
|
||||||
white-space: nowrap;
|
overflow-wrap: anywhere;
|
||||||
overflow: hidden;
|
line-height: 1.3;
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mandateName {
|
.mandateName {
|
||||||
|
|
@ -245,3 +248,23 @@
|
||||||
:global(.dark-theme) .emptyState p {
|
:global(.dark-theme) .emptyState p {
|
||||||
color: var(--text-secondary-dark, #aaa);
|
color: var(--text-secondary-dark, #aaa);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.dashboard {
|
||||||
|
padding: 1rem 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceCard {
|
||||||
|
padding: 1rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardIcon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { Navigate, useParams } from 'react-router-dom';
|
||||||
import { useCurrentInstance } from '../hooks/useCurrentInstance';
|
import { useCurrentInstance } from '../hooks/useCurrentInstance';
|
||||||
import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
|
import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
|
|
@ -28,12 +29,18 @@ import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsVi
|
||||||
import { RealEstatePekView, RealEstateInstanceRolesPlaceholder } from './views/realestate';
|
import { RealEstatePekView, RealEstateInstanceRolesPlaceholder } from './views/realestate';
|
||||||
|
|
||||||
// Automation Views
|
// Automation Views
|
||||||
import { AutomationDefinitionsView, AutomationTemplatesView, AutomationLogsView } from './views/automation';
|
import { AutomationDefinitionsView, AutomationTemplatesView } from './views/automation';
|
||||||
|
|
||||||
|
// Automation2 Views
|
||||||
|
import { Automation2Page } from './views/automation2/Automation2Page';
|
||||||
|
import { Automation2WorkflowsPage } from './views/automation2/Automation2WorkflowsPage';
|
||||||
|
import { Automation2WorkflowsTasksPage } from './views/automation2/Automation2WorkflowsTasksPage';
|
||||||
|
|
||||||
// Workspace Views
|
// Workspace Views
|
||||||
import { WorkspacePage } from './views/workspace/WorkspacePage';
|
import { WorkspacePage } from './views/workspace/WorkspacePage';
|
||||||
import { WorkspaceEditorPage } from './views/workspace/WorkspaceEditorPage';
|
import { WorkspaceEditorPage } from './views/workspace/WorkspaceEditorPage';
|
||||||
import { WorkspaceSettingsPage } from './views/workspace/WorkspaceSettingsPage';
|
import { WorkspaceSettingsPage } from './views/workspace/WorkspaceSettingsPage';
|
||||||
|
import { WorkspaceRagInsightsPage } from './views/workspace/WorkspaceRagInsightsPage';
|
||||||
|
|
||||||
// Teamsbot Views
|
// Teamsbot Views
|
||||||
import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView';
|
import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView';
|
||||||
|
|
@ -126,11 +133,16 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
||||||
automation: {
|
automation: {
|
||||||
definitions: AutomationDefinitionsView,
|
definitions: AutomationDefinitionsView,
|
||||||
templates: AutomationTemplatesView,
|
templates: AutomationTemplatesView,
|
||||||
logs: AutomationLogsView,
|
},
|
||||||
|
automation2: {
|
||||||
|
editor: Automation2Page,
|
||||||
|
workflows: Automation2WorkflowsPage,
|
||||||
|
'workflows-tasks': Automation2WorkflowsTasksPage,
|
||||||
},
|
},
|
||||||
workspace: {
|
workspace: {
|
||||||
dashboard: WorkspacePage,
|
dashboard: WorkspacePage,
|
||||||
editor: WorkspaceEditorPage,
|
editor: WorkspaceEditorPage,
|
||||||
|
'rag-insights': WorkspaceRagInsightsPage,
|
||||||
settings: WorkspaceSettingsPage,
|
settings: WorkspaceSettingsPage,
|
||||||
},
|
},
|
||||||
teamsbot: {
|
teamsbot: {
|
||||||
|
|
@ -161,7 +173,13 @@ interface FeatureViewPageProps {
|
||||||
export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
||||||
const { instance, featureCode, isValid } = useCurrentInstance();
|
const { instance, featureCode, isValid } = useCurrentInstance();
|
||||||
const { currentLanguage } = useLanguage();
|
const { currentLanguage } = useLanguage();
|
||||||
|
const { mandateId, instanceId } = useParams<{ mandateId?: string; instanceId?: string }>();
|
||||||
|
|
||||||
|
// automation2: Dashboard entfernt → Index/Base-URL auf Editor umleiten
|
||||||
|
if (featureCode === 'automation2' && view === 'dashboard' && mandateId && instanceId) {
|
||||||
|
return <Navigate to={`/mandates/${mandateId}/automation2/${instanceId}/editor`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
// Berechtigungs-Check
|
// Berechtigungs-Check
|
||||||
const viewCode = `${featureCode}-${view}`;
|
const viewCode = `${featureCode}-${view}`;
|
||||||
const canView = useCanViewFeatureView(viewCode);
|
const canView = useCanViewFeatureView(viewCode);
|
||||||
|
|
@ -197,7 +215,7 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
||||||
|
|
||||||
// Workspace dashboard is rendered persistently by WorkspaceKeepAlive at MainLayout level;
|
// Workspace dashboard is rendered persistently by WorkspaceKeepAlive at MainLayout level;
|
||||||
// other workspace views (e.g. settings, editor) use the standard FeatureViewPage rendering.
|
// other workspace views (e.g. settings, editor) use the standard FeatureViewPage rendering.
|
||||||
if (featureCode === 'workspace' && view !== 'settings' && view !== 'editor') {
|
if (featureCode === 'workspace' && view !== 'settings' && view !== 'editor' && view !== 'rag-insights') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,12 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.store {
|
.store {
|
||||||
padding: 2rem;
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
max-width: 1000px;
|
max-width: 1000px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header */
|
/* Header */
|
||||||
|
|
@ -39,6 +42,7 @@
|
||||||
border: 1px solid var(--border-color, #e0e0e0);
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
|
@ -59,6 +63,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardIcon {
|
.cardIcon {
|
||||||
|
|
@ -72,6 +77,8 @@
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary, #1a1a1a);
|
color: var(--text-primary, #1a1a1a);
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardBody {
|
.cardBody {
|
||||||
|
|
@ -260,3 +267,17 @@
|
||||||
:global(.dark-theme) .loading {
|
:global(.dark-theme) .loading {
|
||||||
color: var(--text-secondary-dark, #aaa);
|
color: var(--text-secondary-dark, #aaa);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.store {
|
||||||
|
padding: 1rem 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 1.125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FaCogs, FaComments, FaHeadset } from 'react-icons/fa';
|
import { FaCogs, FaComments, FaHeadset, FaProjectDiagram } from 'react-icons/fa';
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
import { useStore } from '../hooks/useStore';
|
import { useStore } from '../hooks/useStore';
|
||||||
import type { StoreFeature } from '../api/storeApi';
|
import type { StoreFeature } from '../api/storeApi';
|
||||||
|
|
@ -15,6 +15,7 @@ import styles from './Store.module.css';
|
||||||
|
|
||||||
const FEATURE_ICONS: Record<string, React.ReactNode> = {
|
const FEATURE_ICONS: Record<string, React.ReactNode> = {
|
||||||
automation: <FaCogs />,
|
automation: <FaCogs />,
|
||||||
|
automation2: <FaProjectDiagram />,
|
||||||
teamsbot: <FaHeadset />,
|
teamsbot: <FaHeadset />,
|
||||||
workspace: <FaComments />,
|
workspace: <FaComments />,
|
||||||
commcoach: <FaComments />,
|
commcoach: <FaComments />,
|
||||||
|
|
@ -26,6 +27,11 @@ const FEATURE_DESCRIPTIONS: Record<string, Record<string, string>> = {
|
||||||
en: 'Create and manage automations to handle recurring tasks efficiently.',
|
en: 'Create and manage automations to handle recurring tasks efficiently.',
|
||||||
fr: 'Creer et gerer des automatisations pour traiter efficacement les taches recurrentes.',
|
fr: 'Creer et gerer des automatisations pour traiter efficacement les taches recurrentes.',
|
||||||
},
|
},
|
||||||
|
automation2: {
|
||||||
|
de: 'n8n-style Flow-Automatisierung mit grafischem Editor, RAG und Tools.',
|
||||||
|
en: 'n8n-style flow automation with visual editor, RAG and tools.',
|
||||||
|
fr: 'Automatisation de flux style n8n avec editeur visuel, RAG et outils.',
|
||||||
|
},
|
||||||
teamsbot: {
|
teamsbot: {
|
||||||
de: 'Integriere einen AI-Bot in deine Microsoft Teams Meetings und Channels.',
|
de: 'Integriere einen AI-Bot in deine Microsoft Teams Meetings und Channels.',
|
||||||
en: 'Integrate an AI bot into your Microsoft Teams meetings and channels.',
|
en: 'Integrate an AI bot into your Microsoft Teams meetings and channels.',
|
||||||
|
|
|
||||||
|
|
@ -905,6 +905,51 @@
|
||||||
background: var(--bg-tertiary, #f8f9fa);
|
background: var(--bg-tertiary, #f8f9fa);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.accessOverviewSubheading {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
margin: 1rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accessOverviewSubheading:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accessOverviewRoleBullets {
|
||||||
|
margin: 0.25rem 0 0.85rem;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accessOverviewInstanceStack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accessOverviewInstanceBlock {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--bg-secondary, #f5f5f5);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accessOverviewInstanceTitle {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--text-primary, #1a1a1a);
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accessOverviewInstanceFeature {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
.emptyHint {
|
.emptyHint {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--text-tertiary, #999);
|
color: var(--text-tertiary, #999);
|
||||||
|
|
|
||||||
|
|
@ -41,18 +41,37 @@ const _formatNextRun = (nextRunTime: string | null): string => {
|
||||||
|
|
||||||
export const AdminAutomationEventsPage: React.FC = () => {
|
export const AdminAutomationEventsPage: React.FC = () => {
|
||||||
const [events, setEvents] = useState<AutomationEvent[]>([]);
|
const [events, setEvents] = useState<AutomationEvent[]>([]);
|
||||||
|
const [pagination, setPagination] = useState<any>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [syncResult, setSyncResult] = useState<string | null>(null);
|
const [syncResult, setSyncResult] = useState<string | null>(null);
|
||||||
|
|
||||||
const _fetchEvents = useCallback(async () => {
|
const _fetchEvents = useCallback(async (params?: any) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const response = await api.get('/api/admin/automation-events');
|
const requestParams: Record<string, string> = {};
|
||||||
// Map eventId to id for FormGeneratorTable compatibility
|
if (params && typeof params === 'object') {
|
||||||
setEvents(response.data.map((e: any) => ({ ...e, id: e.eventId })));
|
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 response = await api.get('/api/admin/automation-events', { params: requestParams });
|
||||||
|
const data = response.data;
|
||||||
|
if (data && typeof data === 'object' && 'items' in data) {
|
||||||
|
setEvents((data.items || []).map((e: any) => ({ ...e, id: e.eventId })));
|
||||||
|
if (data.pagination) setPagination(data.pagination);
|
||||||
|
} else {
|
||||||
|
setEvents((Array.isArray(data) ? data : []).map((e: any) => ({ ...e, id: e.eventId })));
|
||||||
|
setPagination(null);
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.detail || 'Fehler beim Laden der Events');
|
setError(err.response?.data?.detail || 'Fehler beim Laden der Events');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -196,6 +215,7 @@ export const AdminAutomationEventsPage: React.FC = () => {
|
||||||
<FormGeneratorTable
|
<FormGeneratorTable
|
||||||
data={events}
|
data={events}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
apiEndpoint="/api/admin/automation-events"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={true}
|
pagination={true}
|
||||||
pageSize={25}
|
pageSize={25}
|
||||||
|
|
@ -212,6 +232,7 @@ export const AdminAutomationEventsPage: React.FC = () => {
|
||||||
hookData={{
|
hookData={{
|
||||||
handleDelete: _handleDelete,
|
handleDelete: _handleDelete,
|
||||||
refetch: _fetchEvents,
|
refetch: _fetchEvents,
|
||||||
|
pagination,
|
||||||
}}
|
}}
|
||||||
emptyMessage="Keine Automationen gefunden. Nutzen Sie 'Sync All', um Automationen zu synchronisieren."
|
emptyMessage="Keine Automationen gefunden. Nutzen Sie 'Sync All', um Automationen zu synchronisieren."
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
223
src/pages/admin/AdminAutomationLogsPage.tsx
Normal file
223
src/pages/admin/AdminAutomationLogsPage.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
/**
|
||||||
|
* AdminAutomationLogsPage
|
||||||
|
*
|
||||||
|
* SysAdmin-only page for viewing consolidated automation execution logs
|
||||||
|
* across all mandates and feature instances.
|
||||||
|
* Uses FormGeneratorTable with backend-driven pagination.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { FaSync, FaCheck, FaExclamationCircle, FaTimes } from 'react-icons/fa';
|
||||||
|
import api from '../../api';
|
||||||
|
import styles from './Admin.module.css';
|
||||||
|
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
|
|
||||||
|
interface AutomationLogEntry {
|
||||||
|
id: string;
|
||||||
|
timestamp: number;
|
||||||
|
automationId: string;
|
||||||
|
automationLabel: string;
|
||||||
|
mandateName: string;
|
||||||
|
featureInstanceName: string;
|
||||||
|
executedBy: string;
|
||||||
|
status: string;
|
||||||
|
workflowId: string;
|
||||||
|
messages: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _formatTimestamp = (ts: unknown): React.ReactNode => {
|
||||||
|
if (!ts || typeof ts !== 'number') return <span style={{ color: 'var(--text-tertiary, #999)' }}>–</span>;
|
||||||
|
return new Date(ts * 1000).toLocaleString('de-CH', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const _formatStatus = (value: unknown): React.ReactNode => {
|
||||||
|
const status = String(value || '');
|
||||||
|
const map: Record<string, { icon: React.ReactNode; color: string; label: string }> = {
|
||||||
|
completed: { icon: <FaCheck style={{ marginRight: 4 }} />, color: 'var(--success-color, #16a34a)', label: 'Abgeschlossen' },
|
||||||
|
error: { icon: <FaExclamationCircle style={{ marginRight: 4 }} />, color: 'var(--error-color, #dc2626)', label: 'Fehler' },
|
||||||
|
failed: { icon: <FaExclamationCircle style={{ marginRight: 4 }} />, color: 'var(--error-color, #dc2626)', label: 'Fehlgeschlagen' },
|
||||||
|
stopped: { icon: <FaTimes style={{ marginRight: 4 }} />, color: 'var(--warning-color, #d97706)', label: 'Gestoppt' },
|
||||||
|
};
|
||||||
|
const entry = map[status];
|
||||||
|
if (!entry) return status || '–';
|
||||||
|
return (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', color: entry.color, fontWeight: 500 }}>
|
||||||
|
{entry.icon}{entry.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AdminAutomationLogsPage: React.FC = () => {
|
||||||
|
const [logs, setLogs] = useState<AutomationLogEntry[]>([]);
|
||||||
|
const [pagination, setPagination] = useState<any>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const _fetchLogs = useCallback(async (params?: any) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const requestParams: Record<string, string> = {};
|
||||||
|
if (params && typeof params === 'object') {
|
||||||
|
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 response = await api.get('/api/admin/automation-logs', { params: requestParams });
|
||||||
|
const data = response.data;
|
||||||
|
if (data && typeof data === 'object' && 'items' in data) {
|
||||||
|
setLogs(data.items || []);
|
||||||
|
if (data.pagination) setPagination(data.pagination);
|
||||||
|
} else {
|
||||||
|
setLogs(Array.isArray(data) ? data : []);
|
||||||
|
setPagination(null);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.detail || 'Fehler beim Laden der Ausführungsprotokolle');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { _fetchLogs(); }, [_fetchLogs]);
|
||||||
|
|
||||||
|
const columns: ColumnConfig[] = useMemo(() => [
|
||||||
|
{
|
||||||
|
key: 'timestamp',
|
||||||
|
label: 'Zeitpunkt',
|
||||||
|
type: 'number' as const,
|
||||||
|
sortable: true,
|
||||||
|
filterable: false,
|
||||||
|
width: 170,
|
||||||
|
minWidth: 140,
|
||||||
|
formatter: _formatTimestamp,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'automationLabel',
|
||||||
|
label: 'Automatisierung',
|
||||||
|
type: 'string' as const,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
searchable: true,
|
||||||
|
width: 200,
|
||||||
|
minWidth: 130,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'mandateName',
|
||||||
|
label: 'Mandant',
|
||||||
|
type: 'string' as const,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
width: 150,
|
||||||
|
minWidth: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'featureInstanceName',
|
||||||
|
label: 'Feature-Instanz',
|
||||||
|
type: 'string' as const,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
width: 150,
|
||||||
|
minWidth: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'executedBy',
|
||||||
|
label: 'Ausgeführt von',
|
||||||
|
type: 'string' as const,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
width: 140,
|
||||||
|
minWidth: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: 'Status',
|
||||||
|
type: 'string' as const,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
width: 140,
|
||||||
|
minWidth: 100,
|
||||||
|
formatter: _formatStatus,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'workflowId',
|
||||||
|
label: 'Workflow-ID',
|
||||||
|
type: 'string' as const,
|
||||||
|
sortable: false,
|
||||||
|
filterable: false,
|
||||||
|
width: 120,
|
||||||
|
minWidth: 80,
|
||||||
|
formatter: (v: unknown) =>
|
||||||
|
v ? <code style={{ fontSize: '0.8em', color: 'var(--text-secondary)' }}>{String(v).slice(0, 8)}…</code> : '–',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'messages',
|
||||||
|
label: 'Meldungen',
|
||||||
|
type: 'string' as const,
|
||||||
|
sortable: false,
|
||||||
|
filterable: false,
|
||||||
|
searchable: true,
|
||||||
|
width: 300,
|
||||||
|
minWidth: 150,
|
||||||
|
maxWidth: 500,
|
||||||
|
},
|
||||||
|
], []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
|
<div className={styles.pageHeader}>
|
||||||
|
<div>
|
||||||
|
<h1 className={styles.pageTitle}>Ausführungsprotokolle</h1>
|
||||||
|
<p className={styles.pageSubtitle}>
|
||||||
|
Konsolidierte Automation-Logs über alle Mandanten
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.headerActions}>
|
||||||
|
<button
|
||||||
|
className={styles.secondaryButton}
|
||||||
|
onClick={() => _fetchLogs()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className={styles.infoBox} style={{ background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
|
||||||
|
<span style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }}>!</span>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormGeneratorTable
|
||||||
|
data={logs}
|
||||||
|
columns={columns}
|
||||||
|
apiEndpoint="/api/admin/automation-logs"
|
||||||
|
loading={loading}
|
||||||
|
pagination={true}
|
||||||
|
pageSize={25}
|
||||||
|
searchable={true}
|
||||||
|
filterable={true}
|
||||||
|
sortable={true}
|
||||||
|
selectable={false}
|
||||||
|
hookData={{
|
||||||
|
refetch: _fetchLogs,
|
||||||
|
pagination,
|
||||||
|
}}
|
||||||
|
emptyMessage="Keine Ausführungsprotokolle vorhanden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminAutomationLogsPage;
|
||||||
|
|
@ -73,8 +73,10 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
||||||
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const [pagination, setPagination] = useState<any>(null);
|
||||||
|
|
||||||
// Load roles when feature changes
|
// Load roles when feature changes
|
||||||
const fetchRoles = useCallback(async () => {
|
const fetchRoles = useCallback(async (params?: any) => {
|
||||||
if (!selectedFeatureCode) {
|
if (!selectedFeatureCode) {
|
||||||
setRoles([]);
|
setRoles([]);
|
||||||
return;
|
return;
|
||||||
|
|
@ -83,15 +85,32 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/api/features/templates/roles`, {
|
const requestParams: Record<string, string> = { featureCode: selectedFeatureCode };
|
||||||
params: { featureCode: selectedFeatureCode }
|
if (params && typeof params === 'object') {
|
||||||
});
|
const paginationObj: any = {};
|
||||||
const roleList = response.data || [];
|
if (params.page !== undefined) paginationObj.page = params.page;
|
||||||
setRoles(Array.isArray(roleList) ? roleList : []);
|
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 response = await api.get(`/api/features/templates/roles`, { params: requestParams });
|
||||||
|
const data = response.data;
|
||||||
|
if (data && typeof data === 'object' && 'items' in data) {
|
||||||
|
setRoles(Array.isArray(data.items) ? data.items : []);
|
||||||
|
if (data.pagination) setPagination(data.pagination);
|
||||||
|
} else {
|
||||||
|
setRoles(Array.isArray(data) ? data : []);
|
||||||
|
setPagination(null);
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Error loading feature roles:', err);
|
console.error('Error loading feature roles:', err);
|
||||||
setError('Fehler beim Laden der Feature-Rollen');
|
setError('Fehler beim Laden der Feature-Rollen');
|
||||||
setRoles([]);
|
setRoles([]);
|
||||||
|
setPagination(null);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -383,6 +402,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
||||||
onDelete={handleDeleteRole}
|
onDelete={handleDeleteRole}
|
||||||
hookData={{
|
hookData={{
|
||||||
refetch: fetchRoles,
|
refetch: fetchRoles,
|
||||||
|
pagination,
|
||||||
handleDelete: handleDeleteRole,
|
handleDelete: handleDeleteRole,
|
||||||
}}
|
}}
|
||||||
emptyMessage="Keine Feature-Rollen gefunden"
|
emptyMessage="Keine Feature-Rollen gefunden"
|
||||||
|
|
|
||||||
|
|
@ -379,7 +379,7 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
]}
|
]}
|
||||||
hookData={{
|
hookData={{
|
||||||
handleDelete: handleDeleteInvitation,
|
handleDelete: handleDeleteInvitation,
|
||||||
refetch: () => fetchInvitations(selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed }),
|
refetch: (params?: any) => fetchInvitations(params || selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed }),
|
||||||
pagination,
|
pagination,
|
||||||
}}
|
}}
|
||||||
emptyMessage="Keine Einladungen gefunden"
|
emptyMessage="Keine Einladungen gefunden"
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ interface AccessEntry {
|
||||||
interface MandateInfo {
|
interface MandateInfo {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
label?: string;
|
label?: string | null;
|
||||||
roleIds: string[];
|
roleIds: string[];
|
||||||
featureInstances: {
|
featureInstances: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -58,6 +58,18 @@ interface MandateInfo {
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _mandateNameLine(mandate: MandateInfo): string {
|
||||||
|
const label = mandate.label?.trim();
|
||||||
|
if (label) {
|
||||||
|
return `${mandate.name} (${label})`;
|
||||||
|
}
|
||||||
|
return mandate.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _roleDescriptionLine(role: RoleInfo): string {
|
||||||
|
return role.description?.de || role.description?.en || '';
|
||||||
|
}
|
||||||
|
|
||||||
interface UserAccessOverview {
|
interface UserAccessOverview {
|
||||||
user: UserOption;
|
user: UserOption;
|
||||||
isSysAdmin: boolean;
|
isSysAdmin: boolean;
|
||||||
|
|
@ -174,9 +186,14 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
|
||||||
const renderOverviewTab = () => {
|
const renderOverviewTab = () => {
|
||||||
if (!overview) return null;
|
if (!overview) return null;
|
||||||
|
|
||||||
|
const roleById = new Map(overview.roles.map((r) => [r.id, r]));
|
||||||
|
const globalRoles = overview.roles.filter((r) => r.scope === 'global');
|
||||||
|
|
||||||
|
const _resolveRoles = (roleIds: string[]): RoleInfo[] =>
|
||||||
|
roleIds.map((id) => roleById.get(id)).filter((r): r is RoleInfo => !!r);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.scrollableContent}>
|
<div className={styles.scrollableContent}>
|
||||||
{/* SysAdmin Notice */}
|
|
||||||
{overview.isSysAdmin && (
|
{overview.isSysAdmin && (
|
||||||
<div className={styles.infoBox} style={{ background: '#fef3c7', borderColor: '#f59e0b' }}>
|
<div className={styles.infoBox} style={{ background: '#fef3c7', borderColor: '#f59e0b' }}>
|
||||||
<FaInfoCircle style={{ marginRight: '0.5rem', color: '#f59e0b' }} />
|
<FaInfoCircle style={{ marginRight: '0.5rem', color: '#f59e0b' }} />
|
||||||
|
|
@ -184,109 +201,148 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mandates & Feature Instances */}
|
<h3 style={{ marginBottom: '1rem', color: 'var(--text-primary)' }}>Zugriff nach Mandant</h3>
|
||||||
<h3 style={{ marginBottom: '1rem', color: 'var(--text-primary)' }}>Mandate & Feature-Instanzen</h3>
|
|
||||||
{overview.mandates.length === 0 ? (
|
{overview.mandates.length === 0 ? (
|
||||||
<p className={styles.emptyHint}>Keine Mandate-Zuordnungen vorhanden.</p>
|
<p className={styles.emptyHint}>Keine Mandate-Zuordnungen vorhanden.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.rolesList} style={{ flex: 'none', overflow: 'visible' }}>
|
<div className={styles.rolesList} style={{ flex: 'none', overflow: 'visible' }}>
|
||||||
{overview.mandates.map(mandate => (
|
{overview.mandates.map((mandate) => {
|
||||||
<div key={mandate.id} className={styles.roleCard}>
|
const mandateRoles = _resolveRoles(mandate.roleIds);
|
||||||
<div
|
|
||||||
className={styles.roleHeader}
|
return (
|
||||||
onClick={() => toggleMandate(mandate.id)}
|
<div key={mandate.id} className={styles.roleCard}>
|
||||||
>
|
<div className={styles.roleHeader} onClick={() => toggleMandate(mandate.id)}>
|
||||||
<div className={styles.roleInfo}>
|
<div className={styles.roleInfo} style={{ flexWrap: 'wrap', rowGap: '0.35rem' }}>
|
||||||
{expandedMandates.has(mandate.id) ? <FaChevronDown className={styles.expandIcon} /> : <FaChevronRight className={styles.expandIcon} />}
|
{expandedMandates.has(mandate.id) ? (
|
||||||
<span className={styles.roleLabel}>{mandate.label || mandate.name}</span>
|
<FaChevronDown className={styles.expandIcon} />
|
||||||
<span className={styles.roleDescription}>
|
) : (
|
||||||
{mandate.featureInstances.length} Feature-Instanz(en)
|
<FaChevronRight className={styles.expandIcon} />
|
||||||
</span>
|
)}
|
||||||
|
<span className={styles.roleLabel}>{_mandateNameLine(mandate)}</span>
|
||||||
|
<span className={styles.roleDescription}>
|
||||||
|
{mandateRoles.length} Mandantenrolle(n) · {mandate.featureInstances.length} Feature-Instanz(en)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{expandedMandates.has(mandate.id) && (
|
||||||
|
<div className={styles.roleContent}>
|
||||||
|
{mandateRoles.length === 0 ? (
|
||||||
|
<p className={styles.emptyHint}>Keine Rollen direkt am Mandanten.</p>
|
||||||
|
) : (
|
||||||
|
<ul className={styles.accessOverviewRoleBullets}>
|
||||||
|
{mandateRoles.map((r) => (
|
||||||
|
<li key={r.id}>
|
||||||
|
<strong>{r.roleLabel}</strong>
|
||||||
|
<span
|
||||||
|
className={styles.badge}
|
||||||
|
style={{
|
||||||
|
background: getScopeColor(r.scope),
|
||||||
|
color: 'white',
|
||||||
|
marginLeft: '0.35rem',
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.scope}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.accessOverviewSubheading}>Feature-Instanzen</div>
|
||||||
|
{mandate.featureInstances.length === 0 ? (
|
||||||
|
<p className={styles.emptyHint}>Keine Feature-Instanzen zugewiesen.</p>
|
||||||
|
) : (
|
||||||
|
<div className={styles.accessOverviewInstanceStack}>
|
||||||
|
{mandate.featureInstances.map((instance) => {
|
||||||
|
const instanceRoles = _resolveRoles(instance.roleIds);
|
||||||
|
const featureTitle = instance.featureLabel?.de || instance.featureCode;
|
||||||
|
return (
|
||||||
|
<div key={instance.id} className={styles.accessOverviewInstanceBlock}>
|
||||||
|
<div className={styles.accessOverviewInstanceTitle}>
|
||||||
|
{instance.label}{' '}
|
||||||
|
<span className={styles.accessOverviewInstanceFeature}>({featureTitle})</span>
|
||||||
|
</div>
|
||||||
|
{instanceRoles.length === 0 ? (
|
||||||
|
<p className={styles.emptyHint} style={{ margin: '0.35rem 0 0' }}>
|
||||||
|
Keine Rollen.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className={styles.accessOverviewRoleBullets}>
|
||||||
|
{instanceRoles.map((r) => (
|
||||||
|
<li key={r.id}>
|
||||||
|
<strong>{r.roleLabel}</strong>
|
||||||
|
<span
|
||||||
|
className={styles.badge}
|
||||||
|
style={{
|
||||||
|
background: getScopeColor(r.scope),
|
||||||
|
color: 'white',
|
||||||
|
marginLeft: '0.35rem',
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.scope}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{expandedMandates.has(mandate.id) && (
|
);
|
||||||
<div className={styles.roleContent}>
|
})}
|
||||||
{mandate.featureInstances.length === 0 ? (
|
|
||||||
<p className={styles.emptyHint}>Keine Feature-Instanzen zugewiesen.</p>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
|
||||||
{mandate.featureInstances.map(instance => (
|
|
||||||
<div
|
|
||||||
key={instance.id}
|
|
||||||
style={{
|
|
||||||
padding: '0.75rem',
|
|
||||||
background: 'var(--bg-secondary)',
|
|
||||||
borderRadius: '6px',
|
|
||||||
border: '1px solid var(--border-color)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>
|
|
||||||
{instance.label}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>
|
|
||||||
Feature: {instance.featureLabel?.de || instance.featureCode}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', marginTop: '0.25rem' }}>
|
|
||||||
Rollen: {instance.roleIds.length > 0
|
|
||||||
? overview.roles
|
|
||||||
.filter(r => instance.roleIds.includes(r.id))
|
|
||||||
.map(r => r.roleLabel)
|
|
||||||
.join(', ')
|
|
||||||
: 'Keine'
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Roles */}
|
{globalRoles.length > 0 && (
|
||||||
<h3 style={{ marginTop: '2rem', marginBottom: '1rem', color: 'var(--text-primary)' }}>Zugewiesene Rollen</h3>
|
<>
|
||||||
{overview.roles.length === 0 ? (
|
<h3 style={{ marginTop: '2rem', marginBottom: '1rem', color: 'var(--text-primary)' }}>
|
||||||
<p className={styles.emptyHint}>Keine Rollen zugewiesen.</p>
|
Globale Rollen
|
||||||
) : (
|
</h3>
|
||||||
<div className={styles.rolesList} style={{ flex: 'none', overflow: 'visible' }}>
|
<p className={styles.emptyHint} style={{ marginTop: '-0.5rem', marginBottom: '1rem' }}>
|
||||||
{overview.roles.map(role => (
|
Nicht an einen Mandanten gebunden.
|
||||||
<div key={role.id} className={styles.roleCard}>
|
</p>
|
||||||
<div
|
<div className={styles.rolesList} style={{ flex: 'none', overflow: 'visible' }}>
|
||||||
className={styles.roleHeader}
|
{globalRoles.map((role) => (
|
||||||
onClick={() => toggleRole(role.id)}
|
<div key={role.id} className={styles.roleCard}>
|
||||||
>
|
<div className={styles.roleHeader} onClick={() => toggleRole(role.id)}>
|
||||||
<div className={styles.roleInfo}>
|
<div className={styles.roleInfo}>
|
||||||
{expandedRoles.has(role.id) ? <FaChevronDown className={styles.expandIcon} /> : <FaChevronRight className={styles.expandIcon} />}
|
{expandedRoles.has(role.id) ? (
|
||||||
<span className={styles.roleLabel}>{role.roleLabel}</span>
|
<FaChevronDown className={styles.expandIcon} />
|
||||||
<span
|
) : (
|
||||||
className={styles.badge}
|
<FaChevronRight className={styles.expandIcon} />
|
||||||
style={{
|
)}
|
||||||
background: getScopeColor(role.scope),
|
<span className={styles.roleLabel}>{role.roleLabel}</span>
|
||||||
color: 'white',
|
<span
|
||||||
marginLeft: '0.5rem'
|
className={styles.badge}
|
||||||
}}
|
style={{ background: getScopeColor(role.scope), color: 'white', marginLeft: '0.5rem' }}
|
||||||
>
|
>
|
||||||
{role.scope}
|
{role.scope}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{expandedRoles.has(role.id) && (
|
|
||||||
<div className={styles.roleContent}>
|
|
||||||
<div style={{ fontSize: '0.875rem' }}>
|
|
||||||
<p><strong>Beschreibung:</strong> {role.description?.de || role.description?.en || '-'}</p>
|
|
||||||
<p><strong>Quelle:</strong> {role.source === 'mandate'
|
|
||||||
? `Mandate: ${role.sourceMandateName}`
|
|
||||||
: `Feature-Instanz: ${role.sourceInstanceLabel}`
|
|
||||||
}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{expandedRoles.has(role.id) && (
|
||||||
</div>
|
<div className={styles.roleContent}>
|
||||||
))}
|
<div style={{ fontSize: '0.875rem' }}>
|
||||||
</div>
|
<p>
|
||||||
|
<strong>Beschreibung:</strong> {_roleDescriptionLine(role) || '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,9 @@
|
||||||
* User × Role matrix with inline toggles and edit/remove actions.
|
* User × Role matrix with inline toggles and edit/remove actions.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { FaEdit, FaTrash } from 'react-icons/fa';
|
import { FaEdit, FaTrash } from 'react-icons/fa';
|
||||||
|
import { useConfirm } from '../../hooks/useConfirm';
|
||||||
import type { FeatureAccessUser } from '../../hooks/useFeatureAccess';
|
import type { FeatureAccessUser } from '../../hooks/useFeatureAccess';
|
||||||
import type { FeatureInstanceRole } from '../../hooks/useFeatureAccess';
|
import type { FeatureInstanceRole } from '../../hooks/useFeatureAccess';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
|
@ -29,15 +30,20 @@ export const PermissionMatrix: React.FC<PermissionMatrixProps> = ({
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [removingId, setRemovingId] = useState<string | null>(null);
|
const [removingId, setRemovingId] = useState<string | null>(null);
|
||||||
|
const { confirm, ConfirmDialog } = useConfirm();
|
||||||
|
|
||||||
const handleRemove = (user: FeatureAccessUser) => {
|
const handleRemove = useCallback(async (user: FeatureAccessUser) => {
|
||||||
if (removingId) return;
|
if (removingId) return;
|
||||||
if (window.confirm(`"${user.username}" aus dieser Instanz entfernen?`)) {
|
const ok = await confirm(`"${user.username}" aus dieser Instanz entfernen?`, {
|
||||||
setRemovingId(user.userId);
|
title: 'Benutzer entfernen',
|
||||||
onRemoveUser(user);
|
confirmLabel: 'Entfernen',
|
||||||
setRemovingId(null);
|
variant: 'danger',
|
||||||
}
|
});
|
||||||
};
|
if (!ok) return;
|
||||||
|
setRemovingId(user.userId);
|
||||||
|
onRemoveUser(user);
|
||||||
|
setRemovingId(null);
|
||||||
|
}, [removingId, confirm, onRemoveUser]);
|
||||||
|
|
||||||
if (roles.length === 0) {
|
if (roles.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -135,6 +141,7 @@ export const PermissionMatrix: React.FC<PermissionMatrixProps> = ({
|
||||||
+ Benutzer hinzufügen
|
+ Benutzer hinzufügen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<ConfirmDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -16,4 +16,5 @@ export { AdminFeatureInstanceUsersPage } from './AdminFeatureInstanceUsersPage';
|
||||||
export { AdminMandateRolePermissionsPage } from './AdminMandateRolePermissionsPage';
|
export { AdminMandateRolePermissionsPage } from './AdminMandateRolePermissionsPage';
|
||||||
export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage';
|
export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage';
|
||||||
export { AdminAutomationEventsPage } from './AdminAutomationEventsPage';
|
export { AdminAutomationEventsPage } from './AdminAutomationEventsPage';
|
||||||
|
export { AdminAutomationLogsPage } from './AdminAutomationLogsPage';
|
||||||
export { AdminLogsPage } from './AdminLogsPage';
|
export { AdminLogsPage } from './AdminLogsPage';
|
||||||
|
|
|
||||||
|
|
@ -128,9 +128,8 @@ export const AdminInvitationWizardPage: React.FC = () => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (inviteType === 'featureInstance' && selectedMandate) {
|
if (inviteType === 'featureInstance' && selectedMandate) {
|
||||||
fetchInstances(selectedMandate.id).then(data =>
|
// Show all instances for the mandate (including disabled). Previously only `enabled` instances were listed, which hid deactivated instances from admins.
|
||||||
setInstances((data || []).filter((i: FeatureInstance) => i.enabled))
|
fetchInstances(selectedMandate.id).then(data => setInstances(data || []));
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
setInstances([]);
|
setInstances([]);
|
||||||
setSelectedInstance(null);
|
setSelectedInstance(null);
|
||||||
|
|
@ -411,7 +410,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
|
||||||
<div style={{ marginBottom: '16px' }}>
|
<div style={{ marginBottom: '16px' }}>
|
||||||
<label className={styles.formLabel}>Feature-Instanz *</label>
|
<label className={styles.formLabel}>Feature-Instanz *</label>
|
||||||
{instances.length === 0 ? (
|
{instances.length === 0 ? (
|
||||||
<p style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>Keine aktiven Feature-Instanzen für diesen Mandanten vorhanden.</p>
|
<p style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>Keine Feature-Instanzen für diesen Mandanten vorhanden.</p>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<select
|
||||||
className={styles.filterSelect}
|
className={styles.filterSelect}
|
||||||
|
|
@ -423,9 +422,13 @@ export const AdminInvitationWizardPage: React.FC = () => {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="">-- Feature-Instanz wählen --</option>
|
<option value="">-- Feature-Instanz wählen --</option>
|
||||||
{instances.map(inst => (
|
{instances.map(inst => {
|
||||||
<option key={inst.id} value={inst.id}>{inst.label || inst.featureCode}</option>
|
const baseLabel = inst.label || inst.featureCode;
|
||||||
))}
|
const suffix = inst.enabled === false ? ' (deaktiviert)' : '';
|
||||||
|
return (
|
||||||
|
<option key={inst.id} value={inst.id}>{`${baseLabel}${suffix}`}</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ export const FilesPage: React.FC = () => {
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
refetch,
|
refetch,
|
||||||
|
pagination,
|
||||||
fetchFileById,
|
fetchFileById,
|
||||||
updateFileOptimistically,
|
updateFileOptimistically,
|
||||||
} = useUserFiles();
|
} = useUserFiles();
|
||||||
|
|
@ -479,6 +480,7 @@ export const FilesPage: React.FC = () => {
|
||||||
onDeleteMultiple={handleDeleteMultiple}
|
onDeleteMultiple={handleDeleteMultiple}
|
||||||
hookData={{
|
hookData={{
|
||||||
refetch: _tableRefetch,
|
refetch: _tableRefetch,
|
||||||
|
pagination,
|
||||||
permissions,
|
permissions,
|
||||||
handleDelete: handleFileDelete,
|
handleDelete: handleFileDelete,
|
||||||
handleInlineUpdate,
|
handleInlineUpdate,
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import React, { useState, useMemo, useEffect } from 'react';
|
||||||
import { usePrompts, usePromptOperations } from '../../hooks/usePrompts';
|
import { usePrompts, usePromptOperations } from '../../hooks/usePrompts';
|
||||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { FaSync, FaFileAlt, FaPlus } from 'react-icons/fa';
|
import { FaSync, FaPlus } from 'react-icons/fa';
|
||||||
import styles from '../admin/Admin.module.css';
|
import styles from '../admin/Admin.module.css';
|
||||||
|
|
||||||
interface Prompt {
|
interface Prompt {
|
||||||
|
|
@ -168,7 +168,7 @@ export const PromptsPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>Prompts</h1>
|
<h1 className={styles.pageTitle}>Prompts</h1>
|
||||||
|
|
@ -194,68 +194,45 @@ export const PromptsPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.tableContainer}>
|
<div className={styles.tableContainer}>
|
||||||
{loading && (!prompts || prompts.length === 0) ? (
|
<FormGeneratorTable
|
||||||
<div className={styles.loadingContainer}>
|
data={prompts}
|
||||||
<div className={styles.spinner} />
|
columns={columns}
|
||||||
<span>Lade Prompts...</span>
|
apiEndpoint="/api/prompts"
|
||||||
</div>
|
loading={loading}
|
||||||
) : !prompts || prompts.length === 0 ? (
|
pagination={true}
|
||||||
<div className={styles.emptyState}>
|
pageSize={25}
|
||||||
<FaFileAlt className={styles.emptyIcon} />
|
searchable={true}
|
||||||
<h3 className={styles.emptyTitle}>Keine Prompts vorhanden</h3>
|
filterable={true}
|
||||||
<p className={styles.emptyDescription}>
|
sortable={true}
|
||||||
Erstellen Sie einen neuen Prompt, um loszulegen.
|
selectable={false}
|
||||||
</p>
|
actionButtons={[
|
||||||
{canCreate && (
|
...(canCreate ? [{
|
||||||
<button
|
type: 'copy' as const,
|
||||||
className={styles.primaryButton}
|
title: 'Duplizieren',
|
||||||
onClick={() => setShowCreateModal(true)}
|
onAction: handleDuplicate,
|
||||||
>
|
}] : []),
|
||||||
<FaPlus /> Ersten Prompt erstellen
|
...(canUpdate ? [{
|
||||||
</button>
|
type: 'edit' as const,
|
||||||
)}
|
onAction: handleEditClick,
|
||||||
</div>
|
title: 'Bearbeiten',
|
||||||
) : (
|
}] : []),
|
||||||
<FormGeneratorTable
|
...(canDelete ? [{
|
||||||
data={prompts}
|
type: 'delete' as const,
|
||||||
columns={columns}
|
title: 'Löschen',
|
||||||
apiEndpoint="/api/prompts"
|
loading: (row: Prompt) => deletingPrompts.has(row.id),
|
||||||
loading={loading}
|
}] : []),
|
||||||
pagination={true}
|
]}
|
||||||
pageSize={25}
|
onDelete={handleDelete}
|
||||||
searchable={true}
|
hookData={{
|
||||||
filterable={true}
|
refetch,
|
||||||
sortable={true}
|
permissions,
|
||||||
selectable={false}
|
pagination,
|
||||||
actionButtons={[
|
handleDelete: handlePromptDelete,
|
||||||
...(canCreate ? [{
|
handleInlineUpdate,
|
||||||
type: 'copy' as const,
|
updateOptimistically,
|
||||||
title: 'Duplizieren',
|
}}
|
||||||
onAction: handleDuplicate,
|
emptyMessage="Keine Prompts gefunden"
|
||||||
}] : []),
|
/>
|
||||||
...(canUpdate ? [{
|
|
||||||
type: 'edit' as const,
|
|
||||||
onAction: handleEditClick,
|
|
||||||
title: 'Bearbeiten',
|
|
||||||
}] : []),
|
|
||||||
...(canDelete ? [{
|
|
||||||
type: 'delete' as const,
|
|
||||||
title: 'Löschen',
|
|
||||||
loading: (row: Prompt) => deletingPrompts.has(row.id),
|
|
||||||
}] : []),
|
|
||||||
]}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
hookData={{
|
|
||||||
refetch,
|
|
||||||
permissions,
|
|
||||||
pagination,
|
|
||||||
handleDelete: handlePromptDelete,
|
|
||||||
handleInlineUpdate,
|
|
||||||
updateOptimistically,
|
|
||||||
}}
|
|
||||||
emptyMessage="Keine Prompts gefunden"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create Modal */}
|
{/* Create Modal */}
|
||||||
|
|
|
||||||
78
src/pages/billing/AdminSubscriptionsPage.tsx
Normal file
78
src/pages/billing/AdminSubscriptionsPage.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
|
import { useAdminSubscriptions } from '../../hooks/useAdminSubscriptions';
|
||||||
|
import { useConfirm } from '../../hooks/useConfirm';
|
||||||
|
import api from '../../api';
|
||||||
|
import styles from './Billing.module.css';
|
||||||
|
|
||||||
|
const _TERMINAL_STATUSES = new Set(['EXPIRED']);
|
||||||
|
|
||||||
|
const _COLUMNS: ColumnConfig[] = [
|
||||||
|
{ key: 'mandateName', label: 'Mandant', type: 'text', sortable: true, filterable: true, width: 180 },
|
||||||
|
{ key: 'planTitle', label: 'Plan', type: 'text', sortable: true, filterable: true, width: 180 },
|
||||||
|
{ key: 'status', label: 'Status', type: 'text', sortable: true, filterable: true, width: 110 },
|
||||||
|
{ key: 'recurring', label: 'Wiederkehrend', type: 'boolean', sortable: true, filterable: true, width: 120 },
|
||||||
|
{ key: 'activeUsers', label: 'User', type: 'number', sortable: true, width: 70 },
|
||||||
|
{ key: 'activeInstances', label: 'Instanzen', type: 'number', sortable: true, width: 90 },
|
||||||
|
{ key: 'monthlyRevenueCHF', label: 'Revenue/Mt (CHF)', type: 'number', sortable: true, width: 140 },
|
||||||
|
{ key: 'startedAt', label: 'Gestartet', type: 'date', sortable: true, filterable: true, width: 130 },
|
||||||
|
{ key: 'currentPeriodEnd', label: 'Periodenende', type: 'date', sortable: true, filterable: true, width: 130 },
|
||||||
|
{ key: 'snapshotPricePerUserCHF', label: 'Preis/User', type: 'number', sortable: true, width: 100 },
|
||||||
|
{ key: 'snapshotPricePerInstanceCHF', label: 'Preis/Instanz', type: 'number', sortable: true, width: 110 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const AdminSubscriptionsPage: React.FC = () => {
|
||||||
|
const { confirm, ConfirmDialog } = useConfirm();
|
||||||
|
const { data: subscriptions, pagination, loading, refetch } = useAdminSubscriptions();
|
||||||
|
|
||||||
|
const _handleForceCancel = useCallback(async (row: any) => {
|
||||||
|
const ok = await confirm(
|
||||||
|
`Subscription «${row.planTitle}» für Mandant «${row.mandateName}» sofort kündigen? Dies wird auch auf Stripe sofort storniert.`,
|
||||||
|
{ confirmLabel: 'Sofort kündigen', cancelLabel: 'Abbrechen', variant: 'danger' },
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post('/api/subscription/force-cancel', { subscriptionId: row.id });
|
||||||
|
await refetch();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Force cancel failed:', err);
|
||||||
|
}
|
||||||
|
}, [confirm, refetch]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.billingDashboard} style={{ minHeight: 0 }}>
|
||||||
|
<header className={styles.pageHeader} style={{ flexShrink: 0 }}>
|
||||||
|
<h1>Subscription-Übersicht</h1>
|
||||||
|
<p className={styles.subtitle}>Alle Abonnements aller Mandanten</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
|
||||||
|
<FormGeneratorTable
|
||||||
|
data={subscriptions}
|
||||||
|
columns={_COLUMNS}
|
||||||
|
apiEndpoint="/api/subscription/admin/all"
|
||||||
|
loading={loading}
|
||||||
|
pagination={true}
|
||||||
|
pageSize={50}
|
||||||
|
selectable={false}
|
||||||
|
hookData={{ refetch, pagination }}
|
||||||
|
customActions={[
|
||||||
|
{
|
||||||
|
id: 'forceCancel',
|
||||||
|
title: 'Sofort kündigen',
|
||||||
|
icon: '✕',
|
||||||
|
onClick: (row: any) => _handleForceCancel(row),
|
||||||
|
visible: (row: any) => !_TERMINAL_STATUSES.has(row._rawStatus),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
emptyMessage="Keine Subscriptions vorhanden."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminSubscriptionsPage;
|
||||||
|
|
@ -8,15 +8,20 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { Link, useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { useBillingAdmin, type BillingSettings, type AccountSummary, type MandateUserSummary } from '../../hooks/useBilling';
|
import { useBillingAdmin, type BillingSettings, type AccountSummary, type MandateUserSummary } from '../../hooks/useBilling';
|
||||||
import type { CheckoutCreateRequest } from '../../api/billingApi';
|
import type { CheckoutCreateRequest } from '../../api/billingApi';
|
||||||
import { useUserMandates, type Mandate as UserMandateRow } from '../../hooks/useUserMandates';
|
import { useUserMandates, type Mandate as UserMandateRow } from '../../hooks/useUserMandates';
|
||||||
import { useCurrentUser } from '../../hooks/useUsers';
|
import { useCurrentUser } from '../../hooks/useUsers';
|
||||||
|
import { useApiRequest } from '../../hooks/useApi';
|
||||||
|
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
|
import { SubscriptionTab } from './SubscriptionTab';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { getUserDataCache } from '../../utils/userCache';
|
import { getUserDataCache } from '../../utils/userCache';
|
||||||
import styles from './Billing.module.css';
|
import styles from './Billing.module.css';
|
||||||
|
|
||||||
|
type AdminTabType = 'settings' | 'credit' | 'subscription' | 'transactions';
|
||||||
|
|
||||||
const _formatCurrency = (amount: number) => {
|
const _formatCurrency = (amount: number) => {
|
||||||
return new Intl.NumberFormat('de-CH', {
|
return new Intl.NumberFormat('de-CH', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
|
|
@ -206,7 +211,7 @@ interface CreditAdderProps {
|
||||||
const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, onAddCredit }) => {
|
const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, onAddCredit }) => {
|
||||||
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
||||||
const [amount, setAmount] = useState<string>('');
|
const [amount, setAmount] = useState<string>('');
|
||||||
const [description, setDescription] = useState<string>('Manuelles Aufladen durch Admin');
|
const [description, setDescription] = useState<string>('Manuelle Buchung durch Admin');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
|
@ -222,8 +227,8 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
||||||
const _handleSubmit = async (e: React.FormEvent) => {
|
const _handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const numAmount = parseFloat(amount);
|
const numAmount = parseFloat(amount);
|
||||||
if (!numAmount || numAmount <= 0) {
|
if (!numAmount || numAmount === 0) {
|
||||||
setMessage({ type: 'error', text: 'Betrag muss positiv sein' });
|
setMessage({ type: 'error', text: 'Betrag darf nicht null sein' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -232,10 +237,13 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onAddCredit(isPrepayUser ? selectedUserId : undefined, numAmount, description);
|
await onAddCredit(isPrepayUser ? selectedUserId : undefined, numAmount, description);
|
||||||
setMessage({ type: 'success', text: `${_formatCurrency(numAmount)} erfolgreich gutgeschrieben.` });
|
const label = numAmount > 0
|
||||||
|
? `${_formatCurrency(numAmount)} erfolgreich gutgeschrieben.`
|
||||||
|
: `${_formatCurrency(Math.abs(numAmount))} erfolgreich abgezogen.`;
|
||||||
|
setMessage({ type: 'success', text: label });
|
||||||
setAmount('');
|
setAmount('');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setMessage({ type: 'error', text: err.message || 'Fehler beim Aufladen' });
|
setMessage({ type: 'error', text: err.message || 'Fehler bei der Buchung' });
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
@ -243,7 +251,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminSection}>
|
<div className={styles.adminSection}>
|
||||||
<h3>Guthaben manuell aufladen</h3>
|
<h3>Guthaben manuell verwalten</h3>
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
<div className={message.type === 'success' ? styles.successMessage : styles.errorMessage}>
|
<div className={message.type === 'success' ? styles.successMessage : styles.errorMessage}>
|
||||||
|
|
@ -285,8 +293,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
||||||
className={styles.input}
|
className={styles.input}
|
||||||
value={amount}
|
value={amount}
|
||||||
onChange={(e) => setAmount(e.target.value)}
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
placeholder="z.B. 50"
|
placeholder="z.B. 50 oder -20"
|
||||||
min="0.01"
|
|
||||||
step="0.01"
|
step="0.01"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
@ -308,7 +315,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
||||||
className={`${styles.button} ${styles.buttonPrimary}`}
|
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||||
disabled={saving || (isPrepayUser && !selectedUserId) || !amount}
|
disabled={saving || (isPrepayUser && !selectedUserId) || !amount}
|
||||||
>
|
>
|
||||||
{saving ? 'Wird gutgeschrieben...' : 'Manuell aufladen'}
|
{saving ? 'Wird verbucht...' : (parseFloat(amount) < 0 ? 'Guthaben abziehen' : 'Guthaben aufladen')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -456,6 +463,94 @@ const MandateStripeTopUp: React.FC<MandateStripeTopUpProps> = ({ mandateId, crea
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MANDATE TRANSACTIONS TAB (FormGeneratorTable with filters, search, export)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const _mandateTxColumns: ColumnConfig[] = [
|
||||||
|
{ key: 'createdAt', label: 'Datum', type: 'timestamp' as any, sortable: true, width: 160 },
|
||||||
|
{ key: 'userName', label: 'Benutzer', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150 },
|
||||||
|
{ key: 'transactionType', label: 'Typ', type: 'text' as any, sortable: true, filterable: true, width: 100 },
|
||||||
|
{ key: 'description', label: 'Beschreibung', type: 'text' as any, searchable: true, width: 250 },
|
||||||
|
{ key: 'aicoreProvider', label: 'Anbieter', type: 'text' as any, sortable: true, filterable: true, width: 120 },
|
||||||
|
{ key: 'aicoreModel', label: 'Modell', type: 'text' as any, sortable: true, filterable: true, width: 150 },
|
||||||
|
{ key: 'featureCode', label: 'Feature', type: 'text' as any, sortable: true, filterable: true, width: 120 },
|
||||||
|
{ key: 'amount', label: 'Betrag (CHF)', type: 'number' as any, sortable: true, width: 120 },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface MandateTransactionsTabProps {
|
||||||
|
mandateId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MandateTransactionsTab: React.FC<MandateTransactionsTabProps> = ({ mandateId }) => {
|
||||||
|
const { request, isLoading: loading } = useApiRequest();
|
||||||
|
const [transactions, setTransactions] = useState<any[]>([]);
|
||||||
|
const [pagination, setPagination] = useState<any>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const _loadTransactions = useCallback(async (params?: any) => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
const requestParams: Record<string, string> = {};
|
||||||
|
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/billing/admin/transactions/${mandateId}`,
|
||||||
|
method: 'get',
|
||||||
|
params: requestParams,
|
||||||
|
});
|
||||||
|
if (data && typeof data === 'object' && 'items' in data) {
|
||||||
|
setTransactions(Array.isArray(data.items) ? data.items : []);
|
||||||
|
if (data.pagination) setPagination(data.pagination);
|
||||||
|
} else {
|
||||||
|
setTransactions(Array.isArray(data) ? data : []);
|
||||||
|
setPagination(null);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.response?.data?.detail || err.message || 'Fehler beim Laden');
|
||||||
|
setTransactions([]);
|
||||||
|
setPagination(null);
|
||||||
|
}
|
||||||
|
}, [request, mandateId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
_loadTransactions();
|
||||||
|
}, [_loadTransactions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: '400px' }}>
|
||||||
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', margin: '0 0 1rem 0' }}>
|
||||||
|
AI-Verbrauch und Guthaben-Transaktionen. Subscription-Gebühren werden separat über Stripe abgerechnet.
|
||||||
|
</p>
|
||||||
|
{error && <div className={styles.errorMessage}>{error}</div>}
|
||||||
|
<FormGeneratorTable
|
||||||
|
data={transactions}
|
||||||
|
columns={_mandateTxColumns}
|
||||||
|
apiEndpoint={`/api/billing/admin/transactions/${mandateId}`}
|
||||||
|
loading={loading}
|
||||||
|
pagination={true}
|
||||||
|
pageSize={25}
|
||||||
|
searchable={true}
|
||||||
|
filterable={true}
|
||||||
|
sortable={true}
|
||||||
|
selectable={false}
|
||||||
|
emptyMessage="Keine Transaktionen für diesen Mandanten"
|
||||||
|
onRefresh={() => _loadTransactions()}
|
||||||
|
hookData={{ refetch: _loadTransactions, pagination }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// MAIN COMPONENT
|
// MAIN COMPONENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -465,7 +560,9 @@ export const BillingAdmin: React.FC = () => {
|
||||||
const { user: currentUser } = useCurrentUser();
|
const { user: currentUser } = useCurrentUser();
|
||||||
const isSysAdmin = currentUser?.isSysAdmin === true;
|
const isSysAdmin = currentUser?.isSysAdmin === true;
|
||||||
|
|
||||||
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
|
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(
|
||||||
|
searchParams.get('mandate') || null
|
||||||
|
);
|
||||||
const [mandateList, setMandateList] = useState<UserMandateRow[]>([]);
|
const [mandateList, setMandateList] = useState<UserMandateRow[]>([]);
|
||||||
const [mandatesLoading, setMandatesLoading] = useState(true);
|
const [mandatesLoading, setMandatesLoading] = useState(true);
|
||||||
|
|
||||||
|
|
@ -530,7 +627,14 @@ export const BillingAdmin: React.FC = () => {
|
||||||
const canceledParam = searchParams.get('canceled');
|
const canceledParam = searchParams.get('canceled');
|
||||||
const sessionIdParam = searchParams.get('session_id');
|
const sessionIdParam = searchParams.get('session_id');
|
||||||
|
|
||||||
|
const _initialAdminTab = (searchParams.get('tab') as AdminTabType) || 'settings';
|
||||||
|
const [adminTab, setAdminTab] = useState<AdminTabType>(
|
||||||
|
['settings', 'credit', 'subscription', 'transactions'].includes(_initialAdminTab) ? _initialAdminTab : 'settings'
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (adminTab === 'subscription' || searchParams.get('tab') === 'subscription') return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const _confirmCheckoutIfNeeded = async () => {
|
const _confirmCheckoutIfNeeded = async () => {
|
||||||
|
|
@ -580,34 +684,42 @@ export const BillingAdmin: React.FC = () => {
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [successParam, canceledParam, sessionIdParam, selectedMandateId, loadAccounts]);
|
}, [adminTab, successParam, canceledParam, sessionIdParam, selectedMandateId, loadAccounts]);
|
||||||
|
|
||||||
const _clearStripeParams = useCallback(() => {
|
const _clearStripeParams = useCallback(() => {
|
||||||
searchParams.delete('success');
|
searchParams.delete('success');
|
||||||
searchParams.delete('canceled');
|
searchParams.delete('canceled');
|
||||||
searchParams.delete('session_id');
|
searchParams.delete('session_id');
|
||||||
|
searchParams.delete('mandate');
|
||||||
setSearchParams(searchParams, { replace: true });
|
setSearchParams(searchParams, { replace: true });
|
||||||
setStripeReturnMessage(null);
|
setStripeReturnMessage(null);
|
||||||
}, [searchParams, setSearchParams]);
|
}, [searchParams, setSearchParams]);
|
||||||
|
|
||||||
const showStripeForMandateAdmin = !isSysAdmin && !!selectedMandateId && !!settings;
|
const showStripeForMandateAdmin = !!selectedMandateId && !!settings;
|
||||||
|
|
||||||
|
const _tabStyle = (isActive: boolean) => ({
|
||||||
|
padding: '8px 16px',
|
||||||
|
textDecoration: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
backgroundColor: isActive ? 'var(--color-primary, #3b82f6)' : 'transparent',
|
||||||
|
color: isActive ? 'white' : 'var(--color-text, #e0e0e0)',
|
||||||
|
fontWeight: isActive ? 600 : 400,
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: '14px',
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.billingDashboard}>
|
<div className={styles.billingDashboard}>
|
||||||
<header className={styles.pageHeader}>
|
<header className={styles.pageHeader}>
|
||||||
<h1>Billing Administration</h1>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
<p className={styles.subtitle}>
|
<div>
|
||||||
{isSysAdmin
|
<h1>Billing-Verwaltung</h1>
|
||||||
? 'Verwaltung von Abrechnungseinstellungen und Guthaben'
|
<p className={styles.subtitle}>
|
||||||
: 'Guthaben und Konten für Ihre Mandanten'}
|
Abrechnungseinstellungen, Guthaben und Abonnement pro Mandant
|
||||||
</p>
|
</p>
|
||||||
{isSysAdmin && (
|
</div>
|
||||||
<p style={{ marginTop: '8px' }}>
|
</div>
|
||||||
<Link to="/admin/billing/mandates" style={{ color: 'var(--color-primary)' }}>
|
|
||||||
Mandanten-Übersicht (Balances & Transaktionen)
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{stripeReturnMessage && (
|
{stripeReturnMessage && (
|
||||||
|
|
@ -635,9 +747,30 @@ export const BillingAdmin: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{selectedMandateId && (
|
{selectedMandateId ? (
|
||||||
<>
|
<>
|
||||||
{isSysAdmin && (
|
<nav style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: '24px',
|
||||||
|
borderBottom: '1px solid var(--color-border, #333)',
|
||||||
|
paddingBottom: '8px',
|
||||||
|
}}>
|
||||||
|
<button onClick={() => setAdminTab('settings')} style={_tabStyle(adminTab === 'settings')}>
|
||||||
|
Einstellungen
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setAdminTab('credit')} style={_tabStyle(adminTab === 'credit')}>
|
||||||
|
Guthaben
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setAdminTab('subscription')} style={_tabStyle(adminTab === 'subscription')}>
|
||||||
|
Abonnement
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setAdminTab('transactions')} style={_tabStyle(adminTab === 'transactions')}>
|
||||||
|
Transaktionen
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{adminTab === 'settings' && (
|
||||||
<SettingsEditor
|
<SettingsEditor
|
||||||
settings={settings}
|
settings={settings}
|
||||||
onSave={handleSaveSettings}
|
onSave={handleSaveSettings}
|
||||||
|
|
@ -645,24 +778,34 @@ export const BillingAdmin: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isSysAdmin && (
|
{adminTab === 'credit' && (
|
||||||
<CreditAdder
|
<>
|
||||||
settings={settings}
|
{isSysAdmin && (
|
||||||
accounts={accounts}
|
<CreditAdder
|
||||||
users={users}
|
settings={settings}
|
||||||
onAddCredit={_handleAddCredit}
|
accounts={accounts}
|
||||||
/>
|
users={users}
|
||||||
|
onAddCredit={_handleAddCredit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showStripeForMandateAdmin && (
|
||||||
|
<MandateStripeTopUp mandateId={selectedMandateId} createCheckout={createCheckout} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AccountsOverview accounts={accounts} users={users} loading={loading} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStripeForMandateAdmin && (
|
{adminTab === 'subscription' && (
|
||||||
<MandateStripeTopUp mandateId={selectedMandateId} createCheckout={createCheckout} />
|
<SubscriptionTab mandateId={selectedMandateId} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AccountsOverview accounts={accounts} users={users} loading={loading} />
|
{adminTab === 'transactions' && (
|
||||||
|
<MandateTransactionsTab mandateId={selectedMandateId} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
) : (
|
||||||
|
|
||||||
{!selectedMandateId && (
|
|
||||||
<div className={styles.noData}>Bitte wählen Sie einen Mandanten aus.</div>
|
<div className={styles.noData}>Bitte wählen Sie einen Mandanten aus.</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -65,8 +65,9 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onCheckout, checkout
|
||||||
return 'Prepaid (Mandant)';
|
return 'Prepaid (Mandant)';
|
||||||
};
|
};
|
||||||
|
|
||||||
const canTopUp = balance.billingModel === 'PREPAY_USER'
|
// Stripe top-up on this page: only personal prepaid wallets. Mandate pool (PREPAY_MANDATE) is topped up by mandate admins via Administration → Billing.
|
||||||
|| balance.billingModel === 'PREPAY_MANDATE';
|
const canStripeTopUpHere = balance.billingModel === 'PREPAY_USER';
|
||||||
|
const isMandatePrepaidPool = balance.billingModel === 'PREPAY_MANDATE';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}>
|
<div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}>
|
||||||
|
|
@ -82,7 +83,20 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onCheckout, checkout
|
||||||
Niedriges Guthaben
|
Niedriges Guthaben
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{canTopUp && onCheckout && (
|
{isMandatePrepaidPool && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
marginTop: '12px',
|
||||||
|
fontSize: '13px',
|
||||||
|
lineHeight: 1.45,
|
||||||
|
opacity: 0.75,
|
||||||
|
marginBottom: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Aufladung des Mandanten-Guthabens ist nur für Mandanten-Administratoren möglich (Menü Administration → Billing).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{canStripeTopUpHere && onCheckout && (
|
||||||
<div style={{ marginTop: '12px' }}>
|
<div style={{ marginTop: '12px' }}>
|
||||||
{!showCheckout ? (
|
{!showCheckout ? (
|
||||||
<button
|
<button
|
||||||
|
|
@ -489,10 +503,16 @@ export const BillingDataView: React.FC = () => {
|
||||||
setTransactionsError(null);
|
setTransactionsError(null);
|
||||||
|
|
||||||
const params: any = {};
|
const params: any = {};
|
||||||
// Only serialize if it's a plain pagination object (not a React event or other non-serializable object)
|
|
||||||
if (paginationParams && typeof paginationParams === 'object' && 'page' in paginationParams) {
|
if (paginationParams && typeof paginationParams === 'object' && 'page' in paginationParams) {
|
||||||
const { page, pageSize, sortBy, sortDirection, search, filters } = paginationParams;
|
const pObj: any = {};
|
||||||
params.pagination = JSON.stringify({ page, pageSize, sortBy, sortDirection, search, filters });
|
if (paginationParams.page !== undefined) pObj.page = paginationParams.page;
|
||||||
|
if (paginationParams.pageSize !== undefined) pObj.pageSize = paginationParams.pageSize;
|
||||||
|
if (paginationParams.sort) pObj.sort = paginationParams.sort;
|
||||||
|
if (paginationParams.filters) pObj.filters = paginationParams.filters;
|
||||||
|
if (paginationParams.search) pObj.search = paginationParams.search;
|
||||||
|
if (Object.keys(pObj).length > 0) {
|
||||||
|
params.pagination = JSON.stringify(pObj);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await api.get('/api/billing/view/users/transactions', { params });
|
const response = await api.get('/api/billing/view/users/transactions', { params });
|
||||||
|
|
@ -526,10 +546,7 @@ export const BillingDataView: React.FC = () => {
|
||||||
// hookData for FormGeneratorTable
|
// hookData for FormGeneratorTable
|
||||||
const transactionsHookData = useMemo(() => ({
|
const transactionsHookData = useMemo(() => ({
|
||||||
refetch: _loadTransactions,
|
refetch: _loadTransactions,
|
||||||
pagination: transactionsPagination ? {
|
pagination: transactionsPagination || undefined,
|
||||||
totalPages: transactionsPagination.totalPages,
|
|
||||||
totalItems: transactionsPagination.totalItems,
|
|
||||||
} : undefined,
|
|
||||||
}), [_loadTransactions, transactionsPagination]);
|
}), [_loadTransactions, transactionsPagination]);
|
||||||
|
|
||||||
// Table column definitions
|
// Table column definitions
|
||||||
|
|
@ -741,6 +758,7 @@ export const BillingDataView: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,11 @@ const TransactionTable: React.FC<TransactionTableProps> = ({ transactions }) =>
|
||||||
// MAIN COMPONENT
|
// MAIN COMPONENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export const BillingMandateView: React.FC = () => {
|
interface BillingMandateViewProps {
|
||||||
|
embedded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BillingMandateView: React.FC<BillingMandateViewProps> = ({ embedded = false }) => {
|
||||||
const { request, isLoading: loading } = useApiRequest();
|
const { request, isLoading: loading } = useApiRequest();
|
||||||
const [balances, setBalances] = useState<MandateBalance[]>([]);
|
const [balances, setBalances] = useState<MandateBalance[]>([]);
|
||||||
const [transactions, setTransactions] = useState<BillingTransaction[]>([]);
|
const [transactions, setTransactions] = useState<BillingTransaction[]>([]);
|
||||||
|
|
@ -212,13 +216,17 @@ export const BillingMandateView: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.billingDashboard}>
|
<div className={embedded ? '' : styles.billingDashboard}>
|
||||||
<header className={styles.pageHeader}>
|
{!embedded && (
|
||||||
<h1>Mandanten-Billing</h1>
|
<>
|
||||||
<p className={styles.subtitle}>Guthaben und Transaktionen pro Mandant</p>
|
<header className={styles.pageHeader}>
|
||||||
</header>
|
<h1>Mandanten-Billing</h1>
|
||||||
|
<p className={styles.subtitle}>Guthaben und Transaktionen pro Mandant</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<BillingNav />
|
<BillingNav />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Mandate Balances */}
|
{/* Mandate Balances */}
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
|
|
|
||||||
481
src/pages/billing/SubscriptionTab.tsx
Normal file
481
src/pages/billing/SubscriptionTab.tsx
Normal file
|
|
@ -0,0 +1,481 @@
|
||||||
|
/**
|
||||||
|
* SubscriptionTab — State-machine-aligned subscription management UI.
|
||||||
|
*
|
||||||
|
* Shows:
|
||||||
|
* - Current operative subscription with status, recurring flag, and period info
|
||||||
|
* - Scheduled successor (if plan switch in progress)
|
||||||
|
* - Available plans as cards
|
||||||
|
* - ID-based actions: cancel, reactivate, activate
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { useSubscription } from '../../hooks/useSubscription';
|
||||||
|
import { useConfirm } from '../../hooks/useConfirm';
|
||||||
|
import type { SubscriptionPlan, MandateSubscription } from '../../api/subscriptionApi';
|
||||||
|
import styles from './Billing.module.css';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const _lang = (): string =>
|
||||||
|
typeof navigator !== 'undefined' && navigator.language?.toLowerCase().startsWith('de') ? 'de' : 'en';
|
||||||
|
|
||||||
|
const _t = (dict: Record<string, string> | undefined): string => {
|
||||||
|
if (!dict) return '';
|
||||||
|
const l = _lang();
|
||||||
|
return dict[l] || dict['en'] || dict['de'] || Object.values(dict)[0] || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const _formatCurrency = (amount: number) =>
|
||||||
|
new Intl.NumberFormat('de-CH', { style: 'currency', currency: 'CHF' }).format(amount);
|
||||||
|
|
||||||
|
const _formatDate = (iso: string | null | undefined): string => {
|
||||||
|
if (!iso) return '—';
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const _statusLabel: Record<string, { label: string; color: string }> = {
|
||||||
|
PENDING: { label: 'Zahlung ausstehend', color: '#f59e0b' },
|
||||||
|
SCHEDULED: { label: 'Geplant', color: '#8b5cf6' },
|
||||||
|
ACTIVE: { label: 'Aktiv', color: '#22c55e' },
|
||||||
|
TRIALING: { label: 'Testphase', color: '#3b82f6' },
|
||||||
|
PAST_DUE: { label: 'Zahlung ausstehend', color: '#f59e0b' },
|
||||||
|
EXPIRED: { label: 'Abgelaufen', color: '#6b7280' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const _periodLabel: Record<string, string> = {
|
||||||
|
MONTHLY: 'Monatlich',
|
||||||
|
YEARLY: 'Jährlich',
|
||||||
|
NONE: '—',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Plan Card
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface PlanCardProps {
|
||||||
|
plan: SubscriptionPlan;
|
||||||
|
isCurrent: boolean;
|
||||||
|
onActivate: (planKey: string) => void;
|
||||||
|
activatingPlanKey: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrent, onActivate, activatingPlanKey }) => {
|
||||||
|
const activating = activatingPlanKey === plan.planKey;
|
||||||
|
const isFreePlan = plan.pricePerUserCHF === 0 && plan.pricePerFeatureInstanceCHF === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
border: isCurrent ? '2px solid var(--color-primary, #3b82f6)' : '1px solid var(--color-border, #333)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '1.25rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.75rem',
|
||||||
|
background: isCurrent ? 'rgba(59,130,246,0.06)' : 'var(--color-surface, #1a1a2e)',
|
||||||
|
minWidth: 220,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<strong style={{ fontSize: '1rem' }}>{_t(plan.title)}</strong>
|
||||||
|
{isCurrent && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '0.7rem', padding: '2px 8px', borderRadius: '4px',
|
||||||
|
background: 'var(--color-primary, #3b82f6)', color: '#fff', fontWeight: 600,
|
||||||
|
}}>Aktuell</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', margin: 0 }}>
|
||||||
|
{_t(plan.description)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!isFreePlan && (
|
||||||
|
<div style={{ fontSize: '0.85rem' }}>
|
||||||
|
<div>User: <strong>{_formatCurrency(plan.pricePerUserCHF)}</strong> / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}</div>
|
||||||
|
<div>Instanz: <strong>{_formatCurrency(plan.pricePerFeatureInstanceCHF)}</strong> / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isFreePlan && plan.trialDays && (
|
||||||
|
<div style={{ fontSize: '0.85rem' }}>
|
||||||
|
{plan.trialDays} Tage kostenlos
|
||||||
|
{plan.maxUsers && <> · max. {plan.maxUsers} User</>}
|
||||||
|
{plan.maxFeatureInstances && <> · max. {plan.maxFeatureInstances} Instanzen</>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isCurrent && (
|
||||||
|
<button
|
||||||
|
onClick={() => onActivate(plan.planKey)}
|
||||||
|
disabled={!!activatingPlanKey}
|
||||||
|
style={{
|
||||||
|
marginTop: 'auto', padding: '8px 16px', borderRadius: '6px', border: 'none',
|
||||||
|
background: 'var(--color-primary, #3b82f6)', color: '#fff', fontWeight: 600,
|
||||||
|
cursor: activatingPlanKey ? 'wait' : 'pointer',
|
||||||
|
opacity: activatingPlanKey ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activating
|
||||||
|
? 'Weiterleitung...'
|
||||||
|
: (!isFreePlan && !plan.trialDays) ? 'Kostenpflichtig abonnieren' : 'Auswählen'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Subscription Info Card
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface SubInfoProps {
|
||||||
|
sub: MandateSubscription;
|
||||||
|
plan: SubscriptionPlan | null;
|
||||||
|
label: string;
|
||||||
|
onCancel?: (id: string) => void;
|
||||||
|
onReactivate?: (id: string) => void;
|
||||||
|
cancelling: boolean;
|
||||||
|
reactivating: boolean;
|
||||||
|
justPaid?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SubInfoCard: React.FC<SubInfoProps> = ({ sub, plan, label, onCancel, onReactivate, cancelling, reactivating, justPaid }) => {
|
||||||
|
const statusInfo = _statusLabel[sub.status] || _statusLabel.EXPIRED;
|
||||||
|
const isActive = sub.status === 'ACTIVE';
|
||||||
|
const isPending = sub.status === 'PENDING';
|
||||||
|
const isScheduled = sub.status === 'SCHEDULED';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
border: '1px solid var(--color-border, #333)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '1.25rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.5rem',
|
||||||
|
background: 'var(--color-surface, #1a1a2e)',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary, #888)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<strong style={{ fontSize: '1.1rem' }}>{plan ? _t(plan.title) : sub.planKey}</strong>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
|
{isActive && !sub.recurring && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '0.7rem', padding: '2px 8px', borderRadius: '4px',
|
||||||
|
background: '#ef4444', color: '#fff', fontWeight: 600,
|
||||||
|
}}>Gekündigt</span>
|
||||||
|
)}
|
||||||
|
<span style={{
|
||||||
|
fontSize: '0.75rem', padding: '2px 10px', borderRadius: '4px',
|
||||||
|
background: statusInfo.color, color: '#fff', fontWeight: 600,
|
||||||
|
}}>{statusInfo.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isPending && (
|
||||||
|
<div style={{
|
||||||
|
padding: '0.6rem 0.8rem', borderRadius: '6px',
|
||||||
|
background: justPaid ? 'rgba(34,197,94,0.1)' : 'rgba(245,158,11,0.1)',
|
||||||
|
border: `1px solid ${justPaid ? 'rgba(34,197,94,0.3)' : 'rgba(245,158,11,0.3)'}`,
|
||||||
|
color: justPaid ? '#22c55e' : '#f59e0b', fontSize: '0.85rem',
|
||||||
|
}}>
|
||||||
|
{justPaid
|
||||||
|
? 'Zahlung erfolgreich. Abonnement wird aktiviert — bitte warten...'
|
||||||
|
: 'Die Zahlung wurde noch nicht abgeschlossen. Sie können den Checkout abbrechen oder erneut starten.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isScheduled && sub.effectiveFrom && (
|
||||||
|
<div style={{
|
||||||
|
padding: '0.6rem 0.8rem', borderRadius: '6px',
|
||||||
|
background: 'rgba(139,92,246,0.1)', border: '1px solid rgba(139,92,246,0.3)',
|
||||||
|
color: '#8b5cf6', fontSize: '0.85rem',
|
||||||
|
}}>
|
||||||
|
Dieses Abonnement wird am {_formatDate(sub.effectiveFrom)} aktiv, wenn das aktuelle Abonnement ausläuft.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isPending && !isScheduled && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: '0.85rem', color: 'var(--text-secondary, #888)',
|
||||||
|
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.25rem 1rem',
|
||||||
|
}}>
|
||||||
|
<span>Gestartet: {_formatDate(sub.startedAt)}</span>
|
||||||
|
{plan && <span>Periode: {_periodLabel[plan.billingPeriod] || '—'}</span>}
|
||||||
|
{sub.currentPeriodEnd && <span>Periodenende: {_formatDate(sub.currentPeriodEnd)}</span>}
|
||||||
|
{sub.trialEndsAt && <span>Trial endet: {_formatDate(sub.trialEndsAt)}</span>}
|
||||||
|
{isActive && !sub.recurring && sub.currentPeriodEnd && (
|
||||||
|
<span style={{ color: '#ef4444' }}>Läuft aus am: {_formatDate(sub.currentPeriodEnd)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.5rem' }}>
|
||||||
|
{isActive && !sub.recurring && onReactivate && (
|
||||||
|
<button
|
||||||
|
onClick={() => onReactivate(sub.id)}
|
||||||
|
disabled={reactivating}
|
||||||
|
style={{
|
||||||
|
padding: '6px 14px', borderRadius: '6px', border: 'none',
|
||||||
|
background: 'var(--color-primary, #3b82f6)', color: '#fff',
|
||||||
|
fontWeight: 600, cursor: reactivating ? 'wait' : 'pointer', fontSize: '0.85rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{reactivating ? 'Wird reaktiviert...' : 'Reaktivieren'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isActive && sub.recurring && onCancel && (
|
||||||
|
<button
|
||||||
|
onClick={() => onCancel(sub.id)}
|
||||||
|
disabled={cancelling}
|
||||||
|
style={{
|
||||||
|
padding: '6px 14px', borderRadius: '6px',
|
||||||
|
border: '1px solid #ef4444', background: 'transparent',
|
||||||
|
color: '#ef4444', fontWeight: 500,
|
||||||
|
cursor: cancelling ? 'wait' : 'pointer', fontSize: '0.85rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cancelling ? 'Wird gekündigt...' : 'Kündigen'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(isPending || isScheduled) && onCancel && (
|
||||||
|
<button
|
||||||
|
onClick={() => onCancel(sub.id)}
|
||||||
|
disabled={cancelling}
|
||||||
|
style={{
|
||||||
|
padding: '6px 14px', borderRadius: '6px',
|
||||||
|
border: '1px solid #ef4444', background: 'transparent',
|
||||||
|
color: '#ef4444', fontWeight: 500,
|
||||||
|
cursor: cancelling ? 'wait' : 'pointer', fontSize: '0.85rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cancelling ? 'Wird abgebrochen...' : 'Abbrechen'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Subscription Tab
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface SubscriptionTabProps {
|
||||||
|
mandateId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SubscriptionTab: React.FC<SubscriptionTabProps> = ({ mandateId }) => {
|
||||||
|
const {
|
||||||
|
plans,
|
||||||
|
subscription,
|
||||||
|
plan: currentPlan,
|
||||||
|
scheduled,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
activatePlan,
|
||||||
|
cancelSubscription,
|
||||||
|
reactivateSubscription,
|
||||||
|
verifyCheckout,
|
||||||
|
} = useSubscription(mandateId);
|
||||||
|
|
||||||
|
const { confirm, ConfirmDialog } = useConfirm();
|
||||||
|
const [activatingPlanKey, setActivatingPlanKey] = useState<string | null>(null);
|
||||||
|
const [cancelling, setCancelling] = useState(false);
|
||||||
|
const [reactivating, setReactivating] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'info'; text: string } | null>(null);
|
||||||
|
const [justPaid, setJustPaid] = useState(false);
|
||||||
|
const verifyCalledRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
if (params.get('success') === 'true') {
|
||||||
|
const sessionId = params.get('session_id') || '';
|
||||||
|
setCheckoutMessage({ type: 'success', text: 'Zahlung erfolgreich — Abonnement wird aktiviert...' });
|
||||||
|
setJustPaid(true);
|
||||||
|
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.delete('success');
|
||||||
|
url.searchParams.delete('session_id');
|
||||||
|
window.history.replaceState({}, '', url.toString());
|
||||||
|
|
||||||
|
if (sessionId && !verifyCalledRef.current) {
|
||||||
|
verifyCalledRef.current = true;
|
||||||
|
verifyCheckout(sessionId)
|
||||||
|
.then((result) => {
|
||||||
|
if (result.status === 'activated') {
|
||||||
|
setCheckoutMessage({ type: 'success', text: 'Abonnement wurde aktiviert.' });
|
||||||
|
setJustPaid(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
} else if (params.get('canceled') === 'true') {
|
||||||
|
setCheckoutMessage({ type: 'info', text: 'Checkout abgebrochen. Ihr bestehendes Abonnement bleibt aktiv.' });
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.delete('canceled');
|
||||||
|
window.history.replaceState({}, '', url.toString());
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!justPaid) return;
|
||||||
|
if (subscription && subscription.status !== 'PENDING') {
|
||||||
|
setJustPaid(false);
|
||||||
|
setCheckoutMessage({ type: 'success', text: 'Abonnement wurde aktiviert.' });
|
||||||
|
}
|
||||||
|
}, [justPaid, subscription]);
|
||||||
|
|
||||||
|
const _handleActivate = useCallback(async (planKey: string) => {
|
||||||
|
setActivatingPlanKey(planKey);
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
await activatePlan(planKey);
|
||||||
|
} catch (err: any) {
|
||||||
|
setActionError(err?.response?.data?.detail || err.message || 'Fehler beim Aktivieren');
|
||||||
|
} finally {
|
||||||
|
setActivatingPlanKey(null);
|
||||||
|
}
|
||||||
|
}, [activatePlan]);
|
||||||
|
|
||||||
|
const _handleCancel = useCallback(async (subscriptionId: string) => {
|
||||||
|
const sub = subscription?.id === subscriptionId ? subscription : scheduled;
|
||||||
|
const isPendingOrScheduled = sub?.status === 'PENDING' || sub?.status === 'SCHEDULED';
|
||||||
|
const ok = await confirm(
|
||||||
|
isPendingOrScheduled
|
||||||
|
? 'Diesen Vorgang abbrechen?'
|
||||||
|
: 'Abonnement kündigen? Es bleibt bis zum Periodenende aktiv.',
|
||||||
|
{
|
||||||
|
title: isPendingOrScheduled ? 'Vorgang abbrechen' : 'Abonnement kündigen',
|
||||||
|
confirmLabel: isPendingOrScheduled ? 'Ja, abbrechen' : 'Kündigen',
|
||||||
|
cancelLabel: isPendingOrScheduled ? 'Nein, zurück' : undefined,
|
||||||
|
variant: 'danger',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
setCancelling(true);
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
await cancelSubscription(subscriptionId);
|
||||||
|
setCheckoutMessage(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
setActionError(err?.response?.data?.detail || err.message || 'Fehler');
|
||||||
|
} finally {
|
||||||
|
setCancelling(false);
|
||||||
|
}
|
||||||
|
}, [cancelSubscription, subscription, scheduled]);
|
||||||
|
|
||||||
|
const _handleReactivate = useCallback(async (subscriptionId: string) => {
|
||||||
|
setReactivating(true);
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
await reactivateSubscription(subscriptionId);
|
||||||
|
} catch (err: any) {
|
||||||
|
setActionError(err?.response?.data?.detail || err.message || 'Fehler beim Reaktivieren');
|
||||||
|
} finally {
|
||||||
|
setReactivating(false);
|
||||||
|
}
|
||||||
|
}, [reactivateSubscription]);
|
||||||
|
|
||||||
|
if (loading && !subscription) {
|
||||||
|
return <div className={styles.loadingPlaceholder}>Lade Abonnement-Daten...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Checkout feedback */}
|
||||||
|
{checkoutMessage && (
|
||||||
|
<div style={{
|
||||||
|
marginBottom: '1rem', padding: '0.75rem 1rem', borderRadius: '6px',
|
||||||
|
background: checkoutMessage.type === 'success' ? 'rgba(34,197,94,0.12)' : 'rgba(59,130,246,0.12)',
|
||||||
|
border: `1px solid ${checkoutMessage.type === 'success' ? '#22c55e' : '#3b82f6'}`,
|
||||||
|
color: checkoutMessage.type === 'success' ? '#22c55e' : '#3b82f6',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
}}>
|
||||||
|
{checkoutMessage.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error display */}
|
||||||
|
{(error || actionError) && (
|
||||||
|
<div className={styles.errorMessage} style={{ marginBottom: '1rem' }}>
|
||||||
|
{actionError || error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current subscription */}
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>Aktuelles Abonnement</h2>
|
||||||
|
{subscription ? (
|
||||||
|
<SubInfoCard
|
||||||
|
sub={subscription}
|
||||||
|
plan={currentPlan}
|
||||||
|
label={subscription.status === 'PENDING'
|
||||||
|
? (justPaid ? 'Zahlung wird verarbeitet' : 'Checkout in Bearbeitung')
|
||||||
|
: 'Operatives Abonnement'}
|
||||||
|
onCancel={_handleCancel}
|
||||||
|
onReactivate={_handleReactivate}
|
||||||
|
cancelling={cancelling}
|
||||||
|
reactivating={reactivating}
|
||||||
|
justPaid={justPaid}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className={styles.noData}>
|
||||||
|
Kein aktives Abonnement. Wählen Sie unten einen Plan.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Scheduled successor */}
|
||||||
|
{scheduled && (
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>Geplanter Nachfolger</h2>
|
||||||
|
<SubInfoCard
|
||||||
|
sub={scheduled}
|
||||||
|
plan={null}
|
||||||
|
label="Startet nach Ablauf des aktuellen Abonnements"
|
||||||
|
onCancel={_handleCancel}
|
||||||
|
cancelling={cancelling}
|
||||||
|
reactivating={false}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Available plans */}
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>Verfügbare Pläne</h2>
|
||||||
|
{plans.length === 0 ? (
|
||||||
|
<div className={styles.noData}>Keine Pläne verfügbar</div>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
|
||||||
|
gap: '1rem',
|
||||||
|
}}>
|
||||||
|
{plans.map((p) => (
|
||||||
|
<PlanCard
|
||||||
|
key={p.planKey}
|
||||||
|
plan={p}
|
||||||
|
isCurrent={subscription?.planKey === p.planKey && subscription?.status === 'ACTIVE'}
|
||||||
|
onActivate={_handleActivate}
|
||||||
|
activatingPlanKey={activatingPlanKey}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ConfirmDialog />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -11,3 +11,4 @@ export { BillingNav } from './BillingNav';
|
||||||
export { BillingTransactions } from './BillingTransactions';
|
export { BillingTransactions } from './BillingTransactions';
|
||||||
export { BillingMandateView } from './BillingMandateView';
|
export { BillingMandateView } from './BillingMandateView';
|
||||||
export { BillingUserView } from './BillingUserView';
|
export { BillingUserView } from './BillingUserView';
|
||||||
|
export { default as AdminSubscriptionsPage } from './AdminSubscriptionsPage';
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
/**
|
|
||||||
* AutomationLogsView
|
|
||||||
*
|
|
||||||
* Placeholder view for automation execution logs.
|
|
||||||
*/
|
|
||||||
import React from 'react';
|
|
||||||
import styles from '../../FeatureView.module.css';
|
|
||||||
|
|
||||||
export const AutomationLogsView: React.FC = () => (
|
|
||||||
<div className={styles.placeholder}>
|
|
||||||
<h2>Execution Logs</h2>
|
|
||||||
<p>Automatisierungs-Ausführungsprotokolle</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
@ -23,6 +23,8 @@ export const AutomationTemplatesView: React.FC = () => {
|
||||||
error,
|
error,
|
||||||
permissions,
|
permissions,
|
||||||
refetch,
|
refetch,
|
||||||
|
fetchTemplates,
|
||||||
|
pagination,
|
||||||
createTemplate,
|
createTemplate,
|
||||||
updateTemplate,
|
updateTemplate,
|
||||||
deleteTemplate,
|
deleteTemplate,
|
||||||
|
|
@ -176,7 +178,7 @@ export const AutomationTemplatesView: React.FC = () => {
|
||||||
{ type: 'delete' as const, title: 'Löschen', disabled: (row: any) => row.isSystem && !isSysAdmin ? { disabled: true, message: 'System-Vorlagen können nur vom SysAdmin gelöscht werden' } : !canDelete ? { disabled: true, message: 'Keine Berechtigung' } : false },
|
{ type: 'delete' as const, title: 'Löschen', disabled: (row: any) => row.isSystem && !isSysAdmin ? { disabled: true, message: 'System-Vorlagen können nur vom SysAdmin gelöscht werden' } : !canDelete ? { disabled: true, message: 'Keine Berechtigung' } : false },
|
||||||
]}
|
]}
|
||||||
onDelete={(template) => handleDelete(template.id)}
|
onDelete={(template) => handleDelete(template.id)}
|
||||||
hookData={{ refetch, handleDelete, attributes }}
|
hookData={{ refetch: fetchTemplates, pagination, handleDelete, attributes }}
|
||||||
emptyMessage="Keine Vorlagen gefunden"
|
emptyMessage="Keine Vorlagen gefunden"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -4,4 +4,3 @@
|
||||||
|
|
||||||
export { AutomationDefinitionsView } from './AutomationDefinitionsView';
|
export { AutomationDefinitionsView } from './AutomationDefinitionsView';
|
||||||
export { AutomationTemplatesView } from './AutomationTemplatesView';
|
export { AutomationTemplatesView } from './AutomationTemplatesView';
|
||||||
export { AutomationLogsView } from './AutomationLogsView';
|
|
||||||
|
|
|
||||||
38
src/pages/views/automation2/Automation2Page.tsx
Normal file
38
src/pages/views/automation2/Automation2Page.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
/**
|
||||||
|
* Automation2Page
|
||||||
|
*
|
||||||
|
* n8n-style flow builder with backend-driven node list.
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import { Automation2FlowEditor } from '../../../components/Automation2FlowEditor';
|
||||||
|
import styles from '../../FeatureView.module.css';
|
||||||
|
|
||||||
|
export const Automation2Page: React.FC = () => {
|
||||||
|
const instanceId = useInstanceId();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const workflowId = searchParams.get('workflowId');
|
||||||
|
const { currentLanguage } = useLanguage();
|
||||||
|
const language = (currentLanguage?.slice(0, 2) || 'de') as string;
|
||||||
|
|
||||||
|
if (!instanceId) {
|
||||||
|
return (
|
||||||
|
<div className={styles.placeholder}>
|
||||||
|
<h2>Automation 2</h2>
|
||||||
|
<p>Keine Feature-Instanz gefunden.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, minHeight: 0, display: 'flex' }}>
|
||||||
|
<Automation2FlowEditor
|
||||||
|
instanceId={instanceId}
|
||||||
|
language={language}
|
||||||
|
initialWorkflowId={workflowId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
233
src/pages/views/automation2/Automation2WorkflowsPage.tsx
Normal file
233
src/pages/views/automation2/Automation2WorkflowsPage.tsx
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
/**
|
||||||
|
* Automation2WorkflowsPage
|
||||||
|
* List of saved workflows with FormGeneratorTable.
|
||||||
|
* Shows: label, isRunning, stuckAt, createdAt, lastStartedAt, runCount.
|
||||||
|
* Actions: Edit (navigate to editor), Delete, Execute.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { FaPlay, FaSync } from 'react-icons/fa';
|
||||||
|
import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator';
|
||||||
|
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
|
import {
|
||||||
|
fetchWorkflows,
|
||||||
|
deleteWorkflow,
|
||||||
|
executeGraph,
|
||||||
|
type Automation2Workflow,
|
||||||
|
} from '../../../api/automation2Api';
|
||||||
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
|
import { formatUnixTimestamp } from '../../../utils/time';
|
||||||
|
import styles from '../../../pages/admin/Admin.module.css';
|
||||||
|
|
||||||
|
function formatTs(ts?: number): string {
|
||||||
|
if (ts == null || ts <= 0) return '—';
|
||||||
|
const sec = ts < 1e12 ? ts : ts / 1000;
|
||||||
|
const { time } = formatUnixTimestamp(sec, undefined, {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
return time;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Automation2WorkflowsPage: React.FC = () => {
|
||||||
|
const instanceId = useInstanceId();
|
||||||
|
const { mandateId } = useParams<{ mandateId: string }>();
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { showSuccess, showError } = useToast();
|
||||||
|
|
||||||
|
const [workflows, setWorkflows] = useState<Automation2Workflow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [executingId, setExecutingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const list = await fetchWorkflows(request, instanceId);
|
||||||
|
setWorkflows(list);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Automation2] load workflows failed', e);
|
||||||
|
showError('Fehler beim Laden der Workflows');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [instanceId, request, showError]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
async (workflowId: string): Promise<boolean> => {
|
||||||
|
if (!instanceId) return false;
|
||||||
|
try {
|
||||||
|
await deleteWorkflow(request, instanceId, workflowId);
|
||||||
|
showSuccess('Workflow gelöscht');
|
||||||
|
await load();
|
||||||
|
return true;
|
||||||
|
} catch (e: any) {
|
||||||
|
showError(`Fehler: ${e?.message || 'Löschen fehlgeschlagen'}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[instanceId, request, showSuccess, showError, load]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEdit = useCallback(
|
||||||
|
(row: Automation2Workflow) => {
|
||||||
|
if (!mandateId || !instanceId) return;
|
||||||
|
navigate(`/mandates/${mandateId}/automation2/${instanceId}/editor?workflowId=${row.id}`);
|
||||||
|
},
|
||||||
|
[mandateId, instanceId, navigate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleExecute = useCallback(
|
||||||
|
async (row: Automation2Workflow) => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setExecutingId(row.id);
|
||||||
|
try {
|
||||||
|
const result = await executeGraph(request, instanceId, row.graph!, row.id);
|
||||||
|
if (result?.success) {
|
||||||
|
if (result?.paused) {
|
||||||
|
showSuccess('Workflow gestartet und bei Human Task pausiert. Öffne Workflows & Tasks.');
|
||||||
|
} else {
|
||||||
|
showSuccess('Workflow ausgeführt');
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
} else {
|
||||||
|
showError(result?.error || 'Ausführung fehlgeschlagen');
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
showError(`Fehler: ${e?.message || 'Ausführung fehlgeschlagen'}`);
|
||||||
|
} finally {
|
||||||
|
setExecutingId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[instanceId, request, showSuccess, showError, load]
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns: ColumnConfig[] = [
|
||||||
|
{ key: 'label', label: 'Workflow', type: 'string', width: 200, sortable: true },
|
||||||
|
{
|
||||||
|
key: 'isRunning',
|
||||||
|
label: 'Läuft',
|
||||||
|
type: 'boolean',
|
||||||
|
width: 80,
|
||||||
|
formatter: (value: boolean) =>
|
||||||
|
value ? (
|
||||||
|
<span style={{ color: 'var(--success-color, #28a745)', fontWeight: 600 }}>✓ Ja</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: 'var(--text-secondary, #666)' }}>Nein</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'stuckAtNodeLabel',
|
||||||
|
label: 'Steht bei',
|
||||||
|
type: 'string',
|
||||||
|
width: 160,
|
||||||
|
formatter: (value: string, row: Automation2Workflow) =>
|
||||||
|
row.isRunning && (value || row.stuckAtNodeId)
|
||||||
|
? value || row.stuckAtNodeId || '—'
|
||||||
|
: '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'createdAt',
|
||||||
|
label: 'Erstellt',
|
||||||
|
type: 'number',
|
||||||
|
width: 140,
|
||||||
|
formatter: (v: number) => formatTs(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'lastStartedAt',
|
||||||
|
label: 'Zuletzt gestartet',
|
||||||
|
type: 'number',
|
||||||
|
width: 160,
|
||||||
|
formatter: (v: number) => formatTs(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'runCount',
|
||||||
|
label: 'Läufe',
|
||||||
|
type: 'number',
|
||||||
|
width: 80,
|
||||||
|
formatter: (v: number) => (v != null ? String(v) : '0'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const hookData = {
|
||||||
|
refetch: load,
|
||||||
|
handleDelete: (id: string) => handleDelete(id),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!instanceId) {
|
||||||
|
return (
|
||||||
|
<div className={styles.adminPage}>
|
||||||
|
<p>Keine Feature-Instanz gefunden.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
|
<div className={styles.pageHeader}>
|
||||||
|
<div>
|
||||||
|
<h1 className={styles.pageTitle}>Gespeicherte Workflows</h1>
|
||||||
|
<p className={styles.pageSubtitle}>
|
||||||
|
Workflows verwalten, ausführen und bearbeiten
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.headerActions}>
|
||||||
|
<button
|
||||||
|
className={styles.secondaryButton}
|
||||||
|
onClick={() => load()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.tableContainer}>
|
||||||
|
<FormGeneratorTable<Automation2Workflow>
|
||||||
|
data={workflows}
|
||||||
|
columns={columns}
|
||||||
|
loading={loading}
|
||||||
|
pagination={true}
|
||||||
|
pageSize={25}
|
||||||
|
searchable={true}
|
||||||
|
filterable={true}
|
||||||
|
sortable={true}
|
||||||
|
selectable={false}
|
||||||
|
actionButtons={[
|
||||||
|
{
|
||||||
|
type: 'edit',
|
||||||
|
title: 'Bearbeiten',
|
||||||
|
onAction: handleEdit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'delete',
|
||||||
|
title: 'Löschen',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
customActions={[
|
||||||
|
{
|
||||||
|
id: 'execute',
|
||||||
|
icon: <FaPlay />,
|
||||||
|
title: 'Ausführen',
|
||||||
|
onClick: (row) => handleExecute(row),
|
||||||
|
loading: (row) => executingId === row.id,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onDelete={(row) => handleDelete(row.id)}
|
||||||
|
hookData={hookData}
|
||||||
|
emptyMessage="Keine Workflows gefunden. Erstelle einen im Editor."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
281
src/pages/views/automation2/Automation2WorkflowsTasks.module.css
Normal file
281
src/pages/views/automation2/Automation2WorkflowsTasks.module.css
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
.container {
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container h2 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completedHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem 0;
|
||||||
|
text-align: left;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.completedHeader:hover {
|
||||||
|
color: var(--primary-color, #007bff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.completedList {
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskMeta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 0.5rem 1.25rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskMetaRow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaLabel {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaValue {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaValueMono {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-family: monospace;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 3rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflowList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflowItem {
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-primary, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflowHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
background: var(--bg-secondary, #f8f9fa);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflowHeader:hover {
|
||||||
|
background: var(--bg-hover, #e9ecef);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
margin-left: auto;
|
||||||
|
background: var(--primary-color, #007bff);
|
||||||
|
color: white;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskList {
|
||||||
|
padding: 1rem;
|
||||||
|
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--text-tertiary, #999);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskCard {
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-primary, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskCard:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskType {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formFields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formFields button {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formFields label,
|
||||||
|
.taskCard label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formFields input[type='text'],
|
||||||
|
.formFields input[type='number'],
|
||||||
|
.formFields input[type='date'],
|
||||||
|
.taskCard input[type='text'],
|
||||||
|
.taskCard input[type='number'],
|
||||||
|
.taskCard textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskCard textarea {
|
||||||
|
min-height: 80px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openFormButton {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--primary-color, #007bff);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openFormButton:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openFormButton:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popupSubmitButton {
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
background: var(--success-color, #28a745);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popupSubmitButton:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popupSubmitButton:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approvalButtons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approvalButtons button,
|
||||||
|
.taskCard button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approvalButtons button:first-child,
|
||||||
|
.taskCard button[type='button'] {
|
||||||
|
background: var(--primary-color, #007bff);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approvalButtons button:last-of-type:not(:first-child) {
|
||||||
|
background: var(--danger-color, #dc3545);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approvalButtons button:disabled,
|
||||||
|
.taskCard button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
461
src/pages/views/automation2/Automation2WorkflowsTasksPage.tsx
Normal file
461
src/pages/views/automation2/Automation2WorkflowsTasksPage.tsx
Normal file
|
|
@ -0,0 +1,461 @@
|
||||||
|
/**
|
||||||
|
* Automation2WorkflowsTasksPage
|
||||||
|
* Tasks only (no workflow grouping).
|
||||||
|
* Open tasks at top, completed tasks at bottom (expandable, scrollable).
|
||||||
|
* Each task shows workflow, created, due, step, type, and action.
|
||||||
|
*/
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { FaChevronDown, FaChevronRight, FaSpinner } from 'react-icons/fa';
|
||||||
|
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
|
import {
|
||||||
|
fetchTasks,
|
||||||
|
completeTask,
|
||||||
|
type Automation2Task,
|
||||||
|
} from '../../../api/automation2Api';
|
||||||
|
import { Popup } from '../../../components/UiComponents/Popup';
|
||||||
|
import styles from './Automation2WorkflowsTasks.module.css';
|
||||||
|
|
||||||
|
const NODE_TYPE_LABELS: Record<string, string> = {
|
||||||
|
'input.form': 'Formular',
|
||||||
|
'input.approval': 'Genehmigung',
|
||||||
|
'input.upload': 'Upload',
|
||||||
|
'input.comment': 'Kommentar',
|
||||||
|
'input.review': 'Prüfung',
|
||||||
|
'input.selection': 'Auswahl',
|
||||||
|
'input.confirmation': 'Bestätigung',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTimestamp(ts?: number): string {
|
||||||
|
if (ts == null || ts <= 0) return '—';
|
||||||
|
const d = new Date(typeof ts === 'number' && ts < 1e12 ? ts * 1000 : ts);
|
||||||
|
return d.toLocaleString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeStepLabel(config: Record<string, unknown>): string {
|
||||||
|
const title = config?.title;
|
||||||
|
if (typeof title === 'string' && title.trim()) return title;
|
||||||
|
const label = config?.label;
|
||||||
|
if (typeof label === 'string' && label.trim()) return label;
|
||||||
|
if (typeof label === 'object' && label != null && 'de' in (label as Record<string, string>)) {
|
||||||
|
return (label as Record<string, string>).de ?? (label as Record<string, string>).en ?? '';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Automation2WorkflowsTasksPage: React.FC = () => {
|
||||||
|
const instanceId = useInstanceId();
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const [tasks, setTasks] = useState<Automation2Task[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [completedExpanded, setCompletedExpanded] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const taskList = await fetchTasks(request, instanceId);
|
||||||
|
setTasks(taskList);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Automation2] load failed', e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [instanceId, request]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const handleComplete = async (taskId: string, result: Record<string, unknown>) => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setSubmitting(taskId);
|
||||||
|
try {
|
||||||
|
await completeTask(request, instanceId, taskId, result);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Automation2] complete failed', e);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openTasks = tasks.filter((t) => t.status === 'pending');
|
||||||
|
const completedTasks = tasks.filter((t) => t.status !== 'pending');
|
||||||
|
|
||||||
|
if (!instanceId) {
|
||||||
|
return (
|
||||||
|
<div className={styles.placeholder}>
|
||||||
|
<h2>Tasks</h2>
|
||||||
|
<p>Keine Feature-Instanz gefunden.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className={styles.loading}>
|
||||||
|
<FaSpinner className={styles.spinner} />
|
||||||
|
<p>Lade Tasks…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<h2>Tasks</h2>
|
||||||
|
|
||||||
|
{/* Open tasks */}
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>
|
||||||
|
Offene Tasks
|
||||||
|
{openTasks.length > 0 && <span className={styles.badge}>{openTasks.length}</span>}
|
||||||
|
</h3>
|
||||||
|
{openTasks.length === 0 ? (
|
||||||
|
<p className={styles.empty}>Keine offenen Tasks</p>
|
||||||
|
) : (
|
||||||
|
<div className={styles.taskList}>
|
||||||
|
{openTasks.map((task) => (
|
||||||
|
<TaskCard
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
onSubmit={(result) => handleComplete(task.id, result)}
|
||||||
|
submitting={submitting === task.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Completed tasks */}
|
||||||
|
<section className={styles.section}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.completedHeader}
|
||||||
|
onClick={() => setCompletedExpanded((p) => !p)}
|
||||||
|
>
|
||||||
|
{completedExpanded ? <FaChevronDown /> : <FaChevronRight />}
|
||||||
|
<span>Erledigte Tasks</span>
|
||||||
|
{completedTasks.length > 0 && (
|
||||||
|
<span className={styles.badge}>{completedTasks.length}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{completedExpanded && (
|
||||||
|
<div className={styles.completedList}>
|
||||||
|
{completedTasks.length === 0 ? (
|
||||||
|
<p className={styles.empty}>Keine erledigten Tasks</p>
|
||||||
|
) : (
|
||||||
|
completedTasks.map((task) => (
|
||||||
|
<TaskCard
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
onSubmit={(result) => handleComplete(task.id, result)}
|
||||||
|
submitting={submitting === task.id}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TaskCardProps {
|
||||||
|
task: Automation2Task;
|
||||||
|
onSubmit: (result: Record<string, unknown>) => void;
|
||||||
|
submitting: boolean;
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskCard: React.FC<TaskCardProps> = ({
|
||||||
|
task,
|
||||||
|
onSubmit,
|
||||||
|
submitting,
|
||||||
|
readOnly = false,
|
||||||
|
}) => {
|
||||||
|
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
||||||
|
const [formPopupOpen, setFormPopupOpen] = useState(false);
|
||||||
|
const config = task.config ?? {};
|
||||||
|
const nodeType = task.nodeType;
|
||||||
|
const stepLabel = getNodeStepLabel(config);
|
||||||
|
|
||||||
|
const renderInput = () => {
|
||||||
|
if (readOnly) return null;
|
||||||
|
switch (nodeType) {
|
||||||
|
case 'input.form': {
|
||||||
|
const fields =
|
||||||
|
(config.fields as Array<{ name: string; type: string; label: string; required?: boolean }>) ??
|
||||||
|
[];
|
||||||
|
const requiredFields = fields.filter((f) => f.required);
|
||||||
|
const allRequiredFilled = requiredFields.every((f) => {
|
||||||
|
const v = formData[f.name];
|
||||||
|
if (f.type === 'boolean') return true;
|
||||||
|
return v !== undefined && v !== null && String(v).trim() !== '';
|
||||||
|
});
|
||||||
|
const formContent = (
|
||||||
|
<div className={styles.formFields}>
|
||||||
|
{fields.map((f) => (
|
||||||
|
<div key={f.name}>
|
||||||
|
<label>
|
||||||
|
{f.label || f.name}
|
||||||
|
{f.required && ' *'}
|
||||||
|
</label>
|
||||||
|
{f.type === 'boolean' ? (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(formData[f.name] as boolean) ?? false}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((p) => ({ ...p, [f.name]: e.target.checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={
|
||||||
|
f.type === 'number' ? 'number' : f.type === 'date' ? 'date' : 'text'
|
||||||
|
}
|
||||||
|
value={(formData[f.name] as string) ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((p) => ({ ...p, [f.name]: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormPopupOpen(true)}
|
||||||
|
disabled={submitting}
|
||||||
|
className={styles.openFormButton}
|
||||||
|
>
|
||||||
|
Formular bearbeiten
|
||||||
|
</button>
|
||||||
|
<Popup
|
||||||
|
isOpen={formPopupOpen}
|
||||||
|
title="Formular ausfüllen"
|
||||||
|
onClose={() => setFormPopupOpen(false)}
|
||||||
|
size="medium"
|
||||||
|
footerContent={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onSubmit(formData);
|
||||||
|
setFormPopupOpen(false);
|
||||||
|
}}
|
||||||
|
disabled={submitting || !allRequiredFilled}
|
||||||
|
className={styles.popupSubmitButton}
|
||||||
|
>
|
||||||
|
{submitting ? 'Wird gesendet…' : 'Absenden'}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{formContent}
|
||||||
|
</Popup>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'input.approval':
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{config.title != null && String(config.title) !== '' && <h4>{String(config.title)}</h4>}
|
||||||
|
{config.description != null && String(config.description) !== '' && <p>{String(config.description)}</p>}
|
||||||
|
<div className={styles.approvalButtons}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSubmit({ approved: true })}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Genehmigen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSubmit({ approved: false })}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Ablehnen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'input.comment':
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<textarea
|
||||||
|
placeholder={(config.placeholder as string) ?? 'Kommentar...'}
|
||||||
|
value={(formData.comment as string) ?? ''}
|
||||||
|
onChange={(e) => setFormData({ comment: e.target.value })}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSubmit(formData)}
|
||||||
|
disabled={
|
||||||
|
submitting ||
|
||||||
|
((config.required !== false) && !formData.comment)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Absenden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'input.selection': {
|
||||||
|
const options =
|
||||||
|
(config.options as Array<{ value: string; label: string }>) ?? [];
|
||||||
|
const multiple = config.multiple as boolean;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{options.map((o) => (
|
||||||
|
<label key={o.value}>
|
||||||
|
<input
|
||||||
|
type={multiple ? 'checkbox' : 'radio'}
|
||||||
|
name={task.id}
|
||||||
|
value={o.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (multiple) {
|
||||||
|
const prev = (formData.selected as string[]) ?? [];
|
||||||
|
const next = e.target.checked
|
||||||
|
? [...prev, o.value]
|
||||||
|
: prev.filter((v) => v !== o.value);
|
||||||
|
setFormData((p) => ({ ...p, selected: next }));
|
||||||
|
} else {
|
||||||
|
setFormData((p) => ({ ...p, selected: o.value }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{o.label || o.value}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSubmit(formData)}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Absenden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'input.confirmation':
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p>{(config.question as string) ?? 'Bestätigen?'}</p>
|
||||||
|
<div className={styles.approvalButtons}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSubmit({ confirmed: true })}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{typeof config.confirmLabel === 'string' ? config.confirmLabel : 'Bestätigen'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSubmit({ confirmed: false })}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{typeof config.rejectLabel === 'string' ? config.rejectLabel : 'Ablehnen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'input.upload':
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p>Upload-Komponente – noch nicht implementiert</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSubmit({ uploaded: [] })}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Platzhalter absenden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'input.review':
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p>Review – Content anzeigen + Feedback</p>
|
||||||
|
<textarea
|
||||||
|
placeholder="Feedback..."
|
||||||
|
value={(formData.feedback as string) ?? ''}
|
||||||
|
onChange={(e) => setFormData({ feedback: e.target.value })}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSubmit(formData)}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Absenden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p>Unbekannter Task-Typ: {nodeType}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSubmit({})}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Absenden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.taskCard}>
|
||||||
|
<div className={styles.taskMeta}>
|
||||||
|
<div className={styles.taskMetaRow}>
|
||||||
|
<span className={styles.metaLabel}>Workflow</span>
|
||||||
|
<span className={styles.metaValue}>
|
||||||
|
{task.workflowLabel || task.workflowId || '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.taskMetaRow}>
|
||||||
|
<span className={styles.metaLabel}>Erstellt</span>
|
||||||
|
<span className={styles.metaValue}>
|
||||||
|
{formatTimestamp(task.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.taskMetaRow}>
|
||||||
|
<span className={styles.metaLabel}>Fällig</span>
|
||||||
|
<span className={styles.metaValue}>
|
||||||
|
{formatTimestamp(task.dueAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{stepLabel && (
|
||||||
|
<div className={styles.taskMetaRow}>
|
||||||
|
<span className={styles.metaLabel}>Schritt</span>
|
||||||
|
<span className={styles.metaValue}>{stepLabel}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.taskMetaRow}>
|
||||||
|
<span className={styles.metaLabel}>Typ</span>
|
||||||
|
<span className={styles.metaValue}>
|
||||||
|
{NODE_TYPE_LABELS[nodeType] ?? nodeType}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{task.nodeId && (
|
||||||
|
<div className={styles.taskMetaRow}>
|
||||||
|
<span className={styles.metaLabel}>Node</span>
|
||||||
|
<span className={styles.metaValueMono}>{task.nodeId}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{renderInput()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -5,8 +5,9 @@
|
||||||
* Similar to trustee views but hardcoded for chatbot feature.
|
* Similar to trustee views but hardcoded for chatbot feature.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { useChatbot } from '../../../hooks/useChatbot';
|
import { useChatbot } from '../../../hooks/useChatbot';
|
||||||
|
import { useConfirm } from '../../../hooks/useConfirm';
|
||||||
import { TextField } from '../../../components/UiComponents/TextField';
|
import { TextField } from '../../../components/UiComponents/TextField';
|
||||||
import { Button } from '../../../components/UiComponents/Button';
|
import { Button } from '../../../components/UiComponents/Button';
|
||||||
import { AutoScroll } from '../../../components/UiComponents/AutoScroll';
|
import { AutoScroll } from '../../../components/UiComponents/AutoScroll';
|
||||||
|
|
@ -40,7 +41,8 @@ export const ChatbotConversationsView: React.FC = () => {
|
||||||
} = useChatbot();
|
} = useChatbot();
|
||||||
|
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
const { confirm, ConfirmDialog } = useConfirm();
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!inputValue.trim() || isStreaming) return;
|
if (!inputValue.trim() || isStreaming) return;
|
||||||
|
|
@ -76,17 +78,21 @@ export const ChatbotConversationsView: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteThread = async (e: React.MouseEvent, workflowId: string) => {
|
const handleDeleteThread = useCallback(async (e: React.MouseEvent, workflowId: string) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (window.confirm('Möchten Sie diese Konversation wirklich löschen?')) {
|
const ok = await confirm('Möchten Sie diese Konversation wirklich löschen?', {
|
||||||
setDeletingId(workflowId);
|
title: 'Konversation löschen',
|
||||||
try {
|
confirmLabel: 'Löschen',
|
||||||
await deleteThread(workflowId);
|
variant: 'danger',
|
||||||
} finally {
|
});
|
||||||
setDeletingId(null);
|
if (!ok) return;
|
||||||
}
|
setDeletingId(workflowId);
|
||||||
|
try {
|
||||||
|
await deleteThread(workflowId);
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null);
|
||||||
}
|
}
|
||||||
};
|
}, [confirm, deleteThread]);
|
||||||
|
|
||||||
const formatDate = (timestamp?: number) => {
|
const formatDate = (timestamp?: number) => {
|
||||||
if (!timestamp) return '';
|
if (!timestamp) return '';
|
||||||
|
|
@ -269,6 +275,7 @@ export const ChatbotConversationsView: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
|
<ConfirmDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import {
|
||||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { FaSync, FaMapMarkerAlt } from 'react-icons/fa';
|
import { FaSync } from 'react-icons/fa';
|
||||||
import styles from '../../admin/Admin.module.css';
|
import styles from '../../admin/Admin.module.css';
|
||||||
|
|
||||||
export const RealEstateParcelsView: React.FC = () => {
|
export const RealEstateParcelsView: React.FC = () => {
|
||||||
|
|
@ -141,7 +141,7 @@ export const RealEstateParcelsView: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<p className={styles.pageSubtitle}>Parzellen verwalten</p>
|
<p className={styles.pageSubtitle}>Parzellen verwalten</p>
|
||||||
|
|
@ -163,26 +163,7 @@ export const RealEstateParcelsView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.tableContainer}>
|
<div className={styles.tableContainer}>
|
||||||
{loading && (!parcels || parcels.length === 0) ? (
|
<FormGeneratorTable
|
||||||
<div className={styles.loadingContainer}>
|
|
||||||
<div className={styles.spinner} />
|
|
||||||
<span>Lade Parzellen...</span>
|
|
||||||
</div>
|
|
||||||
) : !parcels || parcels.length === 0 ? (
|
|
||||||
<div className={styles.emptyState}>
|
|
||||||
<FaMapMarkerAlt className={styles.emptyIcon} />
|
|
||||||
<h3 className={styles.emptyTitle}>Keine Parzellen vorhanden</h3>
|
|
||||||
<p className={styles.emptyDescription}>
|
|
||||||
Erstellen Sie eine neue Parzelle, um zu beginnen.
|
|
||||||
</p>
|
|
||||||
{canCreate && (
|
|
||||||
<button className={styles.primaryButton} onClick={handleCreateClick}>
|
|
||||||
+ Neue Parzelle
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<FormGeneratorTable
|
|
||||||
data={parcels}
|
data={parcels}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
|
@ -223,7 +204,6 @@ export const RealEstateParcelsView: React.FC = () => {
|
||||||
}}
|
}}
|
||||||
emptyMessage="Keine Parzellen gefunden"
|
emptyMessage="Keine Parzellen gefunden"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(editingParcel || isCreateMode) && (
|
{(editingParcel || isCreateMode) && (
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import {
|
||||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { FaSync, FaBuilding } from 'react-icons/fa';
|
import { FaSync } from 'react-icons/fa';
|
||||||
import styles from '../../admin/Admin.module.css';
|
import styles from '../../admin/Admin.module.css';
|
||||||
|
|
||||||
export const RealEstateProjectsView: React.FC = () => {
|
export const RealEstateProjectsView: React.FC = () => {
|
||||||
|
|
@ -131,7 +131,7 @@ export const RealEstateProjectsView: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<p className={styles.pageSubtitle}>Projekte verwalten</p>
|
<p className={styles.pageSubtitle}>Projekte verwalten</p>
|
||||||
|
|
@ -149,24 +149,7 @@ export const RealEstateProjectsView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.tableContainer}>
|
<div className={styles.tableContainer}>
|
||||||
{loading && (!projects || projects.length === 0) ? (
|
<FormGeneratorTable
|
||||||
<div className={styles.loadingContainer}>
|
|
||||||
<div className={styles.spinner} />
|
|
||||||
<span>Lade Projekte...</span>
|
|
||||||
</div>
|
|
||||||
) : !projects || projects.length === 0 ? (
|
|
||||||
<div className={styles.emptyState}>
|
|
||||||
<FaBuilding className={styles.emptyIcon} />
|
|
||||||
<h3 className={styles.emptyTitle}>Keine Projekte vorhanden</h3>
|
|
||||||
<p className={styles.emptyDescription}>Erstellen Sie ein neues Projekt, um zu beginnen.</p>
|
|
||||||
{canCreate && (
|
|
||||||
<button className={styles.primaryButton} onClick={handleCreateClick}>
|
|
||||||
+ Neues Projekt
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<FormGeneratorTable
|
|
||||||
data={projects}
|
data={projects}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
|
@ -184,7 +167,6 @@ export const RealEstateProjectsView: React.FC = () => {
|
||||||
hookData={{ refetch, permissions, pagination, handleDelete, handleInlineUpdate, updateOptimistically }}
|
hookData={{ refetch, permissions, pagination, handleDelete, handleInlineUpdate, updateOptimistically }}
|
||||||
emptyMessage="Keine Projekte gefunden"
|
emptyMessage="Keine Projekte gefunden"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(editingProject || isCreateMode) && (
|
{(editingProject || isCreateMode) && (
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||||
import { useApiRequest } from '../../../hooks/useApi';
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
import { useToast } from '../../../contexts/ToastContext';
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
|
import { useConfirm } from '../../../hooks/useConfirm';
|
||||||
import {
|
import {
|
||||||
fetchAccountingConnectors,
|
fetchAccountingConnectors,
|
||||||
fetchAccountingConfig,
|
fetchAccountingConfig,
|
||||||
|
|
@ -42,6 +43,7 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
const [dateFrom, setDateFrom] = useState('');
|
const [dateFrom, setDateFrom] = useState('');
|
||||||
const [dateTo, setDateTo] = useState('');
|
const [dateTo, setDateTo] = useState('');
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
|
const { confirm, ConfirmDialog } = useConfirm();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!importDone) return;
|
if (!importDone) return;
|
||||||
|
|
@ -145,7 +147,12 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
|
|
||||||
const handleRemove = async () => {
|
const handleRemove = async () => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
if (!window.confirm('Remove the accounting integration? This does not delete synced data.')) return;
|
const ok = await confirm('Remove the accounting integration? This does not delete synced data.', {
|
||||||
|
title: 'Remove Integration',
|
||||||
|
confirmLabel: 'Remove',
|
||||||
|
variant: 'danger',
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await deleteAccountingConfig(request, instanceId);
|
await deleteAccountingConfig(request, instanceId);
|
||||||
|
|
@ -421,6 +428,7 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<ConfirmDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { useTrusteeDocuments, useTrusteeDocumentOperations, TrusteeDocument } fr
|
||||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { FaSync, FaFileAlt, FaDownload } from 'react-icons/fa';
|
import { FaSync, FaDownload } from 'react-icons/fa';
|
||||||
import { useToast } from '../../../contexts/ToastContext';
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
import api from '../../../api';
|
import api from '../../../api';
|
||||||
import styles from '../../admin/Admin.module.css';
|
import styles from '../../admin/Admin.module.css';
|
||||||
|
|
@ -178,7 +178,7 @@ export const TrusteeDocumentsView: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<p className={styles.pageSubtitle}>Belege und Dokumente verwalten</p>
|
<p className={styles.pageSubtitle}>Belege und Dokumente verwalten</p>
|
||||||
|
|
@ -203,29 +203,7 @@ export const TrusteeDocumentsView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.tableContainer}>
|
<div className={styles.tableContainer}>
|
||||||
{loading && (!documents || documents.length === 0) ? (
|
<FormGeneratorTable
|
||||||
<div className={styles.loadingContainer}>
|
|
||||||
<div className={styles.spinner} />
|
|
||||||
<span>Lade Dokumente...</span>
|
|
||||||
</div>
|
|
||||||
) : !documents || documents.length === 0 ? (
|
|
||||||
<div className={styles.emptyState}>
|
|
||||||
<FaFileAlt className={styles.emptyIcon} />
|
|
||||||
<h3 className={styles.emptyTitle}>Keine Dokumente vorhanden</h3>
|
|
||||||
<p className={styles.emptyDescription}>
|
|
||||||
Erstellen Sie ein neues Dokument, um zu beginnen.
|
|
||||||
</p>
|
|
||||||
{canCreate && (
|
|
||||||
<button
|
|
||||||
className={styles.primaryButton}
|
|
||||||
onClick={handleCreateClick}
|
|
||||||
>
|
|
||||||
+ Neues Dokument
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<FormGeneratorTable
|
|
||||||
data={documents}
|
data={documents}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
apiEndpoint={instanceId ? `/api/trustee/${instanceId}/documents` : undefined}
|
apiEndpoint={instanceId ? `/api/trustee/${instanceId}/documents` : undefined}
|
||||||
|
|
@ -268,7 +246,6 @@ export const TrusteeDocumentsView: React.FC = () => {
|
||||||
}}
|
}}
|
||||||
emptyMessage="Keine Dokumente gefunden"
|
emptyMessage="Keine Dokumente gefunden"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
{/* Create/Edit Modal */}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { useTrusteePositionDocuments, useTrusteePositionDocumentOperations, Trus
|
||||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { FaSync, FaLink } from 'react-icons/fa';
|
import { FaSync } from 'react-icons/fa';
|
||||||
import styles from '../../admin/Admin.module.css';
|
import styles from '../../admin/Admin.module.css';
|
||||||
|
|
||||||
export const TrusteePositionDocumentsView: React.FC = () => {
|
export const TrusteePositionDocumentsView: React.FC = () => {
|
||||||
|
|
@ -146,7 +146,7 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<p className={styles.pageSubtitle}>Belege mit Buchungspositionen verknüpfen</p>
|
<p className={styles.pageSubtitle}>Belege mit Buchungspositionen verknüpfen</p>
|
||||||
|
|
@ -171,29 +171,7 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.tableContainer}>
|
<div className={styles.tableContainer}>
|
||||||
{loading && (!links || links.length === 0) ? (
|
<FormGeneratorTable
|
||||||
<div className={styles.loadingContainer}>
|
|
||||||
<div className={styles.spinner} />
|
|
||||||
<span>Lade Verknüpfungen...</span>
|
|
||||||
</div>
|
|
||||||
) : !links || links.length === 0 ? (
|
|
||||||
<div className={styles.emptyState}>
|
|
||||||
<FaLink className={styles.emptyIcon} />
|
|
||||||
<h3 className={styles.emptyTitle}>Keine Verknüpfungen vorhanden</h3>
|
|
||||||
<p className={styles.emptyDescription}>
|
|
||||||
Verknüpfen Sie Belege mit Buchungspositionen.
|
|
||||||
</p>
|
|
||||||
{canCreate && (
|
|
||||||
<button
|
|
||||||
className={styles.primaryButton}
|
|
||||||
onClick={handleCreateClick}
|
|
||||||
>
|
|
||||||
+ Neue Verknüpfung
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<FormGeneratorTable
|
|
||||||
data={links}
|
data={links}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
apiEndpoint={instanceId ? `/api/trustee/${instanceId}/position-documents` : undefined}
|
apiEndpoint={instanceId ? `/api/trustee/${instanceId}/position-documents` : undefined}
|
||||||
|
|
@ -227,7 +205,6 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
||||||
}}
|
}}
|
||||||
emptyMessage="Keine Verknüpfungen gefunden"
|
emptyMessage="Keine Verknüpfungen gefunden"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create Modal */}
|
{/* Create Modal */}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
import { useApiRequest } from '../../../hooks/useApi';
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { FaSync, FaReceipt, FaDownload } from 'react-icons/fa';
|
import { FaSync, FaDownload } from 'react-icons/fa';
|
||||||
import { useToast } from '../../../contexts/ToastContext';
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
import api from '../../../api';
|
import api from '../../../api';
|
||||||
import { fetchSyncStatus, syncPositionsToAccounting, type AccountingSyncStatus } from '../../../api/trusteeApi';
|
import { fetchSyncStatus, syncPositionsToAccounting, type AccountingSyncStatus } from '../../../api/trusteeApi';
|
||||||
|
|
@ -293,19 +293,7 @@ export const TrusteePositionsView: React.FC = () => {
|
||||||
return col;
|
return col;
|
||||||
});
|
});
|
||||||
|
|
||||||
const createdAtCol = {
|
const allColumns = [...attrColumns, belegeColumn, syncStatusColumn];
|
||||||
key: '_createdAt',
|
|
||||||
label: 'Erstellt am',
|
|
||||||
type: 'timestamp' as any,
|
|
||||||
sortable: true,
|
|
||||||
filterable: false,
|
|
||||||
searchable: false,
|
|
||||||
width: 150,
|
|
||||||
minWidth: 120,
|
|
||||||
maxWidth: 200,
|
|
||||||
};
|
|
||||||
|
|
||||||
const allColumns = [...attrColumns, belegeColumn, syncStatusColumn, createdAtCol];
|
|
||||||
const byKey = new Map(allColumns.map(c => [c.key, c]));
|
const byKey = new Map(allColumns.map(c => [c.key, c]));
|
||||||
|
|
||||||
const ordered: typeof allColumns = [];
|
const ordered: typeof allColumns = [];
|
||||||
|
|
@ -412,7 +400,7 @@ export const TrusteePositionsView: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<p className={styles.pageSubtitle}>Buchungspositionen verwalten</p>
|
<p className={styles.pageSubtitle}>Buchungspositionen verwalten</p>
|
||||||
|
|
@ -437,29 +425,7 @@ export const TrusteePositionsView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.tableContainer}>
|
<div className={styles.tableContainer}>
|
||||||
{loading && (!positions || positions.length === 0) ? (
|
<FormGeneratorTable
|
||||||
<div className={styles.loadingContainer}>
|
|
||||||
<div className={styles.spinner} />
|
|
||||||
<span>Lade Positionen...</span>
|
|
||||||
</div>
|
|
||||||
) : !positions || positions.length === 0 ? (
|
|
||||||
<div className={styles.emptyState}>
|
|
||||||
<FaReceipt className={styles.emptyIcon} />
|
|
||||||
<h3 className={styles.emptyTitle}>Keine Positionen vorhanden</h3>
|
|
||||||
<p className={styles.emptyDescription}>
|
|
||||||
Erstellen Sie eine neue Position, um zu beginnen.
|
|
||||||
</p>
|
|
||||||
{canCreate && (
|
|
||||||
<button
|
|
||||||
className={styles.primaryButton}
|
|
||||||
onClick={handleCreateClick}
|
|
||||||
>
|
|
||||||
+ Neue Position
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<FormGeneratorTable
|
|
||||||
data={positions}
|
data={positions}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
apiEndpoint={instanceId ? `/api/trustee/${instanceId}/positions` : undefined}
|
apiEndpoint={instanceId ? `/api/trustee/${instanceId}/positions` : undefined}
|
||||||
|
|
@ -510,7 +476,6 @@ export const TrusteePositionsView: React.FC = () => {
|
||||||
}}
|
}}
|
||||||
emptyMessage="Keine Positionen gefunden"
|
emptyMessage="Keine Positionen gefunden"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
{/* Create/Edit Modal */}
|
||||||
|
|
|
||||||
155
src/pages/views/workspace/WorkspaceGeneralSettings.tsx
Normal file
155
src/pages/views/workspace/WorkspaceGeneralSettings.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
/**
|
||||||
|
* WorkspaceGeneralSettings -- Per-user workspace settings (e.g. max agent rounds).
|
||||||
|
*
|
||||||
|
* The user can override the instance default. Setting a field to null reverts to the default.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
|
import styles from './WorkspaceSettings.module.css';
|
||||||
|
|
||||||
|
interface GeneralSettingsProps {
|
||||||
|
instanceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MaxAgentRoundsInfo {
|
||||||
|
effective: number;
|
||||||
|
userOverride: number | null;
|
||||||
|
instanceDefault: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WorkspaceGeneralSettings: React.FC<GeneralSettingsProps> = ({ instanceId }) => {
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [maxRoundsInfo, setMaxRoundsInfo] = useState<MaxAgentRoundsInfo>({
|
||||||
|
effective: 25,
|
||||||
|
userOverride: null,
|
||||||
|
instanceDefault: 25,
|
||||||
|
});
|
||||||
|
const [inputValue, setInputValue] = useState<string>('');
|
||||||
|
|
||||||
|
const _loadSettings = useCallback(async () => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await request({
|
||||||
|
url: `/api/workspace/${instanceId}/settings/general`,
|
||||||
|
method: 'get',
|
||||||
|
});
|
||||||
|
const info = (data as any)?.maxAgentRounds;
|
||||||
|
if (info) {
|
||||||
|
setMaxRoundsInfo(info);
|
||||||
|
setInputValue(info.userOverride != null ? String(info.userOverride) : '');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message || 'Fehler beim Laden der Einstellungen');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [instanceId, request]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
_loadSettings();
|
||||||
|
}, [_loadSettings]);
|
||||||
|
|
||||||
|
const _handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
try {
|
||||||
|
const val = inputValue.trim() === '' ? null : parseInt(inputValue, 10);
|
||||||
|
if (val !== null && (isNaN(val) || val < 1 || val > 100)) {
|
||||||
|
setError('Wert muss zwischen 1 und 100 liegen.');
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await request({
|
||||||
|
url: `/api/workspace/${instanceId}/settings/general`,
|
||||||
|
method: 'put',
|
||||||
|
data: { maxAgentRounds: val },
|
||||||
|
});
|
||||||
|
const info = (data as any)?.maxAgentRounds;
|
||||||
|
if (info) {
|
||||||
|
setMaxRoundsInfo(info);
|
||||||
|
setInputValue(info.userOverride != null ? String(info.userOverride) : '');
|
||||||
|
}
|
||||||
|
setSuccess('Einstellungen gespeichert.');
|
||||||
|
setTimeout(() => setSuccess(null), 3000);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message || 'Fehler beim Speichern');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const _handleReset = () => {
|
||||||
|
setInputValue('');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className={styles.loading}>Lade Einstellungen...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasOverride = inputValue.trim() !== '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.settings}>
|
||||||
|
<h2 className={styles.heading}>Generelle Einstellungen</h2>
|
||||||
|
|
||||||
|
{error && <div className={styles.error}>{error}</div>}
|
||||||
|
{success && <div className={styles.success}>{success}</div>}
|
||||||
|
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>Agenten-Konfiguration</h3>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label className={styles.label}>
|
||||||
|
Max. Agenten-Runden
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={styles.input}
|
||||||
|
style={{ maxWidth: 120 }}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={e => setInputValue(e.target.value)}
|
||||||
|
placeholder={String(maxRoundsInfo.instanceDefault)}
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
/>
|
||||||
|
{hasOverride && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.removeBtn}
|
||||||
|
onClick={_handleReset}
|
||||||
|
title="Auf Standard zurücksetzen"
|
||||||
|
>
|
||||||
|
Zurücksetzen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', marginTop: 4, display: 'block' }}>
|
||||||
|
Standard der Instanz: {maxRoundsInfo.instanceDefault}.
|
||||||
|
{maxRoundsInfo.userOverride != null && (
|
||||||
|
<> Ihr Override: {maxRoundsInfo.userOverride}.</>
|
||||||
|
)}
|
||||||
|
{' '}Effektiv: {maxRoundsInfo.effective}.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={styles.saveBtn}
|
||||||
|
onClick={_handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? 'Speichern...' : 'Einstellungen speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
.wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disclaimer {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--bg-secondary, #f5f5f5);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color, #e8e8e8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpiGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpiCard {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-primary, #fff);
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpiValue {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #1a1a1a);
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpiLabel {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartBlock {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-primary, #fff);
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
min-height: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartTitle {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
color: var(--text-primary, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.row2 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #c62828;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
273
src/pages/views/workspace/WorkspaceRagInsightsPage.tsx
Normal file
273
src/pages/views/workspace/WorkspaceRagInsightsPage.tsx
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
/**
|
||||||
|
* WorkspaceRagInsightsPage — Aggregierte, nicht personenbezogene Kennzahlen zum
|
||||||
|
* Knowledge Store / RAG dieser Workspace-Instanz (Präsentationen, Monitoring).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ResponsiveContainer,
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
} from 'recharts';
|
||||||
|
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
|
import styles from './WorkspaceRagInsightsPage.module.css';
|
||||||
|
|
||||||
|
const MIME_LABELS: Record<string, string> = {
|
||||||
|
pdf: 'PDF',
|
||||||
|
office_doc: 'Office (Text)',
|
||||||
|
office_sheet: 'Office (Tabellen)',
|
||||||
|
office_slides: 'Office (Folien)',
|
||||||
|
text: 'Text',
|
||||||
|
image: 'Bild',
|
||||||
|
html: 'HTML',
|
||||||
|
other: 'Sonstige',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHART_COLORS = ['#1976d2', '#00897b', '#6a1b9a', '#e65100', '#5d4037', '#455a64', '#c62828'];
|
||||||
|
|
||||||
|
function _formatBytes(n: number): string {
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
let v = n;
|
||||||
|
let i = 0;
|
||||||
|
while (v >= 1024 && i < units.length - 1) {
|
||||||
|
v /= 1024;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
return `${v < 10 && i > 0 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RagKpis {
|
||||||
|
indexedDocuments: number;
|
||||||
|
indexedBytesTotal: number;
|
||||||
|
contributorUsers: number;
|
||||||
|
contentChunks: number;
|
||||||
|
chunksWithEmbedding: number;
|
||||||
|
embeddingCoveragePercent: number;
|
||||||
|
workflowEntities: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RagStatsResponse {
|
||||||
|
error?: string;
|
||||||
|
scope?: {
|
||||||
|
featureInstanceId?: string;
|
||||||
|
mandateScopedShared?: boolean;
|
||||||
|
workspaceFileIdsResolved?: number;
|
||||||
|
};
|
||||||
|
kpis?: RagKpis;
|
||||||
|
indexedDocumentsByStatus?: Record<string, number>;
|
||||||
|
documentsByMimeCategory?: Record<string, number>;
|
||||||
|
chunksByContentType?: Record<string, number>;
|
||||||
|
timelineIndexedDocuments?: Array<{ date: string; indexedDocuments: number }>;
|
||||||
|
generatedAtUtc?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WorkspaceRagInsightsPage: React.FC = () => {
|
||||||
|
const instanceId = useInstanceId();
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [stats, setStats] = useState<RagStatsResponse | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = (await request({
|
||||||
|
url: `/api/workspace/${instanceId}/rag-statistics`,
|
||||||
|
method: 'get',
|
||||||
|
})) as RagStatsResponse;
|
||||||
|
if (data?.error) {
|
||||||
|
setError(String(data.error));
|
||||||
|
setStats(null);
|
||||||
|
} else {
|
||||||
|
setStats(data ?? null);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen');
|
||||||
|
setStats(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [instanceId, request]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
if (!instanceId) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
|
||||||
|
Keine Workspace-Instanz ausgewählt.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className={styles.wrap} style={{ padding: 24 }}>Lade Kennzahlen …</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div className={styles.error}>{error}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kpis = stats?.kpis;
|
||||||
|
const timeline = stats?.timelineIndexedDocuments ?? [];
|
||||||
|
const mimeRows = Object.entries(stats?.documentsByMimeCategory ?? {}).map(([key, value]) => ({
|
||||||
|
name: MIME_LABELS[key] ?? key,
|
||||||
|
value,
|
||||||
|
}));
|
||||||
|
const statusRows = Object.entries(stats?.indexedDocumentsByStatus ?? {}).map(([name, value]) => ({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
}));
|
||||||
|
const chunkTypeRows = Object.entries(stats?.chunksByContentType ?? {}).map(([name, value]) => ({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<p className={styles.disclaimer}>
|
||||||
|
Dargestellt sind ausschliesslich aggregierte technische Masszahlen dieser Instanz (Anzahl
|
||||||
|
Dokumente, Fragmente, Speicherumfang, Verteilungen). Es werden keine Inhalte, Dateinamen
|
||||||
|
oder personenbezogene Angaben ausgewiesen. Geeignet für interne Berichte und Präsentationen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{stats?.scope?.workspaceFileIdsResolved !== undefined && (
|
||||||
|
<p className={styles.meta} style={{ marginTop: 0 }}>
|
||||||
|
Zuordnung Knowledge ↔ Dateien: {stats.scope.workspaceFileIdsResolved} Datei-ID(s) mit
|
||||||
|
dieser Feature-Instanz in der Dateiverwaltung. Neu indexierte Uploads erhalten die
|
||||||
|
Instanz automatisch; ältere Einträge ohne Zuordnung erscheinen erst nach erneuter
|
||||||
|
Indexierung.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{kpis && (
|
||||||
|
<div className={styles.kpiGrid}>
|
||||||
|
<div className={styles.kpiCard}>
|
||||||
|
<p className={styles.kpiValue}>{kpis.indexedDocuments}</p>
|
||||||
|
<p className={styles.kpiLabel}>Indexierte Dokumente</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.kpiCard}>
|
||||||
|
<p className={styles.kpiValue}>{_formatBytes(kpis.indexedBytesTotal)}</p>
|
||||||
|
<p className={styles.kpiLabel}>Indexiertes Datenvolumen (geschätzt)</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.kpiCard}>
|
||||||
|
<p className={styles.kpiValue}>{kpis.contentChunks}</p>
|
||||||
|
<p className={styles.kpiLabel}>Inhalts-Fragmente (Chunks)</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.kpiCard}>
|
||||||
|
<p className={styles.kpiValue}>
|
||||||
|
{kpis.embeddingCoveragePercent}%
|
||||||
|
</p>
|
||||||
|
<p className={styles.kpiLabel}>Anteil Fragmente mit Embedding</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.kpiCard}>
|
||||||
|
<p className={styles.kpiValue}>{kpis.contributorUsers}</p>
|
||||||
|
<p className={styles.kpiLabel}>Beitragende Benutzer (Anzahl)</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.kpiCard}>
|
||||||
|
<p className={styles.kpiValue}>{kpis.workflowEntities}</p>
|
||||||
|
<p className={styles.kpiLabel}>Workflow-Entitäten (Cache)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.chartBlock}>
|
||||||
|
<h3 className={styles.chartTitle}>Neu indexierte Dokumente pro Tag (letzte Wochen)</h3>
|
||||||
|
{timeline.length === 0 ? (
|
||||||
|
<p className={styles.meta}>Keine Zeitreihen-Daten für den gewählten Zeitraum.</p>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
|
<LineChart data={timeline}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} />
|
||||||
|
<Tooltip />
|
||||||
|
<Line type="monotone" dataKey="indexedDocuments" name="Dokumente" stroke="#1976d2" dot={false} strokeWidth={2} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.row2}>
|
||||||
|
<div className={styles.chartBlock}>
|
||||||
|
<h3 className={styles.chartTitle}>Dokumente nach Format-Kategorie</h3>
|
||||||
|
{mimeRows.length === 0 ? (
|
||||||
|
<p className={styles.meta}>Keine Daten.</p>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<BarChart data={mimeRows} layout="vertical" margin={{ left: 8, right: 16 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis type="number" allowDecimals={false} />
|
||||||
|
<YAxis type="category" dataKey="name" width={120} tick={{ fontSize: 11 }} />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar dataKey="value" name="Anzahl" fill="#00897b" radius={[0, 4, 4, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.chartBlock}>
|
||||||
|
<h3 className={styles.chartTitle}>Index-Status</h3>
|
||||||
|
{statusRows.length === 0 ? (
|
||||||
|
<p className={styles.meta}>Keine Daten.</p>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={statusRows}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
outerRadius={88}
|
||||||
|
label={({ name, percent }) =>
|
||||||
|
`${name ?? ''} ${percent != null ? (percent * 100).toFixed(0) : '0'}%`}
|
||||||
|
>
|
||||||
|
{statusRows.map((_, i) => (
|
||||||
|
<Cell key={`st-${i}`} fill={CHART_COLORS[i % CHART_COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.chartBlock}>
|
||||||
|
<h3 className={styles.chartTitle}>Fragmente nach Inhaltstyp</h3>
|
||||||
|
{chunkTypeRows.length === 0 ? (
|
||||||
|
<p className={styles.meta}>Keine Chunk-Daten.</p>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
|
<BarChart data={chunkTypeRows}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar dataKey="value" name="Fragmente" fill="#6a1b9a" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stats?.generatedAtUtc && (
|
||||||
|
<p className={styles.meta}>Stand (UTC): {stats.generatedAtUtc}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -8,21 +8,23 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
import { WorkspaceSettings } from './WorkspaceSettings';
|
import { WorkspaceSettings } from './WorkspaceSettings';
|
||||||
|
import { WorkspaceGeneralSettings } from './WorkspaceGeneralSettings';
|
||||||
|
|
||||||
type SettingsTab = 'voice';
|
type SettingsTab = 'general' | 'voice';
|
||||||
|
|
||||||
const _TABS: { key: SettingsTab; label: string }[] = [
|
const _TABS: { key: SettingsTab; label: string }[] = [
|
||||||
|
{ key: 'general', label: 'Generelle Einstellungen' },
|
||||||
{ key: 'voice', label: 'Sprache & Stimme' },
|
{ key: 'voice', label: 'Sprache & Stimme' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const WorkspaceSettingsPage: React.FC = () => {
|
export const WorkspaceSettingsPage: React.FC = () => {
|
||||||
const instanceId = useInstanceId();
|
const instanceId = useInstanceId();
|
||||||
const [activeTab, setActiveTab] = useState<SettingsTab>('voice');
|
const [activeTab, setActiveTab] = useState<SettingsTab>('general');
|
||||||
|
|
||||||
if (!instanceId) {
|
if (!instanceId) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
|
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
|
||||||
Keine Workspace-Instanz ausgewaehlt.
|
Keine Workspace-Instanz ausgewählt.
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -61,6 +63,9 @@ export const WorkspaceSettingsPage: React.FC = () => {
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div style={{ flex: 1, overflow: 'auto', padding: '16px 24px' }}>
|
<div style={{ flex: 1, overflow: 'auto', padding: '16px 24px' }}>
|
||||||
|
{activeTab === 'general' && (
|
||||||
|
<WorkspaceGeneralSettings instanceId={instanceId} />
|
||||||
|
)}
|
||||||
{activeTab === 'voice' && (
|
{activeTab === 'voice' && (
|
||||||
<WorkspaceSettings instanceId={instanceId} />
|
<WorkspaceSettings instanceId={instanceId} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -358,7 +358,18 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
const item = event.item as Record<string, unknown> | undefined;
|
const item = event.item as Record<string, unknown> | undefined;
|
||||||
let msg = event.content || 'Unknown error';
|
let msg = event.content || 'Unknown error';
|
||||||
if (item && item.error === 'INSUFFICIENT_BALANCE') {
|
const subscriptionErrors = new Set([
|
||||||
|
'SUBSCRIPTION_INACTIVE',
|
||||||
|
'SUBSCRIPTION_PAYMENT_REQUIRED',
|
||||||
|
'SUBSCRIPTION_PAYMENT_PENDING',
|
||||||
|
'SUBSCRIPTION_EXPIRED',
|
||||||
|
]);
|
||||||
|
if (item && typeof item.error === 'string' && subscriptionErrors.has(item.error)) {
|
||||||
|
msg = typeof item.message === 'string' ? item.message : msg;
|
||||||
|
if (typeof item.subscriptionUiPath === 'string') {
|
||||||
|
msg += `\n\n→ ${item.subscriptionUiPath}`;
|
||||||
|
}
|
||||||
|
} else if (item && item.error === 'INSUFFICIENT_BALANCE') {
|
||||||
const preferDe =
|
const preferDe =
|
||||||
typeof navigator !== 'undefined' && navigator.language?.toLowerCase().startsWith('de');
|
typeof navigator !== 'undefined' && navigator.language?.toLowerCase().startsWith('de');
|
||||||
const de = typeof item.messageDe === 'string' ? item.messageDe : '';
|
const de = typeof item.messageDe === 'string' ? item.messageDe : '';
|
||||||
|
|
|
||||||
|
|
@ -261,6 +261,16 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
|
||||||
{ code: 'logs', label: { de: 'Protokolle', en: 'Logs' }, path: 'logs' },
|
{ code: 'logs', label: { de: 'Protokolle', en: 'Logs' }, path: 'logs' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
automation2: {
|
||||||
|
code: 'automation2',
|
||||||
|
label: { de: 'Automation 2', en: 'Automation 2' },
|
||||||
|
icon: 'sitemap',
|
||||||
|
views: [
|
||||||
|
{ code: 'editor', label: { de: 'Editor', en: 'Editor' }, path: 'editor' },
|
||||||
|
{ code: 'workflows', label: { de: 'Workflows', en: 'Workflows' }, path: 'workflows' },
|
||||||
|
{ code: 'workflows-tasks', label: { de: 'Tasks', en: 'Tasks' }, path: 'workflows-tasks' },
|
||||||
|
]
|
||||||
|
},
|
||||||
neutralization: {
|
neutralization: {
|
||||||
code: 'neutralization',
|
code: 'neutralization',
|
||||||
label: { de: 'Neutralisierung', en: 'Neutralization', fr: 'Neutralisation' },
|
label: { de: 'Neutralisierung', en: 'Neutralization', fr: 'Neutralisation' },
|
||||||
|
|
@ -290,6 +300,7 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
|
||||||
views: [
|
views: [
|
||||||
{ code: 'dashboard', label: { de: 'Dashboard', en: 'Dashboard', fr: 'Tableau de bord' }, path: 'dashboard' },
|
{ code: 'dashboard', label: { de: 'Dashboard', en: 'Dashboard', fr: 'Tableau de bord' }, path: 'dashboard' },
|
||||||
{ code: 'editor', label: { de: 'Editor', en: 'Editor', fr: 'Editeur' }, path: 'editor' },
|
{ code: 'editor', label: { de: 'Editor', en: 'Editor', fr: 'Editeur' }, path: 'editor' },
|
||||||
|
{ code: 'rag-insights', label: { de: 'Wissens-Insights', en: 'Knowledge insights', fr: 'Aperçu des connaissances' }, path: 'rag-insights' },
|
||||||
{ code: 'settings', label: { de: 'Einstellungen', en: 'Settings', fr: 'Parametres' }, path: 'settings' },
|
{ code: 'settings', label: { de: 'Einstellungen', en: 'Settings', fr: 'Parametres' }, path: 'settings' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue